Merge pull request #1228 from getagentseal/feat/windows-capacity-dock

feat(windows): Capacity Dock, an edge-docked quota rail
This commit is contained in:
Resham Joshi 2026-09-03 05:24:38 -07:00 • committed by GitHub
commit 5ffd4a7200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
226 changed files with 30409 additions and 1146 deletions

View file

@ -13,6 +13,8 @@ on:
- app/**
- src/**
- scripts/**
# The installer now builds and ships the tray app, so a tray change must rebuild it.
- windows/**
- package.json
- package-lock.json
push:
@ -38,6 +40,7 @@ jobs:
cache-dependency-path: |
package-lock.json
app/package-lock.json
windows/package-lock.json
- name: Install CLI dependencies
run: npm ci
@ -48,6 +51,24 @@ jobs:
- name: Test installer verifier
run: npm --prefix app test -- scripts/verify-windows-installer.test.ts
# The installer ships the tray app: stage-menubar.mjs takes the MSI that
# `tauri build` leaves in windows/src-tauri/target/release/bundle/msi.
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
workspaces: windows/src-tauri
- name: Install tray dependencies
working-directory: windows
run: npm ci
- name: Build tray app
working-directory: windows
run: npm run tauri build
- name: Build NSIS installer
run: npm --prefix app run package:win

View file

@ -7,8 +7,15 @@ on:
- .github/workflows/build-windows-store.yml
- app/**
- src/**
# The Store package ships the tray exe, so a tray change must rebuild it.
- windows/**
- package.json
- package-lock.json
# A desktop release tag builds the Store package beside the NSIS installer, so both
# artifacts for a version come from one commit.
push:
tags:
- 'desktop-v*'
permissions:
contents: read
@ -26,6 +33,7 @@ jobs:
cache-dependency-path: |
package-lock.json
app/package-lock.json
windows/package-lock.json
- name: Install CLI dependencies
run: npm ci
@ -33,6 +41,24 @@ jobs:
- name: Install desktop dependencies
run: npm ci --prefix app
# The Store package ships the tray exe: stage-menubar.mjs takes it from
# windows/src-tauri/target/release, so no MSI bundle is needed here.
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
workspaces: windows/src-tauri
- name: Install tray dependencies
working-directory: windows
run: npm ci
- name: Build tray app
working-directory: windows
run: npm run tauri build -- --no-bundle
- name: Build Microsoft Store package
run: npm --prefix app run package:store

View file

@ -95,5 +95,11 @@ jobs:
The bundle is unsigned, so Windows SmartScreen warns on first run: click
"More info", then "Run anyway". Signing is planned.
This `.msi` is a developer preview. The recommended install is the Microsoft
Store (https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is signed and
updates itself. Preview builds do not update themselves: the tray app reports
that a newer version exists and links to the release, and you take it by
re-running `codeburn menubar --force` or downloading the new `.msi` here.
files: release-artifacts/*
fail_on_unmatched_files: true

View file

@ -44,6 +44,30 @@ jobs:
timeout-minutes: 5
run: npm run test:locks
# The CLI is shipped to macOS and Windows users, and the Windows tray app depends on it
# there. One run per platform on the newest Node 22, without the serial lock suite, which
# is Linux-tuned. The Windows run uses three workers: the suite spawns the CLI through tsx
# often, and a Windows runner falls over under the default worker count.
test-platforms:
runs-on: ${{ matrix.os }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Test suite
run: npm test -- --maxWorkers=3
desktop:
runs-on: ubuntu-latest
timeout-minutes: 15

View file

@ -2,6 +2,9 @@
## Unreleased
### Added
- **The CLI now computes the anonymous usage aggregate the desktop app and the Windows tray send, so both report the identical shape.** `codeburn status --format menubar-json` gains a `telemetrySnapshot` block (schema 2) built by `src/telemetry-snapshot.ts` from the payload and its own turn-level reports. Its headline signal is the model x task cross: up to 8 models, each with a cost bucket, a turn bucket, a one-shot rate and up to 6 task categories with their share of that model's turns, alongside up to 12 task categories with their top models, providers, MCP servers, skills, tools, a session count and median-length bucket, and cache-hit and retry-tax shares. Every magnitude is a bucket label, never an exact dollar amount, count or duration, and every name is a model or configuration identifier: nothing in the block is derived from a working directory, project name, branch name, file path or message text. The desktop app sends the block verbatim as the once-daily `usage_snapshot` event when the CLI provides it and falls back to its own renderer-side builder for an older CLI, and it adds five name-only interaction events (`optimize_apply`, `plan_set`, `export`, `compare_view`, `settings_change`). Telemetry remains consent-gated behind the onboarding screen, off by default in the EU/EEA/UK/CH, and switchable at any time in Settings > Privacy & data; README documents exactly what each event carries. `STATUS_SNAPSHOT_VERSION` moves to 4 so persisted status snapshots re-derive with the new field.
## 0.9.23 - 2026-08-29
### Added

100
README.md
View file

@ -55,7 +55,8 @@
<strong>Menubar</strong><br/>
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/menubar-app.jpg" alt="CodeBurn macOS menubar" /><br/>
<code>codeburn menubar</code><br/>
<a href="https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.23"><img src="https://img.shields.io/badge/Windows-Tray_app_(.msi)-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows menubar" /></a>
<a href="https://apps.microsoft.com/detail/9P0R4ZL5XMB8"><img src="https://img.shields.io/badge/Windows-Microsoft_Store-F97316?logo=microsoft&logoColor=white" alt="Get CodeBurn from the Microsoft Store" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.23"><img src="https://img.shields.io/badge/Windows-Tray_app_.msi_(preview)-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows tray app developer preview (.msi)" /></a>
</td>
</tr>
<tr>
@ -75,7 +76,7 @@ You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells
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.
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. The optional desktop app can send an anonymous, bucketed usage report if you say yes to it; see [Telemetry](#telemetry).
<p align="center">
<a href="#quick-start">Quick start</a> ·
@ -88,7 +89,8 @@ Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your
<a href="#supported-tools">Supported tools</a> ·
<a href="#commands">Commands</a> ·
<a href="#features">Features</a> ·
<a href="#how-it-reads-your-data">How it reads data</a>
<a href="#how-it-reads-your-data">How it reads data</a> ·
<a href="#telemetry">Telemetry</a>
</p>
## Quick start
@ -348,6 +350,8 @@ Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. An
### Windows
The recommended install is the [Microsoft Store](https://apps.microsoft.com/detail/9P0R4ZL5XMB8) (Store ID `9P0R4ZL5XMB8`), which ships the desktop app with the tray app inside it. Microsoft signs that package at submission, and the Store keeps it up to date, so the tray app leaves its own update checker switched off there.
Windows gets the same ambient view from the system tray, from the same one command:
```powershell
@ -358,7 +362,9 @@ It downloads the `.msi` for your CLI version, verifies its sha256, runs it throu
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.
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` under the `windows-v*` releases and the desktop setup `.exe` are a developer preview. Both are unsigned, so SmartScreen prompts on first run: click "More info", then "Run anyway". The preview builds also do not update themselves. The tray app still tells you when a newer version exists and links to the release; taking it means re-running `codeburn menubar --force` or downloading the new build yourself.
### Linux (GNOME)
@ -502,6 +508,8 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
| Command | What it does |
|---------|--------------|
| `codeburn quota` | Live provider capacity: quota windows for each signed-in coding tool |
| `codeburn quota --format json` | The same capacity readings as JSON |
| `codeburn doctor` | Per-provider detection status: paths probed, sessions found, parse health (`--json`, `--provider`) |
| `codeburn audit` | Per provider-model token source table: where every number comes from |
| `codeburn context` | What fills a session's context window: interactive browser (Claude Code and Codex) |
@ -665,6 +673,31 @@ codeburn doctor --json # machine-readable, pipe to jq
For each provider it shows the exact directories or databases probed (with any env override such as `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `OPENCODE_DATA_DIR` and whether the path exists), how many session files were found, how many of a bounded sample parsed cleanly, the cached file count, and a one-line verdict: `OK (n sessions)`, `NOTHING FOUND` with the likely cause (directory missing, override points at an empty dir, or the tool is not installed), or `ERRORS (n parse failures)`. A provider that throws is caught and reported as its own error row, never crashing the rest of the report.
### Provider quota
`codeburn quota` reads how much of each provider's plan you have already spent, from the credentials the tools themselves keep on this machine. Claude, Codex, Gemini, GitHub Copilot and Kimi are read from their own signed-in sessions; Antigravity is read from its local language server.
```bash
codeburn quota # table of every provider and its windows
codeburn quota --format json # machine-readable, pipe to jq
```
Providers you are not signed in to are listed with `available: false` and no error. Reads run in parallel with a short per-provider timeout, and the command always exits 0 so a status bar or tray can poll it safely.
```json
{
"providers": [
{
"id": "claude",
"name": "Claude",
"available": true,
"plan": "Max 20x",
"windows": [{ "label": "Weekly", "usedPct": 42.5, "resetsAt": "2026-09-08T12:00:00.000Z" }]
}
]
}
```
### JSON Output
`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
@ -746,6 +779,65 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
</details>
## Telemetry
<details>
<summary><strong>What the desktop app sends, and how to turn it off</strong></summary>
The **CLI sends nothing.** No wrapper, no proxy, no phoning home.
The **desktop app and the Windows tray** can send an anonymous usage report, and only after you
decide on the first-launch consent screen. The toggle defaults to **off** in the EU, EEA, UK and
Switzerland, and anywhere the region is unknown; **on** elsewhere. Either way it is your call, and
you can change it any time in **Settings > Privacy & data > Anonymous telemetry**. Turning it off
stops all sending, clears anything queued, and mints a fresh install id so past and future reports
cannot be linked.
The only identifier is a random id generated on your machine. Events carry the calendar day, never
a clock time. Alongside each batch go the app version, platform, architecture and country.
**`usage_snapshot`** goes out at most once per calendar day. It is computed by the CLI so the app
and the tray report the identical shape, and every magnitude in it is a bucket, never an exact
figure. The daily report includes the names of the models, tools, skills and MCP servers you use,
alongside those bucketed counts.
| Field | What it carries |
|-------|-----------------|
| `schema`, `period` | Snapshot version, and the period label you were looking at (for example `30 Days`) |
| `providerCount`, `costBucket` | How many providers had usage, and total spend as a range: `<1`, `1-10`, `10-50`, `50-200`, `200-1k`, `1k+` USD |
| `models` | Up to 8 model names, each with its cost bucket, turn-count bucket, one-shot rate, and up to 6 task categories with a turn bucket and share of that model's turns |
| `categories` | Up to 12 task category names (Coding, Debugging, Planning, …) with a turn bucket, one-shot rate, and up to 3 model names |
| `providers` | Up to 8 provider names with a cost bucket each |
| `mcpServers`, `skills`, `tools` | Up to 12 names each with a call-count bucket: `0`, `1-10`, `10-100`, `100-1k`, `1k+` |
| `sessions` | Session count bucket, and median session length as a bucket: `<5`, `5-15`, `15-60`, `60-240`, `240+` minutes |
| `efficiency` | Cache hit rate and retry tax as shares of the total, to two decimals |
The other events are name-only:
| Event | Fields |
|-------|--------|
| `app_open`, `app_close` | Session length in whole minutes |
| `section_view` | Which section you opened (`overview`, `spend`, …) |
| `cold_start` | Milliseconds to the first painted overview, and whether it timed out |
| `cli_error` | Error kind and the command name, capped at 20 per kind per day |
| `optimize_apply` | The finding id you took a fix for (`unused-mcp`, `claude-md-too-long`, …) and the fix type |
| `plan_set` | Provider and plan preset id |
| `export` | Format (`csv` or `json`) and provider |
| `compare_view` | The two model names being compared |
| `settings_change` | Setting name and its new boolean or enum value |
**Never collected:** prompts, code, file contents, file or folder names, project names, branch names,
working directories, session titles, PR links, API keys, exact dollar amounts, exact counts, clock
times, or IP-based location beyond the country.
The names of the models, tools, skills and MCP servers you use are collected, as the table above
sets out, and a whitelist sanitizer drops anything that is not a short string, a finite number or a
boolean before it leaves the machine.
**To turn it off:** decline on the consent screen, or open **Settings > Privacy & data** and switch
**Anonymous telemetry** off.
</details>
## Environment Variables
<details>

1
app/.gitignore vendored
View file

@ -2,5 +2,6 @@ node_modules/
dist/
release/
build/cli/
build/menubar/
*.log
.vite/

View file

@ -52,6 +52,145 @@ bundled CLI's dependency tree gets stripped out of an `extraResources` copy
in verbatim, which is the electron-builder-recommended mechanism for adding
unpacked files that must not be ASAR-archived.
## The bundled tray app and Capacity Dock (Windows only)
The Windows desktop app also carries the Tauri tray app from `windows/`, so
installing the desktop app gives the user the tray icon and the Capacity Dock
without their having to know that `codeburn menubar` exists. Two switches in the
sidebar's bottom-left corner turn each surface off ("Menu bar" is the tray
process, "Sidebar" is the dock rail); both default on, and neither appears on
macOS or Linux, or in a build with nothing staged.
`scripts/stage-menubar.mjs` puts the tray app into `app/build/menubar`, which
`build.win.extraResources` ships as `resources/menubar`. It copies rather than
downloads, exactly like `stage-cli.mjs`: with no `--from` it takes the local
`windows/src-tauri/target/release` output, and CI downloads the `windows-v*`
release assets in its own step and passes `--from <dir>`.
The two routes need different things staged, which is why there are two scripts:
- `npm run stage-menubar` (run by `package:win`) stages
`CodeBurn.Menubar_<version>_x64_en-US.msi` and its `.sha256`. The digest is
copied from the release when it is there and computed when it is not, so a
local `cargo tauri build` stages the same shape a release does.
- `npm run stage-menubar:store` (run by `package:store`) stages
`codeburn-menubar.exe` itself, plus any sidecar DLL beside it.
**The staged tray app is not the released one, even at the same version.**
`build-windows-installer.yml` runs `npm run tauri build` itself and stages the
MSI that run produced. `release-menubar-windows.yml` runs its own
`npm run tauri build` on a `windows-v*` tag and uploads that MSI as the release
asset. Two builds, two binaries: a Tauri MSI is not byte-reproducible (build
timestamps, the package GUID and the PDB signature differ per run), so the
`CodeBurn.Menubar_<version>_x64_en-US.msi` inside a desktop installer and the
`windows-v<version>` release asset of the same name are different files with
different `.sha256` digests, even off the same commit.
Nothing in the install path depends on them matching: the CLI's installer
verifies the digest of the file it was handed, and the upgrade rules go by the
`DisplayVersion` in the uninstall registry rather than by a hash. What it does
mean is that a version number is not a statement about which binary is on a
machine, and the release asset is not a way to reproduce or verify what a
desktop installer carries. So cut both from one commit when releasing, and say
"built by the desktop installer" or "the windows-v asset" rather than treating
the version as an identifier for either.
### The NSIS route: install through the CLI, never downgrade
`app/electron/menubar.ts` runs at every launch (which is also what covers a
desktop-app update, since the staged MSI travels with the app) and hands the
staged file to the CLI's own Windows installer by spawning
`codeburn menubar --staged-msi <absolute path>`. The path is an argument of the
command rather than an environment variable, so nothing a variable in the
inherited environment happens to name can reach msiexec; the
`CODEBURN_MENUBAR_MSI` variable this replaced is now rejected by the CLI. The
install itself is not reimplemented on the Electron side:
`src/menubar-installer.ts` owns the uninstall-registry read, the checksum
verification, the `msiexec /i ... /passive /norestart` call and the rules that
go with a bundled copy. It prints one `CODEBURN_MENUBAR_RESULT <json>` line that
the desktop app reads.
The spawn itself is not made at every launch. `msiexec /passive` puts an admin
prompt in front of the user, so `companion.v1.json` records the version of the
tray app that is on disk and the version of any staged MSI whose install the
user declined at that prompt (exit 1602). A launch skips the probe when the
recorded tray app is the staged version and is still there, and skips it when
this exact staged version was already refused. A newer staged version is always
offered, and so is turning the "Menu bar" switch on by hand, which clears the
refusal.
Only a refusal is remembered. A spawn that timed out, a CLI that was not there
and a run that died before printing its result are all transient, so nothing is
written down and the install is attempted again at the next launch; recording
them would turn one bad launch into a tray app that is never installed.
When msiexec exits 3010 the install is real but unfinished: Windows could not
replace a file that was in use and has deferred it to the next restart, so the
binary on disk is still the previous tray app while the uninstall registry
already reports the new version. The desktop app starts nothing in that state
and the sidebar corner says "Restart Windows to finish installing the menu bar
app." instead. `BundledInstallResult` carries no field for this, so the desktop
side reads the installer's own notice line; the optional `rebootRequired` in
`MenubarInstallResult` is believed first, for when the CLI starts sending it.
Two of those rules matter here:
- **Deduplication.** The MSI carries a fixed WiX upgrade code
(`windows/src-tauri/tauri.conf.json`, `bundle.windows.wix.upgradeCode`), which
is the value Tauri already derived from the product name, so installing the
bundled copy over a manual `codeburn menubar` install is an in-place upgrade
rather than a second entry in Programs and Features.
- **Never downgrade.** If the installed `DisplayVersion` is newer than the
bundled one, nothing is installed and the existing app is left alone.
**The marker.** The installer writes
`%LOCALAPPDATA%\codeburn-menubar\installed-by.json`, crediting `desktop` only
when it installed onto a machine that had no tray app, and `manual` when it
upgraded one that was already there. An existing marker's verdict is never
rewritten. `build/installer.nsh` (wired as `nsis.include`) reads it in the
uninstaller and removes the tray app only when the marker says `desktop`, so a
tray app the user installed by hand survives an uninstall of the desktop app.
Launch at login on this route is a single `HKCU\...\CurrentVersion\Run` value
named `CodeBurn`, the same value the tray app's own settings toggle writes
(`windows/src-tauri/src/autostart.rs`), so the two can never leave two entries.
### The Store route: no msiexec
A packaged app cannot run `msiexec` and would be fighting the package manager if
it could, so the AppX build ships `codeburn-menubar.exe` inside the package and
launches it from `app\resources\menubar\`. Launch at login is a
`windows.startupTask` extension (`build/appx-extensions.xml`, wired as
`appx.customExtensionsPath`), which also lets the user turn it off in
**Settings > Apps > Startup**; no Run value is written. The tray app detects the
package with `GetCurrentPackageFullName` and skips its own update checker
entirely, since Store updates cover it.
That is why the Store is the recommended Windows install: it is the only
Windows route with a signed artifact (Microsoft signs the package at
submission) and a real update path. The GitHub downloads stay a developer
preview, unsigned and without self-update, until an Authenticode certificate
exists.
The startup task is Windows' to set, not this app's: reaching one would take the
WinRT `Windows.ApplicationModel.StartupTask` API, which Electron has no binding
for and which this app ships no native code to reach, and a Run value written
beside it would start the tray app twice. So `trayPrefs()` reports
`launchAtLoginManaged: true` on this route and the Menu bar pane renders that row
as text plus a button that opens `ms-settings:startupapps`, rather than a switch
that would silently move nothing. That URL is the one exception in the main
process's external-open guard, which is otherwise http(s) only.
### Talking to a running tray app
The tray app has no window of its own, so its argv is the control channel: the
Tauri single-instance plugin hands a running instance whatever a second launch
was started with (`windows/src-tauri/src/lib.rs`). `--quit` exits and
`--reload-settings` re-reads the preference files. Turning "Sidebar" off writes
`enabled: false` into `~/.config/codeburn/windows-dock.json` and then sends
`--reload-settings`, so a running rail disappears at once rather than at the next
launch.
## Versioning
`app/package.json`'s `version` tracks the CLI's version (root
@ -176,6 +315,11 @@ Config (`build.win` + `build.nsis`):
- `win.target: nsis`, `arch: x64`.
- `win.icon: build/icon.png` — electron-builder converts the 1024x1024 PNG to
a multi-resolution `.ico` at build time (same source PNG as the mac icon).
- `win.extraResources: build/menubar -> menubar`: the staged tray app (see
"The bundled tray app and Capacity Dock" above). No `node_modules` is
involved, so unlike the CLI this one can use `extraResources`.
- `nsis.include: build/installer.nsh`: the uninstall macro that removes the
tray app only when the marker says the desktop app installed it.
- `nsis.oneClick: false` — an assisted installer with a wizard, so users get
an **install-directory choice** instead of a silent one-click install.
- `nsis.perMachine: false` — installs per-user (into the user's `AppData`),
@ -188,6 +332,47 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run
anyway"** to launch it. This is expected for an unsigned build; the only fix is
a purchased code-signing (Authenticode/EV) certificate.
**ARM64 builds.** `npm --prefix app run package:win:arm64` builds the installer for ARM64
Windows. It sets `ELECTRON_BUILDER_7Z_FILTER=BCJ`, and that is not optional: 7-Zip
compresses ARM64 executables with its ARM64 branch filter by default, the 7-Zip decoder
inside the NSIS installer predates that filter, and the installer then silently drops
every .exe and .dll while the data files land, leaving an install with no program in it.
The x64 build is unaffected because its default filter is one the decoder knows. The
per-architecture archives inside a universal installer get the same treatment, so an
installer built with both `--x64` and `--arm64` needs the variable too.
**The ARM64 installer carries the x64 tray app.** `package:win:arm64` runs the same
`npm run stage-menubar` as `package:win`, and there is no ARM64 tray app for it to stage:
- `tauri build` builds for the host, so an ARM64 Windows machine produces
`CodeBurn Menubar_<version>_arm64_en-US.msi`, and `stage-menubar.mjs` matches only
`CodeBurn[ .]Menubar_<version>_x64_en-US.msi`. Staging on an ARM64 host therefore fails
outright with `no CodeBurn.Menubar_<version>_x64_en-US.msi` rather than staging the arm64
bundle. What `package:win:arm64` actually ships is the x64 MSI, from an x64 host build or
from the `windows-v*` release assets passed with `--from`.
- Teaching the staging script the arm64 name would not help on its own. The CLI is what runs
the install, and `src/menubar-installer.ts` accepts only the x64 name: its release-asset
pattern, `parseWindowsMsiVersion` and the staged-install path all require
`_x64_en-US.msi`, and a file named anything else is rejected before msiexec sees it. An
ARM64 tray app would need a matching change there and in the tray's own updater, which is a
larger piece of work than the packaging script.
- What it means in practice: on ARM64 Windows the desktop app installs the x64 tray app, and
Windows runs it under x64 emulation. It works, and it is slower and heavier than a native
build would be. Nothing misreports its architecture, because the tray app's version and the
uninstall-registry entry are both the x64 MSI's own.
`build-windows-installer.yml` builds `package:win` on `windows-latest` only, so no released
artifact is affected: `package:win:arm64` is a local build today.
**Distribution policy.** The Microsoft Store build (Store ID `9P0R4ZL5XMB8`) is
the recommended Windows install. This NSIS setup `.exe` and the tray `.msi`
under the `windows-v*` releases are a developer preview: unsigned, SmartScreen
warns on first run, and neither route updates itself. The tray app still
reports that a newer version exists and points at the GitHub release; taking it
means re-running `codeburn menubar --force` or downloading the build by hand.
One-click updating comes back once an Authenticode certificate signs the
artifacts.
### Microsoft Store (`package:store`)
The Store build is a separate AppX target so the GitHub NSIS installer remains
@ -209,6 +394,10 @@ certificate. The AppX declares `runFullTrust` (electron-builder's required
default for Electron apps), so CodeBurn retains access to the user's local
provider session files rather than running in a UWP application sandbox.
The tray app ships inside this package too (`app\resources\menubar\`), with a
`windows.startupTask` extension for launch at login. `msiexec` is never run on
this route. See "The Store route" above.
### Linux (`package:linux`)
`electron-builder --linux` produces a single artifact in `app/release/`:

View file

@ -0,0 +1,9 @@
<!-- Launch at login for the tray app the Store package carries. The NSIS route writes an
HKCU Run value instead (app/electron/menubar.ts); a packaged app declares it here so the
person can turn it off in Settings > Apps > Startup like any other packaged app.
electron-builder splices this file inside the manifest's <Extensions> element, and the
desktop namespace is already declared by its template. The path is where extraResources
land inside the package: the app itself is app\CodeBurn.exe. -->
<desktop:Extension Category="windows.startupTask" Executable="app\resources\menubar\codeburn-menubar.exe" EntryPoint="Windows.FullTrustApplication">
<desktop:StartupTask TaskId="CodeBurnMenubar" Enabled="true" DisplayName="CodeBurn Menubar" />
</desktop:Extension>

33
app/build/installer.nsh Normal file
View file

@ -0,0 +1,33 @@
; Uninstalling CodeBurn Desktop removes the tray app only when this app is what
; put it there. src/menubar-installer.ts writes the marker at install time and
; credits whoever got there first, so a tray app someone installed by hand with
; `codeburn menubar` survives, and the one the desktop app installed goes with it.
;
; PowerShell does the reading: the marker is JSON, and the product code lives
; inside the uninstall string Windows Installer wrote. `$$` is a literal dollar
; for NSIS, so every `$$` below is a PowerShell variable, and every string inside
; the command is single-quoted. NSIS has no doubled-quote escape: a `''` inside a
; single-quoted NSIS string ends it, and the plugin then received a truncated
; command that PowerShell could not parse, so the hook silently did nothing. Each
; inner quote is therefore written as `$\'`, the NSIS escape for a literal quote.
!macro customUnInstall
; An upgrade runs the previous version's uninstaller first, with --updated, and
; the tray app must survive that: the new desktop app reuses it, and removing it
; here would cost every upgrade a tray reinstall and its elevation prompt.
${if} ${isUpdated}
DetailPrint "Upgrade in progress; the tray app stays."
${else}
DetailPrint "Checking whether CodeBurn installed the tray app..."
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$marker = Join-Path $$env:LOCALAPPDATA (Join-Path $\'codeburn-menubar$\' $\'installed-by.json$\'); if (Test-Path $$marker) { $$record = Get-Content -Raw $$marker | ConvertFrom-Json; if ($$record.installedBy -eq $\'desktop$\') { Get-Process -Name $\'codeburn-menubar$\' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; if ($$record.uninstallString -match $\'\{[0-9A-Fa-f-]{36}\}$\') { Start-Process -Wait -FilePath $\'msiexec.exe$\' -ArgumentList $\'/x$\', $$Matches[0], $\'/passive$\', $\'/norestart$\' }; Remove-Item -Force -ErrorAction SilentlyContinue $$marker } }"'
Pop $0
; The launcher this app writes so the tray app has a CLI to run at all
; (app/electron/menubar.ts). It names this app's own executable, which is about
; to be gone, and unlike the tray app itself there is no copy of it anybody
; else could have put there, so it goes unconditionally. The directory follows
; only if the tray app left nothing else in it.
Delete "$LOCALAPPDATA\codeburn-menubar\codeburn-cli.cmd"
RMDir "$LOCALAPPDATA\codeburn-menubar"
${endif}
!macroend

View file

@ -0,0 +1,183 @@
// @vitest-environment node
// Ending a CLI child on Windows. There is no process group to signal, so the whole tree has
// to be walked by pid, and the order matters: the handle may only be ended once taskkill has
// answered. This needs its own file because it mocks `node:os` and `node:child_process`
// wholesale, which the rest of the CLI suite runs against for real.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// vi.mock factories are hoisted above the module body, so the spies they close over have to
// be hoisted with them.
const { platformMock, spawnMock } = vi.hoisted(() => ({
platformMock: vi.fn(() => 'win32'),
spawnMock: vi.fn(),
}))
vi.mock('node:os', async orig => {
const actual = await orig<typeof import('node:os')>()
return { ...actual, platform: platformMock }
})
vi.mock('node:child_process', async orig => {
const actual = await orig<typeof import('node:child_process')>()
return { ...actual, spawn: spawnMock }
})
const { spawnCliAction, killAll, taskkillArgs, taskkillPath } = await import('./cli')
/** Just enough of a ChildProcess for the two things this module does with one: read its
* streams and end it. `kill` is a spy so the ordering can be asserted. */
class FakeChild extends EventEmitter {
readonly stdout = new PassThrough()
readonly stderr = new PassThrough()
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
readonly kill = vi.fn((_signal?: NodeJS.Signals) => true)
constructor(readonly pid: number) { super() }
}
let dir: string
let originalBundled: string | undefined
/** Every spawn after the CLI's own is a taskkill, and each gets its own fake. */
function taskkillSpawns(): Array<{ args: Parameters<typeof spawnMock>; child: FakeChild }> {
return spawnMock.mock.calls.slice(1).map((args, index) => ({
args: args as Parameters<typeof spawnMock>,
child: spawnMock.mock.results[index + 1]!.value as FakeChild,
}))
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeburn-tree-kill-'))
const entry = join(dir, 'cli.js')
writeFileSync(entry, '')
originalBundled = process.env.CODEBURN_BUNDLED_CLI
process.env.CODEBURN_BUNDLED_CLI = entry
platformMock.mockReturnValue('win32')
spawnMock.mockReset()
let next = 4242
spawnMock.mockImplementation(() => new FakeChild(next++))
})
afterEach(() => {
killAll()
if (originalBundled === undefined) delete process.env.CODEBURN_BUNDLED_CLI
else process.env.CODEBURN_BUNDLED_CLI = originalBundled
rmSync(dir, { recursive: true, force: true })
vi.clearAllMocks()
})
/** Start an action, hold its child, and hand back the promise so it can be settled. */
function startAction(): Promise<unknown> {
const flight = spawnCliAction(['currency', 'EUR'])
return flight
}
async function spawnedCli(): Promise<FakeChild> {
// The slot grant is a resolved promise, so the spawn lands on the next microtask turn.
await Promise.resolve()
await Promise.resolve()
return spawnMock.mock.results[0]!.value as FakeChild
}
describe('ending a CLI child on Windows', () => {
it('walks the tree with taskkill out of System32, and only that pid', async () => {
const flight = startAction()
const child = await spawnedCli()
killAll()
const kills = taskkillSpawns()
expect(kills).toHaveLength(1)
const [bin, args, options] = kills[0]!.args as [string, string[], Record<string, unknown>]
expect(bin).toBe(taskkillPath())
expect(bin.toLowerCase()).toContain('\\system32\\taskkill.exe')
expect(args).toEqual(['/pid', String(child.pid), '/T', '/F'])
expect(args).toEqual(taskkillArgs(child.pid))
expect(options).toMatchObject({ stdio: 'ignore', windowsHide: true })
child.emit('close', null)
await flight
})
/// The bug this pins: ending the handle first reparents everything below it, and taskkill
/// then reports the pid as not found and reaps nothing.
it('leaves the handle alone until taskkill has answered', async () => {
const flight = startAction()
const child = await spawnedCli()
killAll()
expect(child.kill).not.toHaveBeenCalled()
taskkillSpawns()[0]!.child.emit('exit', 0)
expect(child.kill).not.toHaveBeenCalled()
child.emit('close', null)
await flight
})
it('falls back to the direct kill when taskkill finds nothing to end', async () => {
const flight = startAction()
const child = await spawnedCli()
killAll()
taskkillSpawns()[0]!.child.emit('exit', 128)
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
child.emit('close', null)
await flight
})
it('falls back to the direct kill when taskkill cannot start at all', async () => {
const flight = startAction()
const child = await spawnedCli()
killAll()
taskkillSpawns()[0]!.child.emit('error', new Error('spawn taskkill.exe ENOENT'))
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
child.emit('close', null)
await flight
})
it('signals the process group instead off Windows', async () => {
platformMock.mockReturnValue('darwin')
const flight = startAction()
const child = await spawnedCli()
const signalled: Array<[number, string | number | undefined]> = []
const originalKill = process.kill
// process.kill is swapped rather than spied: Electron's process typings do not expose it
// to vi.spyOn's key constraint.
process.kill = ((pid: number, signal?: string | number) => {
signalled.push([pid, signal])
return true
}) as typeof process.kill
try {
killAll()
} finally {
process.kill = originalKill
}
expect(taskkillSpawns()).toHaveLength(0)
expect(signalled).toEqual([[-child.pid, 'SIGKILL']])
child.emit('close', null)
await flight
})
})
describe('taskkillPath', () => {
it('takes System32 from SystemRoot and falls back when it is not a drive path', () => {
expect(taskkillPath({ SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\taskkill.exe')
expect(taskkillPath({ SystemRoot: 'D:\\Windows\\' })).toBe('D:\\Windows\\System32\\taskkill.exe')
expect(taskkillPath({ SystemRoot: '../evil' })).toBe('C:\\Windows\\System32\\taskkill.exe')
expect(taskkillPath({})).toBe('C:\\Windows\\System32\\taskkill.exe')
})
})

View file

@ -3,9 +3,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { spawn } from 'node:child_process'
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 { delimiter, dirname, join, isAbsolute, relative, win32, posix } from 'node:path'
import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, startServe, killAll, shutdownAll, CliError, nodeManagerDirs, notFoundStage, reapOrphanServe, resolveCodeburnPath, resolveTarget } from './cli'
import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, startServe, killAll, shutdownAll, CliError, cmdShimArgs, escapeForCmd, nodeManagerDirs, notFoundStage, reapOrphanServe, resolveCodeburnPath, resolveTarget } from './cli'
let dir: string
const originalBin = process.env.CODEBURN_BIN
@ -15,6 +15,15 @@ const originalViteUrl = process.env.VITE_DEV_SERVER_URL
const originalBundled = process.env.CODEBURN_BUNDLED_CLI
const originalDevRepoRoot = process.env.CODEBURN_DEV_REPO_ROOT
/**
* Windows has no catchable SIGTERM: child.kill() is TerminateProcess, which ends the child
* outright, so a grace window, a cleanup handler and a child that ignores the first signal
* are all things the platform cannot do rather than things this code gets wrong. The same
* goes for a resident that closes its own stdin. These stay exactly as they are and simply
* do not run there.
*/
const posixOnly = it.skipIf(process.platform === 'win32')
/** Writes an executable node script and points CODEBURN_BIN at it. */
function fakeBin(name: string, body: string): string {
const p = join(dir, name)
@ -316,7 +325,7 @@ describe('spawnSpecFor (bundled CLI runs via Electron-as-node)', () => {
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
// PATH is still augmented (the bundle's own dir leads), harmless for a CLI
// that itself shells out during pairing/sync.
expect((spec.env.PATH ?? '').split(':')[0]).toBe('/res/cli/dist')
expect((spec.env.PATH ?? '').split(delimiter)[0]).toBe('/res/cli/dist')
})
it('spawns an external CLI directly, with no run-as-node flag', () => {
@ -324,7 +333,84 @@ describe('spawnSpecFor (bundled CLI runs via Electron-as-node)', () => {
expect(spec.bin).toBe('/some/bin/codeburn')
expect(spec.args).toEqual(['status'])
expect(spec.env.ELECTRON_RUN_AS_NODE).toBeUndefined()
expect((spec.env.PATH ?? '').split(':')[0]).toBe('/some/bin')
expect((spec.env.PATH ?? '').split(delimiter)[0]).toBe('/some/bin')
})
// Windows cannot start a script. Both shapes a `codeburn` install takes there get a runner
// that can; a POSIX `codeburn` is a shebang script with an exec bit and still runs directly.
const windowsOnly = it.skipIf(process.platform !== 'win32')
windowsOnly('runs a .js CLI through Node rather than failing with EFTYPE', () => {
const spec = spawnSpecFor({ kind: 'external', bin: 'C:\\repo\\dist\\cli.js' }, ['status'])
expect(spec.bin).toBe(process.execPath)
expect(spec.args).toEqual(['C:\\repo\\dist\\cli.js', 'status'])
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
})
windowsOnly('runs a .cmd shim through cmd.exe, as one verbatim command line', () => {
const spec = spawnSpecFor({ kind: 'external', bin: 'C:\\npm\\codeburn.cmd' }, ['status', '--period', 'today'])
expect(spec.bin.toLowerCase()).toMatch(/\\system32\\cmd\.exe$/)
expect(spec.args.slice(0, 3)).toEqual(['/d', '/s', '/c'])
expect(spec.verbatim).toBe(true)
expect(spec.env.ELECTRON_RUN_AS_NODE).toBeUndefined()
})
windowsOnly('leaves an .exe alone', () => {
const spec = spawnSpecFor({ kind: 'external', bin: 'C:\\tools\\codeburn.exe' }, ['status'])
expect(spec.bin).toBe('C:\\tools\\codeburn.exe')
expect(spec.args).toEqual(['status'])
expect(spec.verbatim).toBeUndefined()
})
})
// Two parsers read this string: cmd itself, then the runtime that splits a command line back
// into argv. Nothing a renderer can put in an argument may reach either as syntax.
describe('cmd.exe command line', () => {
it('quotes every argument and neutralises the shell metacharacters', () => {
// The quotes are for the runtime that splits the line back into argv; the `^` are for
// cmd, which reads the line first and would otherwise act on what is inside them.
expect(escapeForCmd('status', false)).toBe('^"status^"')
expect(escapeForCmd('a&b', false)).toBe('^"a^&b^"')
expect(escapeForCmd('a|b', false)).toBe('^"a^|b^"')
expect(escapeForCmd('a>b<c', false)).toBe('^"a^>b^<c^"')
// A `.cmd` re-expands its own arguments, so those get a second round of escaping.
expect(escapeForCmd('a&b', true)).toBe('^^^"a^^^&b^^^"')
})
it('keeps an embedded quote from ending the argument', () => {
expect(escapeForCmd('say "hi"', false)).toBe('^"say^ \\^"hi\\^"^"')
// A trailing backslash run would otherwise escape the closing quote and swallow the
// argument after it.
expect(escapeForCmd('C:\\dir\\', false)).toBe('^"C:\\dir\\\\^"')
})
it('builds one /d /s /c line with the shim first', () => {
const args = cmdShimArgs('C:\\npm\\codeburn.cmd', ['export', '-o', 'C:\\out dir'])
expect(args.slice(0, 3)).toEqual(['/d', '/s', '/c'])
expect(args[3]).toBe('"^"C:\\npm\\codeburn.cmd^" ^^^"export^^^" ^^^"-o^^^" ^^^"C:\\out^^^ dir^^^""')
})
it.skipIf(process.platform !== 'win32')('runs a real .cmd shim end to end with its arguments intact', async () => {
// The shape a global npm install leaves on PATH: a batch file that re-expands %* into a
// node invocation, which is the second parse the double escaping above exists for.
const target = join(dir, 'echo-args.js')
writeFileSync(target, 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2) }))\n')
const shim = join(dir, 'codeburn.cmd')
writeFileSync(shim, `@echo off\r\n"${process.execPath}" "${target}" %*\r\n`)
process.env.CODEBURN_BIN = shim
await expect(spawnCli(['status', '--model', 'a & b'])).resolves.toEqual({
args: ['status', '--model', 'a & b'],
})
})
it('never lets an argument close the line and start a command', () => {
const line = cmdShimArgs('C:\\npm\\codeburn.cmd', ['status" & calc.exe & "'])[3]!
// Every quote the argument carried is escaped and so is every &, so neither parser can
// read the tail as a second command.
expect(line).not.toMatch(/(^|[^^])& calc/)
expect(line).not.toMatch(/(^|[^^\\])" /)
expect(line).toContain('^^^&')
})
it('spawnCli runs the bundled entry end-to-end as Node', async () => {
@ -439,7 +525,7 @@ describe('no-output watchdog (timeoutMs bounds SILENCE, not total runtime)', ()
})
describe('graceful kill (SIGTERM, then SIGKILL after the grace)', () => {
it('sends SIGTERM first and SIGKILLs a child that ignores it', async () => {
posixOnly('sends SIGTERM first and SIGKILLs a child that ignores it', async () => {
const signalFile = join(dir, 'signals')
const pidFile = join(dir, 'stubborn-pid')
fakeBin(
@ -463,7 +549,7 @@ describe('graceful kill (SIGTERM, then SIGKILL after the grace)', () => {
}, 12_000)
}, 20_000)
it('keeps a child inside the SIGTERM grace reapable, so quit cannot orphan it', async () => {
posixOnly('keeps a child inside the SIGTERM grace reapable, so quit cannot orphan it', async () => {
// The grace timer dies with the app. A child that ignores SIGTERM must still
// be in the reap set when quit sweeps, or it survives the app that spawned it.
//
@ -491,7 +577,7 @@ describe('graceful kill (SIGTERM, then SIGKILL after the grace)', () => {
}, 3_000)
})
it('lets a SIGTERM-handling child exit on its own without waiting for SIGKILL', async () => {
posixOnly('lets a SIGTERM-handling child exit on its own without waiting for SIGKILL', async () => {
const cleanupFile = join(dir, 'cleanup')
fakeBin(
'handles-sigterm.js',
@ -507,7 +593,10 @@ describe('graceful kill (SIGTERM, then SIGKILL after the grace)', () => {
})
})
describe('orphan serve reaping', () => {
// These start real children and then wait for the OS to report them gone, which is the same
// reason the resident-serve block gets room: the assertions are about which process survives,
// never about how quickly a busy machine gets round to ending it.
describe('orphan serve reaping', { timeout: 30_000 }, () => {
/** A long-lived stand-in for an orphaned `codeburn serve --stdio` child. */
function orphanServe(): { pid: number; pidFile: string } {
const bin = join(dir, 'codeburn')
@ -581,15 +670,15 @@ describe('spawn PATH augmentation (GUI-launched apps have a minimal PATH)', () =
it("prepends the resolved binary's own directory so its env-shebang finds node", async () => {
const bin = fakeBin('path-echo.js', 'process.stdout.write(JSON.stringify({ path: process.env.PATH }))')
const result = await spawnCli(['status']) as { path: string }
expect(result.path.split(':')[0]).toBe(dirname(bin))
expect(result.path.split(delimiter)[0]).toBe(dirname(bin))
})
it('spawnEnvFor dedupes and keeps the original PATH entries', () => {
const env = spawnEnvFor('/some/tool/bin/codeburn')
const parts = (env.PATH ?? '').split(':')
const parts = (env.PATH ?? '').split(delimiter)
expect(parts[0]).toBe('/some/tool/bin')
expect(new Set(parts).size).toBe(parts.length)
for (const original of (process.env.PATH ?? '').split(':').filter(Boolean)) {
for (const original of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
expect(parts).toContain(original)
}
})
@ -685,7 +774,10 @@ describe('spawnCli coalescing (read-only)', () => {
})
})
describe('resident serve single-flight', () => {
// Every test here starts several real node children, and the suite runs fifty files at once.
// The assertions are about which child answers, never about how quickly, so the wall clock
// gets room rather than the machine having to be fast enough for the default.
describe('resident serve single-flight', { timeout: 30_000 }, () => {
it('startServe is idempotent and creates only one resident child', async () => {
const files = fakeResidentBin()
startServe()
@ -730,7 +822,7 @@ describe('resident serve single-flight', () => {
expect(readMaybe(oneShotsFile)).toBe('o')
})
it('falls back instead of crashing when a live resident closes its stdin', async () => {
posixOnly('falls back instead of crashing when a live resident closes its stdin', async () => {
const stdinClosedFile = join(dir, 'resident-stdin-closed')
fakeBin(
'closes-stdin-resident.js',
@ -1014,7 +1106,7 @@ describe('resident serve single-flight', () => {
expect(readMaybe(files.actionsFile)).toBe('a')
})
it('SIGTERMs the outgoing resident on a mutation restart instead of hard-killing it', async () => {
posixOnly('SIGTERMs the outgoing resident on a mutation restart instead of hard-killing it', async () => {
// A settings mutation replaces a child that may hold the cache refresh lock.
// Same hazard as a timeout, so it gets the same grace: only a catchable
// signal lets the outgoing child unlink its own lock instead of leaving it

View file

@ -25,7 +25,12 @@ export type SpawnPriority = 'interactive' | 'background'
* acting as Node via `ELECTRON_RUN_AS_NODE`.
*/
export type CliTarget = { kind: 'external'; bin: string } | { kind: 'bundled'; entry: string }
type SpawnSpec = { bin: string; args: string[]; env: NodeJS.ProcessEnv }
/**
* `verbatim` marks a spec that goes through `cmd.exe`: its args are one already
* escaped command line that Windows must hand over untouched, and the real CLI
* runs a generation below, so ending it means ending the tree.
*/
type SpawnSpec = { bin: string; args: string[]; env: NodeJS.ProcessEnv; verbatim?: true }
/**
* Which resolution/spawn stage produced a `not-found`, as a non-sensitive enum
@ -108,11 +113,73 @@ function ownsProcessGroup(): boolean {
return platform() !== 'win32'
}
/** Signal the CLI and every subprocess it created. POSIX children are spawned
* as process-group leaders; Windows keeps the direct-child behavior here and
* relies on its existing orphan-reap path after a crash. */
/** Everything but the stdio shape, which stays at the call site so its literal tuple keeps
* telling the type system which streams the child will have. */
function spawnOptionsFor(spec: SpawnSpec) {
return {
shell: false as const,
env: spec.env,
detached: ownsProcessGroup(),
...(spec.verbatim ? { windowsVerbatimArguments: true } : {}),
}
}
/** `taskkill.exe` out of System32, never by bare name: Windows searches the current
* directory first, so anything dropped beside the app could answer for it. Same rule as
* `comspecPath` below. */
export function taskkillPath(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\\taskkill.exe`
}
/** The one taskkill argv, exported so a test can pin it: end this pid and everything
* below it, without asking. */
export function taskkillArgs(pid: number): string[] {
return ['/pid', String(pid), '/T', '/F']
}
/**
* End a Windows child and every process below it.
*
* `child.kill()` reaches one process. That is never the whole CLI: a `.cmd` shim runs node
* as its own child, and the CLI itself spawns provider subprocesses. `taskkill /T` walks
* the tree from this pid and is the only way to reach them.
*
* The handle is ended only once taskkill has finished, or could not run at all. Ending it
* first is what made the tree kill a no-op: the descendants are reparented the moment the
* top of the tree dies, and `taskkill /pid` on a pid that has already gone reports it as
* not found and reaps nothing.
*
* `/F` on every signal, including SIGTERM: Windows has no catchable termination signal, so
* `child.kill('SIGTERM')` is already an unconditional TerminateProcess. The graceful half
* of {@link killGracefully} exists for POSIX.
*/
function killWindowsTree(child: ChildProcess, signal: NodeJS.Signals): boolean {
const pid = child.pid
if (!pid) return false
const endHandle = () => { try { child.kill(signal) } catch { /* already gone */ } }
let killer: ChildProcess
try {
killer = spawn(taskkillPath(), taskkillArgs(pid), { stdio: 'ignore', windowsHide: true })
} catch {
endHandle()
return true
}
// A non-zero exit means taskkill found nothing to end, which is the state we were after
// anyway; the direct kill then costs nothing. So a taskkill that cannot run at all leaves
// exactly the behaviour there was before.
killer.on('error', endHandle)
killer.on('exit', code => { if (code !== 0) endHandle() })
return true
}
/** Signal the CLI and every subprocess it created. POSIX children are spawned as
* process-group leaders; Windows has no process group to signal, so the tree is walked
* by pid instead. */
function signalOwnedTree(child: ChildProcess, signal: NodeJS.Signals): boolean {
if (ownsProcessGroup() && child.pid) {
if (!ownsProcessGroup()) return killWindowsTree(child, signal)
if (child.pid) {
try { process.kill(-child.pid, signal); return true } catch { /* already gone or no group */ }
}
try { return child.kill(signal) } catch { return false }
@ -264,12 +331,78 @@ export function spawnEnvFor(bin: string): NodeJS.ProcessEnv {
* as the first argument. PATH is still augmented so anything the CLI itself
* shells out to (pairing, sync) resolves the same way an external CLI would.
*/
/** A script run by this process's own runtime: Electron's binary acting as Node. */
function nodeSpec(entry: string, args: string[]): SpawnSpec {
return {
bin: process.execPath,
args: [entry, ...args],
env: { ...spawnEnvFor(entry), ELECTRON_RUN_AS_NODE: '1' },
}
}
/** What a `codeburn` install looks like on Windows: an npm `.cmd` shim on PATH, or the
* repo's own `dist/cli.js` in dev. Neither is something `CreateProcess` can start. */
const NODE_SCRIPT = /\.[cm]?js$/i
const CMD_SCRIPT = /\.(?:cmd|bat)$/i
/** `cmd.exe` out of System32, never by bare name: Windows searches the current directory
* first, so anything dropped beside the app could answer for it. */
function comspecPath(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\\cmd.exe`
}
/**
* Quote one argument for a `cmd.exe /c` command line.
*
* Two parsers see this string, and each needs its own pass. The quotes and the
* backslash doubling are for the runtime that splits a command line back into argv;
* the `^` escapes are for cmd itself, which re-reads the line first and would
* otherwise act on a `&` or a `|` inside the quotes rather than pass it along. A
* `.cmd` shim re-expands its own arguments, so those meta characters are escaped
* twice: once for cmd reading this line, once for the shim reading what it gets.
*
* This is the rule `cross-spawn` applies, restated rather than depended on: the app's
* main process ships no npm dependencies (app/DISTRIBUTION.md).
*/
const CMD_META = /([()\][%!^"`<>&|;, *?])/g
export function escapeForCmd(arg: string, doubleEscape: boolean): string {
let escaped = String(arg)
// Any run of backslashes immediately before a quote is doubled, and the quote escaped.
.replace(/(\\*)"/g, '$1$1\\"')
// A trailing backslash run would otherwise escape the closing quote.
.replace(/(\\*)$/, '$1$1')
escaped = `"${escaped}"`.replace(CMD_META, '^$1')
return doubleEscape ? escaped.replace(CMD_META, '^$1') : escaped
}
/** The `cmd.exe /d /s /c "<line>"` argv for a `.cmd` or `.bat` CLI. Exported for tests:
* everything security-relevant about the shell route is in this one string. */
export function cmdShimArgs(bin: string, args: string[]): string[] {
const line = [escapeForCmd(bin, false), ...args.map(arg => escapeForCmd(arg, true))].join(' ')
// /d skips AutoRun commands from the registry, /s fixes how the quotes around the whole
// line are read, /c runs it and exits.
return ['/d', '/s', '/c', `"${line}"`]
}
export function spawnSpecFor(target: CliTarget, args: string[]): SpawnSpec {
if (target.kind === 'bundled') {
return {
bin: process.execPath,
args: [target.entry, ...args],
env: { ...spawnEnvFor(target.entry), ELECTRON_RUN_AS_NODE: '1' },
if (target.kind === 'bundled') return nodeSpec(target.entry, args)
// Windows has no shebang. `CreateProcess` starts executables, so a `.js` CLI (the repo
// build in dev) and a `.cmd` shim (what a global npm install puts on PATH) both fail
// outright with EFTYPE, which is why nothing the desktop app spawned ever ran here. Each
// is given a runner that can start it. A POSIX `codeburn` is a script with a shebang and
// an exec bit and still runs directly, exactly as before.
if (platform() === 'win32') {
if (NODE_SCRIPT.test(target.bin)) return nodeSpec(target.bin, args)
if (CMD_SCRIPT.test(target.bin)) {
return {
bin: comspecPath(),
args: cmdShimArgs(target.bin, args),
env: spawnEnvFor(target.bin),
verbatim: true,
}
}
}
return { bin: target.bin, args, env: spawnEnvFor(target.bin) }
@ -365,12 +498,24 @@ export function resolveTarget(): CliTarget | null {
const persisted = readPersistedPath()
if (persisted) return { kind: 'external', bin: persisted }
for (const bin of searchDirs().map(dir => join(dir, 'codeburn'))) {
for (const bin of searchDirs().flatMap(pathCandidates)) {
if (isExecutableFile(bin)) return { kind: 'external', bin }
}
return null
}
/**
* What a `codeburn` in one PATH directory can be called. A global npm install writes both
* `codeburn` (a shell script, useless here) and `codeburn.cmd` (the Windows shim) into the
* same directory, so the extensions have to come first or the lookup finds the one Windows
* cannot start. Elsewhere there is only ever the bare name.
*/
function pathCandidates(dir: string): string[] {
return platform() === 'win32'
? [join(dir, 'codeburn.cmd'), join(dir, 'codeburn.exe'), join(dir, 'codeburn.bat'), join(dir, 'codeburn')]
: [join(dir, 'codeburn')]
}
/** The resolved CLI's path for display/status, or null. See {@link resolveTarget}. */
export function resolveCodeburnPath(): string | null {
const target = resolveTarget()
@ -420,7 +565,7 @@ function withoutProgressLines(stderr: string): string {
function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: (chunk: string) => void): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env, detached: ownsProcessGroup() })
const child = spawn(spec.bin, spec.args, { stdio: ['ignore', 'pipe', 'pipe'], ...spawnOptionsFor(spec) })
activeChildren.add(child)
let stdout = ''
let stderr = ''
@ -574,7 +719,7 @@ class ServeClient {
start(): void {
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, detached: ownsProcessGroup() })
const child = spawn(this.spec.bin, [...this.spec.args], { stdio: ['pipe', 'pipe', 'ignore'], ...spawnOptionsFor(this.spec) })
this.child = child
if (this.pidFile && child.pid) {
const record = JSON.stringify({ pid: child.pid, cmd: [this.spec.bin, ...this.spec.args].join(' '), processGroup: ownsProcessGroup() })
@ -948,12 +1093,16 @@ export function spawnCli(
/** Spawn a config-mutating CLI command and return its text output verbatim.
* 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<ActionResult> {
export function spawnCliAction(args: string[], opts: { timeoutMs?: number; extraEnv?: NodeJS.ProcessEnv } = {}): Promise<ActionResult> {
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 })
const spec = spawnSpecFor(target, args)
// Actions never ride the resident `serve` child, so an extra variable simply joins the
// one-shot spawn's env. The bundled-menubar install is the only caller: it names the .msi
// the desktop app staged rather than putting a path on a command line.
if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv }
return (async () => {
try {
await acquireSlot('interactive')
@ -975,7 +1124,7 @@ export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}
// no long silent stretch for a watchdog to misread.
function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise<ActionResult> {
return new Promise<ActionResult>(resolve => {
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env, detached: ownsProcessGroup() })
const child = spawn(spec.bin, spec.args, { stdio: ['ignore', 'pipe', 'pipe'], ...spawnOptionsFor(spec) })
activeChildren.add(child)
let stdout = ''
let stderr = ''

View file

@ -14,7 +14,7 @@ vi.mock('electron', () => ({
shell: { openExternal: vi.fn() },
}))
import { createApplicationMenuTemplate, createBeforeQuitHandler, createBridgeHandlers } from './main'
import { createApplicationMenuTemplate, createBeforeQuitHandler, createBridgeHandlers, externalUrlToOpen } from './main'
import { CliError } from './cli'
import { Telemetry } from './telemetry'
@ -70,6 +70,13 @@ const CHANNELS = [
'codeburn:telemetryOnboarded',
'codeburn:telemetryTrack',
'codeburn:getUpdateStatus',
'codeburn:companionStatus',
'codeburn:setMenuBarEnabled',
'codeburn:setSidebarEnabled',
'codeburn:trayPrefs',
'codeburn:setTrayAppPref',
'codeburn:setTrayDockPref',
'codeburn:setLaunchAtLogin',
'codeburn:pluginList',
'codeburn:pluginInfo',
'codeburn:pluginAdd',
@ -754,3 +761,25 @@ describe('createBridgeHandlers (telemetry wiring)', () => {
expect(JSON.stringify(props)).not.toContain('C:\\')
})
})
// The renderer hands this a URL and the main process hands it to the shell, so the guard is
// what keeps a page from opening anything but the web. See app/electron/main.ts.
describe('externalUrlToOpen', () => {
it('allows the web and nothing else', () => {
expect(externalUrlToOpen('https://github.com/getagentseal/codeburn')).toBe('https://github.com/getagentseal/codeburn')
expect(externalUrlToOpen('http://localhost:5173/')).toBe('http://localhost:5173/')
expect(externalUrlToOpen('file:///etc/passwd')).toBeNull()
expect(externalUrlToOpen('javascript:alert(1)')).toBeNull()
expect(externalUrlToOpen('not a url')).toBeNull()
expect(externalUrlToOpen('')).toBeNull()
})
// The one exception, by exact value: the Store build's Menu bar pane opens the Windows page
// that owns launch at login there.
it('allows the Windows startup-apps page on Windows, by exact value', () => {
expect(externalUrlToOpen('ms-settings:startupapps', 'win32')).toBe('ms-settings:startupapps')
expect(externalUrlToOpen('ms-settings:startupapps', 'darwin')).toBeNull()
expect(externalUrlToOpen('ms-settings:privacy-webcam', 'win32')).toBeNull()
expect(externalUrlToOpen('ms-settings:startupapps&more', 'win32')).toBeNull()
})
})

View file

@ -2,6 +2,7 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type Men
import path from 'node:path'
import { CliError, DESKTOP_COLD_TIMEOUT_MS, PROGRESS_LINE_PREFIX, reapOrphanServe, resolveCodeburnPath, shutdownAll, spawnCli, spawnCliAction, startServe, type ActionResult, type SpawnPriority } from './cli'
import { MenubarCompanion, STARTUP_APPS_SETTINGS_URL, type CompanionStatus } from './menubar'
import { getQuota, sanitizeError } from './quota'
import { Telemetry } from './telemetry'
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
@ -10,6 +11,11 @@ import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './up
let telemetryInstance: Telemetry | null = null
// The once-per-launch + 24h update-availability checker. Null under tests.
let updateChecker: UpdateChecker | null = null
// The bundled tray app and its Capacity Dock (Windows only). Null under tests.
let companion: MenubarCompanion | null = null
/** What the sidebar switches read on a platform that has no tray app to bundle. */
export const NO_COMPANION: CompanionStatus = { supported: false, menuBar: false, sidebar: false, store: false }
/** The slice of Telemetry the bridge handlers use — injectable for tests. */
export type TelemetryBridge = Pick<Telemetry, 'status' | 'setEnabled' | 'completeOnboarding' | 'track'>
@ -231,6 +237,11 @@ type Deps = {
telemetry?: TelemetryBridge | null
/** Cached update-availability status; absent under tests unless injected. */
getUpdateStatus?: () => Promise<UpdateStatus>
/** The bundled tray app and Capacity Dock; absent off Windows and under tests. */
companion?: Pick<
MenubarCompanion,
'status' | 'setMenuBarEnabled' | 'setSidebarEnabled' | 'trayPrefs' | 'setTrayAppPref' | 'setTrayDockPref' | 'setLaunchAtLogin'
> | null
}
type Handler = (...args: any[]) => Promise<Envelope>
@ -240,7 +251,7 @@ type Handler = (...args: any[]) => Promise<Envelope>
* shell) and returns a result envelope. Pure + injectable so the wiring is
* unit-testable without launching Electron.
*/
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance, getUpdateStatus: () => updateChecker ? updateChecker.getStatus() : Promise.resolve(NO_UPDATE_STATUS) }): Record<string, Handler> {
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance, getUpdateStatus: () => updateChecker ? updateChecker.getStatus() : Promise.resolve(NO_UPDATE_STATUS), companion: companion }): Record<string, Handler> {
const emitProgress = deps.emitProgress ?? (() => {})
const telemetry = deps.telemetry ?? null
// Flips true after the first overview fetch succeeds. Until then, every
@ -427,6 +438,25 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// One-shot read of the cached update-availability status. The check itself
// runs in the background (launch + 24h); this returns whatever is known.
'codeburn:getUpdateStatus': async () => ({ ok: true, value: deps.getUpdateStatus ? await deps.getUpdateStatus() : NO_UPDATE_STATUS }),
// The bundled tray app and its Capacity Dock (Windows). Every setter answers with the
// whole status, so the sidebar renders the state that actually took rather than the one
// it asked for: an install that was cancelled leaves the switch where it was.
'codeburn:companionStatus': async () => ({ ok: true, value: deps.companion ? deps.companion.status() : NO_COMPANION }),
'codeburn:setMenuBarEnabled': async (enabled?: boolean) =>
({ ok: true, value: deps.companion ? await deps.companion.setMenuBarEnabled(Boolean(enabled)) : NO_COMPANION }),
'codeburn:setSidebarEnabled': async (enabled?: boolean) =>
({ ok: true, value: deps.companion ? await deps.companion.setSidebarEnabled(Boolean(enabled)) : NO_COMPANION }),
// The tray app's own settings, which live in the files it reads them from. Every setter
// answers with the whole set, so the panes render what landed rather than what was sent.
'codeburn:trayPrefs': async () => ({ ok: true, value: deps.companion ? await deps.companion.trayPrefs() : null }),
// A patch is whatever came over the channel, so it is typed as that and checked where it
// is read (tray-settings.ts, isPatchObject) rather than asserted into a shape here.
'codeburn:setTrayAppPref': async (patch?: unknown) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayAppPref(patch) : null }),
'codeburn:setTrayDockPref': async (patch?: unknown) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayDockPref(patch) : null }),
'codeburn:setLaunchAtLogin': async (enabled?: boolean) =>
({ ok: true, value: deps.companion ? await deps.companion.setLaunchAtLogin(Boolean(enabled)) : null }),
// Plugin management reads (all return parsed JSON)
'codeburn:pluginList': run(() => ['plugin', 'list', '--json']),
'codeburn:pluginInfo': run((name: string) => ['plugin', 'info', vToken(name), '--json']),
@ -467,14 +497,26 @@ function registerHandlers(): void {
return { ok: true, value: res.canceled ? null : (res.filePaths[0] ?? null) }
})
ipcMain.handle('open-external', (_event, url: string) => {
try {
const { protocol } = new URL(url)
if (protocol === 'https:' || protocol === 'http:') return shell.openExternal(url)
} catch { /* malformed URL — refuse to open */ }
return
const allowed = externalUrlToOpen(url)
return allowed === null ? undefined : shell.openExternal(allowed)
})
}
/**
* What the renderer may hand the shell, or null for anything else. The web is http(s) only.
* The one exception is the Windows Settings page for startup apps, allowed by exact value:
* on the Store route launch at login is the package's own startup task, so the tray pane
* points at that page instead of offering a switch it cannot move (app/electron/menubar.ts).
*/
export function externalUrlToOpen(url: string, platform: string = process.platform): string | null {
if (platform === 'win32' && url === STARTUP_APPS_SETTINGS_URL) return url
try {
const { protocol } = new URL(url)
if (protocol === 'https:' || protocol === 'http:') return url
} catch { /* malformed URL, refuse to open */ }
return null
}
export function createApplicationMenuTemplate(isDev = Boolean(process.env.VITE_DEV_SERVER_URL)): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = []
@ -651,6 +693,18 @@ function bootstrap(): void {
} catch (err) {
console.error('telemetry init failed (continuing without):', err)
}
// The tray app and the Capacity Dock the desktop app carries on Windows. Constructed
// before the handlers so the sidebar's switches have something to read, and installed in
// the background so a `/passive` msiexec run never holds the first window back.
companion = new MenubarCompanion({
resourcesPath: app.isPackaged ? process.resourcesPath : null,
stateDir: app.getPath('userData'),
// Electron sets this in an installed AppX package, which is the Store route.
store: (process as NodeJS.Process & { windowsStore?: boolean }).windowsStore === true,
platform: process.platform,
env: process.env,
})
void companion.bootstrap().catch(err => console.error('menubar bootstrap failed:', err))
registerHandlers()
installApplicationMenu()
createWindow()

1141
app/electron/menubar.test.ts Normal file

File diff suppressed because it is too large Load diff

939
app/electron/menubar.ts Normal file
View file

@ -0,0 +1,939 @@
// The tray app and the Capacity Dock, as the desktop app owns them on Windows.
//
// The desktop app ships the matching `windows-v*` build of the Tauri tray app
// (windows/) and puts it on the machine itself, so nobody has to know that
// `codeburn menubar` exists. Two switches in the sidebar turn each surface off:
// "Menu bar" is the tray process, "Sidebar" is the dock rail the tray app
// draws. Both default on.
//
// Two routes, and they share almost nothing:
//
// NSIS the staged `CodeBurn.Menubar_<version>_x64_en-US.msi` is installed
// through the CLI's own installer (src/menubar-installer.ts, reached
// with `codeburn menubar --staged-msi <path>`). The MSI carries a fixed
// WiX upgrade code, so a manual `codeburn menubar` install is upgraded
// in place rather than duplicated, and a newer one is left alone.
// Launch at login is an HKCU Run value.
//
// AppX msiexec is not available to a packaged app and would fight the
// package manager anyway. The tray exe ships inside the package and
// is launched from there; launch at login is the manifest's
// windows.startupTask, so no Run value is written.
//
// The tray app is reached through its own argv: a second launch hands the
// running instance its arguments (windows/src-tauri/src/lib.rs), so `--quit`
// stops it and `--reload-settings` makes it re-read the dock preferences this
// module writes.
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { homedir, uptime } from 'node:os'
import { join, win32 } from 'node:path'
import { spawnCliAction, type ActionResult } from './cli'
import {
DEFAULT_TRAY_APP_PREFS, DEFAULT_TRAY_DOCK_PREFS,
isPatchObject, normalizedPreferred, parseTrayAppPrefs, parseTrayDockPrefs, patchTrayFile,
readTrayFile, sanitizeAppPatch, sanitizeDockPatch, writeFileAtomic,
type TrayAppPrefs, type TrayDockPrefs,
} from './tray-settings'
/** How the staged file is named to the CLI's Windows installer: an option on the command
* rather than an environment variable, so nothing a stray variable in the environment names
* ever reaches msiexec. The source of truth is src/menubar-installer.ts, which rejects the
* variable this flag replaced. */
export const STAGED_MSI_FLAG = '--staged-msi'
/** One machine-readable line the same installer prints, so this side need not read its prose. */
export const BUNDLED_RESULT_PREFIX = 'CODEBURN_MENUBAR_RESULT '
/** The one line the installer prints for msiexec exit 3010, which its machine-readable result
* does not carry a field for. A deferred file replacement leaves the old binary in place, so
* this is read from the prose until the CLI reports it in {@link MenubarInstallResult}. */
const REBOOT_NOTICE = /Windows wants a restart to finish the install/i
/** The release asset name, which is also the only version statement that cannot lie. */
const MSI_PATTERN = /^CodeBurn\.Menubar_(.+)_x64_en-US\.msi$/
/** What the Tauri bundle names its executable. */
const TRAY_EXE = 'codeburn-menubar.exe'
/** The tray app's own Run value name, so the two never write a second copy of each other's. */
const RUN_VALUE = 'CodeBurn'
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
/** A `/passive` msiexec run waits on a UAC prompt, which is a person, not a process. */
const INSTALL_TIMEOUT_MS = 5 * 60_000
/** The env var the tray app honours before anything else it looks at (windows/src-tauri/src/cli.rs). */
export const TRAY_CLI_ENV = 'CODEBURN_BIN'
/** Where the desktop app records the launcher in the tray app's own settings file, so an
* autostarted tray app finds it with no desktop app running to tell it. */
export const TRAY_CLI_PATH_KEY = 'desktopCliPath'
/** Where main.ts points at the CLI copy shipped inside a packaged build. */
const BUNDLED_CLI_ENV = 'CODEBURN_BUNDLED_CLI'
/** The tray app's own data directory, which already holds its install marker. */
const COMPANION_DIR = 'codeburn-menubar'
/** A `.cmd`, because that is the one kind of script the tray app can start directly. */
const CLI_LAUNCHER_NAME = 'codeburn-cli.cmd'
/** How long a launch that follows a `--quit` waits for the old process to be gone. */
const QUIT_SETTLE_MS = 4_000
/** Mirrors BundledInstallResult in src/menubar-installer.ts. */
export type MenubarInstallResult = {
action: 'installed' | 'up-to-date' | 'kept-newer' | 'cancelled'
bundledVersion: string
previousVersion: string | null
exePath: string
uninstallString: string | null
installedBy: 'desktop' | 'manual' | null
/** Whether msiexec exited 3010 and deferred the file replacement to the next restart. The
* CLI does not report this field today, so it is read from the installer's own notice
* line instead; when the CLI starts sending it, this is what is believed. */
rebootRequired?: boolean
}
/** The desktop app's own record of the two switches, in its userData directory. */
export type CompanionSettings = {
menuBar: boolean
sidebar: boolean
/** Where the tray app ended up, so starting and stopping it costs no registry read. */
trayExePath: string | null
/** The tray app version the last install reported at `trayExePath`. A launch whose staged
* .msi carries that same version has nothing to install and nothing to ask, so it skips
* the probe entirely rather than spawning a CLI to be told so. */
trayExeVersion: string | null
/** The staged .msi version whose install was declined or failed. The same file put in
* front of the same person at the next launch is the same admin prompt, and answering it
* is their decision rather than something to re-ask until they give in. Cleared when a
* newer version is staged, or when they turn Menu bar on themselves. */
installDeclinedVersion: string | null
/** When an install left Windows a file replacement it can only finish at the next restart,
* as an ISO timestamp. Saved rather than kept in memory: restarting the desktop app is not
* the restart being waited for, and forgetting it there would start the old tray app under
* the new version name, which is what the flag exists to prevent. Cleared once the machine
* has booted since that moment. */
restartRequiredSince: string | null
/** False until the first launch has applied the defaults, which is what keeps a later
* manual "dock off" from being seeded back on at every launch. */
seeded: boolean
}
export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = {
menuBar: true,
sidebar: true,
trayExePath: null,
trayExeVersion: null,
installDeclinedVersion: null,
restartRequiredSince: null,
seeded: false,
}
export type MenubarDeps = {
/** Where the packaged app keeps its extraResources, or null in a build that has none. */
resourcesPath: string | null
/** The desktop app's userData directory, which holds the companion settings file. */
stateDir: string
/** True inside an AppX package: no msiexec, no Run value, the exe ships with the package. */
store: boolean
platform: string
env: NodeJS.ProcessEnv
/** The desktop app's own executable, which is also the Node that runs the bundled CLI. */
execPath?: string
/** Injected so tests never spawn: the CLI action, a detached launch, and a `reg.exe` run. */
runCli?: (args: string[], opts: { timeoutMs?: number; extraEnv?: NodeJS.ProcessEnv }) => Promise<ActionResult>
launch?: (exePath: string, args: string[], extraEnv?: NodeJS.ProcessEnv) => void
runReg?: (args: string[]) => Promise<void>
/** The tray app's existing Run value, or null when there is none. */
readRunKey?: () => Promise<string | null>
/** Whether the tray app has a process right now. Injected so tests never spawn. */
isRunning?: () => Promise<boolean>
/** Whether a path is on disk. Injected so the stale-path handling is testable. */
exists?: (path: string) => boolean
home?: string
/** When Windows last started, in epoch milliseconds. Injected by tests so the pending
* restart boundary can be crossed without rebooting. */
bootedAtMs?: () => number
}
// Where things are ----------------------------------------------------------------------------
/** The staged tray app: `resources/menubar` in a packaged build, `build/menubar` in dev. */
export function menubarResourcesDir(deps: Pick<MenubarDeps, 'resourcesPath'>): string {
return deps.resourcesPath
? join(deps.resourcesPath, 'menubar')
: join(__dirname, '..', '..', 'build', 'menubar')
}
/** The staged .msi and the version its name declares, or null when nothing was staged. */
export function findStagedMsi(dir: string): { path: string; version: string } | null {
let names: string[]
try { names = readdirSync(dir) } catch { return null }
for (const name of names) {
const version = MSI_PATTERN.exec(name)?.[1]
if (version) return { path: join(dir, name), version }
}
return null
}
/** The tray executable shipped inside an AppX package, or null when it is not there. */
export function findPackagedTrayExe(dir: string): string | null {
const exe = join(dir, TRAY_EXE)
return existsSync(exe) ? exe : null
}
/** `~/.config/codeburn/windows-dock.json`, which is where the tray app's dock reads its
* preferences from before its page exists (windows/src-tauri/src/dock.rs). */
export function dockPrefsPath(home = homedir()): string {
return join(home, '.config', 'codeburn', 'windows-dock.json')
}
function companionSettingsPath(stateDir: string): string {
return join(stateDir, 'companion.v1.json')
}
// The CLI the tray app runs -----------------------------------------------------------------
// The tray app gets everything it shows by spawning `codeburn`, which it looks for on PATH
// and in the usual npm, pnpm, Volta, fnm, scoop and bun locations. Someone who installed only
// the desktop app never ran `npm install -g codeburn`, so there is nothing in any of them, and
// the tray app they were just given has no data to show.
//
// The desktop app has a CLI: the version-matched copy under `resources/cli`, which it runs
// through its own executable with `ELECTRON_RUN_AS_NODE=1` (app/electron/cli.ts). That is not
// something the tray app could spawn on its own, so this side writes a one-line `.cmd` that
// does it and forwards every argument, and records where it put it.
//
// A file rather than an environment variable because the tray app is also started at login, by
// a Run value or the package's startup task, with no desktop app anywhere to hand it an
// environment. The recorded path is consulted last (windows/src-tauri/src/cli.rs), so a real
// global install still wins.
/** `%LOCALAPPDATA%\codeburn-menubar`, the directory the tray app's install marker is in. */
export function companionDataDir(env: NodeJS.ProcessEnv, home: string): string {
const local = env.LOCALAPPDATA
const base = local && win32.isAbsolute(local) ? local.replace(/[\\/]+$/, '') : join(home, 'AppData', 'Local')
return join(base, COMPANION_DIR)
}
export function cliLauncherPath(env: NodeJS.ProcessEnv, home: string): string {
return join(companionDataDir(env, home), CLI_LAUNCHER_NAME)
}
/**
* Anything `cmd.exe` would read rather than pass along. Both paths come from the app's own
* install location, so this never fires in practice; it is here because the alternative to
* failing is writing a batch file that means something other than what it says.
*/
const CMD_UNQUOTABLE = /["%\r\n]/
/** The launcher's contents. `%*` forwards the arguments verbatim, stdio is inherited, and
* the CLI's exit code becomes the launcher's. */
export function cliLauncherScript(execPath: string, entry: string): string | null {
if (!execPath || !entry || CMD_UNQUOTABLE.test(execPath) || CMD_UNQUOTABLE.test(entry)) return null
return [
'@echo off',
'rem Written by the CodeBurn desktop app, and rewritten at every one of its launches.',
'rem It runs the CLI the desktop app carries, through the desktop app\'s own executable.',
'setlocal',
'set "ELECTRON_RUN_AS_NODE=1"',
`"${execPath}" "${entry}" %*`,
'exit /b %ERRORLEVEL%',
'',
].join('\r\n')
}
/**
* Write the launcher and hand back its path, or null when there is nothing to point it at:
* off Windows, or in a dev build, where `CODEBURN_BUNDLED_CLI` is unset because the repo's
* own CLI is used instead and is already on the machine.
*/
export function writeCliLauncher(opts: {
platform: string
env: NodeJS.ProcessEnv
home: string
execPath: string
exists?: (path: string) => boolean
}): string | null {
if (opts.platform !== 'win32') return null
const entry = opts.env[BUNDLED_CLI_ENV]
const exists = opts.exists ?? existsSync
if (!entry || !exists(entry)) return null
const script = cliLauncherScript(opts.execPath, entry)
if (!script) return null
const path = cliLauncherPath(opts.env, opts.home)
try {
// Rewritten at every launch while an autostarted tray app may be reading it.
writeFileAtomic(path, script)
return path
} catch (err) {
console.error('the tray app could not be given a CLI to run:', err)
return null
}
}
// Settings -------------------------------------------------------------------------------------
export function readCompanionSettings(stateDir: string): CompanionSettings {
try {
// An unreadable file falls back to the defaults, which means seeding again: reinstalling
// the tray app and re-enabling the rail. A byte order mark is not a good enough reason
// for that, and anything that edits this file by hand on Windows tends to leave one.
const text = readFileSync(companionSettingsPath(stateDir), 'utf8').replace(/^/, '')
const raw = JSON.parse(text) as Partial<CompanionSettings>
return {
menuBar: raw.menuBar ?? DEFAULT_COMPANION_SETTINGS.menuBar,
sidebar: raw.sidebar ?? DEFAULT_COMPANION_SETTINGS.sidebar,
trayExePath: typeof raw.trayExePath === 'string' ? raw.trayExePath : null,
trayExeVersion: typeof raw.trayExeVersion === 'string' ? raw.trayExeVersion : null,
installDeclinedVersion: typeof raw.installDeclinedVersion === 'string' ? raw.installDeclinedVersion : null,
restartRequiredSince: typeof raw.restartRequiredSince === 'string' ? raw.restartRequiredSince : null,
seeded: raw.seeded === true,
}
} catch {
return { ...DEFAULT_COMPANION_SETTINGS }
}
}
export function writeCompanionSettings(stateDir: string, settings: CompanionSettings): void {
try {
writeFileAtomic(companionSettingsPath(stateDir), `${JSON.stringify(settings, null, 2)}\n`)
} catch (err) {
console.error('companion settings could not be saved:', err)
}
}
/** Whether the tray app already has an opinion about the rail, or undefined when nobody has
* said. The difference matters exactly once: a default is for a machine that has made no
* choice, never an override of one already made. */
export function readDockEnabled(home = homedir()): boolean | undefined {
try {
const parsed: unknown = JSON.parse(readFileSync(dockPrefsPath(home), 'utf8'))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
const enabled = (parsed as Record<string, unknown>).enabled
return typeof enabled === 'boolean' ? enabled : undefined
} catch {
return undefined
}
}
/** Read, change one key, write back. The tray app owns every other key in this file (the
* rail's placement, size and provider set), so the whole object is preserved. */
export function writeDockEnabled(enabled: boolean, home = homedir()): void {
const path = dockPrefsPath(home)
let prefs: Record<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) prefs = parsed as Record<string, unknown>
} catch { /* a missing or unreadable file simply starts from nothing */ }
prefs.enabled = enabled
writeFileAtomic(path, `${JSON.stringify(prefs, null, 2)}\n`)
}
// Talking to the tray app ----------------------------------------------------------------------
/** The CLI's install path prints one JSON line; everything else it says is for a person. */
export function parseInstallResult(stdout: string): MenubarInstallResult | null {
for (const line of stdout.split(/\r?\n/)) {
if (!line.startsWith(BUNDLED_RESULT_PREFIX)) continue
try { return JSON.parse(line.slice(BUNDLED_RESULT_PREFIX.length)) as MenubarInstallResult } catch { return null }
}
return null
}
/** `reg.exe` argv for the tray app's launch-at-login value. The tray app's own toggle writes
* the same value name (windows/src-tauri/src/autostart.rs), so the two agree by construction
* instead of leaving two entries behind. */
export function runKeyArgs(enabled: boolean, exePath: string): string[] {
return enabled
? ['add', RUN_KEY, '/v', RUN_VALUE, '/t', 'REG_SZ', '/d', `"${exePath}"`, '/f']
: ['delete', RUN_KEY, '/v', RUN_VALUE, '/f']
}
/** `reg query <key> /v CodeBurn` prints the value on its own indented line. Null means the
* value is not there, which is the only thing the caller acts on. */
export function parseRunKeyValue(regOutput: string): string | null {
const match = new RegExp(`^\\s+${RUN_VALUE}\\s{4}REG_\\w+\\s{4}(.*)$`, 'm').exec(regOutput)
const value = match?.[1]?.trim()
return value ? value : null
}
/** The executable a Run value points at: it is stored quoted, and only the file matters. */
export function runKeyTarget(value: string): string {
return value.trim().replace(/^"(.*)"$/, '$1')
}
/** Windows searches the current directory before PATH, so `reg` by bare name lets anything
* dropped beside the app impersonate it. Same rule as src/menubar-installer.ts. */
export function system32Path(exe: string, env: NodeJS.ProcessEnv): string {
const root = env.SystemRoot
const base = root && /^[a-zA-Z]:[\\/]/.test(root) ? root.replace(/[\\/]+$/, '') : 'C:\\Windows'
return `${base}\\System32\\${exe}`
}
function detachedLaunch(exePath: string, args: string[], extraEnv?: NodeJS.ProcessEnv): void {
try {
const child = spawn(exePath, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}),
})
child.on('error', err => console.error(`could not launch ${exePath}: ${err.message}`))
child.unref()
} catch (err) {
console.error(`could not launch ${exePath}:`, err)
}
}
function regRun(args: string[], env: NodeJS.ProcessEnv): Promise<void> {
return new Promise(resolve => {
const child = spawn(system32Path('reg.exe', env), args, { stdio: 'ignore', windowsHide: true })
// Deleting a value that is not there is the state we wanted anyway, so no exit code is
// read: a failure here must never stop a toggle the person just flipped.
child.on('error', () => resolve())
child.on('close', () => resolve())
})
}
/** `tasklist` filtered to the tray exe prints its name when it is running and a "No tasks"
* line when it is not. Anything going wrong reads as not running, which only shortens a wait. */
function trayHasProcess(env: NodeJS.ProcessEnv): Promise<boolean> {
return new Promise(resolve => {
try {
const child = spawn(system32Path('tasklist.exe', env), ['/FI', `IMAGENAME eq ${TRAY_EXE}`, '/NH'], {
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
let out = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', chunk => { out += chunk })
child.on('error', () => resolve(false))
child.on('close', () => resolve(out.toLowerCase().includes(TRAY_EXE)))
} catch {
resolve(false)
}
})
}
function regQueryRunValue(env: NodeJS.ProcessEnv): Promise<string | null> {
return new Promise(resolve => {
const child = spawn(system32Path('reg.exe', env), ['query', RUN_KEY, '/v', RUN_VALUE], {
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
let out = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', chunk => { out += chunk })
child.on('error', () => resolve(null))
// reg exits non-zero for a value that is not there, which is the answer rather than a
// failure; anything else that goes wrong reads the same way, and the only cost of
// guessing "absent" is one write of a value that already said the same thing.
child.on('close', code => resolve(code === 0 ? parseRunKeyValue(out) : null))
})
}
// The controller ---------------------------------------------------------------------------------
/** What the two tray panes in the desktop app's Settings render from. */
export type TrayPrefs = {
app: TrayAppPrefs
dock: TrayDockPrefs
launchAtLogin: boolean
/**
* True where launch at login is not this app's to set. The Store package declares it as a
* `windows.startupTask` (app/build/appx-extensions.xml) and Windows owns both the state and
* the switch, in Settings > Apps > Startup. Reaching a startup task from here would take
* the WinRT `Windows.ApplicationModel.StartupTask` API, which Electron has no binding for
* and which this app ships no native code to reach; and a Run value written beside the
* startup task would start the tray app twice. So the pane names the owner and points at
* that page rather than offering a switch that moves nothing.
*/
launchAtLoginManaged: boolean
}
/** The Windows Settings page listing every startup app, the Store package's own included.
* Named here because the desktop app's external-open guard allows it by exact value. */
export const STARTUP_APPS_SETTINGS_URL = 'ms-settings:startupapps'
/**
* What the four tray-preference methods answer where there is no tray app. Every one of them
* reads or writes `~/.config/codeburn/windows-*.json` or `reg.exe`, all of which belong to a
* Windows tray app that a macOS or Linux build has never installed; their IPC handlers are
* registered on every platform, so the guard has to be here rather than at the call site.
* The defaults are what the same files parse to when they are not there, so a pane rendering
* this shows the same thing it would on a Windows machine with nothing written yet.
*/
export function unsupportedTrayPrefs(): TrayPrefs {
return {
app: { ...DEFAULT_TRAY_APP_PREFS },
dock: { ...DEFAULT_TRAY_DOCK_PREFS },
launchAtLogin: false,
launchAtLoginManaged: false,
}
}
export type CompanionStatus = {
/** False on every platform but Windows, and on a Windows build with nothing staged. */
supported: boolean
menuBar: boolean
sidebar: boolean
/** The Store route, where launch at login is the package's own startup task. */
store: boolean
/** True when this run installed a tray app that Windows can only finish putting in place
* at the next restart. Nothing is started until then, so the switches say why rather than
* looking on with nothing running. Absent on a preload that predates it. */
restartRequired?: boolean
}
export class MenubarCompanion {
private settings: CompanionSettings
/** When `--quit` was last sent, so a launch right after it can wait for the exit. */
private quitAskedAt = 0
/** Set by an install msiexec could only half apply; see {@link MenubarCompanion.launchTray}.
* Read from the saved timestamp rather than a field, so quitting the desktop app and
* opening it again is not mistaken for the Windows restart that actually finishes the
* install. It clears itself once the machine has booted since the install. */
private get restartRequired(): boolean {
const since = this.settings.restartRequiredSince
if (!since) return false
const at = Date.parse(since)
// An unreadable timestamp counts as still pending: not launching is the safe side, since
// the worst it costs is a tray app that starts one session late.
if (Number.isNaN(at)) return true
if (this.bootedAtMs() > at) {
this.save({ restartRequiredSince: null })
return false
}
return true
}
/** When Windows last started, which is what tells a finished file replacement from one
* still pending. Injectable so the boundary is testable without rebooting. */
private bootedAtMs(): number {
return this.deps.bootedAtMs?.() ?? Date.now() - uptime() * 1000
}
/** The launcher written for the tray app this run, or null when there is no bundled CLI
* to point one at. Handed to every tray app this side starts. */
private cliLauncher: string | null = null
constructor(private readonly deps: MenubarDeps) {
this.settings = readCompanionSettings(deps.stateDir)
}
private get home(): string { return this.deps.home ?? homedir() }
private get launch(): (exe: string, args: string[], extraEnv?: NodeJS.ProcessEnv) => void {
return this.deps.launch ?? detachedLaunch
}
/**
* What a tray app started from here inherits. The recorded path in the settings file is
* what an autostarted tray app reads, and it is read after PATH; this makes the very first
* launch work too, before the tray app has read anything at all.
*/
private launchEnv(): NodeJS.ProcessEnv | undefined {
return this.cliLauncher ? { [TRAY_CLI_ENV]: this.cliLauncher } : undefined
}
/**
* Start the tray app, or say nothing at all when a restart is owed.
*
* An install that could not replace a running binary is finished by Windows at the next
* restart, and until then the file at that path is the previous tray app. Starting it would
* put the old one in the notification area under the new version's name, which is the exact
* confusion the install path takes trouble to avoid elsewhere. Stopping one is still fine,
* so `--quit` does not come through here.
*/
private launchTray(exePath: string): void {
if (this.restartRequired) return
this.launch(exePath, ['--reload-settings'], this.launchEnv())
}
/**
* Rewrite the launcher and record where it is. Runs at every bootstrap, so a desktop app
* that has been updated points the tray app at its new copy of the CLI rather than at the
* one the previous version installed.
*/
private refreshCliLauncher(): void {
this.cliLauncher = writeCliLauncher({
platform: this.deps.platform,
env: this.deps.env,
home: this.home,
execPath: this.deps.execPath ?? process.execPath,
exists: this.exists,
})
if (!this.cliLauncher) return
try {
patchTrayFile('app', { [TRAY_CLI_PATH_KEY]: this.cliLauncher }, this.home)
} catch (err) {
console.error('the tray app could not be told where its CLI is:', err)
}
}
private get isRunning(): () => Promise<boolean> {
return this.deps.isRunning ?? (() => trayHasProcess(this.deps.env))
}
/**
* A `--quit` takes the tray app a moment to act on, and a launch that arrives while the old
* process is still on its way out hands its request to that process (its single-instance
* window is still there) and exits with it, leaving the switch on and nothing running. So a
* launch that follows a quit waits for the process to be gone first, within reason.
*/
private async awaitQuit(): Promise<void> {
if (Date.now() - this.quitAskedAt > QUIT_SETTLE_MS) return
const deadline = Date.now() + QUIT_SETTLE_MS
while (Date.now() < deadline && await this.isRunning()) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
private get runCli() { return this.deps.runCli ?? spawnCliAction }
private runReg(args: string[]): Promise<void> {
return this.deps.runReg ? this.deps.runReg(args) : regRun(args, this.deps.env)
}
/** Windows only, and only where a tray app was actually staged into the build. */
supported(): boolean {
if (this.deps.platform !== 'win32') return false
const dir = menubarResourcesDir(this.deps)
return this.deps.store ? findPackagedTrayExe(dir) !== null : findStagedMsi(dir) !== null
}
status(): CompanionStatus {
return {
supported: this.supported(),
menuBar: this.settings.menuBar,
sidebar: this.settings.sidebar,
store: this.deps.store,
restartRequired: this.restartRequired,
}
}
private save(patch: Partial<CompanionSettings>): void {
this.settings = { ...this.settings, ...patch }
writeCompanionSettings(this.deps.stateDir, this.settings)
}
/**
* Put the tray app on the machine and bring both surfaces in line with the switches. Runs
* at every launch, which is also what covers a desktop-app update: the staged .msi moves
* with the app, and the installer decides for itself whether it is newer than what is
* installed. The install itself is not re-run at every launch; see
* {@link MenubarCompanion.installIfNeeded}.
*/
async bootstrap(): Promise<void> {
if (!this.supported()) return
// Before anything else, and whether or not the switch is on: a tray app started at the
// next login has to find a CLI without this process being there to tell it.
this.refreshCliLauncher()
// The defaults are seeded once, and only onto a machine that has made no choice. A tray
// app someone installed by hand has preferences of its own, and a default that overrode
// them would be this app deciding something the person already decided.
const firstRun = !this.settings.seeded
const existingDock = firstRun ? readDockEnabled(this.home) : undefined
if (firstRun) {
// An existing rail preference is mirrored into the switch rather than overwritten, so
// the corner opens saying what the rail is actually doing.
this.save({ seeded: true, ...(existingDock === undefined ? {} : { sidebar: existingDock }) })
}
if (!this.settings.menuBar) return
const exePath = this.deps.store
? findPackagedTrayExe(menubarResourcesDir(this.deps))
: await this.installIfNeeded()
if (!exePath) return
if (!this.exists(exePath)) {
// Storing a path to nothing is what made the switches send arguments into the void.
console.error(`the tray app was reported at ${exePath}, which is not there`)
return
}
this.save({ trayExePath: exePath })
if (firstRun && existingDock === undefined && this.settings.sidebar) this.applyDockSetting(true)
// A tray app this launch just put on the machine has no Run value yet: the previous
// one's uninstall took it, and that was the installer's doing, not the person's choice.
await this.reconcileRunKey(firstRun || this.placedTray)
this.placedTray = false
this.launchTray(exePath)
}
/** True between an install that placed a tray app and the seeding that follows it. */
private placedTray = false
/**
* Launch at login, without overriding a choice. An existing Run value was written by the
* tray app's own toggle or by an earlier launch of this one, so it is left alone; only a
* first run, or a tray app freshly installed by this launch, seeds one. The exception is a
* value pointing at a file that is not there, which is not a preference but the old wrong
* path, and would fail silently at every login.
*/
private async reconcileRunKey(seed: boolean): Promise<void> {
const existing = await this.existingRunKey()
if (existing === null) {
if (seed) await this.setRunKey(true)
return
}
if (!this.exists(runKeyTarget(existing))) await this.setRunKey(true)
}
private existingRunKey(): Promise<string | null> {
if (this.deps.readRunKey) return this.deps.readRunKey()
// The Store route has no Run value at all, so there is nothing to ask about.
return this.deps.store ? Promise.resolve(null) : regQueryRunValue(this.deps.env)
}
private get exists(): (path: string) => boolean {
return this.deps.exists ?? existsSync
}
/**
* Where the tray app actually is, checked rather than remembered. A stored path can be
* wrong: written by a build that derived the binary name from the product name, or left
* behind by a tray app that has since been uninstalled. Re-resolving costs one
* `codeburn menubar`, which re-reads the registry and installs nothing when the version
* already matches.
*/
private async resolveTrayExe(): Promise<string | null> {
if (this.deps.store) return findPackagedTrayExe(menubarResourcesDir(this.deps))
const stored = this.settings.trayExePath
if (stored && this.exists(stored)) return stored
const resolved = await this.installIfNeeded()
return resolved && this.exists(resolved) ? resolved : null
}
/**
* The install, minus the launches that have nothing to install.
*
* `codeburn menubar` is not free and it is not quiet: a real install runs msiexec
* `/passive`, which puts an admin prompt in front of the person. Two launches are skipped
* outright:
*
* - the tray app recorded on this machine is already the version this build stages, and
* it is still on disk, so the CLI would read the registry and answer "up to date";
* - this exact staged version was already declined or failed. Putting the same prompt up
* again at every launch is asking the same question until the answer changes, and the
* first answer was theirs to give.
*
* A newer staged version is a different question and is always asked. So is the Menu bar
* switch going on by hand, which clears the record before it gets here.
*/
private async installIfNeeded(): Promise<string | null> {
const staged = findStagedMsi(menubarResourcesDir(this.deps))
if (!staged) return null
const stored = this.settings.trayExePath
if (stored && this.settings.trayExeVersion === staged.version && this.exists(stored)) return stored
if (this.settings.installDeclinedVersion === staged.version) return stored
return this.install(staged)
}
/** The install itself belongs to the CLI: it owns the registry read, the checksum, the
* msiexec call and the never-downgrade rule, and this side only stages the file and
* records what came back. */
private async install(staged: { path: string; version: string }): Promise<string | null> {
const result = await this.runCli(['menubar', STAGED_MSI_FLAG, staged.path], {
timeoutMs: INSTALL_TIMEOUT_MS,
})
const parsed = parseInstallResult(result.stdout)
if (!parsed) {
console.error(`menubar install did not report a result: ${result.stderr.trim() || `exit ${String(result.code)}`}`)
// Not remembered as a refusal. Nothing was put in front of anyone here: the spawn timed
// out, the CLI was not there, or it died before it could say anything, and all three are
// the kind of thing that comes right on its own. Writing the version down would turn one
// bad launch into a tray app that is never installed again.
// A failed install must not orphan a working tray app that was already there.
return this.settings.trayExePath
}
if (parsed.action === 'cancelled') {
// This one is a decision, and it was theirs. The same file put in front of the same
// person at the next launch is the same admin prompt, so it is not offered again until
// a newer version is staged or they turn the switch on themselves.
this.save({ installDeclinedVersion: staged.version })
return null
}
// Windows Installer does not fail on a file that is in use: it installs what it can and
// leaves the rest to the next restart. The uninstall registry then reports the new
// version while the binary on disk is still the old one, so launching it would start the
// old tray app under the new version's name. Nothing is launched until the restart.
if (parsed.rebootRequired === true || REBOOT_NOTICE.test(result.stdout)) {
this.save({ restartRequiredSince: new Date().toISOString() })
console.log('CodeBurn Menubar was installed; Windows needs a restart to finish it.')
}
const exePath = parsed.exePath || this.settings.trayExePath
this.placedTray = parsed.action === 'installed'
this.save({
installDeclinedVersion: null,
// What is actually on disk now, which is the staged version except under
// 'kept-newer', where a newer tray app was left alone.
trayExeVersion: exePath ? (parsed.action === 'kept-newer' ? parsed.previousVersion : staged.version) : null,
})
return exePath
}
/**
* Menu bar off stops the process and drops the Run value; on installs if it has to, then
* starts it again.
*
* The rail is a window of the tray app, so it cannot outlive it: turning Menu bar off turns
* Sidebar off with it. The preference is written before the process is asked to quit, so
* what the tray app finds the next time it starts is the state the switches are showing,
* rather than a rail that comes back on its own.
*/
async setMenuBarEnabled(enabled: boolean): Promise<CompanionStatus> {
this.save({ menuBar: enabled })
if (!this.supported()) return this.status()
if (!enabled) {
this.save({ sidebar: false })
this.applyDockSetting(false)
await this.setRunKey(false)
const exePath = this.settings.trayExePath
if (exePath && this.exists(exePath)) {
this.launch(exePath, ['--quit'])
this.quitAskedAt = Date.now()
}
return this.status()
}
// Turning the switch on by hand is the retry: an install that was declined or failed is
// offered again, because this time they asked for it rather than being asked.
this.save({ installDeclinedVersion: null })
const exePath = await this.resolveTrayExe()
if (!exePath) {
// Nothing to turn on. The switch says so rather than claiming a tray app that is not
// there, and Sidebar cannot be on without one either.
this.save({ menuBar: false })
return this.status()
}
this.save({ trayExePath: exePath })
await this.setRunKey(true)
await this.awaitQuit()
this.launchTray(exePath)
return this.status()
}
/**
* The rail is a window of the tray app and every setting it reads belongs to the tray app,
* so there is no rail without one. Turning Sidebar on with Menu bar off turns Menu bar on
* first, installing and starting the tray app if it has to; if that cannot be done there is
* nothing to show a rail in, and the switch stays off.
*/
async setSidebarEnabled(enabled: boolean): Promise<CompanionStatus> {
if (!this.supported()) {
this.save({ sidebar: enabled })
return this.status()
}
if (enabled && !this.settings.menuBar) {
await this.setMenuBarEnabled(true)
if (!this.settings.menuBar) return this.status()
}
this.save({ sidebar: enabled })
this.applyDockSetting(enabled)
if (!this.settings.menuBar) return this.status()
// The file is written either way; this only decides whether a running rail hears about
// it now or at the next launch, so a tray app that cannot be found is not worth an
// install here.
const exePath = await this.resolveTrayExe()
if (exePath) {
this.save({ trayExePath: exePath })
this.launchTray(exePath)
}
return this.status()
}
// The tray app's own settings ------------------------------------------------------------
/** Everything the two tray panes render from, read straight out of the files the tray app
* reads them from, so the panes and the tray app can never be showing different answers. */
async trayPrefs(): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
return {
app: parseTrayAppPrefs(readTrayFile('app', this.home)),
dock: parseTrayDockPrefs(readTrayFile('dock', this.home)),
// On the Store route there is no Run value and no way to ask Windows about the
// package's startup task, so this is not a state to render: `launchAtLoginManaged`
// is what the pane goes by there.
launchAtLogin: (await this.existingRunKey()) !== null,
launchAtLoginManaged: this.deps.store,
}
}
/** A setting the tray app keeps in `windows-settings.json`. Every other key in that file
* is left alone, and the running tray app is told to re-read it. */
async setTrayAppPref(patch: unknown): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
const clean = sanitizeAppPatch(patch)
if (Object.keys(clean).length > 0) {
try {
patchTrayFile('app', clean, this.home)
await this.nudgeTray()
} catch (err) {
console.error('tray settings could not be saved:', err)
}
}
return this.trayPrefs()
}
/**
* A Capacity Dock setting. `enabled` is the Sidebar switch under another name, so it goes
* through the same path and keeps the rule that the rail cannot outlive the tray app.
* A provider set that no longer holds the resting provider moves it, as the rail would.
*/
async setTrayDockPref(patch: unknown): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
// The `in` below is the first thing that reads the argument, and it reaches here straight
// off an IPC message, so what it is has to be settled before it is asked about a key.
if (!isPatchObject(patch)) return this.trayPrefs()
if ('enabled' in patch) {
await this.setSidebarEnabled(patch.enabled === true)
return this.trayPrefs()
}
const clean = sanitizeDockPatch(patch)
if (Object.keys(clean).length > 0) {
try {
if (Array.isArray(clean.providers)) {
const current = parseTrayDockPrefs(readTrayFile('dock', this.home))
clean.preferred = normalizedPreferred(current.preferred, clean.providers as string[])
}
patchTrayFile('dock', clean, this.home)
await this.nudgeTray()
} catch (err) {
console.error('Capacity Dock settings could not be saved:', err)
}
}
return this.trayPrefs()
}
/** Launch at login is the one tray setting that is not in a file: it is the Run value, and
* this app owns it, so the pane writes it here rather than through the tray app. Not on
* the Store route, where Windows owns it: see {@link TrayPrefs.launchAtLoginManaged}. */
async setLaunchAtLogin(enabled: boolean): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
if (!this.deps.store) await this.setRunKey(enabled)
return this.trayPrefs()
}
/** Makes a running tray app re-read both preference files and repaint. Nothing to do when
* the switch is off or the executable is not there; the files are read at the next start. */
private async nudgeTray(): Promise<void> {
if (!this.settings.menuBar) return
const exePath = this.settings.trayExePath
if (exePath && this.exists(exePath)) this.launchTray(exePath)
}
private applyDockSetting(enabled: boolean): void {
try {
writeDockEnabled(enabled, this.home)
} catch (err) {
console.error('Capacity Dock preference could not be saved:', err)
}
}
/** One Run value for the tray app, owned here. The Store route has the manifest's own
* startup task instead, and writing a Run value beside it would start it twice. */
private async setRunKey(enabled: boolean): Promise<void> {
if (this.deps.store) return
const exePath = this.settings.trayExePath
if (enabled && !exePath) return
await this.runReg(runKeyArgs(enabled, exePath ?? ''))
}
}

View file

@ -69,6 +69,16 @@ const bridge = {
ipcRenderer.on('codeburn:update', listener)
return () => { ipcRenderer.removeListener('codeburn:update', listener) }
},
// The bundled tray app and its Capacity Dock (Windows). Every setter answers with the
// whole status, so the sidebar renders what took rather than what it asked for.
companionStatus: () => invoke('codeburn:companionStatus'),
setMenuBarEnabled: (enabled: boolean) => invoke('codeburn:setMenuBarEnabled', enabled),
setSidebarEnabled: (enabled: boolean) => invoke('codeburn:setSidebarEnabled', enabled),
// The tray app's own settings, in the two files it reads them from.
trayPrefs: () => invoke('codeburn:trayPrefs'),
setTrayAppPref: (patch: Record<string, unknown>) => invoke('codeburn:setTrayAppPref', patch),
setTrayDockPref: (patch: Record<string, unknown>) => invoke('codeburn:setTrayDockPref', patch),
setLaunchAtLogin: (enabled: boolean) => invoke('codeburn:setLaunchAtLogin', enabled),
// Plugin management
pluginList: () => invoke('codeburn:pluginList'),
pluginInfo: (name: string) => invoke('codeburn:pluginInfo', name),

View file

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { classifyProcessLine, decodeAntigravityStatus, decodeAntigravitySummary, fetchAntigravityQuota } from './antigravity'
import { classifyProcessLine, decodeAntigravityStatus, decodeAntigravitySummary, fetchAntigravityQuota, parseNetstatPorts } from './antigravity'
afterEach(() => vi.restoreAllMocks())
@ -86,7 +86,7 @@ describe('Antigravity local probe', () => {
const execFile = vi.fn(async (_file: string, args: string[]) => args[0] === '-ax'
? { stdout: psOutput }
: { stdout: `python 1234 user 12u IPv4 0x1 0t0 TCP 127.0.0.1:60123 (LISTEN)\n` })
const quota = await fetchAntigravityQuota({ execFile, request })
const quota = await fetchAntigravityQuota({ execFile, request, platform: "darwin" })
expect(quota.connection).toBe('connected')
expect(quota.primary?.percent).toBeCloseTo(0.3)
expect(quota.primary?.label).toBe('Gemini Models · Weekly limit')
@ -107,7 +107,7 @@ describe('Antigravity local probe', () => {
const execFile = vi.fn(async (_file: string, args: string[]) => args[0] === '-ax'
? { stdout: cliLine }
: { stdout: `agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n` })
const quota = await fetchAntigravityQuota({ execFile, request })
const quota = await fetchAntigravityQuota({ execFile, request, platform: "darwin" })
expect(quota.connection).toBe('connected')
expect(quota.planLabel).toBeNull()
expect(headers[0]).toBeUndefined()
@ -120,7 +120,7 @@ describe('Antigravity local probe', () => {
const execFile = vi.fn(async (_file: string, args: string[]) => args[0] === '-ax'
? { stdout: cliLine }
: { stdout: `agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n` })
const quota = await fetchAntigravityQuota({ execFile, request })
const quota = await fetchAntigravityQuota({ execFile, request, platform: "darwin" })
expect(quota.planLabel).toBe('AI Pro')
expect(quota.details.map(row => row.label)).toEqual(['gemini-2.5-pro'])
})
@ -128,7 +128,7 @@ describe('Antigravity local probe', () => {
it('reports disconnected when nothing local is listening', async () => {
const request = vi.fn()
const execFile = vi.fn(async () => ({ stdout: '' }))
const quota = await fetchAntigravityQuota({ execFile, request })
const quota = await fetchAntigravityQuota({ execFile, request, platform: "darwin" })
expect(quota.connection).toBe('disconnected')
expect(request).not.toHaveBeenCalled()
})
@ -138,17 +138,73 @@ describe('Antigravity local probe', () => {
const execFile = vi.fn(async (_file: string, args: string[]) => args[0] === '-ax'
? { stdout: cliLine }
: { stdout: `agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n` })
const quota = await fetchAntigravityQuota({ execFile, request })
const quota = await fetchAntigravityQuota({ execFile, request, platform: "darwin" })
expect(quota.connection).toBe('disconnected')
})
it('degrades an unexpected ps failure to transientFailure with sanitized diagnostics', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const execFile = vi.fn(async () => { throw new Error('ps exploded Bearer sk-secret eyJabc.def\0tail') })
const quota = await fetchAntigravityQuota({ execFile })
const quota = await fetchAntigravityQuota({ execFile, platform: "darwin" })
expect(quota.connection).toBe('transientFailure')
const logged = warn.mock.calls.flat().join(' ')
expect(logged).not.toMatch(/sk-secret|eyJabc|\0/)
expect(logged).toContain('[REDACTED]')
})
// The desktop app's own stderr on Windows carried "ps: unknown option -- x" on every quota
// poll: `ps` there is not a spawn that comes back empty, it is a spawn that fails.
it('never asks Windows for ps or lsof', async () => {
const asked: string[] = []
const request = vi.fn(async () => { throw new Error('the test must not probe a port') })
const quota = await fetchAntigravityQuota({
platform: 'win32',
execFile: async (file, args) => {
asked.push(file)
expect(file).not.toMatch(/(^|[\\/])(ps|lsof)(\.exe)?$/i)
expect(args).not.toContain('-ax')
return { stdout: '' }
},
request,
})
expect(quota.connection).toBe('disconnected')
expect(asked[0]).toMatch(/powershell\.exe$/i)
expect(request).not.toHaveBeenCalled()
})
it('finds a language server through the Windows process listing', async () => {
const line = '4321 C:\\Users\\x\\AppData\\Local\\antigravity\\language_server_windows_x64.exe'
+ ' --app_data_dir=antigravity --csrf_token=abc --extension_server_port=51234'
const quota = await fetchAntigravityQuota({
platform: 'win32',
execFile: async file => /powershell\.exe$/i.test(file)
? { stdout: `${line}\r\n` }
: { stdout: ' TCP 127.0.0.1:51234 0.0.0.0:0 LISTENING 4321\r\n' },
request: async (port, _tls, _path, _body, csrf) => {
expect(port).toBe(51234)
expect(csrf).toBe('abc')
return {
status: 200,
text: JSON.stringify({ groups: [{ displayName: 'Weekly', buckets: [{ displayName: 'Sonnet', remaining: { remainingFraction: 0.25 } }] }] }),
}
},
})
expect(quota.connection).toBe('connected')
expect(quota.primary?.label).toBe('Weekly · Sonnet')
})
it('reads only this pid out of a netstat listing', () => {
const stdout = [
' Proto Local Address Foreign Address State PID',
' TCP 127.0.0.1:51234 0.0.0.0:0 LISTENING 4321',
' TCP 127.0.0.1:60000 0.0.0.0:0 LISTENING 9999',
' TCP [::]:51999 [::]:0 LISTENING 4321',
' UDP 127.0.0.1:5353 *:* 4321',
].join('\r\n')
expect(parseNetstatPorts(stdout, '4321')).toEqual([51234, 51999])
expect(parseNetstatPorts(stdout, '1')).toEqual([])
})
})

View file

@ -1,4 +1,4 @@
// Live Antigravity quota from LOCAL surfaces only — the Antigravity app's own
// Live Antigravity quota from LOCAL surfaces only - the Antigravity app's own
// language server or a signed-in `agy` CLI's embedded server. The local
// Antigravity language-server protocol is undocumented and
// experimental). No Google OAuth fallback in v1: when no local server answers,
@ -9,9 +9,10 @@
// (preferred; falls back to)
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
//
// Discovery uses `ps` to find candidate processes (app language
// servers need their `--csrf_token`; the `agy` CLI needs none), then `lsof`
// lists each pid's listening TCP ports. Local HTTPS uses a self-signed cert,
// Discovery lists processes to find candidates (app language
// servers need their `--csrf_token`; the `agy` CLI needs none), then lists each
// pid's listening TCP ports: `ps` and `lsof` on POSIX, Win32_Process and
// `netstat` on Windows, which has neither. Local HTTPS uses a self-signed cert,
// so TLS verification is relaxed ONLY for the 127.0.0.1 loopback probes.
import { execFile } from 'node:child_process'
import http from 'node:http'
@ -37,6 +38,8 @@ export type LocalRequestFn = (
export type AntigravityDeps = {
execFile: ExecFileFn
request: LocalRequestFn
/// Which process listing to ask for. Injected so both branches are testable from either OS.
platform: string
}
const execFileAsync: ExecFileFn = promisify(execFile)
@ -70,6 +73,7 @@ function postLocal(port: number, tls: boolean, pathName: string, body: string, c
const defaults: AntigravityDeps = {
execFile: execFileAsync,
request: postLocal,
platform: process.platform,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
@ -101,7 +105,7 @@ export function classifyProcessLine(line: string): Candidate | null {
if (!pid) return null
// A tokenless desktop language-server match is skipped so a later, valid
// server can be found; the CLI exposes no CSRF flag and needs none. IDE
// language servers are excluded too — their payloads lack the weekly groups.
// language servers are excluded too - their payloads lack the weekly groups.
if (!isCli && (!APP_MARKER.test(line) || IDE_MARKER.test(line))) return null
const csrf = flagValue(line, '--csrf_token')
if (!isCli && !csrf) return null
@ -109,8 +113,56 @@ export function classifyProcessLine(line: string): Candidate | null {
return { pid, cli: isCli, csrf, extPort: Number.isFinite(extPort) && extPort > 0 ? extPort : undefined }
}
async function discoverCandidates(deps: AntigravityDeps): Promise<Candidate[]> {
/// Windows has no `ps`, and asking for one is a spawn failure rather than an empty answer:
/// every quota poll there printed `ps: unknown option -- x` and reported a transient failure.
/// Win32_Process is the same listing by another name, and it is already how
/// src/providers/antigravity.ts finds these servers. The Where-Object is only a prefilter to
/// keep the output small; `classifyProcessLine` still decides what counts.
const WINDOWS_PROCESS_SCRIPT = [
"$ErrorActionPreference = 'SilentlyContinue'",
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
'Get-CimInstance Win32_Process'
+ " | Where-Object { $_.CommandLine -and ($_.CommandLine -like '*antigravity*'"
+ " -or $_.CommandLine -like '*language_server*' -or $_.CommandLine -like '*agy*') }"
+ ' | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }',
].join('; ')
/// Windows searches the current directory before PATH, so a system tool by bare name lets
/// anything dropped beside the caller answer for it.
function system32(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}`
}
/// One line per process, `<pid> <command line>`, on every platform.
async function listProcesses(deps: AntigravityDeps): Promise<string> {
if (deps.platform === 'win32') {
const { stdout } = await deps.execFile(system32('WindowsPowerShell\\v1.0\\powershell.exe'), [
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_PROCESS_SCRIPT,
])
return stdout
}
const { stdout } = await deps.execFile('ps', ['-ax', '-o', 'pid=,command='])
return stdout
}
/// `netstat -ano` rows are `Proto Local Foreign State PID`. The state word is localized,
/// so it is not matched on: a row for this pid that is not listening only gives a port the
/// probe then fails to reach, which costs one refused connection and no wrong answer.
export function parseNetstatPorts(stdout: string, pid: string): number[] {
const ports = new Set<number>()
for (const line of stdout.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 5 || !/^TCP$/i.test(parts[0]!) || parts[parts.length - 1] !== pid) continue
const port = Number(parts[1]!.split(':').pop())
if (Number.isFinite(port) && port > 0) ports.add(port)
}
return [...ports]
}
async function discoverCandidates(deps: AntigravityDeps): Promise<Candidate[]> {
const stdout = await listProcesses(deps)
const seen = new Set<string>()
const candidates: Candidate[] = []
for (const line of stdout.split('\n')) {
@ -125,6 +177,11 @@ async function discoverCandidates(deps: AntigravityDeps): Promise<Candidate[]> {
async function listeningPorts(deps: AntigravityDeps, pid: string): Promise<number[]> {
try {
// No lsof on Windows either; netstat is the listing that ships with it.
if (deps.platform === 'win32') {
const { stdout } = await deps.execFile(system32('netstat.exe'), ['-ano', '-p', 'TCP'])
return parseNetstatPorts(stdout, pid)
}
const { stdout } = await deps.execFile('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-p', pid])
const ports = [...stdout.matchAll(/:(\d+)\s/g)].map(match => Number(match[1]))
return [...new Set(ports)].filter(port => Number.isFinite(port) && port > 0)

View file

@ -0,0 +1,243 @@
// @vitest-environment node
// What happens to `~/.codex/auth.json` when the desktop app refreshes a Codex login.
//
// The grant retires the refresh token on disk the moment the endpoint accepts it, so
// everything between parsing the response and having the new one on disk is a window in
// which losing the process signs the user out of Codex. These run against a real credential
// file, because the write is the thing being pinned.
import { readFileSync } from 'node:fs'
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchCodexQuota } from './codex'
const NOW = Date.parse('2026-09-02T12:00:00.000Z')
const OLD_REFRESH = 'refresh-token-that-the-grant-retires'
const NEW_REFRESH = 'refresh-token-the-grant-returns'
let root: string
let authPath: string
// `last_refresh` is older than the eight-day staleness window, so every fetch here takes the
// proactive refresh path.
function authDoc(): Record<string, unknown> {
return {
auth_mode: 'chatgpt',
tokens: {
access_token: 'access-old',
refresh_token: OLD_REFRESH,
id_token: 'id-old',
account_id: 'account-1',
},
last_refresh: new Date(NOW - 30 * 86_400_000).toISOString(),
}
}
async function writeAuth(doc: Record<string, unknown>, mode = 0o600): Promise<void> {
// A real login file is private to its user, and readSecureFile rejects group or world bits.
await writeFile(authPath, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode })
}
async function readAuth(): Promise<Record<string, any>> {
return JSON.parse(await readFile(authPath, 'utf8')) as Record<string, any>
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
const rotatedGrant = () => json({ access_token: 'access-new', refresh_token: NEW_REFRESH, id_token: 'id-new' })
const usagePayload = () => json({
plan_type: 'pro',
rate_limit: { primary_window: { used_percent: 10, limit_window_seconds: 18_000 } },
})
type Handler = () => Response | Promise<Response>
function routes(handlers: { token: Handler; usage: Handler }): typeof fetch {
return (async (input: unknown) => {
const url = typeof input === 'string' ? input : String((input as { url?: string }).url ?? input)
return url.includes('auth.openai.com') ? handlers.token() : handlers.usage()
}) as unknown as typeof fetch
}
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'codeburn-app-codex-refresh-'))
authPath = path.join(root, 'auth.json')
})
afterEach(async () => {
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
})
describe('Codex quota credential rotation', () => {
it('writes the rotated refresh token to auth.json and leaves no temp file behind', async () => {
await writeAuth(authDoc())
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect(result.quota.connection).toBe('connected')
const saved = await readAuth()
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
expect(saved.tokens.access_token).toBe('access-new')
expect(saved.tokens.id_token).toBe('id-new')
expect(saved.tokens.account_id).toBe('account-1')
expect(saved.auth_mode).toBe('chatgpt')
expect(saved.last_refresh).toBe(new Date(NOW).toISOString())
expect(await readdir(root)).toEqual(['auth.json'])
})
it('keeps the rotated refresh token when the quota request fails after the refresh', async () => {
await writeAuth(authDoc())
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: () => json({ error: 'boom' }, 500) }),
})
expect(result.quota.connection).toBe('transientFailure')
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('has the rotated refresh token on disk before it makes another request', async () => {
await writeAuth(authDoc())
// The renderer's poll is racing a per-provider timeout, and quit reaps the main process
// outright, so anything still awaited after the grant can simply be killed. Reading the
// file from inside the next request proves the write already completed.
let onDiskAtUsage: string | null = null
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({
token: rotatedGrant,
usage: () => {
onDiskAtUsage = readFileSync(authPath, 'utf8')
throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
},
}),
})
expect(result.quota.connection).toBe('transientFailure')
expect(onDiskAtUsage).not.toBeNull()
expect(JSON.parse(onDiskAtUsage!).tokens.refresh_token).toBe(NEW_REFRESH)
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('persists a rotated refresh token even when the grant returns no access token', async () => {
await writeAuth(authDoc())
await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: () => json({ refresh_token: NEW_REFRESH }), usage: usagePayload }),
})
const saved = await readAuth()
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
expect(saved.tokens.access_token).toBe('access-old')
})
it('falls back to the credential it holds when the merge re-read fails', async () => {
await writeAuth(authDoc())
await fetchCodexQuota({
authPath,
now: () => NOW,
readFileSync: () => { throw new Error('EBUSY: resource busy or locked') },
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('reports a credential write it could not complete instead of failing silently', async () => {
await writeAuth(authDoc())
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await fetchCodexQuota({
authPath,
now: () => NOW,
writeFileSync: () => { throw new Error('EROFS: read-only file system') },
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect(errors).toHaveBeenCalledTimes(1)
const message = String(errors.mock.calls[0]?.[0])
expect(message).toContain(authPath)
expect(message).toContain('codex login')
expect(message).not.toContain(NEW_REFRESH)
})
it('discards the rotation rather than resurrecting a login that was removed', async () => {
await writeAuth(authDoc())
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await fetchCodexQuota({
authPath,
now: () => NOW,
// The Codex CLI signed the user out between our read and the grant.
readFileSync: () => null,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(OLD_REFRESH)
expect(errors).toHaveBeenCalledTimes(1)
})
it('sends the grant on its own timeout rather than the caller’s abort signal', async () => {
await writeAuth(authDoc())
const caller = new AbortController()
const signals: Array<AbortSignal | null | undefined> = []
await fetchCodexQuota({
authPath,
now: () => NOW,
signal: caller.signal,
fetch: (async (input: unknown, init?: RequestInit) => {
const url = typeof input === 'string' ? input : String(input)
signals.push(init?.signal)
if (url.includes('auth.openai.com')) {
// A per-provider timeout firing here would cancel a rotation nothing can undo.
caller.abort()
return rotatedGrant()
}
return usagePayload()
}) as unknown as typeof fetch,
})
// The grant request never carried the caller's signal, so aborting it did not reach it.
expect(signals[0]?.aborted).toBe(false)
// The usage request that follows does carry it, which is what the abort is for.
expect(signals[1]?.aborted).toBe(true)
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
// POSIX mode bits do not exist on Windows, where the ACL carries the privacy.
it.skipIf(process.platform === 'win32')('rewrites the credential with the permissions it already had', async () => {
for (const mode of [0o600, 0o400]) {
await rm(authPath, { force: true })
await writeAuth(authDoc(), mode)
const before = (await stat(authPath)).mode & 0o777
await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
expect((await stat(authPath)).mode & 0o777).toBe(before)
expect(before & 0o077).toBe(0)
}
})
})

View file

@ -205,9 +205,17 @@ describe('Codex quota', () => {
const fetchMock = vi.fn(async (url: string) => url.includes('/oauth/token')
? new Response(JSON.stringify({ access_token: 'new-access', refresh_token: 'new-refresh', id_token: 'new-id' }), { status: 200 })
: new Response(JSON.stringify({ plan_type: 'pro', rate_limit: {} }), { status: 200 }))
const writeFile = vi.fn(async () => undefined)
await fetchCodexQuota({ fetch: fetchMock as typeof fetch, readFile: vi.fn(async () => JSON.stringify(stale)), writeFile, now: () => now })
const saved = JSON.parse((writeFile.mock.calls[0]! as unknown as [string, string])[1])
const writeFileSync = vi.fn(() => undefined)
await fetchCodexQuota({
fetch: fetchMock as typeof fetch,
readFile: vi.fn(async () => JSON.stringify(stale)),
// The rotated tokens merge into whatever auth.json holds at that moment, not into the
// copy this call read before the grant.
readFileSync: vi.fn(() => JSON.stringify(stale)),
writeFileSync,
now: () => now,
})
const saved = JSON.parse((writeFileSync.mock.calls[0]! as unknown as [string, string])[1])
expect(saved.OPENAI_API_KEY).toBe('preserve-me')
expect(saved.tokens).toMatchObject({ access_token: 'new-access', refresh_token: 'new-refresh', id_token: 'new-id', account_id: 'acct_1' })
expect((fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1].method).toBe('POST')
@ -227,13 +235,13 @@ describe('Codex menubar keychain source', () => {
it('resolves quota from the menubar keychain when auth.json is absent, read-only', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ plan_type: 'pro', rate_limit: { primary_window: { used_percent: 10, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } } }), { status: 200 }))
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
const writeFileSync = vi.fn(() => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFileSync, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('connected')
expect(result.quota.planLabel).toBe('Pro')
const init = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(init.headers).toMatchObject({ Authorization: 'Bearer eyJmenubar.token.sig', 'ChatGPT-Account-Id': 'acct_mb' })
expect(writeFile).not.toHaveBeenCalled()
expect(writeFileSync).not.toHaveBeenCalled()
expect(keychain).toHaveBeenCalledWith('org.agentseal.codeburn.menubar.codex.oauth.v1')
})
@ -244,22 +252,22 @@ describe('Codex menubar keychain source', () => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => (init?.headers as Record<string, string>).Authorization === 'Bearer eyJrotated.token.sig'
? new Response(JSON.stringify({ plan_type: 'pro', rate_limit: {} }), { status: 200 })
: new Response('', { status: 401 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
const writeFileSync = vi.fn(() => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFileSync, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('connected')
expect(fetchMock.mock.calls.every(call => String(call[0]).includes('/wham/usage'))).toBe(true)
expect(keychain).toHaveBeenCalledTimes(2)
expect(writeFile).not.toHaveBeenCalled()
expect(writeFileSync).not.toHaveBeenCalled()
})
it('returns transientFailure on a keychain 401 with no rotation, never writing back', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const fetchMock = vi.fn(async (_url: string) => new Response('', { status: 401 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
const writeFileSync = vi.fn(() => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFileSync, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('transientFailure')
expect(fetchMock.mock.calls.every(call => String(call[0]).includes('/wham/usage'))).toBe(true)
expect(writeFile).not.toHaveBeenCalled()
expect(writeFileSync).not.toHaveBeenCalled()
})
it('surfaces accessDenied when the menubar keychain is blocked and no file exists', async () => {
@ -273,12 +281,12 @@ describe('Codex menubar keychain source', () => {
it('prefers the menubar keychain over ~/.codex/auth.json and keeps it read-only', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ plan_type: 'plus', rate_limit: {} }), { status: 200 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => JSON.stringify(auth)), writeFile, keychain, allowKeychain: true, now: () => now })
const writeFileSync = vi.fn(() => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => JSON.stringify(auth)), writeFileSync, keychain, allowKeychain: true, now: () => now })
expect(result.quota.connection).toBe('connected')
const init = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(init.headers).toMatchObject({ Authorization: 'Bearer eyJmenubar.token.sig' })
expect(writeFile).not.toHaveBeenCalled()
expect(writeFileSync).not.toHaveBeenCalled()
})
it('falls through to ~/.codex/auth.json when the keychain has no menubar item', async () => {

View file

@ -1,7 +1,7 @@
import os from 'node:os'
import path from 'node:path'
import { atomicWriteSecureFile, fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security'
import { atomicWriteSecureFileSync, fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, readSecureFileSync, sanitizeError } from './security'
import type { KeychainOutcome } from './security'
import type { QuotaProvider, QuotaWindow } from './types'
@ -11,7 +11,7 @@ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const EIGHT_DAYS = 8 * 24 * 60 * 60_000
// The CodeBurn menubar caches its ChatGPT-mode Codex OAuth here as a
// `CredentialRecord` JSON blob (accessToken/refreshToken/idToken/accountId/…),
// account "default". Same brand, same machine, already consented — preferred
// account "default". Same brand, same machine, already consented and preferred
// over any OpenAI-owned storage.
const MENUBAR_KEYCHAIN_SERVICE = 'org.agentseal.codeburn.menubar.codex.oauth.v1'
@ -26,7 +26,9 @@ export type CodexDeps = {
authPath: string
openaiAuthPath: string
readFile: typeof readSecureFile
writeFile: typeof atomicWriteSecureFile
// Synchronous by contract, not by convenience: see `persistRotated`.
readFileSync: typeof readSecureFileSync
writeFileSync: typeof atomicWriteSecureFileSync
keychain: (service: string) => Promise<KeychainOutcome>
now: () => number
}
@ -36,7 +38,8 @@ const defaults: CodexDeps = {
authPath: path.join(os.homedir(), '.codex', 'auth.json'),
openaiAuthPath: path.join(os.homedir(), 'Library', 'Application Support', 'com.openai.codex', 'auth.json'),
readFile: readSecureFile,
writeFile: atomicWriteSecureFile,
readFileSync: readSecureFileSync,
writeFileSync: atomicWriteSecureFileSync,
keychain: service => readKeychainPassword(service, ['default', null]),
now: Date.now,
}
@ -104,7 +107,7 @@ async function discoverSource(deps: CodexDeps, allowKeychain: boolean): Promise<
if (fileAuth) return { name: 'authFile', auth: fileAuth, writable: true, reread: () => readAuth(deps) }
// (c) com.openai.codex App Support, only if it holds a plaintext auth JSON
// with a usable token. Tokens encrypted via "Codex Safe Storage" have no
// plaintext access_token here, so they fall through — we never decrypt.
// plaintext access_token here, so they fall through, and we never decrypt.
const openaiAuth = await readAuth(deps, deps.openaiAuthPath).catch(() => null)
if (openaiAuth?.tokens?.access_token) {
return { name: 'openaiAppSupport', auth: openaiAuth, writable: false, reread: () => readAuth(deps, deps.openaiAuthPath).catch(() => null) }
@ -238,28 +241,93 @@ export function decodeCodexUsage(body: unknown): QuotaProvider {
}
}
async function refresh(auth: AuthDoc, deps: CodexDeps, signal?: AbortSignal): Promise<AuthDoc | null> {
function str(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}
/** The document the rotated tokens are merged into: whatever the Codex CLI has
* on disk right now, so a login it wrote since our own read is not clobbered.
* `null` means the login this rotation belongs to is gone (file removed, or no
* longer ChatGPT mode) and writing would resurrect it. An unreadable or
* unparseable file is NOT one of those cases: it falls back to the document we
* already hold, because dropping a rotated refresh token signs the user out. */
function mergeBase(auth: AuthDoc, deps: CodexDeps): AuthDoc | null {
let raw: string | null
try {
raw = deps.readFileSync(deps.authPath)
} catch {
return auth
}
if (raw === null) return null
let latest: unknown
try {
latest = JSON.parse(raw)
} catch {
return auth
}
if (!latest || typeof latest !== 'object') return auth
const doc = latest as AuthDoc
return doc.auth_mode === 'chatgpt' ? doc : null
}
type RotatedTokens = { access: string | null; refresh: string | null; id: string | null }
/**
* Merges the rotated tokens into auth.json and writes them, synchronously and
* atomically. Synchronous from the moment the token response is parsed to the
* moment the file is renamed into place: the grant has already invalidated the
* refresh token on disk, so an abort, a per-provider timeout or the `quota`
* command's `process.exit` landing in an await here would leave the user with
* nothing but a dead token, which reads as being signed out of Codex.
*/
function persistRotated(auth: AuthDoc, rotated: RotatedTokens, deps: CodexDeps): AuthDoc | null {
const base = mergeBase(auth, deps)
if (!base) {
console.error(`Codex refreshed its login but the credential at ${deps.authPath} no longer holds that ChatGPT login, so the new token was discarded. Run \`codex login\` if Codex signs you out.`)
return null
}
const merged: AuthDoc = {
...base,
tokens: {
...base.tokens,
...(rotated.access ? { access_token: rotated.access } : {}),
...(rotated.refresh ? { refresh_token: rotated.refresh } : {}),
...(rotated.id ? { id_token: rotated.id } : {}),
},
last_refresh: new Date(deps.now()).toISOString(),
}
try {
deps.writeFileSync(deps.authPath, `${JSON.stringify(merged, null, 2)}\n`)
} catch (error) {
// Never silent: the token still on disk is the one the grant just killed,
// so the user has to be told why Codex will ask them to sign in again.
console.error(`Codex refresh token rotated but could not be saved to ${deps.authPath}: ${sanitizeError(error)}. Run \`codex login\` if Codex signs you out.`)
return null
}
return merged
}
async function refresh(auth: AuthDoc, deps: CodexDeps): Promise<AuthDoc | null> {
const refreshToken = auth.tokens?.refresh_token
if (!refreshToken) return null
// Deliberately not the caller's abort signal, only this request's own
// timeout: the grant retires the refresh token on disk the moment the
// endpoint accepts it, so a per-provider timeout firing while the response
// is still arriving would cancel a rotation we can no longer undo. A slow
// grant costs the caller nothing, since its own race has already given up.
const response = await deps.fetch(TOKEN_ENDPOINT, {
method: 'POST', signal: quotaRequestSignal(signal),
method: 'POST', signal: quotaRequestSignal(),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: CLIENT_ID, grant_type: 'refresh_token', refresh_token: refreshToken, scope: 'openid profile email' }),
})
if (!response.ok) return null
const next = await response.json() as Record<string, unknown>
if (typeof next.access_token !== 'string' || !next.access_token) return null
const latest = await readAuth(deps)
if (!latest || latest.auth_mode !== 'chatgpt') return null
latest.tokens = {
...latest.tokens,
access_token: next.access_token,
...(typeof next.refresh_token === 'string' ? { refresh_token: next.refresh_token } : {}),
...(typeof next.id_token === 'string' ? { id_token: next.id_token } : {}),
}
latest.last_refresh = new Date(deps.now()).toISOString()
await deps.writeFile(deps.authPath, `${JSON.stringify(latest, null, 2)}\n`)
return latest
const rotated: RotatedTokens = { access: str(next.access_token), refresh: str(next.refresh_token), id: str(next.id_token) }
// A rotated refresh token is persisted even when the access token is missing
// or malformed: the grant has already retired the one on disk either way.
if (!rotated.access && !rotated.refresh) return null
const merged = persistRotated(auth, rotated, deps)
return merged?.tokens?.access_token ? merged : null
}
async function usage(auth: AuthDoc, deps: CodexDeps, signal?: AbortSignal): Promise<Response | null> {
@ -287,7 +355,7 @@ export async function fetchCodexQuota(options: Partial<CodexDeps> & { signal?: A
if (source.writable) {
const refreshedAt = typeof auth.last_refresh === 'string' ? Date.parse(auth.last_refresh) : NaN
if (!Number.isFinite(refreshedAt) || deps.now() - refreshedAt > EIGHT_DAYS) {
const next = await refresh(auth, deps, options.signal)
const next = await refresh(auth, deps)
if (next) auth = next
}
}
@ -298,7 +366,7 @@ export async function fetchCodexQuota(options: Partial<CodexDeps> & { signal?: A
if (reread?.tokens?.access_token && reread.tokens.access_token !== auth.tokens?.access_token) {
auth = reread
} else if (source.writable) {
const next = await refresh(reread ?? auth, deps, options.signal)
const next = await refresh(reread ?? auth, deps)
if (!next) return { quota: empty('transientFailure') }
auth = next
} else {

View file

@ -1,5 +1,8 @@
import { execFile } from 'node:child_process'
import { constants, type Stats } from 'node:fs'
import {
chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync,
openSync, readFileSync, renameSync, unlinkSync, writeFileSync, type Stats,
} from 'node:fs'
import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
@ -123,6 +126,74 @@ export async function readSecureFile(filePath: string, maxBytes = 64 * 1024): Pr
}
}
/** Synchronous twin of {@link readSecureFile}, for the rotation path that must
* not yield to the event loop between reading and writing a credential. */
export function readSecureFileSync(filePath: string, maxBytes = 64 * 1024): string | null {
let before: Stats
try {
before = lstatSync(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
if (before.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${filePath}`)
assertSafeMode(before, filePath)
if (before.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
const fd = openSync(filePath, constants.O_RDONLY | NOFOLLOW)
try {
const after = fstatSync(fd)
assertSafeMode(after, filePath)
if (after.dev !== before.dev || after.ino !== before.ino) throw new Error(`Credential file changed while opening: ${filePath}`)
if (after.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
return readFileSync(fd, { encoding: 'utf8' })
} finally {
closeSync(fd)
}
}
// Rewrite a credential with exactly the owner-only permissions it already had,
// so a refreshed token neither widens the file the user chose nor drops a
// tighter mode like 0o400. Group or world bits (or Windows, where mode bits are
// not the access control) mean there is nothing safe to copy: use owner rw.
function privateModeFor(filePath: string): number {
if (process.platform === 'win32') return 0o600
try {
const stats = lstatSync(filePath)
if (!stats.isFile()) return 0o600
const mode = stats.mode & 0o777
return mode !== 0 && (mode & 0o077) === 0 ? mode : 0o600
} catch {
return 0o600
}
}
/**
* Writes a credential file atomically and synchronously. Synchronous on
* purpose: a caller holding a freshly rotated token has to get it onto disk
* without an await for an abort, a timeout or a quit to land in.
*/
export function atomicWriteSecureFileSync(filePath: string, contents: string): void {
const mode = privateModeFor(filePath)
const dir = path.dirname(filePath)
mkdirSync(dir, { recursive: true, mode: 0o700 })
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`)
try {
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode)
try {
writeFileSync(fd, contents, 'utf8')
fsyncSync(fd)
} finally {
closeSync(fd)
}
if (process.platform !== 'win32') chmodSync(tempPath, mode)
renameSync(tempPath, filePath)
} catch (error) {
try { unlinkSync(tempPath) } catch { /* nothing to clean up */ }
throw error
}
}
export async function atomicWriteSecureFile(filePath: string, contents: string): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`)

View file

@ -110,7 +110,7 @@ describe('events', () => {
telemetry.track('totally_made_up', { a: 1 })
telemetry.track('section_view', {
section: 'x'.repeat(500),
junk: { nested: 'object' },
junk: new Date(),
fn: () => {},
nan: NaN,
ok: 42,
@ -128,6 +128,46 @@ describe('events', () => {
expect(body.events[0]!.day).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('keeps the daily snapshot shape but drops anything deeper or bigger', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
telemetry.track('usage_snapshot', {
schema: 2,
models: [{ name: 'Sonnet 4.5', tasks: [{ name: 'Coding', share: 0.75 }] }],
categories: [{ name: 'Coding', topModels: ['Sonnet 4.5', 'Opus 4.6'] }],
sessions: { countBucket: '100-1k' },
// One container deeper than the snapshot's own deepest shape.
tooDeep: [{ a: [{ b: [{ c: 1 }] }] }],
wide: Array.from({ length: 40 }, (_, i) => ({ name: `n${i}` })),
})
await telemetry.flush()
const props = (posts[0]!.body as { events: Array<{ name: string; props: Record<string, unknown> }> }).events[1]!.props
expect(props.models).toEqual([{ name: 'Sonnet 4.5', tasks: [{ name: 'Coding', share: 0.75 }] }])
expect(props.categories).toEqual([{ name: 'Coding', topModels: ['Sonnet 4.5', 'Opus 4.6'] }])
expect(props.sessions).toEqual({ countBucket: '100-1k' })
expect(props.tooDeep).toBeUndefined()
expect(props.wide).toHaveLength(12)
})
it('accepts the name-only interaction events', async () => {
const { telemetry, posts } = make()
telemetry.completeOnboarding(true)
telemetry.track('optimize_apply', { kind: 'claude-md-too-long', fixType: 'file-content' })
telemetry.track('plan_set', { provider: 'cursor', plan: 'cursor-pro' })
telemetry.track('export', { format: 'json', provider: 'all' })
telemetry.track('compare_view', { modelA: 'Sonnet 4.5', modelB: 'Opus 4.6' })
telemetry.track('settings_change', { setting: 'theme', value: 'dark' })
await telemetry.flush()
const events = (posts[0]!.body as { events: Array<{ name: string; props: Record<string, unknown> }> }).events
expect(events.map(event => event.name)).toEqual([
'app_open', 'optimize_apply', 'plan_set', 'export', 'compare_view', 'settings_change',
])
expect(events[1]!.props).toEqual({ kind: 'claude-md-too-long', fixType: 'file-content' })
expect(events[5]!.props).toEqual({ setting: 'theme', value: 'dark' })
})
it('stamps events with the LOCAL calendar day, not the UTC day', async () => {
const savedTZ = process.env.TZ
process.env.TZ = 'America/Los_Angeles' // UTC-07/08

View file

@ -14,7 +14,12 @@ import { dirname, join } from 'node:path'
// Either way the user decides on the consent screen.
// - The only identifier is a random UUID minted locally. No fingerprinting.
// - Events carry day-granularity timestamps only, and props pass a whitelist
// sanitizer (short strings / finite numbers / booleans, capped arrays).
// sanitizer: every leaf is a short string, a finite number or a boolean, and
// the nesting, key count and array length are all capped. Anything else
// (functions, dates, deep trees) is dropped rather than encoded.
// - The richest event is `usage_snapshot`, the CLI-computed daily aggregate in
// `payload.telemetrySnapshot`. It is bucketed and name-only by construction:
// see src/telemetry-snapshot.ts for the contract it holds.
// - Dev / unpackaged builds never send (CODEBURN_TELEMETRY_DEV=1 overrides
// for end-to-end testing).
@ -40,6 +45,13 @@ export const EVENT_NAMES = new Set([
'cold_start',
'usage_snapshot',
'cli_error',
// Name-only interaction events. Props carry a fix id, a provider, a format,
// a model name or a setting name and its boolean/enum value, never free text.
'optimize_apply',
'plan_set',
'export',
'compare_view',
'settings_change',
])
const MAX_QUEUE = 200
@ -47,6 +59,13 @@ const MAX_CLI_ERRORS_PER_KIND_PER_DAY = 20
const MAX_STRING = 64
const MAX_ARRAY = 12
const MAX_KEYS = 16
// Containers may nest this deep below the props object. The daily usage
// snapshot is the deepest shape we send: props -> models[] -> model -> tasks[]
// -> task. Anything deeper is dropped whole.
const MAX_DEPTH = 5
// Belt and braces on top of the per-level caps: one event can never encode more
// than this many leaf values, whatever shape it arrives in.
const MAX_LEAVES = 1000
export type TelemetryStatus = {
installId: string
@ -99,47 +118,54 @@ function loadCliErrorBudget(day: unknown, counts: unknown): Pick<PersistedState,
return { cliErrorDay: day, cliErrorCounts: cleanCounts }
}
function sanitizeValue(value: unknown): unknown | undefined {
if (typeof value === 'string') return value.slice(0, MAX_STRING)
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
if (typeof value === 'boolean') return value
return undefined
type LeafBudget = { left: number }
function sanitizeValue(value: unknown, depth: number, budget: LeafBudget): unknown | undefined {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
if (budget.left <= 0) return undefined
if (typeof value === 'number' && !Number.isFinite(value)) return undefined
budget.left--
return typeof value === 'string' ? value.slice(0, MAX_STRING) : value
}
if (depth >= MAX_DEPTH) return undefined
if (Array.isArray(value)) {
const items: unknown[] = []
for (const entry of value.slice(0, MAX_ARRAY)) {
const sv = sanitizeValue(entry, depth + 1, budget)
if (sv !== undefined) items.push(sv)
}
return items.length > 0 ? items : undefined
}
// Plain objects only. A Date, Map, function or class instance carries state
// we have no whitelist for, so it is dropped rather than walked.
if (value === null || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return undefined
const flat = sanitizeObject(value as Record<string, unknown>, depth + 1, budget)
return Object.keys(flat).length > 0 ? flat : undefined
}
/** Whitelist sanitizer: primitives, plus one level of arrays-of-flat-objects. */
export function sanitizeProps(props: unknown): Record<string, unknown> {
if (!props || typeof props !== 'object' || Array.isArray(props)) return {}
function sanitizeObject(obj: Record<string, unknown>, depth: number, budget: LeafBudget): Record<string, unknown> {
const out: Record<string, unknown> = {}
let keys = 0
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
for (const [key, value] of Object.entries(obj)) {
if (keys >= MAX_KEYS) break
const k = key.slice(0, MAX_STRING)
if (Array.isArray(value)) {
const items: Record<string, unknown>[] = []
for (const entry of value.slice(0, MAX_ARRAY)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const flat: Record<string, unknown> = {}
let inner = 0
for (const [ik, iv] of Object.entries(entry as Record<string, unknown>)) {
if (inner >= MAX_KEYS) break
const sv = sanitizeValue(iv)
if (sv === undefined) continue
flat[ik.slice(0, MAX_STRING)] = sv
inner++
}
if (Object.keys(flat).length > 0) items.push(flat)
}
if (items.length > 0) { out[k] = items; keys++ }
continue
}
const sv = sanitizeValue(value)
const sv = sanitizeValue(value, depth, budget)
if (sv === undefined) continue
out[k] = sv
out[key.slice(0, MAX_STRING)] = sv
keys++
}
return out
}
/** Whitelist sanitizer. Keeps short strings, finite numbers and booleans, plus
* plain objects and arrays of them nested up to MAX_DEPTH, each level capped by
* MAX_KEYS / MAX_ARRAY and the whole event by MAX_LEAVES. Everything else is
* dropped. Deep enough for the daily usage snapshot's model x task cross and
* nothing more. */
export function sanitizeProps(props: unknown): Record<string, unknown> {
if (!props || typeof props !== 'object' || Array.isArray(props)) return {}
return sanitizeObject(props as Record<string, unknown>, 1, { left: MAX_LEAVES })
}
export class Telemetry {
private readonly deps: Required<Pick<Deps, 'stateDir' | 'country' | 'isPackaged' | 'appVersion'>> & Deps
private state: PersistedState

View file

@ -0,0 +1,113 @@
// @vitest-environment node
// How the tray app's settings files are written, as opposed to what goes in them.
//
// The tray app reads `windows-settings.json` and `windows-dock.json` on a cadence of its own
// and again on every `--reload-settings`, while this side writes them. A plain
// `writeFileSync` truncates the target and then fills it, so a read landing between the two
// sees an empty or half-written file, parses it as no preferences at all, and every setting
// in it collapses to a default.
//
// Its own file because pinning the write and the rename means mocking `node:fs` wholesale,
// which the rest of the tray-settings suite runs against for real.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
const actualFs = await vi.importActual<typeof import('node:fs')>('node:fs')
const { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } = actualFs
// vi.mock factories are hoisted above the module body, so the spies they close over have to
// be hoisted with them. Each defaults to the real thing and is redirected per test.
const { writeMock, renameMock } = vi.hoisted(() => ({
writeMock: vi.fn(),
renameMock: vi.fn(),
}))
vi.mock('node:fs', async orig => {
const actual = await orig<typeof import('node:fs')>()
writeMock.mockImplementation(actual.writeFileSync)
renameMock.mockImplementation(actual.renameSync)
return { ...actual, writeFileSync: writeMock, renameSync: renameMock }
})
const { patchTrayFile, writeFileAtomic } = await import('./tray-settings')
let home: string
const dockPath = () => join(home, '.config', 'codeburn', 'windows-dock.json')
/** Every path `writeFileSync` was given, which must never include the target itself. */
function written(): string[] {
return writeMock.mock.calls.map(call => String((call as [string])[0]))
}
beforeEach(() => {
home = mkdtempSync(join(actualFs.realpathSync(tmpdir()), 'tray-atomic-'))
mkdirSync(join(home, '.config', 'codeburn'), { recursive: true })
writeMock.mockClear()
renameMock.mockClear()
writeMock.mockImplementation(actualFs.writeFileSync)
renameMock.mockImplementation(actualFs.renameSync)
})
afterEach(() => {
rmSync(home, { recursive: true, force: true })
})
describe('writing a file the tray app reads', () => {
it('writes a temp file in the same directory and renames it over the target', () => {
patchTrayFile('dock', { scale: 1.1 }, home)
expect(written()).toHaveLength(1)
const temp = written()[0]!
// The target is never opened for writing, so it is never briefly empty.
expect(temp).not.toBe(dockPath())
// The same directory, because a rename across volumes is a copy, and a copy is not atomic.
expect(dirname(temp)).toBe(dirname(dockPath()))
expect(renameMock).toHaveBeenCalledWith(temp, dockPath())
expect(JSON.parse(readFileSync(dockPath(), 'utf8'))).toEqual({ scale: 1.1 })
expect(readdirSync(dirname(dockPath()))).toEqual(['windows-dock.json'])
})
it('still merges into what the tray app already wrote', () => {
writeFileSync(dockPath(), JSON.stringify({ enabled: true, placement: { docked: 'right' } }))
expect(patchTrayFile('dock', { scale: 1.1 }, home)).toEqual({
enabled: true,
placement: { docked: 'right' },
scale: 1.1,
})
expect(JSON.parse(readFileSync(dockPath(), 'utf8'))).toEqual({
enabled: true,
placement: { docked: 'right' },
scale: 1.1,
})
})
/// The whole point of the rename: everything up to it can be lost without the tray app ever
/// seeing anything but the file it had.
it('leaves the old file whole when the rename never happens', () => {
writeFileSync(dockPath(), JSON.stringify({ enabled: true, scale: 0.6 }))
renameMock.mockImplementation(() => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) })
expect(() => patchTrayFile('dock', { scale: 1.1 }, home)).toThrow('EPERM')
expect(JSON.parse(readFileSync(dockPath(), 'utf8'))).toEqual({ enabled: true, scale: 0.6 })
// And no half-written temp file is left in the directory the tray app reads.
expect(readdirSync(dirname(dockPath()))).toEqual(['windows-dock.json'])
})
it('reports a failed write rather than letting it read as a save', () => {
writeMock.mockImplementation(() => { throw new Error('ENOSPC') })
expect(() => writeFileAtomic(dockPath(), 'x')).toThrow('ENOSPC')
expect(renameMock).not.toHaveBeenCalled()
})
it('creates the directory when nothing has written there yet', () => {
rmSync(join(home, '.config'), { recursive: true, force: true })
writeFileAtomic(dockPath(), 'hello')
expect(readFileSync(dockPath(), 'utf8')).toBe('hello')
})
})

View file

@ -0,0 +1,227 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_TRAY_APP_PREFS,
canDeselect,
isPatchObject,
normalizeScale,
normalizedPreferred,
parseTrayAppPrefs,
parseTrayDockPrefs,
patchTrayFile,
readTrayFile,
sanitizeAppPatch,
sanitizeDockPatch,
} from './tray-settings'
describe('reading the tray app files', () => {
let home: string
const settingsPath = () => join(home, '.config', 'codeburn', 'windows-settings.json')
const dockPath = () => join(home, '.config', 'codeburn', 'windows-dock.json')
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'tray-settings-'))
mkdirSync(join(home, '.config', 'codeburn'), { recursive: true })
})
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
it('is empty for a machine where the tray app has never run', () => {
expect(readTrayFile('app', home)).toEqual({})
expect(readTrayFile('dock', home)).toEqual({})
})
it('reads each file from the path the tray app reads it from', () => {
writeFileSync(settingsPath(), JSON.stringify({ metric: 'tokens' }))
writeFileSync(dockPath(), JSON.stringify({ enabled: true }))
expect(readTrayFile('app', home)).toEqual({ metric: 'tokens' })
expect(readTrayFile('dock', home)).toEqual({ enabled: true })
})
it('survives a byte order mark and refuses anything that is not an object', () => {
writeFileSync(settingsPath(), `${JSON.stringify({ metric: 'tokens' })}`)
expect(readTrayFile('app', home)).toEqual({ metric: 'tokens' })
writeFileSync(settingsPath(), '[1, 2]')
expect(readTrayFile('app', home)).toEqual({})
})
// Both files hold keys this side has no business touching: the rail's placement, the tray
// app's own theme cache, the provider set it seeded for itself.
it('merges into what is already there rather than replacing it', () => {
writeFileSync(dockPath(), JSON.stringify({
enabled: true,
placement: { docked: 'right', x: 1, y: 0.37 },
scale: 0.6,
}))
patchTrayFile('dock', { scale: 1.1 }, home)
expect(JSON.parse(readFileSync(dockPath(), 'utf8'))).toEqual({
enabled: true,
placement: { docked: 'right', x: 1, y: 0.37 },
scale: 1.1,
})
})
it('creates the directory when nothing has written there yet', () => {
rmSync(join(home, '.config'), { recursive: true, force: true })
patchTrayFile('app', { metric: 'cost' }, home)
expect(JSON.parse(readFileSync(settingsPath(), 'utf8'))).toEqual({ metric: 'cost' })
})
})
// The tray app collapses a value it cannot read to a default, so sending one would silently
// lose the setting. Everything is checked against the same closed set it parses with.
describe('parseTrayAppPrefs', () => {
it('reads every key the Menu bar pane shows', () => {
expect(parseTrayAppPrefs({
metric: 'totalTokens',
menubarPeriod: 'week',
accent: 'green',
trayBadge: true,
usageRefreshSeconds: 300,
quotaCadenceSeconds: 900,
terminal: 'powershell',
})).toEqual({
metric: 'totalTokens',
menubarPeriod: 'week',
accent: 'green',
trayBadge: true,
usageRefreshSeconds: 300,
quotaCadenceSeconds: 900,
terminal: 'powershell',
})
})
it('falls back to the tray app defaults for anything it would not understand', () => {
expect(parseTrayAppPrefs({
metric: 'credits',
menubarPeriod: '30days',
accent: 'chartreuse',
trayBadge: 'yes',
usageRefreshSeconds: 45,
quotaCadenceSeconds: -5,
terminal: 'bash',
})).toEqual(DEFAULT_TRAY_APP_PREFS)
expect(parseTrayAppPrefs({})).toEqual(DEFAULT_TRAY_APP_PREFS)
})
})
describe('parseTrayDockPrefs', () => {
it('reads every key the Capacity Dock pane shows', () => {
expect(parseTrayDockPrefs({
enabled: true,
preferred: 'claude',
scale: 0.9,
theme: 'glass',
gaugeShape: 'squircle',
providers: ['claude', 'codex'],
manualSelection: true,
})).toEqual({
enabled: true,
preferred: 'claude',
scale: 0.9,
theme: 'glass',
gaugeShape: 'squircle',
providers: ['claude', 'codex'],
manualSelection: true,
})
})
it('still understands the appearance by the name it used to have', () => {
expect(parseTrayDockPrefs({ theme: 'acrylic' }).theme).toBe('glass')
})
it('clamps and snaps the size to the steps the slider offers', () => {
expect(normalizeScale(0.2)).toBe(0.6)
expect(normalizeScale(9)).toBe(1.2)
expect(normalizeScale(0.93)).toBe(0.95)
expect(normalizeScale('big')).toBe(0.6)
})
})
describe('sanitizeAppPatch', () => {
it('passes each key through, one at a time', () => {
expect(sanitizeAppPatch({ metric: 'iconOnly' })).toEqual({ metric: 'iconOnly' })
expect(sanitizeAppPatch({ menubarPeriod: 'month' })).toEqual({ menubarPeriod: 'month' })
expect(sanitizeAppPatch({ accent: 'purple' })).toEqual({ accent: 'purple' })
expect(sanitizeAppPatch({ trayBadge: true })).toEqual({ trayBadge: true })
expect(sanitizeAppPatch({ usageRefreshSeconds: 0 })).toEqual({ usageRefreshSeconds: 0 })
expect(sanitizeAppPatch({ quotaCadenceSeconds: 60 })).toEqual({ quotaCadenceSeconds: 60 })
expect(sanitizeAppPatch({ terminal: 'commandPrompt' })).toEqual({ terminal: 'commandPrompt' })
})
// The renderer cannot reach a key the tray app owns, and cannot store a value it would
// later fail to read.
it('drops keys it is not allowed to write and repairs values it is', () => {
expect(sanitizeAppPatch({ theme: 'dark', claudeConfigDirs: ['C:\\x'] })).toEqual({})
expect(sanitizeAppPatch({ accent: 'not-a-colour' })).toEqual({ accent: 'ember' })
expect(sanitizeAppPatch({ trayBadge: 'on' })).toEqual({ trayBadge: false })
})
})
describe('sanitizeDockPatch', () => {
it('passes each key through, one at a time', () => {
expect(sanitizeDockPatch({ enabled: true })).toEqual({ enabled: true })
expect(sanitizeDockPatch({ scale: 1.05 })).toEqual({ scale: 1.05 })
expect(sanitizeDockPatch({ theme: 'glass' })).toEqual({ theme: 'glass' })
expect(sanitizeDockPatch({ gaugeShape: 'squircle' })).toEqual({ gaugeShape: 'squircle' })
expect(sanitizeDockPatch({ preferred: 'codex' })).toEqual({ preferred: 'codex' })
})
// Editing the set by hand is what stops the tray app seeding it from whatever is connected.
it('latches the manual selection whenever the provider set is written', () => {
expect(sanitizeDockPatch({ providers: ['claude', 'codex'] }))
.toEqual({ providers: ['claude', 'codex'], manualSelection: true })
})
it('refuses anything that is not a provider id, and the rail placement', () => {
expect(sanitizeDockPatch({ placement: { docked: 'left' } })).toEqual({})
expect(sanitizeDockPatch({ preferred: '../../etc' })).toEqual({ preferred: null })
expect(sanitizeDockPatch({ providers: ['claude', 'Bad Id', 42] }))
.toEqual({ providers: ['claude'], manualSelection: true })
})
})
// Both patches arrive over IPC, so the argument is whatever the renderer sent rather than the
// object the signature promises. `'metric' in patch` throws a TypeError on a string, a number
// and null, and on an array it answers about indices, so the shape is settled first.
describe('a patch that is not an object at all', () => {
const notObjects: unknown[] = [null, undefined, 'metric', 42, ['metric'], [], true]
it('is dropped whole by isPatchObject', () => {
for (const value of notObjects) expect(isPatchObject(value)).toBe(false)
expect(isPatchObject({})).toBe(true)
expect(isPatchObject({ metric: 'cost' })).toBe(true)
})
it('writes nothing through sanitizeAppPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeAppPatch(value)).toEqual({})
})
it('writes nothing through sanitizeDockPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeDockPatch(value)).toEqual({})
})
})
describe('the rail always has something to show', () => {
it('moves the resting provider when it leaves the set', () => {
expect(normalizedPreferred('claude', ['claude', 'codex'])).toBe('claude')
expect(normalizedPreferred('gemini', ['claude', 'codex'])).toBe('claude')
expect(normalizedPreferred('claude', [])).toBeNull()
})
it('keeps the last connected provider switched on', () => {
expect(canDeselect('claude', ['claude', 'codex'], ['claude', 'codex'])).toBe(true)
expect(canDeselect('claude', ['claude'], ['claude'])).toBe(false)
// One that is not connected is not what the rail is being kept alive by.
expect(canDeselect('codex', ['claude', 'codex'], ['claude'])).toBe(true)
expect(canDeselect('gemini', ['claude'], ['claude'])).toBe(true)
})
})

View file

@ -0,0 +1,239 @@
// The tray app's own preferences, read and written from the desktop app.
//
// The tray app (windows/) keeps them in two files that it reads from Rust before any of its
// windows exist, so those files are the interface rather than any running process:
//
// ~/.config/codeburn/windows-settings.json everything only the tray app cares about
// ~/.config/codeburn/windows-dock.json the Capacity Dock, whose placement lives here too
//
// Both hold keys this side has no business touching (the rail's placement, the tray app's
// theme cache), so every write is a merge of the named keys into whatever is already there.
// And every value is checked against the same closed set the tray app parses it with
// (windows/src/lib/appSettings.ts, windows/src/lib/dockPrefs.ts): a value it cannot
// understand collapses to a default there, so sending one would silently lose the setting.
//
// After a write the tray app is nudged with `--reload-settings`, which makes it re-read both
// files and broadcast to its own windows. That is done by the caller, which owns the process.
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export type TraySettingsFile = 'app' | 'dock'
function filePath(file: TraySettingsFile, home: string): string {
return join(home, '.config', 'codeburn', file === 'app' ? 'windows-settings.json' : 'windows-dock.json')
}
/**
* Write a file the tray app reads, without ever showing it a half-written one.
*
* `writeFileSync` truncates the target and then fills it, and the tray app reads these files
* on a schedule of its own and again on every `--reload-settings`. A read landing between the
* two sees an empty or cut-off file, parses it as no preferences at all, and every setting in
* it collapses to a default. So the bytes go to a temp file in the same directory first, and
* the rename over the target is the only step a reader can observe: it gets the whole old
* file or the whole new one. The same directory matters, because a rename across volumes is
* a copy, and a copy is not atomic.
*
* Nothing is left behind when the rename cannot happen, and the failure is passed on rather
* than swallowed: a settings write that did not land must not read as one that did.
*/
export function writeFileAtomic(path: string, contents: string): void {
mkdirSync(dirname(path), { recursive: true })
const temp = `${path}.${process.pid}.${Date.now()}.tmp`
try {
writeFileSync(temp, contents)
renameSync(temp, path)
} catch (err) {
try { unlinkSync(temp) } catch { /* nothing to clean up */ }
throw err
}
}
export function readTrayFile(file: TraySettingsFile, home = homedir()): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(readFileSync(filePath(file, home), 'utf8').replace(/^/, ''))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed as Record<string, unknown>
} catch {
return {}
}
}
/** Merge the given keys into the file, leaving every other key the tray app owns alone. */
export function patchTrayFile(
file: TraySettingsFile,
patch: Record<string, unknown>,
home = homedir(),
): Record<string, unknown> {
const merged = { ...readTrayFile(file, home), ...patch }
writeFileAtomic(filePath(file, home), `${JSON.stringify(merged, null, 2)}\n`)
return merged
}
// What each setting may be -----------------------------------------------------------------
export const DISPLAY_METRICS = ['cost', 'tokens', 'totalTokens', 'iconOnly'] as const
export const MENUBAR_PERIODS = ['today', 'week', 'month', 'all'] as const
export const ACCENTS = ['ember', 'blue', 'purple', 'pink', 'red', 'orange', 'yellow', 'green', 'graphite'] as const
export const USAGE_CADENCES = [-1, 0, 60, 300, 900] as const
export const QUOTA_CADENCES = [0, 60, 120, 300, 900] as const
export const TERMINALS = ['windowsTerminal', 'powershell', 'commandPrompt'] as const
export const DOCK_THEMES = ['graphite', 'glass'] as const
export const DOCK_GAUGE_SHAPES = ['circle', 'squircle'] as const
export const DOCK_SCALE_MIN = 0.6
export const DOCK_SCALE_MAX = 1.2
export const DOCK_SCALE_STEP = 0.05
export type TrayAppPrefs = {
metric: string
menubarPeriod: string
accent: string
trayBadge: boolean
usageRefreshSeconds: number
quotaCadenceSeconds: number
terminal: string
}
export type TrayDockPrefs = {
enabled: boolean
preferred: string | null
scale: number
theme: string
gaugeShape: string
providers: string[]
manualSelection: boolean
}
export const DEFAULT_TRAY_APP_PREFS: TrayAppPrefs = {
metric: 'cost',
menubarPeriod: 'today',
accent: 'ember',
trayBadge: false,
usageRefreshSeconds: -1,
quotaCadenceSeconds: 120,
terminal: 'windowsTerminal',
}
export const DEFAULT_TRAY_DOCK_PREFS: TrayDockPrefs = {
enabled: false,
preferred: null,
scale: DOCK_SCALE_MIN,
theme: 'graphite',
gaugeShape: 'circle',
providers: [],
manualSelection: false,
}
function oneOf<T extends string | number>(value: unknown, allowed: readonly T[], fallback: T): T {
return (allowed as readonly unknown[]).includes(value) ? (value as T) : fallback
}
/** The same reading the tray app does, so what the pane shows is what the tray app sees. */
export function parseTrayAppPrefs(raw: Record<string, unknown>): TrayAppPrefs {
return {
metric: oneOf(raw.metric, DISPLAY_METRICS, DEFAULT_TRAY_APP_PREFS.metric),
menubarPeriod: oneOf(raw.menubarPeriod, MENUBAR_PERIODS, DEFAULT_TRAY_APP_PREFS.menubarPeriod),
accent: oneOf(raw.accent, ACCENTS, DEFAULT_TRAY_APP_PREFS.accent),
trayBadge: typeof raw.trayBadge === 'boolean' ? raw.trayBadge : DEFAULT_TRAY_APP_PREFS.trayBadge,
usageRefreshSeconds: oneOf(raw.usageRefreshSeconds, USAGE_CADENCES, DEFAULT_TRAY_APP_PREFS.usageRefreshSeconds),
quotaCadenceSeconds: oneOf(raw.quotaCadenceSeconds, QUOTA_CADENCES, DEFAULT_TRAY_APP_PREFS.quotaCadenceSeconds),
terminal: oneOf(raw.terminal, TERMINALS, DEFAULT_TRAY_APP_PREFS.terminal),
}
}
/** The rail's scale is a slider, so it is clamped and snapped rather than picked from a set. */
export function normalizeScale(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_TRAY_DOCK_PREFS.scale
const clamped = Math.min(DOCK_SCALE_MAX, Math.max(DOCK_SCALE_MIN, value))
const steps = Math.round((clamped - DOCK_SCALE_MIN) / DOCK_SCALE_STEP)
return Number((DOCK_SCALE_MIN + steps * DOCK_SCALE_STEP).toFixed(2))
}
export function parseTrayDockPrefs(raw: Record<string, unknown>): TrayDockPrefs {
return {
enabled: raw.enabled === true,
preferred: typeof raw.preferred === 'string' ? raw.preferred : null,
scale: normalizeScale(raw.scale),
// `acrylic` is the spelling the setting had before the appearance was renamed Glass.
theme: raw.theme === 'glass' || raw.theme === 'acrylic' ? 'glass' : 'graphite',
gaugeShape: oneOf(raw.gaugeShape, DOCK_GAUGE_SHAPES, DEFAULT_TRAY_DOCK_PREFS.gaugeShape),
providers: Array.isArray(raw.providers) ? raw.providers.filter((id): id is string => typeof id === 'string') : [],
manualSelection: raw.manualSelection === true,
}
}
/** Provider ids are the CLI's own, and they end up in a file the tray app parses. */
const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
/**
* Whether a patch is a shape the key checks below can be run against at all.
*
* These patches arrive over IPC, so what turns up is whatever the renderer sent rather than
* what the type says. `'metric' in patch` throws a TypeError on a string, a number or null,
* and on an array it answers about indices, so the shape is settled before any key is read
* out of it. Nothing but a plain object can carry a setting, so everything else is dropped.
*/
export function isPatchObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Keep only the keys this pane is allowed to write, each checked against what the tray app
* can read back. Anything else in the patch is dropped rather than merged, so a renderer
* cannot reach the rail's placement or any other key the tray app owns.
*/
export function sanitizeAppPatch(patch: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (!isPatchObject(patch)) return out
if ('metric' in patch) out.metric = oneOf(patch.metric, DISPLAY_METRICS, DEFAULT_TRAY_APP_PREFS.metric)
if ('menubarPeriod' in patch) out.menubarPeriod = oneOf(patch.menubarPeriod, MENUBAR_PERIODS, DEFAULT_TRAY_APP_PREFS.menubarPeriod)
if ('accent' in patch) out.accent = oneOf(patch.accent, ACCENTS, DEFAULT_TRAY_APP_PREFS.accent)
if ('trayBadge' in patch) out.trayBadge = patch.trayBadge === true
if ('usageRefreshSeconds' in patch) out.usageRefreshSeconds = oneOf(patch.usageRefreshSeconds, USAGE_CADENCES, DEFAULT_TRAY_APP_PREFS.usageRefreshSeconds)
if ('quotaCadenceSeconds' in patch) out.quotaCadenceSeconds = oneOf(patch.quotaCadenceSeconds, QUOTA_CADENCES, DEFAULT_TRAY_APP_PREFS.quotaCadenceSeconds)
if ('terminal' in patch) out.terminal = oneOf(patch.terminal, TERMINALS, DEFAULT_TRAY_APP_PREFS.terminal)
return out
}
export function sanitizeDockPatch(patch: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (!isPatchObject(patch)) return out
if ('enabled' in patch) out.enabled = patch.enabled === true
if ('scale' in patch) out.scale = normalizeScale(patch.scale)
if ('theme' in patch) out.theme = patch.theme === 'glass' ? 'glass' : 'graphite'
if ('gaugeShape' in patch) out.gaugeShape = oneOf(patch.gaugeShape, DOCK_GAUGE_SHAPES, DEFAULT_TRAY_DOCK_PREFS.gaugeShape)
if ('preferred' in patch) {
out.preferred = typeof patch.preferred === 'string' && PROVIDER_ID.test(patch.preferred) ? patch.preferred : null
}
if ('providers' in patch) {
const providers = Array.isArray(patch.providers)
? patch.providers.filter((id): id is string => typeof id === 'string' && PROVIDER_ID.test(id))
: []
out.providers = providers
// Editing the set by hand is what stops the tray app auto-seeding it from whatever is
// connected, exactly as its own settings window latches it.
out.manualSelection = true
}
return out
}
/**
* The rail can only rest on a provider it is showing, so a resting provider that has left the
* set gives way to the first one still in it. CapacityDockPreferences.normalizedPreferred.
*/
export function normalizedPreferred(preferred: string | null, selected: string[]): string | null {
if (preferred !== null && selected.includes(preferred)) return preferred
return selected[0] ?? null
}
/**
* The rail must never end up with nothing to show, so the last connected provider in the set
* cannot be switched off. CapacityDockProviderSelection.canDeselect.
*/
export function canDeselect(id: string, selected: string[], connected: readonly string[]): boolean {
if (!selected.includes(id)) return true
if (!connected.includes(id)) return true
return selected.filter(entry => connected.includes(entry)).length > 1
}

View file

@ -12,11 +12,14 @@
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json",
"stage-cli": "npm --prefix .. run build:cli && node scripts/stage-cli.mjs",
"stage-menubar": "node scripts/stage-menubar.mjs --target nsis",
"stage-menubar:store": "node scripts/stage-menubar.mjs --target appx",
"package": "npm run stage-cli && npm run build && electron-builder --mac",
"package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64",
"package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64",
"package:win": "npm run stage-cli && npm run build && electron-builder --win",
"package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64",
"package:win": "npm run stage-cli && npm run stage-menubar && npm run build && electron-builder --win",
"package:win:arm64": "npm run stage-cli && npm run stage-menubar && npm run build && cross-env ELECTRON_BUILDER_7Z_FILTER=BCJ electron-builder --win nsis --arm64",
"package:store": "npm run stage-cli && npm run stage-menubar:store && npm run build && electron-builder --win appx --x64",
"package:linux": "npm run stage-cli && npm run build && electron-builder --linux",
"package:snap": "npm run stage-cli && npm run build && electron-builder --linux snap --x64"
},
@ -96,14 +99,25 @@
]
}
],
"icon": "build/icon.png"
"icon": "build/icon.png",
"extraResources": [
{
"from": "build/menubar",
"to": "menubar",
"filter": [
"**/*"
]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"artifactName": "CodeBurn-Setup-${version}.${ext}"
"artifactName": "CodeBurn-Setup-${version}.${ext}",
"include": "build/installer.nsh"
},
"appx": {
"customExtensionsPath": "build/appx-extensions.xml",
"applicationId": "CodeBurn",
"identityName": "Codeburn.CodeBurn",
"publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89",

View file

@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => ({
getAliases: vi.fn(),
setCurrency: vi.fn(),
resetCurrency: vi.fn(),
telemetryTrack: vi.fn<(name: string, props?: Record<string, unknown>) => Promise<boolean>>(),
}))
vi.mock('./lib/ipc', async orig => {
@ -191,6 +192,7 @@ function installDefaultMocks() {
mocks.getAliases.mockResolvedValue([])
mocks.setCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
mocks.telemetryTrack.mockResolvedValue(true)
}
describe('App shortcuts', () => {
@ -1118,6 +1120,53 @@ describe('currency correctness', () => {
})
})
describe('usage_snapshot dispatch', () => {
beforeEach(() => {
installDefaultMocks()
localStorage.clear()
localStorage.setItem('codeburn.defaultPeriod', '30days')
})
it('forwards the CLI-computed snapshot verbatim, with no by-model join', async () => {
const payload = overviewPayload()
// Opaque to the renderer: whatever the CLI put here is what goes out.
payload.telemetrySnapshot = { schema: 2, period: 'Last 30 days', costBucket: '10-50', models: [] }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
await waitFor(() => expect(mocks.telemetryTrack).toHaveBeenCalledWith('usage_snapshot', payload.telemetrySnapshot))
expect(mocks.getModels).not.toHaveBeenCalled()
})
it('falls back to the renderer builder when an older CLI omits the field', async () => {
const payload = overviewPayload()
payload.current.topModels = [{ name: 'claude-opus-4-8', cost: 30, savingsUSD: 0, savingsBaselineModel: '', calls: 400 }]
mocks.getOverview.mockResolvedValue(payload)
mocks.getModels.mockResolvedValue([])
render(<App />)
await waitFor(() => expect(mocks.telemetryTrack).toHaveBeenCalledWith('usage_snapshot', expect.objectContaining({
period: 'Last 30 days',
costBucket: '10-50',
})))
expect(mocks.getModels).toHaveBeenCalled()
})
it('sends nothing when the snapshot field is explicitly null and the payload is empty', async () => {
const payload = overviewPayload()
payload.telemetrySnapshot = null
mocks.getOverview.mockResolvedValue(payload)
mocks.getModels.mockResolvedValue([])
render(<App />)
// A null snapshot is an older/failed CLI, not a reason to skip the event.
await waitFor(() => expect(mocks.telemetryTrack).toHaveBeenCalledWith('usage_snapshot', expect.objectContaining({ period: 'Last 30 days' })))
})
})
describe('usage_snapshot telemetry props', () => {
// The renderer builds these props; the main-process sanitizer (sanitizeProps)
// is the last gate before the wire. Test the composition, which is what ships.

View file

@ -19,6 +19,7 @@ import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
import { clearOverviewHeadlines, readOverviewHeadline, writeOverviewHeadline } from './lib/overviewSnapshot'
import { codeburn } from './lib/ipc'
import { trackEvent } from './lib/track'
import { isModifierChord, shortcutLabel } from './lib/platform'
import { localDateKey, PERIOD_LABELS } from './lib/period'
import { readDisabledProviders } from './lib/providers'
@ -50,6 +51,7 @@ function costBucket(usd: number): string {
// Bucket occurrence counts (MCP-server / skill invocations) the same way costBucket
// coarsens dollars: telemetry carries usage magnitude, never an exact tally.
function countBucket(n: number): string {
if (!(n > 0)) return '0'
if (n < 10) return '1-10'
if (n < 100) return '10-100'
if (n < 1000) return '100-1k'
@ -71,7 +73,11 @@ export function topCategoryByModel(rows: ModelReportRow[]): Map<string, string>
return map
}
/** The once-per-day anonymous aggregate (main process dedups by calendar day). */
/** The once-per-day anonymous aggregate, built in the renderer.
* Fallback only: current CLIs compute the richer `payload.telemetrySnapshot`
* (src/telemetry-snapshot.ts) so the desktop app and the Windows tray send the
* identical shape. This builder covers a CLI that predates that field.
* The main process dedups the event by calendar day either way. */
export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Map<string, string>): Record<string, unknown> {
return {
period: payload.current.label,
@ -362,16 +368,13 @@ function AppMain() {
if (typeof codeburn.completeOnboarding === 'function') void codeburn.completeOnboarding(enabled).catch(() => {})
}, [])
const trackEvent = useCallback((name: string, props?: Record<string, unknown>) => {
if (typeof codeburn.telemetryTrack === 'function') void codeburn.telemetryTrack(name, props).catch(() => {})
}, [])
// Once-per-day anonymous usage aggregate, only from the canonical view
// (all providers, standard period, no config scope) so buckets are stable.
// Gated to the first qualifying render per calendar day so the extra by-model
// report fetch runs at most once/day, not on every poll (main also dedups the
// event). The fetch enriches each model with its dominant task category; if it
// fails we still emit the snapshot, just without the model x category cross.
// Gated to the first qualifying render per calendar day (main also dedups the
// event). Current CLIs hand us the finished, already-bucketed snapshot and we
// forward it verbatim; only an older CLI falls back to the renderer builder,
// which needs an extra by-model report fetch to get the model x category cross
// and still emits without it if that fetch fails.
const snapshotDayRef = useRef<string | null>(null)
useEffect(() => {
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
@ -379,6 +382,11 @@ function AppMain() {
if (snapshotDayRef.current === today) return
snapshotDayRef.current = today
const payload = overview.data
const fromCli = payload.telemetrySnapshot
if (fromCli && typeof fromCli === 'object') {
trackEvent('usage_snapshot', fromCli)
return
}
void (async () => {
let modelCategories: Map<string, string> | undefined
try {
@ -386,7 +394,7 @@ function AppMain() {
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
}, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])
}, [overview.data, provider, customRange, claudeConfigSource, scope, period])
useEffect(() => {
let saved: string | null = null
@ -608,7 +616,7 @@ function AppMain() {
setSettingsPane(pane)
setSection(next)
trackEvent('section_view', { section: next })
}, [trackEvent])
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {

View file

@ -12,12 +12,23 @@ export type SocialLink = {
icon: ReactNode
}
/** Where CodeBurn lives on the web. These used to sit as a row of glyphs in the sidebar's
* bottom corner, which is where the two Windows switches are now; About was already listing
* them under Links, so that is the one place they appear. */
export const SOCIALS: SocialLink[] = [
{ label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: <svg viewBox="0 0 24 24"><path d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.62-5.48 5.92.43.37.81 1.1.81 2.22 0 1.6-.01 2.9-.01 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12.5C24 5.87 18.63.5 12 .5z" /></svg> },
{ label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: <svg viewBox="0 0 24 24"><path d="M20.32 4.37A19.8 19.8 0 0 0 15.45 3c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.47 0C8.71 3.9 8.45 3.38 8.24 3a19.7 19.7 0 0 0-4.88 1.37C.86 8.75.05 13.02.45 17.23a19.9 19.9 0 0 0 6 3.03c.48-.66.91-1.36 1.28-2.11-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38a14.2 14.2 0 0 0 12.16 0c.16.14.32.26.49.38-.63.38-1.31.7-2 .96.37.75.8 1.45 1.28 2.11a19.8 19.8 0 0 0 6-3.03c.47-4.87-.8-9.1-3.83-12.86zM8.02 14.65c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41z" /></svg> },
{ label: 'X', url: 'https://x.com/_codeburn', icon: <svg viewBox="0 0 24 24"><path d="M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.49l8.6-9.83L0 1.15h7.59l5.24 6.93 6.07-6.93zm-1.29 19.5h2.04L6.49 3.24H4.3l13.31 17.41z" /></svg> },
{ label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: <svg viewBox="0 0 24 24"><path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.38.56A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14C4.5 20.5 12 20.5 12 20.5s7.5 0 9.38-.56A3.02 3.02 0 0 0 23.5 17.8 31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" /></svg> },
{ label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: <svg viewBox="0 0 256 256"><path fill="currentColor" d="M218.123 218.127h-37.931v-59.403c0-14.165-.253-32.4-19.728-32.4-19.756 0-22.779 15.434-22.779 31.369v60.43h-37.93V95.967h36.413v16.694h.51a39.907 39.907 0 0 1 35.928-19.733c38.445 0 45.533 25.288 45.533 58.186l-.016 67.013ZM56.955 79.27c-12.157.002-22.014-9.852-22.016-22.009-.002-12.157 9.851-22.014 22.008-22.016 12.157-.003 22.014 9.851 22.016 22.008A22.013 22.013 0 0 1 56.955 79.27m18.966 138.858H37.95V95.967h37.97v122.16ZM237.033.018H18.89C8.58-.098.125 8.161-.001 18.471v219.053c.122 10.315 8.576 18.582 18.89 18.474h218.144c10.336.128 18.823-8.139 18.966-18.474V18.454c-.147-10.33-8.635-18.588-18.966-18.453" /></svg> },
]
function openExternal(event: MouseEvent<HTMLAnchorElement>, url: string): void {
event.preventDefault()
void codeburn.openExternal(url)
}
export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClose: () => void }) {
export function AboutModal({ socials = SOCIALS, onClose }: { socials?: SocialLink[]; onClose: () => void }) {
const status = useUpdateStatus()
const [checked, setChecked] = useState(false)

View file

@ -59,7 +59,9 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean
<h2 className="onboard-title">Help improve CodeBurn</h2>
<p className="onboard-body">
Share anonymous usage statistics: model and provider mix, task success rates, performance and errors.
Never your prompts, code, project names or anything that identifies you.
The daily report includes the names of the models, tools, skills and MCP servers you use, alongside
bucketed counts of how often each one came up.
Never your prompts, your code, or your project and file names.
</p>
<div className="onboard-consent">
<span id="onboard-consent-label">Anonymous telemetry</span>

View file

@ -1,16 +1,31 @@
// @vitest-environment jsdom
import { afterEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from './Sidebar'
// The preload bridge is read once at module load, so the switches need it mocked rather than
// assigned onto `window` after the fact.
const bridge = vi.hoisted(() => ({
companionStatus: vi.fn(),
setMenuBarEnabled: vi.fn(),
setSidebarEnabled: vi.fn(),
openExternal: vi.fn(),
}))
vi.mock('../lib/ipc', () => ({ codeburn: bridge, normalizeCliError: (err: unknown) => err }))
function setPlatform(platform: string): void {
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
}
describe('Sidebar', () => {
beforeEach(() => {
bridge.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false })
})
afterEach(() => {
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
vi.clearAllMocks()
})
it.each([
@ -48,4 +63,150 @@ describe('Sidebar', () => {
// motionEnabled() is off under vitest, so the idle flicker never attaches.
expect(container.querySelector('.fm-flicker')).toBeNull()
})
it('keeps About and the social glyphs in the corner off Windows', () => {
setPlatform('darwin')
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument()
expect([...container.querySelectorAll('.foot .social a')].map(a => a.getAttribute('aria-label')))
.toEqual(['GitHub', 'Discord', 'X', 'YouTube', 'LinkedIn'])
})
it('opens a glyph through the shell rather than navigating the window', () => {
setPlatform('linux')
render(<Sidebar active="overview" onNavigate={() => {}} />)
fireEvent.click(screen.getByRole('link', { name: 'GitHub' }))
expect(bridge.openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn')
})
// Windows is the one platform that gives that corner to something else: the two companion
// switches sit above About, and a 186px sidebar has no room for both.
it('gives the corner to the companion switches on Windows', async () => {
setPlatform('win32')
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(container.querySelector('.foot .social')).toBeNull()
// About still lists every one of them under Links, on every platform.
fireEvent.click(screen.getByRole('link', { name: 'About' }))
expect(await screen.findByRole('link', { name: /GitHub/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /LinkedIn/ })).toBeInTheDocument()
})
})
describe('Sidebar companion switches', () => {
const SUPPORTED = { supported: true, menuBar: true, sidebar: true, store: false }
afterEach(() => { vi.clearAllMocks() })
async function renderSwitches(status = SUPPORTED) {
bridge.companionStatus.mockResolvedValue(status)
render(<Sidebar active="overview" onNavigate={() => {}} />)
return screen.findByRole('switch', { name: 'Menu bar' })
}
it('shows nothing where the main process reports no bundled tray app', async () => {
bridge.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false })
render(<Sidebar active="overview" onNavigate={() => {}} />)
await waitFor(() => expect(bridge.companionStatus).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('survives a preload that has never heard of them', async () => {
bridge.companionStatus.mockRejectedValue(new Error('no such channel'))
render(<Sidebar active="overview" onNavigate={() => {}} />)
await waitFor(() => expect(bridge.companionStatus).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders both switches on, in the sidebar corner', async () => {
const menuBar = await renderSwitches()
expect(menuBar).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'true')
})
it('turning Menu bar off sends false and renders the status that came back', async () => {
const menuBar = await renderSwitches()
bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: false })
fireEvent.click(menuBar)
expect(bridge.setMenuBarEnabled).toHaveBeenCalledWith(false)
await waitFor(() => expect(menuBar).toHaveAttribute('aria-checked', 'false'))
expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'true')
})
it('turning Sidebar off leaves Menu bar alone', async () => {
await renderSwitches()
bridge.setSidebarEnabled.mockResolvedValue({ ...SUPPORTED, sidebar: false })
fireEvent.click(screen.getByRole('switch', { name: 'Sidebar' }))
expect(bridge.setSidebarEnabled).toHaveBeenCalledWith(false)
await waitFor(() => expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'false'))
expect(screen.getByRole('switch', { name: 'Menu bar' })).toHaveAttribute('aria-checked', 'true')
})
/// An install the person cancelled at the UAC prompt comes back unchanged, and the switch
/// has to show that rather than the state it optimistically painted.
it('stays where it was when the main process reports no change', async () => {
const menuBar = await renderSwitches({ ...SUPPORTED, menuBar: false })
bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: false })
fireEvent.click(menuBar)
await waitFor(() => expect(bridge.setMenuBarEnabled).toHaveBeenCalledWith(true))
expect(menuBar).toHaveAttribute('aria-checked', 'false')
})
// The rail is a window of the tray app, so it cannot be switched on without one.
it('disables Sidebar while Menu bar is off, and says why', async () => {
await renderSwitches({ ...SUPPORTED, menuBar: false, sidebar: false })
const sidebar = screen.getByRole('switch', { name: 'Sidebar' })
expect(sidebar).toBeDisabled()
expect(sidebar).toHaveAttribute('title', 'The Capacity Dock needs the menu bar app')
expect(screen.getByRole('switch', { name: 'Menu bar' })).toBeEnabled()
fireEvent.click(sidebar)
expect(bridge.setSidebarEnabled).not.toHaveBeenCalled()
})
it('enables Sidebar again once Menu bar comes back on', async () => {
const menuBar = await renderSwitches({ ...SUPPORTED, menuBar: false, sidebar: false })
expect(screen.getByRole('switch', { name: 'Sidebar' })).toBeDisabled()
bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: true, sidebar: false })
fireEvent.click(menuBar)
await waitFor(() => expect(screen.getByRole('switch', { name: 'Sidebar' })).toBeEnabled())
})
it('turning Menu bar off takes Sidebar down with it', async () => {
const menuBar = await renderSwitches()
bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: false, sidebar: false })
fireEvent.click(menuBar)
await waitFor(() => expect(menuBar).toHaveAttribute('aria-checked', 'false'))
const sidebar = screen.getByRole('switch', { name: 'Sidebar' })
expect(sidebar).toHaveAttribute('aria-checked', 'false')
expect(sidebar).toBeDisabled()
})
it('refuses a second click while one is still in flight', async () => {
const menuBar = await renderSwitches()
bridge.setMenuBarEnabled.mockReturnValue(new Promise(() => {}))
fireEvent.click(menuBar)
fireEvent.click(screen.getByRole('switch', { name: 'Sidebar' }))
expect(bridge.setMenuBarEnabled).toHaveBeenCalledTimes(1)
expect(bridge.setSidebarEnabled).not.toHaveBeenCalled()
})
})

View file

@ -1,8 +1,9 @@
import { useState, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { codeburn } from '../lib/ipc'
import { shortcutLabel } from '../lib/platform'
import { AboutModal, type SocialLink } from './AboutModal'
import { isWindowsPlatform, shortcutLabel } from '../lib/platform'
import type { CompanionStatus } from '../lib/types'
import { AboutModal, SOCIALS } from './AboutModal'
import { FlameMark } from './FlameMark'
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' | 'plugins'
@ -40,14 +41,6 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R
) },
]
const SOCIALS: SocialLink[] = [
{ label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: <svg viewBox="0 0 24 24"><path d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.62-5.48 5.92.43.37.81 1.1.81 2.22 0 1.6-.01 2.9-.01 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12.5C24 5.87 18.63.5 12 .5z" /></svg> },
{ label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: <svg viewBox="0 0 24 24"><path d="M20.32 4.37A19.8 19.8 0 0 0 15.45 3c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.47 0C8.71 3.9 8.45 3.38 8.24 3a19.7 19.7 0 0 0-4.88 1.37C.86 8.75.05 13.02.45 17.23a19.9 19.9 0 0 0 6 3.03c.48-.66.91-1.36 1.28-2.11-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38a14.2 14.2 0 0 0 12.16 0c.16.14.32.26.49.38-.63.38-1.31.7-2 .96.37.75.8 1.45 1.28 2.11a19.8 19.8 0 0 0 6-3.03c.47-4.87-.8-9.1-3.83-12.86zM8.02 14.65c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41z" /></svg> },
{ label: 'X', url: 'https://x.com/_codeburn', icon: <svg viewBox="0 0 24 24"><path d="M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.49l8.6-9.83L0 1.15h7.59l5.24 6.93 6.07-6.93zm-1.29 19.5h2.04L6.49 3.24H4.3l13.31 17.41z" /></svg> },
{ label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: <svg viewBox="0 0 24 24"><path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.38.56A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14C4.5 20.5 12 20.5 12 20.5s7.5 0 9.38-.56A3.02 3.02 0 0 0 23.5 17.8 31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" /></svg> },
{ label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: <svg viewBox="0 0 256 256"><path fill="currentColor" d="M218.123 218.127h-37.931v-59.403c0-14.165-.253-32.4-19.728-32.4-19.756 0-22.779 15.434-22.779 31.369v60.43h-37.93V95.967h36.413v16.694h.51a39.907 39.907 0 0 1 35.928-19.733c38.445 0 45.533 25.288 45.533 58.186l-.016 67.013ZM56.955 79.27c-12.157.002-22.014-9.852-22.016-22.009-.002-12.157 9.851-22.014 22.008-22.016 12.157-.003 22.014 9.851 22.016 22.008A22.013 22.013 0 0 1 56.955 79.27m18.966 138.858H37.95V95.967h37.97v122.16ZM237.033.018H18.89C8.58-.098.125 8.161-.001 18.471v219.053c.122 10.315 8.576 18.582 18.89 18.474h218.144c10.336.128 18.823-8.139 18.966-18.474V18.454c-.147-10.33-8.635-18.588-18.966-18.453" /></svg> },
]
export function Sidebar({
active,
onNavigate,
@ -83,24 +76,118 @@ export function Sidebar({
</div>
))}
<div className="push" />
<CompanionSwitches />
<div className="foot">
<a className="about" href="#about" onClick={event => { event.preventDefault(); setAboutOpen(true) }}>About</a>
<div className="social">
{SOCIALS.map(social => (
<a
key={social.label}
href={social.url}
title={social.label}
aria-label={social.label}
onClick={event => { event.preventDefault(); void codeburn.openExternal(social.url) }}
>
{social.icon}
</a>
))}
</div>
<SocialGlyphs />
</div>
</nav>
{aboutOpen ? <AboutModal socials={SOCIALS} onClose={() => setAboutOpen(false)} /> : null}
{aboutOpen ? <AboutModal onClose={() => setAboutOpen(false)} /> : null}
</>
)
}
/**
* The row of brand glyphs in the corner, beside About.
*
* Windows is the one platform that gives that corner to something else: the two companion
* switches sit above About, and a 186px sidebar has no room for both. Every other platform
* keeps the glyphs it always had, since nothing replaced them there. About lists the same
* links under Links on every platform.
*/
function SocialGlyphs() {
if (isWindowsPlatform()) return null
return (
<div className="social">
{SOCIALS.map(social => (
<a
key={social.label}
href={social.url}
title={social.label}
aria-label={social.label}
onClick={event => { event.preventDefault(); void codeburn.openExternal(social.url) }}
>
{social.icon}
</a>
))}
</div>
)
}
/**
* The two surfaces the Windows desktop app carries besides its own window: the tray app
* ("Menu bar") and the Capacity Dock rail it draws ("Sidebar"). Both are on by default and
* live above About, in the corner the social glyphs share on every other platform.
*
* Nothing renders until the main process says this build has a tray app staged, which is why
* there is no placeholder row and no disabled switch: on macOS, on Linux, and in a dev build
* with nothing staged, the corner is exactly what it always was.
*/
function CompanionSwitches() {
const [status, setStatus] = useState<CompanionStatus | null>(null)
const [busy, setBusy] = useState<'menuBar' | 'sidebar' | null>(null)
useEffect(() => {
let live = true
// `codeburn` is the preload bridge, absent in a plain browser and under tests, and
// `companionStatus` is absent on a preload that predates these two switches.
void codeburn?.companionStatus?.()
.then(next => { if (live) setStatus(next) })
.catch(() => {})
return () => { live = false }
}, [])
if (!status?.supported) return null
// Every setter answers with the whole status, so a switch shows what took rather than what
// was asked for: an install the person cancelled at the UAC prompt leaves it where it was.
const toggle = (key: 'menuBar' | 'sidebar') => {
if (busy) return
const call = key === 'menuBar' ? codeburn.setMenuBarEnabled : codeburn.setSidebarEnabled
if (!call) return
setBusy(key)
void call.call(codeburn, !status[key])
.then(setStatus)
.catch(() => {})
.finally(() => setBusy(null))
}
// The rail is a window of the tray app, and every setting it reads belongs to the tray app,
// so there is no rail without one. With Menu bar off the Sidebar switch has nothing to
// control and says so, rather than looking available and turning the tray app on underneath.
const railBlocked = !status.menuBar
const row = (key: 'menuBar' | 'sidebar', label: string, hint: string) => {
const blocked = key === 'sidebar' && railBlocked
const title = blocked ? 'The Capacity Dock needs the menu bar app' : hint
return (
<div className={blocked ? 'companion-row blocked' : 'companion-row'}>
<span className="companion-label" title={title}>{label}</span>
<button
type="button"
role="switch"
aria-checked={status[key]}
aria-label={label}
title={title}
disabled={busy !== null || blocked}
className={status[key] ? 'switch sm on' : 'switch sm'}
onClick={() => toggle(key)}
>
<span className="switch-knob" />
</button>
</div>
)
}
return (
<div className="companion">
{row('menuBar', 'Menu bar', 'Show CodeBurn in the Windows notification area')}
{row('sidebar', 'Sidebar', 'Show the Capacity Dock rail on the screen edge')}
{/* Windows finishes an install it could not complete at the next restart, and until
then the old tray app is what is on disk, so nothing was started. */}
{status.restartRequired ? (
<p className="companion-note">Restart Windows to finish installing the menu bar app.</p>
) : null}
</div>
)
}

View file

@ -24,6 +24,14 @@ export function isMacPlatform(): boolean {
return userAgentPlatform() === 'darwin'
}
/** True when the Electron preload reports win32 (or the UA names Windows). */
export function isWindowsPlatform(): boolean {
const platform = bridgePlatform()
if (platform) return platform === 'win32'
if (typeof navigator === 'undefined') return false
return /windows/i.test(navigator.userAgent)
}
/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */
export function modKeyLabel(): string {
return isMacPlatform() ? '⌘' : 'Ctrl+'

13
app/renderer/lib/track.ts Normal file
View file

@ -0,0 +1,13 @@
import { codeburn } from './ipc'
/** Fire-and-forget consent-gated telemetry. The main process decides whether
* anything is sent (see app/electron/telemetry.ts); the renderer only names the
* event. Nothing about it is load-bearing, so a preload that predates the
* bridge method, returns nothing, or throws must degrade to "no tracking"
* rather than take a click handler down with it. */
export function trackEvent(name: string, props?: Record<string, unknown>): void {
if (typeof codeburn.telemetryTrack !== 'function') return
try {
void Promise.resolve(codeburn.telemetryTrack(name, props)).catch(() => {})
} catch { /* a bridge that throws synchronously is still just no tracking */ }
}

View file

@ -302,6 +302,12 @@ export type MenubarPayload = {
currency?: { code: string; symbol: string; rate: number }
combined?: CombinedUsage
claudeConfigs?: ClaudeConfigSelector
// The CLI's anonymised, fully bucketed daily aggregate (src/telemetry-snapshot.ts).
// Sent verbatim as the `usage_snapshot` telemetry event, so the desktop app and
// the Windows tray report identical shapes. Opaque here: the renderer never reads
// inside it. Absent on CLIs that predate the field, which is when the renderer's
// own fallback builder takes over.
telemetrySnapshot?: Record<string, unknown> | null
}
// ————— src/types.ts + src/models-report.ts —————
@ -682,6 +688,48 @@ export type UpdateStatus = {
tag: string | null
}
/** The tray app and the Capacity Dock the Windows desktop app bundles (app/electron/menubar.ts).
* `supported` is false everywhere else, and on a Windows build with nothing staged. */
export type CompanionStatus = {
supported: boolean
menuBar: boolean
sidebar: boolean
/** The Store route, where launch at login is the package's own startup task. */
store: boolean
/** True when this run installed a tray app that Windows can only finish putting in place at
* the next restart. Nothing is started until then, so the corner says so rather than
* showing two switches on with nothing running. */
restartRequired?: boolean
}
/** The tray app's own settings, from the two files it reads them from
* (windows-settings.json, windows-dock.json) plus the HKCU Run value. */
export type TrayPrefs = {
app: {
metric: string
menubarPeriod: string
accent: string
trayBadge: boolean
usageRefreshSeconds: number
quotaCadenceSeconds: number
terminal: string
}
dock: {
enabled: boolean
preferred: string | null
scale: number
theme: string
gaugeShape: string
providers: string[]
manualSelection: boolean
}
launchAtLogin: boolean
/** True where launch at login belongs to Windows rather than to this app: the Store
* package declares it as its own startup task, so the pane points at Settings > Apps >
* Startup instead of showing a switch (app/electron/menubar.ts). */
launchAtLoginManaged: boolean
}
export interface CodeburnBridge {
/** Subscribe to cold-start scan progress; returns an unsubscribe fn. */
onProgress(cb: (event: ScanProgressEvent) => void): () => void
@ -734,6 +782,16 @@ export interface CodeburnBridge {
completeOnboarding(enabled: boolean): Promise<TelemetryStatus | null>
telemetryTrack(name: string, props?: Record<string, unknown>): Promise<boolean>
openExternal(url: string): Promise<void>
/** The bundled tray app and Capacity Dock (Windows). Optional so a preload that
* predates them degrades to "not supported" rather than throwing. */
companionStatus?(): Promise<CompanionStatus>
setMenuBarEnabled?(enabled: boolean): Promise<CompanionStatus>
setSidebarEnabled?(enabled: boolean): Promise<CompanionStatus>
/** The tray app's own settings. Null when there is no tray app to have any. */
trayPrefs?(): Promise<TrayPrefs | null>
setTrayAppPref?(patch: Record<string, unknown>): Promise<TrayPrefs | null>
setTrayDockPref?(patch: Record<string, unknown>): Promise<TrayPrefs | null>
setLaunchAtLogin?(enabled: boolean): Promise<TrayPrefs | null>
// Plugin management
pluginList(): Promise<unknown>
pluginInfo(name: string): Promise<unknown>

View file

@ -9,6 +9,7 @@ import { Compare } from './Compare'
const mocks = vi.hoisted(() => ({
getCompareModels: vi.fn<(period: string, provider: string) => Promise<ModelStats[]>>(),
getCompare: vi.fn<(period: string, provider: string, modelA: string, modelB: string) => Promise<CompareJsonReport>>(),
telemetryTrack: vi.fn<(name: string, props?: Record<string, unknown>) => Promise<boolean>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
@ -47,6 +48,22 @@ describe('Compare', () => {
beforeEach(() => {
mocks.getCompareModels.mockReset()
mocks.getCompare.mockReset()
mocks.telemetryTrack.mockReset().mockResolvedValue(true)
})
it('reports each distinct pair put on screen as a name-only compare_view', async () => {
const user = userEvent.setup()
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" />)
await waitFor(() => expect(mocks.telemetryTrack).toHaveBeenCalledWith('compare_view', { modelA: 'Opus 4.8', modelB: 'Sonnet 5' }))
expect(mocks.telemetryTrack).toHaveBeenCalledTimes(1)
// Re-rendering the same pair does not fire again.
await user.click(await screen.findByLabelText('First model'))
await user.click(screen.getByRole('option', { name: 'Opus 4.8 · 4,812 calls' }))
expect(mocks.telemetryTrack).toHaveBeenCalledTimes(1)
})
it('defaults to the top two and renders formatted report panels and winners', async () => {

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Dropdown } from '../components/Dropdown'
@ -10,6 +10,7 @@ import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { reportMemoKey } from '../lib/reportMemoKey'
import { trackEvent } from '../lib/track'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
@ -58,6 +59,17 @@ export function Compare({
setModelB(current => current && available.has(current) ? current : models.data?.[1]?.model ?? null)
}, [models.data])
// One event per distinct pair actually put on screen, not per keystroke in
// the pickers. Model names only.
const comparedPair = useRef<string | null>(null)
useEffect(() => {
if (!modelA || !modelB || modelA === modelB) return
const pair = `${modelA} ${modelB}`
if (comparedPair.current === pair) return
comparedPair.current = pair
trackEvent('compare_view', { modelA, modelB })
}, [modelA, modelB])
const resetToDefaults = useCallback(() => {
if (!models.data) return
setModelA(models.data[0]?.model ?? null)

View file

@ -5,14 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MenubarPayload, OptimizeJsonReport, YieldJsonReport } from '../lib/types'
import { Optimize, OptimizeContent } from './Optimize'
const { getOverview, getOptimizeReport, getYield } = vi.hoisted(() => ({
const { getOverview, getOptimizeReport, getYield, telemetryTrack } = vi.hoisted(() => ({
getOverview: vi.fn(),
getOptimizeReport: vi.fn(),
getYield: vi.fn(),
telemetryTrack: vi.fn<(name: string, props?: Record<string, unknown>) => Promise<boolean>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getOptimizeReport, getYield } }
return { ...actual, codeburn: { getOverview, getOptimizeReport, getYield, telemetryTrack } }
})
function makePayload(): MenubarPayload {
@ -126,6 +127,7 @@ describe('Optimize', () => {
getOptimizeReport.mockReset().mockResolvedValue(makeOptimizeReport())
getYield.mockReset().mockResolvedValue(makeYield())
writeText.mockReset().mockResolvedValue(undefined)
telemetryTrack.mockReset().mockResolvedValue(true)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
})
@ -169,6 +171,18 @@ describe('Optimize', () => {
])
})
it('reports taking a fix as a name-only optimize_apply event', async () => {
render(<Optimize period="30days" provider="all" />)
fireEvent.click(await screen.findByRole('button', { name: /Opus is doing your small talk/ }))
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(telemetryTrack).toHaveBeenCalledWith('optimize_apply', { kind: 'unused-mcp', fixType: 'paste' }))
// The finding's own text never travels with it.
const props = JSON.stringify(telemetryTrack.mock.calls[0]![1])
expect(props).not.toContain('Use Sonnet')
expect(props).not.toContain('small talk')
})
it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => {
render(<Optimize period="30days" provider="all" />)

View file

@ -11,6 +11,7 @@ import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { reportMemoKey } from '../lib/reportMemoKey'
import { trackEvent } from '../lib/track'
import type { DateRange, FindingClass, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
@ -179,6 +180,10 @@ function ActionableFindingRows({ findings, byClass }: { findings: OptimizeFindin
const copyFix = async (finding: OptimizeFinding) => {
await navigator.clipboard.writeText(actionText(finding.fix))
// Taking a fix is the closest thing the app has to applying one. The id is a
// fixed detector name (`claude-md-too-long`, `unused-mcp`, ...), never text
// from the finding.
trackEvent('optimize_apply', { kind: finding.id, fixType: finding.fix.type })
setCopiedId(finding.id)
window.setTimeout(() => setCopiedId(current => current === finding.id ? null : current), 1_500)
}

View file

@ -232,3 +232,42 @@
transform: rotate(360deg);
}
}
/* Windows: plugins have no runtime there yet, so the page says so instead of loading.
Same centred card the empty state uses, with the section's own glyph above it. */
.soon {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
padding: 44px 24px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--fill);
}
.soonMark {
width: 30px;
height: 30px;
fill: none;
stroke: var(--accent);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
opacity: .9;
}
.soonTitle {
color: var(--ink);
font-size: 14px;
font-weight: 600;
}
.soonBody {
margin: 0;
max-width: 42ch;
color: var(--mut);
font-size: 12.5px;
line-height: 1.55;
}

View file

@ -1,6 +1,29 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
// The section reads the preload bridge at module load, so it is mocked rather than
// assigned onto window after the fact. isWindowsPlatform reads window.codeburn.platform.
const bridge = vi.hoisted(() => ({
pluginList: vi.fn(),
pluginInfo: vi.fn(),
pluginAdd: vi.fn(),
pluginRemove: vi.fn(),
pluginVerify: vi.fn(),
syncAutoStatus: vi.fn(),
}))
vi.mock('../lib/ipc', () => ({ codeburn: bridge, normalizeCliError: (err: unknown) => err }))
const { PluginsSection } = await import('./Plugins')
afterEach(() => {
delete (window as unknown as { codeburn?: unknown }).codeburn
vi.clearAllMocks()
})
function setPlatform(platform: string): void {
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
}
describe('PluginsSection', () => {
it('renders row actions for loaded plugins', () => {
@ -128,3 +151,31 @@ describe('PluginsSection', () => {
expect(cancelButton?.disabled).toBe(true)
})
})
describe('PluginsSection on Windows', () => {
// The plugin runtime has not shipped for Windows, so the CLI never answers and the page
// used to sit on "Loading plugins..." for good.
it('renders the coming-soon panel and never asks the CLI for anything', async () => {
setPlatform('win32')
render(<PluginsSection />)
expect(screen.getByText(/Plugins are coming to Windows/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Plugins' })).toBeInTheDocument()
expect(screen.queryByText(/Loading plugins/)).toBeNull()
// No CLI call, so no spinner to wait on and no timer to unwind.
expect(bridge.pluginList).not.toHaveBeenCalled()
await Promise.resolve()
expect(bridge.pluginList).not.toHaveBeenCalled()
})
it('still loads on every other platform', async () => {
setPlatform('darwin')
bridge.pluginList.mockResolvedValue([])
render(<PluginsSection />)
await waitFor(() => expect(bridge.pluginList).toHaveBeenCalled())
expect(screen.queryByText(/Plugins are coming to Windows/)).toBeNull()
})
})

View file

@ -4,6 +4,7 @@ import { showToast } from '../lib/toast'
import { PluginDetailsModal } from './PluginDetails'
import { InstallFlowModal } from './InstallFlow'
import styles from './Plugins.module.css'
import { isWindowsPlatform } from '../lib/platform'
interface PluginInfo {
name: string
@ -18,7 +19,36 @@ interface PluginInfo {
}
}
/**
* The plugin runtime has not shipped for Windows, so the CLI there never answers and the page
* sat on "Loading plugins..." for good. This branch loads nothing at all: no CLI call, no
* spinner, no timer, and none of the loader's hooks even mount, because the dispatcher below
* returns before reaching them.
*/
function PluginsComingSoon() {
return (
<div className={styles.container}>
<h1>Plugins</h1>
<div className={styles.soon}>
<svg className={styles.soonMark} viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="1" />
<path d="M12 2v6m0 8v6M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24M2 12h6m8 0h6M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24" />
</svg>
<div className={styles.soonTitle}>Plugins are coming to Windows</div>
<p className={styles.soonBody}>
They arrive in a later Windows release; on macOS and Linux they are available today.
</p>
</div>
</div>
)
}
export function PluginsSection() {
// Decided before the loader renders rather than inside it, so its effects never run.
return isWindowsPlatform() ? <PluginsComingSoon /> : <PluginsList />
}
function PluginsList() {
const [plugins, setPlugins] = useState<PluginInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

View file

@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
import { Settings } from './Settings'
const mocks = vi.hoisted(() => ({
@ -27,6 +27,14 @@ const mocks = vi.hoisted(() => ({
resetPlan: vi.fn<(provider: string) => Promise<ActionResult>>(),
chooseDirectory: vi.fn<() => Promise<string | null>>(),
exportData: vi.fn<(format: string, provider: string, path: string) => Promise<ActionResult>>(),
companionStatus: vi.fn(),
trayPrefs: vi.fn(),
setTrayAppPref: vi.fn(),
setTrayDockPref: vi.fn(),
setLaunchAtLogin: vi.fn(),
telemetryTrack: vi.fn<(name: string, props?: Record<string, unknown>) => Promise<boolean>>(),
telemetryStatus: vi.fn<() => Promise<TelemetryStatus | null>>(),
setTelemetryEnabled: vi.fn<(enabled: boolean) => Promise<TelemetryStatus | null>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
@ -48,6 +56,14 @@ const quotaProviders: QuotaProvider[] = [
{ provider: 'claude', connection: 'connected', primary: null, details: [], planLabel: 'Max 20x', footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
]
const trayPrefs = {
app: { metric: 'cost', menubarPeriod: 'today', accent: 'ember', trayBadge: false, usageRefreshSeconds: -1, quotaCadenceSeconds: 120, terminal: 'windowsTerminal' },
dock: { enabled: true, preferred: 'claude', scale: 0.6, theme: 'graphite', gaugeShape: 'circle', providers: ['claude'], manualSelection: true },
launchAtLogin: false,
}
const telemetryOff: TelemetryStatus = {
installId: '8f1c2b4d', country: 'DE', enabled: false, defaultEnabled: false, onboarded: true,
}
const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
@ -81,6 +97,12 @@ describe('Settings', () => {
mocks.resetPlan.mockResolvedValue(actionOk)
mocks.chooseDirectory.mockResolvedValue('/Users/toruk/Exports')
mocks.exportData.mockResolvedValue(actionOk)
mocks.telemetryTrack.mockResolvedValue(true)
mocks.telemetryStatus.mockResolvedValue(telemetryOff)
mocks.setTelemetryEnabled.mockImplementation(async enabled => ({ ...telemetryOff, enabled }))
// No bundled tray app unless a test says otherwise, which is every platform but Windows.
mocks.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false })
mocks.trayPrefs.mockResolvedValue(trayPrefs)
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
})
@ -106,6 +128,74 @@ describe('Settings', () => {
expect(await screen.findByText('Updated')).toBeInTheDocument()
})
it('reports settings, plan and export interactions as name-only events', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Dark' }))
expect(mocks.telemetryTrack).toHaveBeenCalledWith('settings_change', { setting: 'theme', value: 'dark' })
await user.click(await screen.findByLabelText('Currency'))
await user.click(screen.getByRole('option', { name: 'CNY' }))
expect(mocks.telemetryTrack).toHaveBeenCalledWith('settings_change', { setting: 'currency', value: 'CNY' })
await user.click(screen.getByRole('button', { name: 'Plans' }))
await user.click(await screen.findByLabelText('Add a plan'))
await user.click(screen.getByRole('option', { name: 'Cursor Pro' }))
await user.click(screen.getByRole('button', { name: 'Add' }))
expect(mocks.telemetryTrack).toHaveBeenCalledWith('plan_set', { provider: 'cursor', plan: 'cursor-pro' })
await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!)
await user.click(screen.getByRole('button', { name: 'Choose folder…' }))
await screen.findByText('/Users/toruk/Exports')
await user.click(screen.getByRole('button', { name: 'JSON' }))
await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!)
expect(mocks.telemetryTrack).toHaveBeenCalledWith('export', { format: 'json', provider: 'all' })
// The chosen folder is a real path on this machine and never travels.
const sent = JSON.stringify(mocks.telemetryTrack.mock.calls)
expect(sent).not.toContain('/Users/toruk')
})
// The main process drops any event raised while telemetry is off, so an opt-in tracked
// before the write lands is one that can never be sent. It goes out after the toggle takes.
it('reports the telemetry opt-in only once the toggle has taken', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Privacy & data' }))
await user.click(await screen.findByRole('switch', { name: 'Anonymous telemetry' }))
expect(mocks.setTelemetryEnabled).toHaveBeenCalledWith(true)
await waitFor(() => {
expect(mocks.telemetryTrack).toHaveBeenCalledWith('settings_change', { setting: 'telemetry', value: true })
})
const tracked = mocks.telemetryTrack.mock.invocationCallOrder.at(-1)!
expect(tracked).toBeGreaterThan(mocks.setTelemetryEnabled.mock.invocationCallOrder[0]!)
})
it('sends nothing when the write does not take, and nothing on the way out', async () => {
const user = userEvent.setup()
// A settings write that failed leaves telemetry off, so there is no opt-in to report.
mocks.setTelemetryEnabled.mockResolvedValue(telemetryOff)
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Privacy & data' }))
await user.click(await screen.findByRole('switch', { name: 'Anonymous telemetry' }))
await waitFor(() => expect(mocks.setTelemetryEnabled).toHaveBeenCalled())
// Turning it off mints a fresh install id and drops the queue, so an opt-out event would
// never arrive anywhere either.
mocks.telemetryStatus.mockResolvedValue({ ...telemetryOff, enabled: true })
mocks.setTelemetryEnabled.mockResolvedValue(telemetryOff)
render(<Settings period="month" />)
await user.click(screen.getAllByRole('button', { name: 'Privacy & data' }).at(-1)!)
await user.click((await screen.findAllByRole('switch', { name: 'Anonymous telemetry' })).at(-1)!)
await waitFor(() => expect(mocks.setTelemetryEnabled).toHaveBeenCalledWith(false))
const telemetryEvents = mocks.telemetryTrack.mock.calls.filter(([, props]) => props?.setting === 'telemetry')
expect(telemetryEvents).toEqual([])
})
it('persists theme choices and applies forced themes to the root', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
@ -367,4 +457,33 @@ describe('Settings', () => {
await waitFor(() => expect(screen.getAllByText('Locate the codeburn CLI')).toHaveLength(2))
expect(screen.getByText('permission denied; grant Full Disk Access')).toHaveStyle({ color: 'var(--warn)' })
})
// The tray app has settings of its own, and they only exist while it does. Each pane is
// shown only while its switch in the sidebar corner is on.
it("offers no tray panes where there is no bundled tray app", async () => {
render(<Settings period="month" />)
await waitFor(() => expect(mocks.companionStatus).toHaveBeenCalled())
expect(screen.queryByRole("button", { name: "Menu bar" })).toBeNull()
expect(screen.queryByRole("button", { name: "Capacity Dock" })).toBeNull()
})
it("offers both tray panes while both switches are on", async () => {
mocks.companionStatus.mockResolvedValue({ supported: true, menuBar: true, sidebar: true, store: false })
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(await screen.findByRole("button", { name: "Menu bar" }))
expect(await screen.findByRole("heading", { name: "Menu bar" })).toBeInTheDocument()
await user.click(screen.getByRole("button", { name: "Capacity Dock" }))
expect(await screen.findByRole("heading", { name: "Capacity Dock" })).toBeInTheDocument()
})
it("drops the Capacity Dock pane when the Sidebar switch is off", async () => {
mocks.companionStatus.mockResolvedValue({ supported: true, menuBar: true, sidebar: false, store: false })
render(<Settings period="month" />)
expect(await screen.findByRole("button", { name: "Menu bar" })).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Capacity Dock" })).toBeNull()
})
})

View file

@ -20,12 +20,14 @@ import { PROVIDER_NAMES, QUOTA_PROVIDERS, readDisabledProviders, writeDisabledPr
import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
import { reportMemoKey } from '../lib/reportMemoKey'
import { showToast } from '../lib/toast'
import { trackEvent } from '../lib/track'
import { ToastHost } from '../components/ToastHost'
import { rateLimitedNote } from './Plans'
import { SharingPane } from './SettingsSharing'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, ProviderName, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
import { CapacityDockPane, MenuBarPane } from './SettingsTray'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CompanionStatus, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, ProviderName, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy' | 'sharing'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy' | 'sharing' | 'menubar' | 'dock'
type Pane = SettingsPane
type Theme = 'system' | 'light' | 'dark'
@ -69,6 +71,27 @@ const RAIL_ITEMS: Array<{ id: Pane; label: string; icon: React.ReactNode }> = [
{ id: 'privacy', label: 'Privacy & data', icon: <path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /> },
]
/// Appended to the rail only while the matching switch in the sidebar corner is on.
const TRAY_RAIL_ITEMS: Record<'menubar' | 'dock', { id: Pane; label: string; icon: React.ReactNode }> = {
menubar: { id: 'menubar', label: 'Menu bar', icon: <><rect x="2" y="4" width="20" height="5" rx="1.5" /><circle cx="18" cy="6.5" r="1" /><path d="M5 14h6M5 18h10" /></> },
dock: { id: 'dock', label: 'Capacity Dock', icon: <><rect x="16" y="4" width="5" height="16" rx="2.5" /><circle cx="18.5" cy="9" r="1.4" /><circle cx="18.5" cy="15" r="1.4" /><path d="M3 12h8" /></> },
}
/// The sidebar's two switches decide which tray panes exist, so this reads the same status
/// the corner does. Null until the main process answers, and on any platform without a
/// bundled tray app it stays unsupported and neither pane appears.
function useCompanionStatus(): CompanionStatus | null {
const [status, setStatus] = useState<CompanionStatus | null>(null)
useEffect(() => {
let live = true
void codeburn?.companionStatus?.()
.then(next => { if (live) setStatus(next) })
.catch(() => {})
return () => { live = false }
}, [])
return status
}
function periodLabel(period: Period): string {
if (period === 'today') return 'today'
if (period === 'week') return 'last 7 days'
@ -105,6 +128,20 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
// The tray app's own two panes, Windows only, each shown only while its switch in the
// sidebar corner is on: there is nothing to configure about a tray app that is not running,
// and the rail is one of its windows.
const companion = useCompanionStatus()
const railItems = [
...RAIL_ITEMS,
...(companion?.supported && companion.menuBar ? [TRAY_RAIL_ITEMS.menubar] : []),
...(companion?.supported && companion.sidebar ? [TRAY_RAIL_ITEMS.dock] : []),
]
// A pane whose switch has just been turned off cannot stay on screen.
const paneExists = railItems.some(item => item.id === pane)
useEffect(() => {
if (!paneExists) setPane('general')
}, [paneExists])
return (
<>
@ -112,7 +149,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
<ToastHost />
<div className={motionClass('body set-body', 'section-fade')}>
<nav className="set-rail" aria-label="Settings sections">
{RAIL_ITEMS.map(item => (
{railItems.map(item => (
<button key={item.id} className={pane === item.id ? 'set-rail-item on' : 'set-rail-item'} aria-current={pane === item.id ? 'page' : undefined} onClick={() => setPane(item.id)}>
<svg viewBox="0 0 24 24" aria-hidden="true">{item.icon}</svg>{item.label}
</button>
@ -128,6 +165,8 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
{pane === 'export' && <ExportPane period={period} refreshToken={refreshToken} />}
{pane === 'sharing' && <SharingPane />}
{pane === 'privacy' && <PrivacyPane />}
{pane === 'menubar' && <MenuBarPane />}
{pane === 'dock' && <CapacityDockPane refreshToken={refreshToken} />}
</main>
</div>
<Hint items={[{ k: shortcutLabel('1-8'), label: 'Navigate' }, { k: shortcutLabel('R'), label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
@ -176,6 +215,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
const chooseTheme = (next: Theme) => {
setTheme(next)
writeSetting('codeburn.theme', next)
trackEvent('settings_change', { setting: 'theme', value: next })
}
const finishCurrency = (result: ActionResult) => {
showToast(result.ok ? 'Updated' : result.stderr || 'Unable to update currency', result.ok ? 'ok' : 'error')
@ -207,10 +247,10 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<div className="about-sec">
<div className="about-sec-h">Display</div>
<div className="about-row"><label className="tx" htmlFor="settings-currency">Currency</label><span className="r">
{plans.data ? <Dropdown id="settings-currency" ariaLabel="Currency" value={plans.data.currency} options={currencies.map(code => ({ value: code, label: code }))} onChange={value => void codeburn.setCurrency(value).then(finishCurrency)} width={92} /> : plans.error ? <SettingsErrorText error={plans.error} /> : <span className="set-cap">Loading…</span>}
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
{plans.data ? <Dropdown id="settings-currency" ariaLabel="Currency" value={plans.data.currency} options={currencies.map(code => ({ value: code, label: code }))} onChange={value => { trackEvent('settings_change', { setting: 'currency', value }); void codeburn.setCurrency(value).then(finishCurrency) }} width={92} /> : plans.error ? <SettingsErrorText error={plans.error} /> : <span className="set-cap">Loading…</span>}
<button className="set-text-button" onClick={() => { trackEvent('settings_change', { setting: 'currency', value: 'USD' }); void codeburn.resetCurrency().then(finishCurrency) }}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value); trackEvent('settings_change', { setting: 'defaultPeriod', value }) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>Runs automatically at this interval. Press {shortcutLabel('R')} to refresh sooner.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
@ -391,6 +431,7 @@ function PlansPane({ period, refreshToken, onNavigate, onConfigMutated }: { peri
}
const add = () => {
const preset = MANUAL_PLAN_PRESETS.find(item => item.id === presetId)!
trackEvent('plan_set', { provider: preset.provider, plan: preset.id })
void codeburn.setPlan(preset.id, preset.provider).then(finish)
}
@ -441,6 +482,9 @@ function ExportPane({ period, refreshToken }: { period: Period; refreshToken: nu
if (!destination) return
setExporting(true)
try {
// Format and provider only. The destination is a real path on this
// machine and never leaves it.
trackEvent('export', { format, provider })
const result = await codeburn.exportData(format, provider, destination)
showToast(result.ok ? `Exported to ${destination}` : (result.stderr || 'Export failed'), result.ok ? 'ok' : 'error')
} finally {
@ -496,9 +540,18 @@ function TelemetryClaim() {
if (!status) return null
const toggle = () => {
if (typeof codeburn.setTelemetryEnabled !== 'function') return
codeburn.setTelemetryEnabled(!status.enabled).then(value => setStatus(value)).catch(() => {})
// Only the opt-IN is reportable: turning telemetry off mints a fresh install
// id and drops the queue, so an opt-out event would never be sent anyway.
// And it is tracked after the switch has taken, never before: telemetry that is
// still off drops the event on the floor, so an opt-in recorded ahead of the
// write was one that could never be sent.
const optingIn = !status.enabled
codeburn.setTelemetryEnabled(optingIn).then(value => {
setStatus(value)
if (optingIn && value?.enabled) trackEvent('settings_change', { setting: 'telemetry', value: true })
}).catch(() => {})
}
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19v-5M9 19V9M14 19v-8M19 19V5" /></svg><div style={{ flex: 1 }}><div className="set-claim-t">Anonymous telemetry</div><div className="set-claim-d">Optional usage statistics: model mix, task success, performance and errors. Never prompts, code or anything identifying.</div></div>
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19v-5M9 19V9M14 19v-8M19 19V5" /></svg><div style={{ flex: 1 }}><div className="set-claim-t">Anonymous telemetry</div><div className="set-claim-d">Optional usage statistics: model mix, task success, performance and errors. The daily report includes the names of the models, tools, skills and MCP servers you use, alongside bucketed counts of how often each one came up. Never your prompts, your code, or your project and file names.</div></div>
<button type="button" role="switch" aria-checked={status.enabled} aria-label="Anonymous telemetry" className={status.enabled ? 'switch on' : 'switch'} onClick={toggle}><span className="switch-knob" /></button>
</div>
}

View file

@ -0,0 +1,222 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
// The preload bridge is read once at module load, so it has to be mocked rather than
// assigned onto `window` after the fact.
const bridge = vi.hoisted(() => ({
companionStatus: vi.fn(),
trayPrefs: vi.fn(),
setTrayAppPref: vi.fn(),
setTrayDockPref: vi.fn(),
setLaunchAtLogin: vi.fn(),
getQuota: vi.fn(),
openExternal: vi.fn(),
}))
vi.mock('../lib/ipc', () => ({ codeburn: bridge, normalizeCliError: (err: unknown) => err }))
const { CapacityDockPane, MenuBarPane } = await import('./SettingsTray')
const PREFS = {
app: {
metric: 'cost',
menubarPeriod: 'today',
accent: 'ember',
trayBadge: false,
usageRefreshSeconds: -1,
quotaCadenceSeconds: 120,
terminal: 'windowsTerminal',
},
dock: {
enabled: true,
preferred: 'claude',
scale: 0.6,
theme: 'graphite',
gaugeShape: 'circle',
providers: ['claude', 'codex'],
manualSelection: true,
},
launchAtLogin: false,
launchAtLoginManaged: false,
}
const connected = (...ids: string[]) => ids.map(id => ({
provider: id, connection: 'connected', primary: null, details: [], planLabel: null, footerLines: [],
}))
beforeEach(() => {
bridge.trayPrefs.mockResolvedValue(PREFS)
bridge.setTrayAppPref.mockImplementation(async (patch: Record<string, unknown>) =>
({ ...PREFS, app: { ...PREFS.app, ...patch } }))
bridge.setTrayDockPref.mockImplementation(async (patch: Record<string, unknown>) =>
({ ...PREFS, dock: { ...PREFS.dock, ...patch } }))
bridge.setLaunchAtLogin.mockImplementation(async (enabled: boolean) => ({ ...PREFS, launchAtLogin: enabled }))
bridge.getQuota.mockResolvedValue(connected('claude', 'codex'))
})
afterEach(() => { vi.clearAllMocks() })
/** Dropdown is a custom listbox, not a select: a trigger button labelled with its aria
* label, and options that appear once it is open. */
async function choose(ariaLabel: string, optionLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: ariaLabel }))
fireEvent.click(await screen.findByRole("option", { name: optionLabel }))
}
async function selected(ariaLabel: string): Promise<string> {
return (await screen.findByRole("button", { name: ariaLabel })).textContent ?? ""
}
describe('MenuBarPane', () => {
it('shows each of the tray app settings the desktop app does not already have', async () => {
render(<MenuBarPane />)
expect(await selected('Tray metric')).toContain('Cost')
expect(await selected('Tray period')).toContain('Today')
expect(await selected('Usage refresh')).toContain('Auto')
expect(await selected('Quota refresh')).toContain('2 minutes')
expect(await selected('Terminal')).toContain('Windows Terminal')
expect(screen.getByRole('switch', { name: "Show today's figure in the tray" })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('switch', { name: 'Launch at login' })).toHaveAttribute('aria-checked', 'false')
// The nine presets from the tray app's own accent picker.
expect(screen.getAllByRole('radio')).toHaveLength(9)
expect(screen.getByRole('radio', { name: 'Ember' })).toHaveAttribute('aria-checked', 'true')
})
it.each([
['Tray metric', 'Icon only', { metric: 'iconOnly' }],
['Tray period', 'Week', { menubarPeriod: 'week' }],
['Usage refresh', '5 minutes', { usageRefreshSeconds: 300 }],
['Quota refresh', '15 minutes', { quotaCadenceSeconds: 900 }],
['Terminal', 'Windows PowerShell', { terminal: 'powershell' }],
])('writes %s', async (label, option, patch) => {
render(<MenuBarPane />)
await choose(label, option)
expect(bridge.setTrayAppPref).toHaveBeenCalledWith(patch)
})
it('writes the accent and the tray figure switch', async () => {
render(<MenuBarPane />)
fireEvent.click(await screen.findByRole('radio', { name: 'Green' }))
expect(bridge.setTrayAppPref).toHaveBeenCalledWith({ accent: 'green' })
fireEvent.click(screen.getByRole('switch', { name: "Show today's figure in the tray" }))
expect(bridge.setTrayAppPref).toHaveBeenCalledWith({ trayBadge: true })
})
it('writes launch at login through its own channel, since it is a Run value', async () => {
render(<MenuBarPane />)
fireEvent.click(await screen.findByRole('switch', { name: 'Launch at login' }))
expect(bridge.setLaunchAtLogin).toHaveBeenCalledWith(true)
await waitFor(() => expect(screen.getByRole('switch', { name: 'Launch at login' })).toHaveAttribute('aria-checked', 'true'))
})
// The Store package declares launch at login as its own startup task, which only Windows
// can turn on and off. A switch there would move nothing, so there is not one.
describe('where Windows owns launch at login', () => {
beforeEach(() => { bridge.trayPrefs.mockResolvedValue({ ...PREFS, launchAtLoginManaged: true }) })
it('says who owns it instead of showing a switch', async () => {
render(<MenuBarPane />)
expect(await screen.findByText(/Managed by Windows/)).toBeInTheDocument()
expect(screen.queryByRole('switch', { name: 'Launch at login' })).toBeNull()
})
it('opens the Windows page that does own it', async () => {
render(<MenuBarPane />)
fireEvent.click(await screen.findByRole('button', { name: 'Open Startup Apps' }))
expect(bridge.openExternal).toHaveBeenCalledWith('ms-settings:startupapps')
expect(bridge.setLaunchAtLogin).not.toHaveBeenCalled()
})
})
// The main process decides what a value ends up as, so the pane renders its answer.
it('renders what came back rather than what was asked for', async () => {
bridge.setTrayAppPref.mockResolvedValue({ ...PREFS, app: { ...PREFS.app, metric: 'cost' } })
render(<MenuBarPane />)
await choose('Tray metric', 'Total tokens')
await waitFor(() => expect(bridge.setTrayAppPref).toHaveBeenCalled())
expect(await selected('Tray metric')).toContain('Cost')
})
})
describe('CapacityDockPane', () => {
it('shows the rail settings the tray app keeps', async () => {
render(<CapacityDockPane />)
expect(await selected('Resting provider')).toContain('Claude')
expect((screen.getByLabelText('Capacity Dock size') as HTMLInputElement).value).toBe('0.6')
expect(await selected('Capacity Dock appearance')).toContain('Graphite')
expect(await selected('Gauge shape')).toContain('Circle')
expect(screen.getByRole('switch', { name: 'Show the Capacity Dock' })).toHaveAttribute('aria-checked', 'true')
})
it.each([
['Capacity Dock appearance', 'Glass', { theme: 'glass' }],
['Gauge shape', 'Squircle', { gaugeShape: 'squircle' }],
['Resting provider', 'Codex', { preferred: 'codex' }],
])('writes %s', async (label, option, patch) => {
render(<CapacityDockPane />)
await choose(label, option)
expect(bridge.setTrayDockPref).toHaveBeenCalledWith(patch)
})
it('writes the size from the slider', async () => {
render(<CapacityDockPane />)
fireEvent.change(await screen.findByLabelText('Capacity Dock size'), { target: { value: '1.1' } })
expect(bridge.setTrayDockPref).toHaveBeenCalledWith({ scale: 1.1 })
})
it('writes the provider set when a switch is turned off', async () => {
render(<CapacityDockPane />)
fireEvent.click(await screen.findByRole('switch', { name: 'Codex on the Capacity Dock' }))
expect(bridge.setTrayDockPref).toHaveBeenCalledWith({ providers: ['claude'] })
})
// The rail must never end up with nothing to show.
it('will not let the last connected provider be switched off', async () => {
bridge.getQuota.mockResolvedValue(connected('claude'))
bridge.trayPrefs.mockResolvedValue({ ...PREFS, dock: { ...PREFS.dock, providers: ['claude'] } })
render(<CapacityDockPane />)
const claude = await screen.findByRole('switch', { name: 'Claude on the Capacity Dock' })
expect(claude).toBeDisabled()
fireEvent.click(claude)
expect(bridge.setTrayDockPref).not.toHaveBeenCalled()
})
it('keeps a provider on the rail after its connection drops, so it can be taken off', async () => {
bridge.getQuota.mockResolvedValue(connected('claude'))
render(<CapacityDockPane />)
const codex = await screen.findByRole('switch', { name: 'Codex on the Capacity Dock' })
expect(codex).toBeEnabled()
fireEvent.click(codex)
expect(bridge.setTrayDockPref).toHaveBeenCalledWith({ providers: ['claude'] })
})
it('says so rather than showing an empty list when nothing is connected', async () => {
bridge.getQuota.mockResolvedValue([])
bridge.trayPrefs.mockResolvedValue({ ...PREFS, dock: { ...PREFS.dock, providers: [] } })
render(<CapacityDockPane />)
expect(await screen.findByText(/No providers are connected yet/)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,326 @@
// The tray app's own settings, inside the desktop app's Settings.
//
// The tray app (windows/) has a settings window of its own, and everything here writes the
// same two files it reads: `windows-settings.json` and `windows-dock.json`. Only the settings
// the desktop app does not already have appear; currency, period, scope and the daily budget
// are shared through the CLI config and live in General.
//
// Both panes are Windows only, and each is shown only while its switch in the sidebar corner
// is on: there is nothing to configure about a tray app that is not running, and the rail is
// one of its windows.
import { useCallback, useEffect, useState } from 'react'
import { Dropdown } from '../components/Dropdown'
import { ProviderLogo } from '../components/ProviderLogo'
import { usePolled } from '../hooks/usePolled'
import { codeburn } from '../lib/ipc'
import { PROVIDER_NAMES, QUOTA_PROVIDERS } from '../lib/providers'
import type { QuotaProvider, TrayPrefs } from '../lib/types'
/** The Windows Settings page that owns launch at login for the Store build. The same value
* is the one exception in the main process's external-open guard (app/electron/main.ts). */
const STARTUP_APPS_SETTINGS_URL = 'ms-settings:startupapps'
/// The nine presets from the tray app's Theme/ThemeState.swift port
/// (windows/src/lib/accent.ts). Only the base shade is needed to draw a swatch.
const ACCENTS: Array<{ id: string; label: string; base: string }> = [
{ id: 'ember', label: 'Ember', base: '#C9521D' },
{ id: 'blue', label: 'Blue', base: '#0A84FF' },
{ id: 'purple', label: 'Purple', base: '#BF5AF2' },
{ id: 'pink', label: 'Pink', base: '#FF375F' },
{ id: 'red', label: 'Red', base: '#FF453A' },
{ id: 'orange', label: 'Orange', base: '#FF9F0A' },
{ id: 'yellow', label: 'Yellow', base: '#FFD60A' },
{ id: 'green', label: 'Green', base: '#30D158' },
{ id: 'graphite', label: 'Graphite', base: '#98989D' },
]
const METRICS = [
{ value: 'cost', label: 'Cost ($)' },
{ value: 'tokens', label: 'Tokens (up/down)' },
{ value: 'totalTokens', label: 'Total tokens' },
{ value: 'iconOnly', label: 'Icon only' },
]
const MENUBAR_PERIODS = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'all', label: '6 months' },
]
// The cadences are seconds, and the dropdown speaks strings, so they travel as strings and
// are turned back into numbers on the way to the file.
const USAGE_CADENCES = [
{ value: '-1', label: 'Auto' },
{ value: '0', label: 'Manual' },
{ value: '60', label: '1 minute' },
{ value: '300', label: '5 minutes' },
{ value: '900', label: '15 minutes' },
]
const QUOTA_CADENCES = [
{ value: '0', label: 'Manual' },
{ value: '60', label: '1 minute' },
{ value: '120', label: '2 minutes' },
{ value: '300', label: '5 minutes' },
{ value: '900', label: '15 minutes' },
]
const TERMINALS = [
{ value: 'windowsTerminal', label: 'Windows Terminal' },
{ value: 'powershell', label: 'Windows PowerShell' },
{ value: 'commandPrompt', label: 'Command Prompt' },
]
const DOCK_THEMES = [
{ value: 'graphite', label: 'Graphite' },
{ value: 'glass', label: 'Glass' },
]
const DOCK_GAUGE_SHAPES = [
{ value: 'circle', label: 'Circle' },
{ value: 'squircle', label: 'Squircle' },
]
const SCALE_MIN = 0.6
const SCALE_MAX = 1.2
const SCALE_STEP = 0.05
/**
* One read of the tray app's settings, and one writer per file. Every setter answers with the
* whole set, because the main process is what decides what a value ends up as: an unreadable
* one collapses to a default, and a provider set moves the resting provider with it.
*/
export function useTrayPrefs(): {
prefs: TrayPrefs | null
setApp: (patch: Record<string, unknown>) => void
setDock: (patch: Record<string, unknown>) => void
setLaunchAtLogin: (enabled: boolean) => void
} {
const [prefs, setPrefs] = useState<TrayPrefs | null>(null)
useEffect(() => {
let live = true
void codeburn?.trayPrefs?.()
.then(next => { if (live) setPrefs(next) })
.catch(() => {})
return () => { live = false }
}, [])
const apply = useCallback((run: (() => Promise<TrayPrefs | null>) | undefined) => {
if (!run) return
void run().then(next => { if (next) setPrefs(next) }).catch(() => {})
}, [])
return {
prefs,
setApp: patch => apply(codeburn.setTrayAppPref && (() => codeburn.setTrayAppPref!(patch))),
setDock: patch => apply(codeburn.setTrayDockPref && (() => codeburn.setTrayDockPref!(patch))),
setLaunchAtLogin: enabled => apply(codeburn.setLaunchAtLogin && (() => codeburn.setLaunchAtLogin!(enabled))),
}
}
function Switch({ on, label, disabled, onToggle }: {
on: boolean
label: string
disabled?: boolean
onToggle: () => void
}) {
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-label={label}
disabled={disabled}
className={on ? 'switch on' : 'switch'}
onClick={onToggle}
>
<span className="switch-knob" />
</button>
)
}
export function MenuBarPane() {
const { prefs, setApp, setLaunchAtLogin } = useTrayPrefs()
if (!prefs) return <section className="set-p on"><p className="set-cap">Loading menu bar settings…</p></section>
const app = prefs.app
return (
<section className="set-p on">
<div>
<h3 className="set-h">Menu bar</h3>
<p className="set-sub">The CodeBurn icon in the Windows notification area. These are its own settings; currency, period and the daily budget are shared and live in General.</p>
</div>
<div className="card">
<div className="about-sec">
<div className="about-sec-h">Tray figure</div>
<div className="about-row"><label className="tx" htmlFor="tray-metric">Metric<small>What the tray shows beside the flame.</small></label><span className="r">
<Dropdown id="tray-metric" ariaLabel="Tray metric" value={app.metric} options={METRICS} onChange={value => setApp({ metric: value })} width={168} />
</span></div>
<div className="about-row"><label className="tx" htmlFor="tray-period">Period<small>The span the tray figure is measured over.</small></label><span className="r">
<Dropdown id="tray-period" ariaLabel="Tray period" value={app.menubarPeriod} options={MENUBAR_PERIODS} onChange={value => setApp({ menubarPeriod: value })} width={120} />
</span></div>
<div className="about-row"><span className="tx">Show today's figure<small>A second tray icon carrying the number. Off keeps it in the tooltip and the menu.</small></span><span className="r">
<Switch on={app.trayBadge} label="Show today's figure in the tray" onToggle={() => setApp({ trayBadge: !app.trayBadge })} />
</span></div>
</div>
<div className="about-sec">
<div className="about-sec-h">Appearance</div>
<div className="about-row"><span className="tx">Accent<small>Tints the popover, the settings window and the Capacity Dock.</small></span><span className="r">
<span className="tray-accents" role="radiogroup" aria-label="Accent">
{ACCENTS.map(accent => (
<button
key={accent.id}
type="button"
role="radio"
aria-checked={app.accent === accent.id}
aria-label={accent.label}
title={accent.label}
className={app.accent === accent.id ? 'tray-accent on' : 'tray-accent'}
style={{ background: accent.base }}
onClick={() => setApp({ accent: accent.id })}
/>
))}
</span>
</span></div>
</div>
<div className="about-sec">
<div className="about-sec-h">Refresh</div>
<div className="about-row"><label className="tx" htmlFor="tray-usage">Usage<small>Auto follows the popover and the power state. Manual never refreshes on its own.</small></label><span className="r">
<Dropdown id="tray-usage" ariaLabel="Usage refresh" value={String(app.usageRefreshSeconds)} options={USAGE_CADENCES} onChange={value => setApp({ usageRefreshSeconds: Number(value) })} width={124} />
</span></div>
<div className="about-row"><label className="tx" htmlFor="tray-quota">Quota<small>One run answers for every provider, so this is one setting rather than ten.</small></label><span className="r">
<Dropdown id="tray-quota" ariaLabel="Quota refresh" value={String(app.quotaCadenceSeconds)} options={QUOTA_CADENCES} onChange={value => setApp({ quotaCadenceSeconds: Number(value) })} width={124} />
</span></div>
</div>
<div className="about-sec set-last-sec">
<div className="about-sec-h">System</div>
<div className="about-row"><label className="tx" htmlFor="tray-terminal">Terminal<small>Where Open Full Report runs.</small></label><span className="r">
<Dropdown id="tray-terminal" ariaLabel="Terminal" value={app.terminal} options={TERMINALS} onChange={value => setApp({ terminal: value })} width={168} />
</span></div>
{prefs.launchAtLoginManaged ? (
// The Store package declares launch at login as its own startup task, which only
// Windows can turn on and off. A switch here would move nothing, so this says who
// owns it and opens the page that does.
<div className="about-row"><span className="tx">Launch at login<small>Managed by Windows for this build, in Settings &gt; Apps &gt; Startup.</small></span><span className="r">
<button
type="button"
className="set-text-button"
onClick={() => { void codeburn.openExternal(STARTUP_APPS_SETTINGS_URL) }}
>
Open Startup Apps
</button>
</span></div>
) : (
<div className="about-row"><span className="tx">Launch at login<small>Starts the menu bar app when you sign in.</small></span><span className="r">
<Switch on={prefs.launchAtLogin} label="Launch at login" onToggle={() => setLaunchAtLogin(!prefs.launchAtLogin)} />
</span></div>
)}
</div>
</div>
</section>
)
}
export function CapacityDockPane({ refreshToken }: { refreshToken?: number }) {
const { prefs, setDock } = useTrayPrefs()
const quota = usePolled<QuotaProvider[]>(() => codeburn.getQuota(), [refreshToken])
if (!prefs) return <section className="set-p on"><p className="set-cap">Loading Capacity Dock settings…</p></section>
const dock = prefs.dock
const connected = (quota.data ?? [])
.filter(entry => entry.connection === 'connected')
.map(entry => entry.provider as string)
// Everything connected, plus anything already on the rail, so a provider whose connection
// later fails can still be taken off it.
const manageable = QUOTA_PROVIDERS.filter(id => dock.providers.includes(id) || connected.includes(id))
// The rail must never end up with nothing to show, so the last connected provider stays on.
const canDeselect = (id: string) =>
!dock.providers.includes(id)
|| !connected.includes(id)
|| dock.providers.filter(entry => connected.includes(entry)).length > 1
const toggleProvider = (id: string) => {
const on = dock.providers.includes(id)
if (on && !canDeselect(id)) return
setDock({ providers: on ? dock.providers.filter(entry => entry !== id) : [...dock.providers, id] })
}
const restingOptions = (dock.providers.length > 0 ? dock.providers : manageable)
.map(id => ({ value: id, label: PROVIDER_NAMES[id as keyof typeof PROVIDER_NAMES] ?? id }))
return (
<section className="set-p on">
<div>
<h3 className="set-h">Capacity Dock</h3>
<p className="set-sub">The rail on the screen edge. Drag it to move it; these are the settings the menu bar app keeps for it.</p>
</div>
<div className="card">
<div className="about-sec">
<div className="about-sec-h">Rail</div>
<div className="about-row"><span className="tx">Show the rail<small>The same switch as Sidebar in the corner.</small></span><span className="r">
<Switch on={dock.enabled} label="Show the Capacity Dock" onToggle={() => setDock({ enabled: !dock.enabled })} />
</span></div>
<div className="about-row"><label className="tx" htmlFor="dock-resting">Resting provider<small>The one row the rail shows until you hover it.</small></label><span className="r">
{restingOptions.length > 0
? <Dropdown id="dock-resting" ariaLabel="Resting provider" value={dock.preferred ?? restingOptions[0]!.value} options={restingOptions} onChange={value => setDock({ preferred: value })} width={140} />
: <span className="set-cap">No providers yet</span>}
</span></div>
<div className="about-row"><label className="tx" htmlFor="dock-scale">Size<small>{Math.round(dock.scale * 100)} percent.</small></label><span className="r">
<input
id="dock-scale"
className="set-range"
type="range"
aria-label="Capacity Dock size"
min={SCALE_MIN}
max={SCALE_MAX}
step={SCALE_STEP}
value={dock.scale}
onChange={event => setDock({ scale: Number(event.target.value) })}
/>
</span></div>
</div>
<div className="about-sec">
<div className="about-sec-h">Appearance</div>
<div className="about-row"><label className="tx" htmlFor="dock-theme">Surface<small>Graphite is opaque. Glass is translucent, painted inside the rail's own outline.</small></label><span className="r">
<Dropdown id="dock-theme" ariaLabel="Capacity Dock appearance" value={dock.theme} options={DOCK_THEMES} onChange={value => setDock({ theme: value })} width={124} />
</span></div>
<div className="about-row"><label className="tx" htmlFor="dock-gauge">Gauge<small>The shape of the ring around each provider glyph.</small></label><span className="r">
<Dropdown id="dock-gauge" ariaLabel="Gauge shape" value={dock.gaugeShape} options={DOCK_GAUGE_SHAPES} onChange={value => setDock({ gaugeShape: value })} width={124} />
</span></div>
</div>
<div className="about-sec set-last-sec">
<div className="about-sec-h">Providers</div>
{manageable.length === 0
? <p className="set-cap">No providers are connected yet. The rail follows whatever signs in.</p>
: manageable.map(id => {
const on = dock.providers.includes(id)
const locked = on && !canDeselect(id)
return (
<div className="about-row" key={id}>
<span className="tx set-dock-prov">
<ProviderLogo provider={id} />
{PROVIDER_NAMES[id as keyof typeof PROVIDER_NAMES] ?? id}
{locked && <small>The rail needs at least one connected provider.</small>}
{!connected.includes(id) && <small>Not connected.</small>}
</span>
<span className="r">
<Switch on={on} disabled={locked} label={`${PROVIDER_NAMES[id as keyof typeof PROVIDER_NAMES] ?? id} on the Capacity Dock`} onToggle={() => toggleProvider(id)} />
</span>
</div>
)
})}
</div>
</div>
</section>
)
}

View file

@ -130,10 +130,44 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 4px; padding: 11px 8px 2px; border-top: 1px solid var(--line); }
.foot .about { color: var(--mut); text-decoration: none; font-size: 12px; font-weight: 520; cursor: pointer; }
.foot .about:hover { color: var(--ink); }
/* On every platform but Windows, where the corner above About carries the two companion
switches instead and a 186px sidebar has no room for both. */
.foot .social { display: flex; gap: 2px; margin-left: auto; }
.foot .social a { color: var(--mut2); display: flex; padding: 5px; border-radius: 6px; cursor: pointer; }
.foot .social a:hover { color: var(--accent); background: var(--hover); }
.foot .social svg { width: 15px; height: 15px; fill: currentColor; stroke: none; }
/* Windows only: the tray app and the Capacity Dock rail, above About. Same 8px gutter and
12.5px type as a nav item, so the corner reads as one column rather than two. */
.companion { padding: 8px 8px 2px; border-top: 1px solid var(--line); }
.companion-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.companion-label { color: var(--mut); font-size: 12px; font-weight: 480; }
.companion-row .switch { margin-left: auto; }
/* The one line the corner ever says out loud: an install Windows can only finish at the next
restart, which is why neither switch has started anything. */
.companion-note { margin: 2px 0 4px; color: var(--mut); font-size: 11px; line-height: 1.35; }
/* Sidebar while the menu bar app is off: the rail is one of its windows, so there is nothing
to switch. Dimmed rather than hidden, so the row does not appear and vanish under a pointer
on its way to About. */
.companion-row.blocked .companion-label { opacity: .5; }
/* The two tray-app panes in Settings: an accent picker, a size slider and a provider row. */
.tray-accents { display: inline-flex; gap: 5px; align-items: center; }
.tray-accent {
width: 16px; height: 16px; border-radius: 50%; padding: 0; cursor: pointer;
border: 1px solid color-mix(in srgb, var(--ink) 22%, transparent);
}
.tray-accent.on { outline: 2px solid var(--ink); outline-offset: 2px; }
.tray-accent:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.set-range { width: 150px; accent-color: var(--accent); }
.set-dock-prov { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.set-dock-prov img, .set-dock-prov svg { width: 16px; height: 16px; flex: 0 0 auto; }
.set-dock-prov small { flex-basis: 100%; }
/* The bare .switch is painted for the dark onboarding sheet; on the sidebar it keys off the
theme tokens, exactly as the Settings surface does. */
.companion .switch { border-color: var(--line2); background: var(--hover); }
.companion .switch .switch-knob { background: var(--mut); }
.companion .switch.on { background: var(--accent); border-color: var(--accent); }
.companion .switch.on .switch-knob { background: #fff; }
/* About overlay: a compact, portal-free card using the reskin's surface tokens. */
.about-modal-backdrop {
@ -1093,6 +1127,11 @@ html.page-hidden *::after {
.switch.on { background: #f2701c; border-color: #f2701c; }
.switch.on .switch-knob { transform: translateX(16px); background: #fff; }
.switch:focus-visible { outline: 1px solid #f2701c; outline-offset: 1px; }
/* Compact variant for the 186px sidebar, where the full-size switch crowds the label out. */
.switch.sm { width: 30px; height: 17px; }
.switch.sm .switch-knob { width: 12px; height: 12px; }
.switch.sm.on .switch-knob { transform: translateX(13px); }
.switch:disabled { opacity: .55; cursor: default; }
/* On themed (Settings) surfaces the same switch keys off theme tokens. */
.set-p .switch { border-color: var(--line2); background: var(--hover); }
.set-p .switch .switch-knob { background: var(--mut); }

View file

@ -154,7 +154,8 @@ try {
const modelsHelp = execFileSync(electronPath, [launchPath, 'models', '--help'], {
env: electronEnv,
encoding: 'utf8',
timeout: 5_000,
// An x64 Electron under emulation on an ARM64 machine takes several seconds to start.
timeout: 30_000,
})
if (!/^Usage: codeburn models\b/m.test(modelsHelp)) {
throw new Error('stage-cli: Electron launch shim lost ordinary CLI arguments')

View file

@ -0,0 +1,117 @@
// Stage the Windows tray app into app/build/menubar so electron-builder ships it
// as extraResources. The counterpart of stage-cli.mjs, and it copies rather than
// downloads for the same reason: CI fetches the release assets in its own step
// and passes them with --from, and a developer points at a local build.
//
// Two layouts, because the two routes need different things:
//
// --target nsis (default)
// build/menubar/CodeBurn.Menubar_<version>_x64_en-US.msi
// build/menubar/CodeBurn.Menubar_<version>_x64_en-US.msi.sha256
//
// app/electron/menubar.ts hands that path to `codeburn menubar`, whose
// Windows installer verifies the digest before msiexec sees the file. The
// digest is copied when the source has one and computed when it does not, so
// a local `cargo tauri build` stages the same shape a release does.
//
// --target appx
// build/menubar/codeburn-menubar.exe (plus anything beside it the app needs)
//
// A packaged app cannot run msiexec, so the Store build launches the exe out
// of its own package and the manifest's startup task covers launch at login.
//
// Sources, in order: --from <dir>, then the local Tauri build output.
import { createHash } from 'node:crypto'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url)) // app/scripts
const appDir = join(here, '..')
const root = join(appDir, '..')
const stage = join(appDir, 'build', 'menubar')
// Tauri names the bundle after productName, so a local build is `CodeBurn Menubar_...`;
// GitHub rewrites that space to a dot on release upload, and the dot form is what the CLI
// installer, the Electron side and the tray's own updater all expect. Stage the dot form.
const MSI_PATTERN = /^CodeBurn[ .]Menubar_(.+)_x64_en-US\.msi$/
const TRAY_EXE = 'codeburn-menubar.exe'
/// What the Tauri build puts beside the exe and the app needs at runtime. Anything absent is
/// simply not copied: WebView2Loader ships only when the loader is not linked statically.
const TRAY_SIDECARS = ['WebView2Loader.dll']
function option(name, fallback) {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
const value = process.argv[index + 1]
if (!value) throw new Error(`${name} requires a value`)
return value
}
const target = option('--target', 'nsis')
if (target !== 'nsis' && target !== 'appx') {
throw new Error(`stage-menubar: --target must be nsis or appx, not ${target}`)
}
const defaultSource = target === 'nsis'
? join(root, 'windows', 'src-tauri', 'target', 'release', 'bundle', 'msi')
: join(root, 'windows', 'src-tauri', 'target', 'release')
const source = option('--from', defaultSource)
function fail(message) {
throw new Error(
`stage-menubar: ${message}\n` +
` looked in ${source}\n` +
' build it with `cargo tauri build` in windows/, or pass --from <dir> with the\n' +
' downloaded windows-v* release assets.'
)
}
// The stage is rebuilt from scratch every time: a leftover .msi from an older version would
// otherwise ship beside the current one and be found first.
rmSync(stage, { recursive: true, force: true })
mkdirSync(stage, { recursive: true })
if (!existsSync(source) || !statSync(source).isDirectory()) fail('no source directory')
if (target === 'nsis') {
const msiName = readdirSync(source).find(name => MSI_PATTERN.test(name))
if (!msiName) fail('no CodeBurn.Menubar_<version>_x64_en-US.msi')
const stagedName = msiName.replace(' ', '.')
const msiSource = join(source, msiName)
copyFileSync(msiSource, join(stage, stagedName))
// The release carries its own .sha256; a local build does not, and computing it here keeps
// the installer's verification meaningful in both cases rather than skipped in one.
const checksumSource = `${msiSource}.sha256`
if (existsSync(checksumSource)) {
copyFileSync(checksumSource, join(stage, `${stagedName}.sha256`))
} else {
const digest = createHash('sha256').update(readFileSync(msiSource)).digest('hex')
writeFileSync(join(stage, `${stagedName}.sha256`), `${digest} ${stagedName}\n`)
}
const version = MSI_PATTERN.exec(msiName)[1]
const appVersion = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8')).version
// A mismatch is not fatal: a desktop release can legitimately ship the newest tray build
// there is. It is worth saying out loud, because it is usually a stale local bundle.
if (version !== appVersion) {
console.warn(`stage-menubar: staging tray app ${version} into desktop app ${appVersion}`)
}
console.log(`stage-menubar: staged ${stagedName} -> ${stage}`)
} else {
const exeSource = join(source, TRAY_EXE)
if (!existsSync(exeSource)) fail(`no ${TRAY_EXE}`)
copyFileSync(exeSource, join(stage, TRAY_EXE))
const copied = [basename(exeSource)]
for (const sidecar of TRAY_SIDECARS) {
const path = join(source, sidecar)
if (!existsSync(path)) continue
copyFileSync(path, join(stage, sidecar))
copied.push(sidecar)
}
console.log(`stage-menubar: staged ${copied.join(', ')} -> ${stage}`)
}

View file

@ -3,7 +3,7 @@ import { dirname, join } from 'path'
import { randomUUID } from 'crypto'
import type { ActionPlan, ActionRecord, FileChange } from './types.js'
import { appendRecord, defaultActionsDir, withLock } from './journal.js'
import { backupDirFor, relBackupPath, revertChange, sha256File, snapshotFile } from './backup.js'
import { backupDirFor, pathExists, relBackupPath, revertChange, sha256File, snapshotFile } from './backup.js'
// The only mutation path. Order: back up every file the plan touches, apply
// the mutations, hash the results, then journal. If a mutation or the journal
@ -58,11 +58,16 @@ export async function runAction(plan: ActionPlan, actionsDir: string = defaultAc
try {
await rename(pc.path, pc.movedTo)
} catch (err) {
// rename cannot replace a directory destination. It is already
// snapshotted (destBackup), so clear it and retry. Other codes
// (e.g. a missing source) rethrow before any destination damage.
const code = (err as NodeJS.ErrnoException).code
if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EISDIR' && code !== 'ENOTDIR') throw err
// rename cannot replace a directory destination. POSIX reports
// that as ENOTEMPTY/EEXIST/EISDIR/ENOTDIR; Windows reports EPERM
// or EACCES. The destination is already snapshotted (destBackup),
// so clear it and retry. Requiring the destination to exist keeps
// the broader Windows codes from turning an unrelated permission
// failure into a delete; other codes (e.g. a missing source)
// rethrow before any destination damage.
const code = (err as NodeJS.ErrnoException).code ?? ''
const occupied = ['ENOTEMPTY', 'EEXIST', 'EISDIR', 'ENOTDIR', 'EPERM', 'EACCES'].includes(code)
if (!occupied || !(await pathExists(pc.movedTo))) throw err
await rm(pc.movedTo, { recursive: true, force: true })
await rename(pc.path, pc.movedTo)
}

View file

@ -38,7 +38,10 @@ function isCodeBurnHook(command: unknown): boolean {
}
function shellQuote(value: string): string {
if (process.platform === 'win32') return `"${value.replace(/(["\\])/g, '\\$1')}"`
// cmd.exe has no backslash escape, so backslash-escaping a Windows path
// corrupts it: double quotes on their own are the whole quoting rule, and a
// path can never contain the one character they would need to protect.
if (process.platform === 'win32') return `"${value}"`
return `'${value.replace(/'/g, `'\\''`)}'`
}

View file

@ -711,8 +711,14 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor
)
}
const _home = homedir()
// homedir() returns backslashes and an arbitrarily cased drive letter on Windows
// while project paths arrive in either separator, so the home prefix is compared
// against a normalized, case-folded copy there.
const _home = homedir().replace(/\\/g, '/')
const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
const _foldPath = (value: string): string => (process.platform === 'win32' ? value.toLowerCase() : value)
const _homeFolded = _foldPath(_home)
const _homePrefixFolded = _foldPath(_homePrefix)
function ellipsizeEnd(value: string, width: number): string {
if (value.length <= width) return value
@ -723,9 +729,10 @@ function ellipsizeEnd(value: string, width: number): string {
export function shortProject(absPath: string, width = Infinity): string {
const normalized = absPath.replace(/\\/g, '/')
const folded = _foldPath(normalized)
let path: string
if (normalized === _home) path = ''
else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length)
if (folded === _homeFolded) path = ''
else if (folded.startsWith(_homePrefixFolded)) path = normalized.slice(_homePrefix.length)
else path = normalized
path = path.replace(/^\/+/, '')
path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,4 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import { sep } from 'path'
import type { ProjectSummary } from '../types.js'
import { flagsPath, guardDir } from './store.js'
@ -54,8 +53,24 @@ export function flagsAgeMs(flags: GuardFlags): number {
return Number.isNaN(t) ? Number.POSITIVE_INFINITY : Date.now() - t
}
// A flagged project path comes from provider data and can be POSIX-shaped on a
// Windows host, so a Windows host has to count either separator as a directory
// boundary. Everywhere else a backslash is an ordinary filename character and
// must never split a path.
const WINDOWS_HOST = process.platform === 'win32'
function endsWithSeparator(p: string): boolean {
return p.endsWith('/') || (WINDOWS_HOST && p.endsWith('\\'))
}
function norm(p: string): string {
return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p
return p.length > 1 && endsWithSeparator(p) ? p.slice(0, -1) : p
}
function isWithin(target: string, base: string): boolean {
if (target === base) return true
if (target.startsWith(`${base}/`)) return true
return WINDOWS_HOST && target.startsWith(`${base}\\`)
}
// Openers for the flagged project the cwd sits in (exact match or a subdir of
@ -65,7 +80,7 @@ export function matchFlag(flags: GuardFlags, cwd: string): string[] {
let best: ProjectFlag | null = null
for (const flag of flags.projects) {
const base = norm(flag.path)
if (target === base || target.startsWith(base + sep)) {
if (isWithin(target, base)) {
if (!best || base.length > norm(best.path).length) best = flag
}
}

View file

@ -63,9 +63,18 @@ import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from
// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
// than crashing with an unhandled error event.
//
// The exit still drains outstanding credential rotations first. `codeburn quota
// | head` is the ordinary way to read that command, and a token grant the
// server has already accepted must reach disk whichever door the process
// leaves through. The drain resolves immediately when nothing is outstanding,
// which is every other command.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') process.exit(0)
throw err
if (err.code !== 'EPIPE') throw err
void import('./quota/security.js')
.then(({ awaitCredentialWrites }) => awaitCredentialWrites())
.catch(() => undefined)
.then(() => process.exit(0))
})
function collect(val: string, acc: string[]): string[] {
@ -1401,9 +1410,10 @@ program
.command('menubar')
.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 }) => {
.option('--staged-msi <path>', 'Windows: install the CodeBurn.Menubar .msi staged inside an installed CodeBurn desktop app, by absolute path')
.action(async (opts: { force?: boolean; stagedMsi?: string }) => {
try {
const result = await installMenubarApp({ force: opts.force, cliVersion: version })
const result = await installMenubarApp({ force: opts.force, cliVersion: version, stagedMsi: opts.stagedMsi })
// A cancelled Windows installer leaves nothing to point at.
if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`)
} catch (err) {
@ -2609,6 +2619,34 @@ program
process.stdout.write(renderDoctorTable(report, { color: opts.color }))
})
program
.command('quota')
.description('Live provider capacity: quota windows for each signed-in coding tool on this machine')
.option('--format <format>', 'Output format: table, json', 'table')
.option('--no-color', 'Disable ANSI colors')
.action(async (opts) => {
const { collectQuota, renderQuotaTable } = await import('./quota/index.js')
const { awaitCredentialWrites } = await import('./quota/security.js')
try {
const report = await collectQuota()
const out = opts.format === 'json'
? JSON.stringify(report, null, 2) + '\n'
: renderQuotaTable(report, { color: opts.color }) + '\n'
await new Promise<void>(resolve => { process.stdout.write(out, () => resolve()) })
} finally {
// A provider the per-provider timeout gave up on may still be between an
// accepted token grant and the credential file it has to rewrite. That
// token is already dead on disk, so exiting through it signs the user out.
// In a finally because a throw anywhere above leaves through the same
// door: one provider failing must not strand another's rotation.
await awaitCredentialWrites()
}
// A keychain lookup or local-server probe abandoned by the per-provider
// timeout keeps its child alive long past the output, so leave on purpose
// once the output has flushed and nothing is mid-rotation.
process.exit(0)
})
registerActCommands(program)
registerGuardCommands(program)
registerSyncCommands(program)

View file

@ -1,9 +1,9 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createWriteStream } from 'node:fs'
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
import { ProxyAgent, fetch as undiciFetch } from 'undici'
@ -27,6 +27,12 @@ const SUPPORTED_OS = 'darwin'
/// 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'
/// What the installed executable is actually called. Tauri names the binary after the Cargo
/// package (windows/src-tauri/Cargo.toml) and the install directory after the product, so the
/// two differ: `CodeBurn Menubar\codeburn-menubar.exe`. Joining the product name onto
/// InstallLocation builds a path to nothing, which is what the post-install launch, the
/// desktop app's stored path and the Run value it seeds were all built from.
const WINDOWS_BINARY_NAME = 'codeburn-menubar.exe'
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')
@ -43,6 +49,10 @@ export type InstallOptions = {
force?: boolean
cliVersion?: string
platform?: string
/// An absolute path to a `CodeBurn.Menubar_<version>_x64_en-US.msi` staged inside an
/// installed CodeBurn desktop app, from `codeburn menubar --staged-msi`. Validated by
/// assertStagedMsiPath before anything is run on it.
stagedMsi?: string
windows?: WindowsInstallHooks
}
@ -98,7 +108,7 @@ export type ReleaseApiFetch = (url: string, options?: FetchOptions) =>
/// 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
/// that class of blip while adding at most ~1.5s before a genuinely broken download reports
/// back — `codeburn menubar` is interactive, so failing fast still matters.
/// back, and `codeburn menubar` is interactive, so failing fast still matters.
const ASSET_MAX_ATTEMPTS = 3
const ASSET_BASE_DELAY_MS = 500
@ -265,7 +275,7 @@ async function fetchLatestReleaseAssets(spec: ReleaseSpec = MAC_RELEASE, fetchIm
/// 5xx means "GitHub/the CDN is unhappy right now" and is worth another attempt. 4xx is not:
/// 404/410 must keep falling through to the release-API path untouched, and a 403/429 rate limit
/// cannot clear inside a 1.5s backoff window — hammering it would only spend more of the budget,
/// cannot clear inside a 1.5s backoff window, and hammering it would only spend more of the budget,
/// so those surface immediately with the retry-after hint instead.
function isTransientStatus(status: number): boolean {
return status >= 500 && status <= 599
@ -363,15 +373,9 @@ async function fetchReleaseAsset<T>(
}
}
export async function verifyChecksum(
archivePath: string,
checksumUrl: string,
options: AssetFetchOptions = {},
): Promise<void> {
// Only the transport is retried. The digest comparison below is deliberately outside the retry:
// an integrity failure must abort on the first look and never re-download.
const text = await fetchReleaseAsset(checksumUrl, 'Checksum download', response => response.text(), options)
const expected = text.trim().split(/\s+/)[0]!.toLowerCase()
/// `<digest> <name>`, as sha256sum writes it; only the digest is compared.
async function assertSha256(archivePath: string, checksumText: string): Promise<void> {
const expected = checksumText.trim().split(/\s+/)[0]!.toLowerCase()
const fileBytes = await readFile(archivePath)
const actual = createHash('sha256').update(fileBytes).digest('hex')
if (actual !== expected) {
@ -384,6 +388,31 @@ export async function verifyChecksum(
}
}
export async function verifyChecksum(
archivePath: string,
checksumUrl: string,
options: AssetFetchOptions = {},
): Promise<void> {
// Only the transport is retried. The digest comparison is deliberately outside the retry:
// an integrity failure must abort on the first look and never re-download.
const text = await fetchReleaseAsset(checksumUrl, 'Checksum download', response => response.text(), options)
await assertSha256(archivePath, text)
}
/// The same check for an .msi that arrived with another installer rather than over the network:
/// its digest sits in `<msi>.sha256` beside it. A missing digest file is a refusal rather than a
/// skip, because the whole point is that nothing unverified reaches msiexec.
export async function verifyStagedChecksum(archivePath: string): Promise<void> {
const checksumPath = `${archivePath}.sha256`
let text: string
try {
text = await readFile(checksumPath, 'utf8')
} catch {
throw new Error(`Missing checksum file ${checksumPath}; refusing to run an unverified installer.`)
}
await assertSha256(archivePath, text)
}
export async function downloadToFile(
url: string,
destPath: string,
@ -517,7 +546,10 @@ async function killRunningApp(): Promise<void> {
/// Windows mirror of the mac install below: pin the release to the CLI's own version, fall back
/// to the newest windows-v* release, verify the sha256 before anything executes the file, hand
/// the .msi to msiexec, then launch what it installed.
/// the .msi to msiexec, then launch what it installed. Two routes come before that one: an .msi
/// the desktop app already staged and named with `--staged-msi` (assertStagedMsiPath), and a
/// Microsoft Store install of the desktop app, which carries the tray app inside its own
/// package (findStoreMenubar).
const WINDOWS_UNINSTALL_KEYS = [
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
@ -526,19 +558,304 @@ const WINDOWS_UNINSTALL_KEYS = [
/// 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
/// How long a running tray app gets to act on a `--quit` before it is closed outright. The same
/// window the desktop app allows for the same request (app/electron/menubar.ts, QUIT_SETTLE_MS).
const TRAY_QUIT_TIMEOUT_MS = 4000
const TRAY_QUIT_POLL_MS = 200
/// tasklist and taskkill answer immediately on a healthy machine; the bound is only there so a
/// wedged one cannot hold up the install for ever.
const TRAY_PROCESS_TIMEOUT_MS = 5000
export type WindowsInstallHooks = {
fetchOptions?: AssetFetchOptions
apiFetch?: ReleaseApiFetch
runInstaller?: (exe: string, args: string[]) => Promise<number>
queryRegistry?: () => Promise<string>
launch?: (exePath: string) => void
/// The Store package's InstallLocation, or an empty string when the desktop app is not
/// installed from the Store. Replaced in tests so nothing spawns PowerShell.
queryStorePackage?: () => Promise<string>
/// Whether a tray process is running right now. Replaced in tests so nothing spawns tasklist.
isTrayRunning?: () => Promise<boolean>
/// Close a tray process that would not quit when asked. Replaced in tests so nothing spawns
/// taskkill.
killTray?: () => Promise<void>
/// The wait between two `isTrayRunning` polls, so tests need not spend the real one.
sleep?: (ms: number) => Promise<void>
launch?: (exePath: string, args?: string[]) => void
log?: (message: string) => void
stagingDir?: string
env?: NodeJS.ProcessEnv
}
export type InstalledWindowsMenubar = { version: string; exePath: string }
export type InstalledWindowsMenubar = {
version: string
exePath: string
/// `MsiExec.exe /X{product-code}` as Windows Installer wrote it. The desktop app's own
/// uninstaller has no other handle on the product it installed, so the bundled install
/// records this in the marker file.
uninstallString?: string
}
// The bundled .msi route ---------------------------------------------------------------------
/// How the Electron desktop app used to name the `CodeBurn.Menubar_<version>_x64_en-US.msi` it
/// ships in its own resources. It is no longer a route, only a refusal: an environment variable
/// is settable by anything that can start this process, and this one decided which file msiexec
/// would run, with the `.sha256` it is checked against written by whoever wrote the file. The
/// desktop app passes `--staged-msi <path>` instead, which is validated (assertStagedMsiPath).
export const BUNDLED_MSI_ENV = 'CODEBURN_MENUBAR_MSI'
/// The flag that replaced it.
export const STAGED_MSI_FLAG = '--staged-msi'
/// One line on stdout so the caller that staged the MSI learns what happened without reading
/// prose. Everything else the install prints stays human-readable. Unchanged by the move from
/// the environment variable to the flag: it is the desktop app's only parse of this command.
export const BUNDLED_RESULT_PREFIX = 'CODEBURN_MENUBAR_RESULT '
/// Where a packaged desktop app keeps the tray installer, relative to the directory holding
/// `CodeBurn.exe`: electron-builder puts extraResources under `resources`, and the tray build
/// is staged into `resources\menubar` (app/electron/menubar.ts, menubarResourcesDir).
export const STAGED_MSI_DIR_SEGMENTS = ['resources', 'menubar'] as const
/// The desktop app's own binary, which is what makes the directory above an installed CodeBurn
/// rather than a directory anyone happened to name `resources\menubar`.
export const DESKTOP_APP_EXE = 'CodeBurn.exe'
async function isRegularFile(path: string): Promise<boolean> {
try {
// lstat, not stat: a symbolic link standing in for any of these is a way to point a
// validated shape at an unvalidated file.
return (await lstat(path)).isFile()
} catch {
return false
}
}
/**
* Decides whether a `--staged-msi` path may be handed to msiexec, and refuses if not.
*
* Whatever this returns is executed as the installing user, so the path has to prove it came
* from an installed CodeBurn desktop app rather than from anyone who could put a file on this
* machine and name it. It must be absolute, it must be the release asset by name, it must carry
* the `.sha256` it will be checked against, and it must sit in the one place a packaged desktop
* app stages it: `<app>\resources\menubar`, with `<app>\CodeBurn.exe` beside those. A path that
* fails any of that is refused here, before anything runs it.
*
* The digest beside the file is not by itself a guarantee - the party that writes one writes the
* other - which is why the location check, and not the checksum, is what this route trusts.
*/
export async function assertStagedMsiPath(msiPath: string): Promise<string> {
const refuse = (reason: string): never => {
throw new Error(`Refusing ${STAGED_MSI_FLAG} ${msiPath}: ${reason}`)
}
if (typeof msiPath !== 'string' || msiPath.trim() === '') refuse('no path was given.')
if (!isAbsolute(msiPath)) refuse('the path is not absolute.')
// Resolved before every other check, so `..` cannot walk out of the directory the checks
// below are about to confirm.
const resolved = resolve(msiPath)
if (!parseWindowsMsiVersion(basename(resolved))) {
refuse('the file is not named like a CodeBurn.Menubar_<version>_x64_en-US.msi release asset.')
}
const stagedDir = dirname(resolved)
const [expectedParent, expectedDir] = STAGED_MSI_DIR_SEGMENTS
// Case-insensitive because Windows paths are, so a `Resources\Menubar` from a caller that
// built the path by hand is the same directory rather than a different one.
const named = (dir: string, expected: string) => basename(dir).toLowerCase() === expected
if (!named(stagedDir, expectedDir!) || !named(dirname(stagedDir), expectedParent!)) {
refuse(`the file is not inside a ${STAGED_MSI_DIR_SEGMENTS.join('\\')} directory.`)
}
const appDir = dirname(dirname(stagedDir))
if (!(await isRegularFile(join(appDir, DESKTOP_APP_EXE)))) {
refuse(`there is no ${DESKTOP_APP_EXE} in ${appDir}, so this is not an installed CodeBurn desktop app.`)
}
if (!(await isRegularFile(resolved))) refuse('there is no regular file at that path.')
if (!(await isRegularFile(`${resolved}.sha256`))) refuse('there is no .sha256 file beside it.')
return resolved
}
export type BundledInstallAction = 'installed' | 'up-to-date' | 'kept-newer' | 'cancelled'
export type BundledInstallResult = {
action: BundledInstallAction
bundledVersion: string
/// What the uninstall registry held before this run, or null when nothing was installed.
previousVersion: string | null
exePath: string
uninstallString: string | null
/// Who the marker file credits with the install: never this run when someone else got there
/// first, so the desktop uninstaller only ever removes what the desktop app put there.
installedBy: MenubarInstalledBy | null
}
export type MenubarInstalledBy = 'desktop' | 'manual'
export type MenubarInstallMarker = {
installedBy: MenubarInstalledBy
version: string
uninstallString: string | null
installedAt: string
}
/// Numeric dotted compare, shortest field wins nothing: 0.9.9 is older than 0.9.10, and a
/// missing or non-numeric field counts as 0 so an empty DisplayVersion reads as the oldest
/// thing there is rather than throwing.
export function compareMenubarVersions(a: string, b: string): number {
const pa = a.trim().replace(/^v/, '').split('.')
const pb = b.trim().replace(/^v/, '').split('.')
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = Number.parseInt(pa[i] ?? '0', 10) || 0
const y = Number.parseInt(pb[i] ?? '0', 10) || 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
/// `CodeBurn.Menubar_0.9.23_x64_en-US.msi` -> `0.9.23`. The staged file is the release asset
/// verbatim, so its name is the only version statement that cannot disagree with its contents.
export function parseWindowsMsiVersion(fileName: string): string | undefined {
return /^CodeBurn\.Menubar_(.+)_x64_en-US\.msi$/.exec(fileName)?.[1]
}
/// Never downgrade: a menubar newer than the bundled one was put there by a `codeburn menubar`
/// install or a newer desktop build, and replacing it with what this build happens to carry
/// would take working features away.
export function decideBundledInstall(
installedVersion: string | undefined,
bundledVersion: string,
force = false,
): Exclude<BundledInstallAction, 'cancelled'> {
if (installedVersion === undefined) return 'installed'
if (force) return 'installed'
const order = compareMenubarVersions(installedVersion, bundledVersion)
if (order > 0) return 'kept-newer'
return order === 0 ? 'up-to-date' : 'installed'
}
/// Beside the tray app's own `status.json` and `update.json`, which is where anything about a
/// specific install of the menubar already lives.
export function menubarMarkerPath(env: NodeJS.ProcessEnv = process.env): string {
const local = env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
return join(local, 'codeburn-menubar', 'installed-by.json')
}
export async function readMenubarMarker(env: NodeJS.ProcessEnv = process.env): Promise<MenubarInstallMarker | undefined> {
try {
const parsed = JSON.parse(await readFile(menubarMarkerPath(env), 'utf8')) as Partial<MenubarInstallMarker>
if (parsed.installedBy !== 'desktop' && parsed.installedBy !== 'manual') return undefined
return {
installedBy: parsed.installedBy,
version: typeof parsed.version === 'string' ? parsed.version : '',
uninstallString: typeof parsed.uninstallString === 'string' ? parsed.uninstallString : null,
installedAt: typeof parsed.installedAt === 'string' ? parsed.installedAt : '',
}
} catch {
return undefined
}
}
/// The marker answers one question for the desktop app's uninstaller: did this app put the
/// menubar here? Only a first install onto a machine that had none can say yes, and once a
/// marker exists its verdict is never rewritten - an upgrade refreshes the version and the
/// uninstall string, nothing else.
///
/// `fallback` is the verdict for a machine that has no marker yet, and it is the route that
/// knows it: the desktop route credits itself only when it found nothing already installed,
/// while a plain `codeburn menubar` is always 'manual'. Writing that 'manual' down is what
/// stops a later desktop install from having to guess: a registry read that comes back empty
/// because of a hive this process cannot see would otherwise read as "nothing was here", and
/// the desktop uninstaller would take the user's own tray app away with it.
async function writeMenubarMarker(
installed: InstalledWindowsMenubar,
fallback: MenubarInstalledBy,
env: NodeJS.ProcessEnv,
): Promise<MenubarInstalledBy> {
const existing = await readMenubarMarker(env)
const installedBy: MenubarInstalledBy = existing?.installedBy ?? fallback
const marker: MenubarInstallMarker = {
installedBy,
version: installed.version,
uninstallString: installed.uninstallString ?? null,
installedAt: new Date().toISOString(),
}
const path = menubarMarkerPath(env)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(marker, null, 2)}\n`)
return installedBy
}
/// Install from a file another installer staged. No network, no release lookup, no launch: the
/// caller that staged the MSI owns the tray app's process, so starting it is its business. The
/// `--quit` this route may send is not a launch of that kind: a second process started with it
/// hands the argument to the running instance and exits, and nothing is sent when nothing is
/// running (stopRunningMenubar).
async function installStagedWindowsMenubar(
msiPath: string,
options: InstallOptions,
): Promise<InstallResult> {
const hooks = options.windows ?? {}
const log = hooks.log ?? console.log
const env = hooks.env ?? process.env
const queryRegistry = hooks.queryRegistry ?? (() => queryWindowsUninstallRegistry(env))
const bundledVersion = parseWindowsMsiVersion(basename(msiPath))
if (!bundledVersion) {
throw new Error(`${msiPath} is not a CodeBurn.Menubar_<version>_x64_en-US.msi.`)
}
const before = parseInstalledWindowsMenubar(await queryRegistry())
const decision = decideBundledInstall(before?.version, bundledVersion, options.force)
const report = async (
action: BundledInstallAction,
installed: InstalledWindowsMenubar | undefined,
installedBy: MenubarInstalledBy | null,
): Promise<InstallResult> => {
const result: BundledInstallResult = {
action,
bundledVersion,
previousVersion: before?.version ?? null,
exePath: installed?.exePath ?? '',
uninstallString: installed?.uninstallString ?? null,
installedBy,
}
log(`${BUNDLED_RESULT_PREFIX}${JSON.stringify(result)}`)
return { installedPath: result.exePath, launched: false }
}
if (decision !== 'installed') {
log(decision === 'kept-newer'
? `CodeBurn Menubar ${before!.version} is newer than the bundled ${bundledVersion}; leaving it alone.`
: `CodeBurn Menubar ${bundledVersion} is already installed.`)
const installedBy = before ? await writeMenubarMarker(before, 'manual', env) : null
return report(decision, before, installedBy)
}
log('Verifying checksum...')
await verifyStagedChecksum(msiPath)
// After the checksum, so a file that is not going to be installed never costs anyone their
// running tray app.
await stopRunningMenubar(before?.exePath, hooks, log, env)
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 report('cancelled', undefined, null)
}
if (exitCode !== 0 && exitCode !== MSI_EXIT_REBOOT_REQUIRED) {
throw new Error(`msiexec exited with ${exitCode} while installing ${basename(msiPath)}.`)
}
if (exitCode === MSI_EXIT_REBOOT_REQUIRED) log('Windows wants a restart to finish the install.')
const installed = parseInstalledWindowsMenubar(await queryRegistry())
if (!installed) {
throw new Error('CodeBurn Menubar installed, but it was not found in the uninstall registry.')
}
const installedBy = await writeMenubarMarker(installed, before === undefined ? 'desktop' : 'manual', env)
log(`Installed CodeBurn Menubar ${installed.version}.`)
return report('installed', installed, installedBy)
}
/// 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
@ -558,19 +875,122 @@ export function parseInstalledWindowsMenubar(regOutput: string): InstalledWindow
if (match) values.set(match[1]!.trim(), match[2]!.trim())
}
if (values.get('DisplayName') !== WINDOWS_PRODUCT_NAME) continue
const location = values.get('InstallLocation')
// DisplayIcon is `<exe>[,<index>]` and points at the installed binary when there is no
// InstallLocation to join onto.
// DisplayIcon is `<exe>[,<index>]` and names the installed binary outright, so where an
// installer wrote one it is the answer rather than a guess. Tauri's MSI leaves it empty,
// so the path that actually gets used is InstallLocation joined with the binary name.
const icon = values.get('DisplayIcon')?.split(',')[0]?.trim()
const exePath = location
? `${location.replace(/[\\/]+$/, '')}\\${WINDOWS_PRODUCT_NAME}.exe`
: icon
const location = values.get('InstallLocation')
const exePath = icon
|| (location ? `${location.replace(/[\\/]+$/, '')}\\${WINDOWS_BINARY_NAME}` : undefined)
if (!exePath) continue
return { version: values.get('DisplayVersion') ?? '', exePath }
const uninstallString = values.get('UninstallString')
return {
version: values.get('DisplayVersion') ?? '',
exePath,
...(uninstallString ? { uninstallString } : {}),
}
}
return undefined
}
// The Microsoft Store route -------------------------------------------------------------------
/// The identity the Store build of the desktop app is published under (app/package.json,
/// build.appx.identityName). Mirrored rather than imported: this module compiles with rootDir
/// src/ and the published package ships only dist/, so app/ is reachable neither at build time
/// nor at runtime. tests/menubar-installer-windows.test.ts fails if the two ever drift apart.
export const STORE_IDENTITY_NAME = 'Codeburn.CodeBurn'
/// Where the tray app sits inside that package: electron-builder puts extraResources under
/// `app\resources`, and app/build/appx-extensions.xml names the same path in its startup task.
const STORE_TRAY_SEGMENTS = ['app', 'resources', 'menubar', WINDOWS_BINARY_NAME]
/// Get-AppxPackage on a warm machine answers well inside a second, but a cold PowerShell behind
/// a slow disk or a policy-loaded profile can take much longer, and `codeburn menubar` is
/// interactive. Past this the answer is "no Store install" and the .msi route takes over.
const STORE_QUERY_TIMEOUT_MS = 5000
export type StoreMenubar = { installLocation: string; exePath: string }
export type WindowsMenubarSourceDecision = {
source: 'store' | 'msi'
storePresent: boolean
msiPresent: boolean
}
/// Which copy of the tray app this run should use. The Store copy wins whenever it is there: it
/// is already installed and already registered for launch at login, so installing the .msi
/// beside it would leave the machine running two tray apps with two autostart entries. Nothing
/// is ever uninstalled here, and --force is the one way to ask for the .msi anyway.
export function decideWindowsMenubarSource(
storePresent: boolean,
msiPresent: boolean,
force = false,
): WindowsMenubarSourceDecision {
return { source: storePresent && !force ? 'store' : 'msi', storePresent, msiPresent }
}
/// Find the tray app inside an installed Store package of the desktop app.
///
/// The uninstall registry cannot answer this, which is the whole reason this exists: a Store
/// (AppX) install writes no uninstall key, so a machine that already carries the tray app inside
/// the packaged desktop app reads as empty to parseInstalledWindowsMenubar. Believing that would
/// download the .msi and stand a second copy in Program Files, with a second launch-at-login
/// entry, beside the one the package already provides.
///
/// `queryStorePackage` returns the package's InstallLocation, or an empty string when there is
/// none. Every way this can come up short - no PowerShell, blocked by policy, too slow, a
/// package whose exe is not where it should be - means "not a Store install".
export async function findStoreMenubar(
queryStorePackage: () => Promise<string>,
): Promise<StoreMenubar | undefined> {
let output: string
try {
output = await queryStorePackage()
} catch {
return undefined
}
const installLocation = output
.split(/\r?\n/)
.map(line => line.trim().replace(/^"(.*)"$/, '$1'))
.find(line => line.length > 0)
if (!installLocation) return undefined
const exePath = join(installLocation, ...STORE_TRAY_SEGMENTS)
return (await exists(exePath)) ? { installLocation, exePath } : undefined
}
async function queryStoreInstallLocation(env: NodeJS.ProcessEnv): Promise<string> {
// Same rule msiexec and reg follow above: an absolute System32 path, never a bare name.
const powershell = resolveSystem32Path('WindowsPowerShell\\v1.0\\powershell.exe', env)
return captureBounded(powershell, [
'-NoProfile',
'-NonInteractive',
'-Command',
`Get-AppxPackage -Name ${STORE_IDENTITY_NAME} | Select-Object -First 1 -ExpandProperty InstallLocation`,
], STORE_QUERY_TIMEOUT_MS)
}
/// captureCommand with a wall-clock limit and no rejection: a missing binary, a non-zero exit
/// and a hang all mean the same thing to the one caller, so they all come back empty.
async function captureBounded(command: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((resolve) => {
let settled = false
let out = ''
const finish = (value: string) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })
const timer = setTimeout(() => {
proc.kill()
finish('')
}, timeoutMs)
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.on('error', () => finish(''))
proc.on('close', code => finish(code === 0 ? out : ''))
})
}
async function queryWindowsUninstallRegistry(env: NodeJS.ProcessEnv): Promise<string> {
const reg = resolveSystem32Path('reg.exe', env)
// reg exits non-zero for a hive the machine does not have; an empty block is the right answer.
@ -588,12 +1008,74 @@ async function runMsiexec(exe: string, args: string[]): Promise<number> {
})
}
function launchWindowsApp(exePath: string): void {
const proc = spawn(exePath, [], { detached: true, stdio: 'ignore' })
function launchWindowsApp(exePath: string, args: string[] = []): void {
const proc = spawn(exePath, args, { detached: true, stdio: 'ignore' })
proc.on('error', err => console.error(`Could not launch ${exePath}: ${err.message}`))
proc.unref()
}
async function trayHasProcess(env: NodeJS.ProcessEnv): Promise<boolean> {
const tasklist = resolveSystem32Path('tasklist.exe', env)
// tasklist prints a "no tasks are running" notice rather than failing, and captureBounded
// turns everything that can go wrong into an empty string, so both read as "not running".
const output = await captureBounded(
tasklist,
['/FI', `IMAGENAME eq ${WINDOWS_BINARY_NAME}`, '/NH'],
TRAY_PROCESS_TIMEOUT_MS,
)
return output.toLowerCase().includes(WINDOWS_BINARY_NAME)
}
async function killTrayProcess(env: NodeJS.ProcessEnv): Promise<void> {
const taskkill = resolveSystem32Path('taskkill.exe', env)
await captureBounded(taskkill, ['/IM', WINDOWS_BINARY_NAME, '/F'], TRAY_PROCESS_TIMEOUT_MS)
}
/// Stop a running tray app before msiexec is allowed near the files it is running from.
///
/// Windows Installer does not fail on a file that is in use: it installs the new one and defers
/// the swap to the next reboot, so an upgrade over a running tray leaves the old binary running
/// and the launch that follows hands its arguments to that old process through the tray app's
/// single-instance window (windows/src-tauri/src/lib.rs). The user is then told they are on the
/// new version while the old one is still what is running.
///
/// So the tray app is asked to stop through that same single-instance protocol, which is how the
/// desktop app asks (app/electron/menubar.ts): a second launch of the installed exe with
/// `--quit` reaches the running instance. taskkill is the fallback for a tray that does not
/// answer within the window, and a machine with no tray running skips all of it.
async function stopRunningMenubar(
exePath: string | undefined,
hooks: WindowsInstallHooks,
log: (message: string) => void,
env: NodeJS.ProcessEnv,
): Promise<void> {
const isRunning = hooks.isTrayRunning ?? (() => trayHasProcess(env))
if (!(await isRunning())) return
const launch = hooks.launch ?? launchWindowsApp
const sleep = hooks.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
if (exePath) {
log('CodeBurn Menubar is running; asking it to quit before installing...')
launch(exePath, ['--quit'])
// Counted polls rather than a wall clock, so the wait is the same length however long the
// supplied sleep actually takes.
for (let attempt = 0; attempt < Math.ceil(TRAY_QUIT_TIMEOUT_MS / TRAY_QUIT_POLL_MS); attempt++) {
await sleep(TRAY_QUIT_POLL_MS)
if (!(await isRunning())) {
log('CodeBurn Menubar stopped.')
return
}
}
}
log('CodeBurn Menubar did not stop on its own; closing it now.')
await (hooks.killTray ?? (() => killTrayProcess(env)))()
if (await isRunning()) {
log('CodeBurn Menubar is still running; Windows will finish the upgrade after the next restart.')
}
}
async function stageWindowsInstaller(
assets: ResolvedAssets,
stagingDir: string,
@ -614,11 +1096,55 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise<Instal
const log = hooks.log ?? console.log
const env = hooks.env ?? process.env
const queryRegistry = hooks.queryRegistry ?? (() => queryWindowsUninstallRegistry(env))
const queryStorePackage = hooks.queryStorePackage ?? (() => queryStoreInstallLocation(env))
const launch = hooks.launch ?? launchWindowsApp
const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : ''
// Refused rather than honoured, and before anything else, so a machine carrying the old
// variable is told why instead of quietly taking the safe route and looking like it worked.
if (env[BUNDLED_MSI_ENV]) {
throw new Error(
`${BUNDLED_MSI_ENV} is no longer honoured: an environment variable chose which installer ran, `
+ `and the checksum it was verified against was written by whoever wrote that installer. `
+ `Pass ${STAGED_MSI_FLAG} <absolute path> instead; it is accepted only for a release-named .msi `
+ `staged in an installed CodeBurn desktop app's ${STAGED_MSI_DIR_SEGMENTS.join('\\')} directory.`,
)
}
// An MSI already on disk short-circuits the whole release lookup: the desktop app ships the
// matching build and points at it rather than sending every install of the desktop app to
// GitHub for a file it already has. This stays ahead of the Store lookup because it is an
// explicit instruction from whoever staged the file, and the Store build never passes it.
if (options.stagedMsi) {
return installStagedWindowsMenubar(await assertStagedMsiPath(options.stagedMsi), options)
}
// The Store package is looked for before the uninstall registry, because it is the one install
// the registry cannot see at all (findStoreMenubar).
const store = await findStoreMenubar(queryStorePackage)
const installed = parseInstalledWindowsMenubar(await queryRegistry())
const decision = decideWindowsMenubarSource(store !== undefined, installed !== undefined, options.force)
if (store && decision.source === 'store') {
// No ownership marker on this route: nothing was installed, the tray app lives inside the
// Store package and goes away with it, and there is no MSI product code for the desktop
// app's uninstaller to act on even if it wanted to (app/build/installer.nsh).
log('CodeBurn Menubar comes from the Microsoft Store CodeBurn desktop app; nothing to download.')
if (installed) {
log(`A separate .msi install is also present at ${installed.exePath}; leaving it in place and using the Store copy.`)
}
launch(store.exePath)
log('Launched CodeBurn Menubar.')
return { installedPath: store.exePath, launched: true }
}
if (decision.storePresent) {
log('CodeBurn Menubar comes from the Microsoft Store CodeBurn desktop app; --force installs the .msi as well and leaves the Store copy in place.')
}
if (installed && !options.force && (!cliVersion || installed.version === cliVersion)) {
// Nothing was installed, but this is still the route that owns a hand-installed tray app,
// and a machine that was set up before the marker existed has none to show for it.
await writeMenubarMarker(installed, 'manual', env)
launch(installed.exePath)
log('Launched CodeBurn Menubar.')
return { installedPath: installed.exePath, launched: true }
@ -648,6 +1174,9 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise<Instal
msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log)
}
// The .msi is downloaded and verified by now, so nothing is stopped for an install that was
// never going to happen.
await stopRunningMenubar(installed?.exePath, hooks, log, env)
log('Installing...')
const msiexec = resolveSystem32Path('msiexec.exe', env)
const exitCode = await (hooks.runInstaller ?? runMsiexec)(msiexec, ['/i', msiPath, '/passive', '/norestart'])
@ -664,6 +1193,10 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise<Instal
if (!nowInstalled) {
throw new Error('CodeBurn Menubar installed, but it was not found in the uninstall registry; start it from the Start menu.')
}
// A `codeburn menubar` install is the user's own, so the marker says 'manual'. An install
// the desktop app already credited to itself keeps that verdict: writeMenubarMarker never
// rewrites one, so upgrading a desktop-installed tray from the CLI does not orphan it.
await writeMenubarMarker(nowInstalled, 'manual', env)
launch(nowInstalled.exePath)
log('Launched CodeBurn Menubar.')
return { installedPath: nowInstalled.exePath, launched: true }
@ -674,6 +1207,9 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise<Instal
export async function installMenubarApp(options: InstallOptions = {}): Promise<InstallResult> {
if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options)
// There is no staged .msi anywhere but Windows, so a path here is a caller that thinks it is
// on another platform. Say so rather than ignoring the argument and installing something else.
if (options.stagedMsi) throw new Error(`${STAGED_MSI_FLAG} is a Windows option; this is ${options.platform ?? platform()}.`)
await ensureSupportedPlatform()
await persistCodeburnPath()

View file

@ -92,6 +92,7 @@ import { getShortModelName } from './models.js'
import type { ReworkedFile } from './workflow-insights.js'
import type { PrRow, BranchRow } from './sessions-report.js'
import type { LiveSessionsBlock } from './live-sessions.js'
import { buildTelemetrySnapshot, type TelemetrySnapshot, type TelemetrySnapshotInput } from './telemetry-snapshot.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
@ -392,6 +393,12 @@ export type MenubarPayload = {
currency: { code: string; symbol: string; rate: number }
combined?: CombinedUsage
claudeConfigs?: ClaudeConfigSelector
/// Anonymised, fully bucketed daily aggregate for consent-gated product
/// telemetry: the `usage_snapshot` event that the desktop app and the Windows
/// tray both send verbatim, so the two surfaces cannot drift apart. Opaque to
/// every other consumer. `null` when it could not be computed. See
/// src/telemetry-snapshot.ts for the privacy contract it holds.
telemetrySnapshot: TelemetrySnapshot | null
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
@ -558,6 +565,10 @@ export type BreakdownArrays = {
/// menubar payload defaults to an empty savings block — keeping the
/// schema stable for consumers that don't care about local savings.
localModelSavings?: LocalModelSavings
/// Inputs the telemetry snapshot needs that the payload itself does not
/// carry: the per-(model, task category) turn cross and session wall-clock
/// durations. The raw values never enter the payload, only their buckets.
telemetry?: TelemetrySnapshotInput
}
export function buildMenubarPayload(
@ -618,7 +629,11 @@ export function buildMenubarPayload(
const c = getCurrency()
return { code: c.code, symbol: c.symbol, rate: c.rate }
})(),
telemetrySnapshot: null,
}
// Derived from the payload that was just assembled, so the snapshot can never
// report something the payload does not already say.
payload.telemetrySnapshot = buildTelemetrySnapshot(payload, breakdowns?.telemetry)
if (claudeConfigs && claudeConfigs.options.length > 1) {
payload.claudeConfigs = claudeConfigs
}

View file

@ -164,7 +164,10 @@ async function resolveCanonicalProjectPathUncached(cwd: string): Promise<{ path:
const worktreeMarker = '/.git/worktrees/'
const markerIndex = normalizedGitDir.lastIndexOf(worktreeMarker)
if (markerIndex === -1) return { path: dir === trimmed ? dir : cwd, isWorktree: false }
return { path: normalizedGitDir.slice(0, markerIndex), isWorktree: true }
// The separator scan runs on a slash-normalized copy so a Windows gitdir
// matches too, but the slice comes off the original: the main repo path is
// compared against session cwds and displayed, so it must stay native.
return { path: gitDir.slice(0, markerIndex), isWorktree: true }
}
const parent = dirname(dir)
if (parent === dir) return { path: cwd, isWorktree: false }

View file

@ -13,6 +13,7 @@
import type { Command } from 'commander'
import { stat, mkdir, readFile, writeFile, rm, readdir, copyFile, mkdtemp } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
import { homedir } from 'os'
import { spawn } from 'child_process'
import { createHash } from 'crypto'
@ -274,12 +275,25 @@ async function copyPluginTree(sourceDir: string, destDir: string): Promise<void>
await walk(sourceDir, destDir)
}
/// Resolve the tar binary to spawn.
///
/// A bare `tar` on Windows can resolve to the GNU tar shipped with Git for
/// Windows, which reads any argument containing a colon as `host:path` and so
/// fails on every absolute path ("Cannot connect to C:"). Windows ships bsdtar
/// at %SystemRoot%\System32\tar.exe, which understands drive letters, so prefer
/// that and fall back to a PATH lookup when it is absent.
export function tarBin(): string {
if (process.platform !== 'win32') return 'tar'
const systemTar = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe')
return existsSync(systemTar) ? systemTar : 'tar'
}
/// Validate tarball entries to prevent directory traversal attacks.
/// Lists all entries and rejects if any: starts with /, contains .., starts with ~, or contains \.
export async function validateTarEntries(tarFile: string): Promise<void> {
return new Promise((resolve, reject) => {
let output = ''
const child = spawn('tar', ['-tzf', tarFile])
const child = spawn(tarBin(), ['-tzf', tarFile])
child.on('error', reject)
if (child.stdout) {
child.stdout.on('data', chunk => {
@ -319,7 +333,7 @@ export async function validateTarEntries(tarFile: string): Promise<void> {
// fifo) can escape the extraction dir before verifyPlugin's post-extract
// symlink check runs, on platforms whose tar follows links mid-archive.
// Reject by type flag, which every tar puts at column 0 of a verbose list.
const verbose = spawn('tar', ['-tvzf', tarFile])
const verbose = spawn(tarBin(), ['-tvzf', tarFile])
let vout = ''
verbose.on('error', reject)
if (verbose.stdout) verbose.stdout.on('data', c => { vout += c.toString('utf8') })
@ -439,7 +453,7 @@ async function addRemote(name: string, pluginsDir: string): Promise<void> {
// Extract tar
await new Promise<void>((resolve, reject) => {
const child = spawn('tar', ['-xzf', tarFile, '-C', tempDir])
const child = spawn(tarBin(), ['-xzf', tarFile, '-C', tempDir])
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve()

View file

@ -117,7 +117,15 @@ export function getDesktopSessionsDirs(): string[] {
const appDataInput = process.env['APPDATA']
const localAppDataInput = process.env['LOCALAPPDATA']
const platform = process.platform
const cacheKey = JSON.stringify([platform, override ?? null, appDataInput ?? null, localAppDataInput ?? null])
// homedir() feeds every non-override branch below, so a key without it hands
// back the previous home's paths once the home directory moves.
const cacheKey = JSON.stringify([
platform,
override ?? null,
appDataInput ?? null,
localAppDataInput ?? null,
homedir(),
])
const cached = desktopSessionsDirsCache.get(cacheKey)
if (cached) return [...cached]

View file

@ -1238,7 +1238,9 @@ function inferJetBrainsProject(raw: string): string | undefined {
let p = m[1]
try { p = decodeURIComponent(p) } catch { /* leave as-is */ }
const dir = p.slice(0, p.lastIndexOf('/'))
if (dir.startsWith('/')) seen.add(dir)
// A Windows URL is file:///C:/repo/One.ts, so the captured path carries a
// leading slash that is part of the URL, not of the drive path.
if (dir.startsWith('/')) seen.add(dir.replace(/^\/(?=[a-zA-Z]:\/)/, ''))
}
if (seen.size === 0) return undefined

View file

@ -417,8 +417,15 @@ function collectTools(messages: HermesMessageRow[]): { tools: string[]; toolSequ
function isRealWorkspace(cwd: string | null | undefined): cwd is string {
if (!cwd?.trim()) return false
const trimmed = cwd.trim()
// A state.db is only ever read from this machine's own Hermes home
// (`HERMES_HOME` or `~/.hermes`), so the shapes it can hold are the host's.
// A Windows host also takes a POSIX-rooted path, which its own tooling
// produces (WSL, MSYS); a POSIX host takes only a POSIX-rooted path, so a
// literal `C:\...` string stays the non-workspace it has always been there.
// Slash-UNC (//server/share) stays out: it is not a local workspace.
const isAbsolute = process.platform === 'win32'
? /^[a-zA-Z]:[\\/]/.test(trimmed) || /^\\\\[^\\/]+\\/.test(trimmed)
|| (trimmed.startsWith('/') && !trimmed.startsWith('//'))
: trimmed.startsWith('/') && !trimmed.startsWith('//')
if (!isAbsolute) return false
const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '')

293
src/quota/antigravity.ts Normal file
View file

@ -0,0 +1,293 @@
// Live Antigravity quota from LOCAL surfaces only - the Antigravity app's own
// language server or a signed-in `agy` CLI's embedded server. The local
// Antigravity language-server protocol is undocumented and
// experimental). No Google OAuth fallback in v1: when no local server answers,
// the provider reports disconnected and the UI shows its Connect affordance.
//
// Endpoints (localhost only, Connect-RPC JSON):
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary
// (preferred; falls back to)
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
//
// Discovery lists processes to find candidates (app language
// servers need their `--csrf_token`; the `agy` CLI needs none), then lists each
// pid's listening TCP ports: `ps` and `lsof` on POSIX, Win32_Process and
// `netstat` on Windows, which has neither. Local HTTPS uses a self-signed cert,
// so TLS verification is relaxed ONLY for the 127.0.0.1 loopback probes.
import { execFile } from 'node:child_process'
import http from 'node:http'
import https from 'node:https'
import { promisify } from 'node:util'
import { fraction, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary'
const STATUS_PATH = '/exa.language_server_pb.LanguageServerService/GetUserStatus'
const LOCAL_TIMEOUT_MS = 3_000
export type ExecFileFn = (file: string, args: string[]) => Promise<{ stdout: string }>
export type LocalRequestFn = (
port: number,
tls: boolean,
pathName: string,
body: string,
csrf?: string,
) => Promise<{ status: number; text: string } | null>
export type AntigravityDeps = {
execFile: ExecFileFn
request: LocalRequestFn
/// Which process listing to ask for. Injected so both branches are testable from either OS.
platform: string
}
const execFileAsync: ExecFileFn = promisify(execFile)
function postLocal(port: number, tls: boolean, pathName: string, body: string, csrf?: string): Promise<{ status: number; text: string } | null> {
return new Promise(resolve => {
const request = (tls ? https : http).request({
host: '127.0.0.1',
port,
method: 'POST',
path: pathName,
...(tls ? { rejectUnauthorized: false } : {}),
headers: {
'Content-Type': 'application/json',
'Connect-Protocol-Version': '1',
...(csrf ? { 'X-Codeium-Csrf-Token': csrf } : {}),
},
timeout: LOCAL_TIMEOUT_MS,
}, response => {
let text = ''
response.setEncoding('utf8')
response.on('data', chunk => { text += chunk })
response.on('end', () => resolve({ status: response.statusCode ?? 0, text }))
})
request.on('timeout', () => { request.destroy(); resolve(null) })
request.on('error', () => resolve(null))
request.end(body)
})
}
const defaults: AntigravityDeps = {
execFile: execFileAsync,
request: postLocal,
platform: process.platform,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'antigravity', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
// Process kinds distinguish the app language server from standalone processes.
// carries richer quota data than the IDE variant, so IDE matches are skipped;
// a CLI (`agy`) match is accepted because its tokenless server exposes the
// same summary payload.
const LANGUAGE_SERVER = /language[_-]server(_macos)?(_arm)?/
const IDE_MARKER = /antigravity[-_]ide/i
const CLI_MARKER = /antigravity[-_]cli/i
const AGY_BINARY = /(^|\/|\s)agy(\s|$)/
const APP_MARKER = /--app_data_dir[= ]"?antigravity(?![\w-])|\/antigravity\//i
type Candidate = { pid: string; cli: boolean; csrf?: string; extPort?: number }
function flagValue(line: string, flag: string): string | undefined {
const match = line.match(new RegExp(`${flag}[= ]([^\\s]+)`))
return match?.[1]
}
export function classifyProcessLine(line: string): Candidate | null {
const isServer = LANGUAGE_SERVER.test(line)
const isCli = !isServer && (CLI_MARKER.test(line) || AGY_BINARY.test(line))
if (!isServer && !isCli) return null
const pid = line.trim().match(/^(\d+)/)?.[1]
if (!pid) return null
// A tokenless desktop language-server match is skipped so a later, valid
// server can be found; the CLI exposes no CSRF flag and needs none. IDE
// language servers are excluded too - their payloads lack the weekly groups.
if (!isCli && (!APP_MARKER.test(line) || IDE_MARKER.test(line))) return null
const csrf = flagValue(line, '--csrf_token')
if (!isCli && !csrf) return null
const extPort = Number(flagValue(line, '--extension_server_port'))
return { pid, cli: isCli, csrf, extPort: Number.isFinite(extPort) && extPort > 0 ? extPort : undefined }
}
/// Windows has no `ps`, and asking for one is a spawn failure rather than an empty answer:
/// every quota poll there printed `ps: unknown option -- x` and reported a transient failure.
/// Win32_Process is the same listing by another name, and it is already how
/// src/providers/antigravity.ts finds these servers. The Where-Object is only a prefilter to
/// keep the output small; `classifyProcessLine` still decides what counts.
const WINDOWS_PROCESS_SCRIPT = [
"$ErrorActionPreference = 'SilentlyContinue'",
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
'Get-CimInstance Win32_Process'
+ " | Where-Object { $_.CommandLine -and ($_.CommandLine -like '*antigravity*'"
+ " -or $_.CommandLine -like '*language_server*' -or $_.CommandLine -like '*agy*') }"
+ ' | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }',
].join('; ')
/// Windows searches the current directory before PATH, so a system tool by bare name lets
/// anything dropped beside the caller answer for it.
function system32(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}`
}
/// One line per process, `<pid> <command line>`, on every platform.
async function listProcesses(deps: AntigravityDeps): Promise<string> {
if (deps.platform === 'win32') {
const { stdout } = await deps.execFile(system32('WindowsPowerShell\\v1.0\\powershell.exe'), [
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_PROCESS_SCRIPT,
])
return stdout
}
const { stdout } = await deps.execFile('ps', ['-ax', '-o', 'pid=,command='])
return stdout
}
/// `netstat -ano` rows are `Proto Local Foreign State PID`. The state word is localized,
/// so it is not matched on: a row for this pid that is not listening only gives a port the
/// probe then fails to reach, which costs one refused connection and no wrong answer.
export function parseNetstatPorts(stdout: string, pid: string): number[] {
const ports = new Set<number>()
for (const line of stdout.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 5 || !/^TCP$/i.test(parts[0]!) || parts[parts.length - 1] !== pid) continue
const port = Number(parts[1]!.split(':').pop())
if (Number.isFinite(port) && port > 0) ports.add(port)
}
return [...ports]
}
async function discoverCandidates(deps: AntigravityDeps): Promise<Candidate[]> {
const stdout = await listProcesses(deps)
const seen = new Set<string>()
const candidates: Candidate[] = []
for (const line of stdout.split('\n')) {
const candidate = classifyProcessLine(line)
if (!candidate || seen.has(candidate.pid)) continue
seen.add(candidate.pid)
candidates.push(candidate)
}
// The app source ranks above the CLI: richer quota data beats availability.
return candidates.sort((a, b) => Number(a.cli) - Number(b.cli))
}
async function listeningPorts(deps: AntigravityDeps, pid: string): Promise<number[]> {
try {
// No lsof on Windows either; netstat is the listing that ships with it.
if (deps.platform === 'win32') {
const { stdout } = await deps.execFile(system32('netstat.exe'), ['-ano', '-p', 'TCP'])
return parseNetstatPorts(stdout, pid)
}
const { stdout } = await deps.execFile('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-p', pid])
const ports = [...stdout.matchAll(/:(\d+)\s/g)].map(match => Number(match[1]))
return [...new Set(ports)].filter(port => Number.isFinite(port) && port > 0)
} catch {
return []
}
}
function resetTimeOf(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value > 1e12 ? value : value * 1000).toISOString()
}
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString()
return null
}
function windowOf(label: string, remainingFraction: unknown, resetTime?: unknown): QuotaWindow | null {
const remaining = fraction(typeof remainingFraction === 'number' ? (1 - remainingFraction) * 100 : NaN)
if (remaining === null) return null
return { label, percent: remaining, resetsAt: resetTimeOf(resetTime ?? null) }
}
/** Preferred payload: two named quota groups of model buckets. */
export function decodeAntigravitySummary(body: unknown): QuotaWindow[] {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const windows: QuotaWindow[] = []
for (const group of Array.isArray(data.groups) ? data.groups : []) {
const groupName = typeof group?.displayName === 'string' ? group.displayName : ''
for (const bucket of Array.isArray(group?.buckets) ? group.buckets : []) {
const name = [groupName, typeof bucket?.displayName === 'string' ? bucket.displayName : bucket?.bucketId]
.filter(Boolean).join(' · ')
if (!name) continue
const window = windowOf(name, bucket?.remaining?.remainingFraction)
if (window) windows.push(window)
}
}
return windows
}
/** Legacy payload: flat per-model quota rows under GetUserStatus. */
export function decodeAntigravityStatus(body: unknown): QuotaWindow[] {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const configs = data.userStatus?.cascadeModelConfigData?.clientModelConfigs
const windows: QuotaWindow[] = []
for (const config of Array.isArray(configs) ? configs : []) {
const name = typeof config?.modelName === 'string' ? config.modelName : ''
if (!name) continue
const window = windowOf(name, config?.quotaInfo?.remainingFraction, config?.quotaInfo?.resetTime)
if (window) windows.push(window)
}
return windows
}
function planFromStatus(body: unknown): string | null {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const plan = data.planName ?? data.userStatus?.planName ?? data.account_plan
return typeof plan === 'string' && plan.trim() ? plan.trim() : null
}
async function probePort(deps: AntigravityDeps, port: number, csrf?: string): Promise<{ windows: QuotaWindow[]; planLabel: string | null } | null> {
const body = JSON.stringify({})
for (const tls of [true, false]) {
const summary = await deps.request(port, tls, SUMMARY_PATH, body, csrf)
if (summary?.status === 200) {
const windows = decodeAntigravitySummary(parseJson(summary.text))
if (windows.length > 0) return { windows, planLabel: null }
}
const status = await deps.request(port, tls, STATUS_PATH, body, csrf)
if (status?.status === 200) {
const parsed = parseJson(status.text)
const windows = decodeAntigravityStatus(parsed)
if (windows.length > 0) return { windows, planLabel: planFromStatus(parsed) }
}
}
return null
}
function parseJson(text: string): unknown {
try { return JSON.parse(text) } catch { return null }
}
export async function fetchAntigravityQuota(deps: Partial<AntigravityDeps> = {}): Promise<QuotaProvider> {
const resolved = { ...defaults, ...deps }
try {
const candidates = await discoverCandidates(resolved)
if (candidates.length === 0) return empty('disconnected')
for (const candidate of candidates) {
const ports = await listeningPorts(resolved, candidate.pid)
if (candidate.extPort !== undefined && !ports.includes(candidate.extPort)) ports.push(candidate.extPort)
for (const port of ports) {
const found = await probePort(resolved, port, candidate.csrf)
if (found) {
const windows = [...found.windows].sort((a, b) => b.percent - a.percent)
return {
provider: 'antigravity', connection: 'connected',
primary: windows[0] ?? null,
details: windows,
planLabel: found.planLabel,
footerLines: [],
}
}
}
}
return empty('disconnected')
} catch (error) {
console.warn(`Antigravity quota unavailable: ${sanitizeError(error)}`)
return empty('transientFailure')
}
}

152
src/quota/claude.ts Normal file
View file

@ -0,0 +1,152 @@
import os from 'node:os'
import path from 'node:path'
import { fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security.js'
import type { KeychainOutcome } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
const KEYCHAIN_SERVICE = 'Claude Code-credentials'
type ClaudeCredential = { accessToken: string; expiresAt?: number; rateLimitTier?: string }
export type ClaudeDeps = {
fetch: typeof fetch
credentialPath: string
readFile: typeof readSecureFile
now: () => number
keychain?: () => Promise<KeychainOutcome>
}
const defaults: ClaudeDeps = {
fetch: globalThis.fetch,
credentialPath: path.join(os.homedir(), '.claude', '.credentials.json'),
readFile: readSecureFile,
now: Date.now,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
function parseCredential(raw: string): ClaudeCredential | null {
const clean = raw.replace(/\r/g, '').replace(/\n[ \t]*/g, '')
const oauth = (JSON.parse(clean) as { claudeAiOauth?: Record<string, unknown> }).claudeAiOauth
if (!oauth || typeof oauth.accessToken !== 'string' || oauth.accessToken.length === 0) return null
return {
accessToken: oauth.accessToken,
expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : undefined,
rateLimitTier: typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined,
}
}
async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential | null> {
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
return raw ? parseCredential(raw) : null
}
export async function readClaudeKeychain(): Promise<KeychainOutcome> {
// Claude Code has written the item under both `$USER` (2.1.x) and the older
// hardcoded "agentseal" account; a user-scoped miss must fall through to the
// service-only lookup rather than reporting disconnected.
const user = process.env.USER
return readKeychainPassword(KEYCHAIN_SERVICE, user ? [user, null] : [null])
}
function windowOf(label: string, value: unknown): QuotaWindow | null {
if (!value || typeof value !== 'object') return null
const row = value as Record<string, unknown>
const percent = fraction(row.utilization)
if (percent === null) return null
const resetsAt = typeof row.resets_at === 'string' && !Number.isNaN(Date.parse(row.resets_at))
? new Date(row.resets_at).toISOString() : null
return { label, percent, resetsAt }
}
function tierLabel(raw: string | undefined): string {
const value = raw?.toLowerCase() ?? ''
if (value.includes('max_20x') || value.includes('max20x') || value.includes('max-20x')) return 'Max 20x'
if (value.includes('max_5x') || value.includes('max5x') || value.includes('max-5x') || value.includes('max')) return 'Max 5x'
if (value.includes('pro')) return 'Pro'
if (value.includes('team')) return 'Team'
if (value.includes('enterprise')) return 'Enterprise'
return 'Subscription'
}
export function decodeClaudeUsage(body: unknown, credential: ClaudeCredential): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, unknown> : {}
const five = windowOf('5-hour', data.five_hour)
const weekly = windowOf('Weekly', data.seven_day)
const opus = windowOf('Weekly · Opus', data.seven_day_opus)
const sonnet = windowOf('Weekly · Sonnet', data.seven_day_sonnet)
const scoped: QuotaWindow[] = []
if (Array.isArray(data.limits)) {
for (const item of data.limits) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, any>
const display = row.scope?.model?.display_name
const percent = fraction(row.percent)
if (row.kind !== 'weekly_scoped' || typeof display !== 'string' || percent === null) continue
const resetsAt = typeof row.resets_at === 'string' && !Number.isNaN(Date.parse(row.resets_at))
? new Date(row.resets_at).toISOString() : null
scoped.push({ label: `Weekly · ${display}`, percent, resetsAt })
}
}
return {
provider: 'claude', connection: 'connected', primary: weekly,
details: [five, weekly, opus, sonnet].filter((row): row is QuotaWindow => row !== null).concat(scoped),
planLabel: tierLabel(credential.rateLimitTier), footerLines: [],
}
}
async function request(token: string, deps: ClaudeDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(parent),
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': 'claude-code/2.1.0',
},
})
}
export type ClaudeResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise<ClaudeResult> {
const deps = { ...defaults, ...options }
try {
let credential = await credentialFromFile(deps)
if (!credential && options.allowKeychain && process.platform === 'darwin') {
const outcome = await (deps.keychain ?? readClaudeKeychain)()
if (outcome.status === 'accessDenied') return { quota: empty('accessDenied') }
credential = outcome.status === 'found' ? parseCredential(outcome.value) : null
}
if (!credential) return { quota: empty('disconnected') }
let response: Response
if (credential.expiresAt !== undefined && credential.expiresAt - deps.now() <= 5 * 60_000) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
credential = reread
}
response = await request(credential.accessToken, deps, options.signal)
if (response.status === 401) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
credential = reread
response = await request(credential.accessToken, deps, options.signal)
}
if (response.status === 429) {
let hint: unknown
try { hint = (await response.json() as Record<string, unknown>).retry_after } catch { hint = undefined }
const parsed = typeof hint === 'number' ? hint : typeof hint === 'string' ? Number(hint) : NaN
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(parsed) ? parsed : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeClaudeUsage(await response.json(), credential) }
} catch (error) {
// Deliberately sanitize before the only diagnostic sink. Tokens are never returned.
console.warn(`Claude quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

126
src/quota/clinepass.ts Normal file
View file

@ -0,0 +1,126 @@
// Live ClinePass quota from the public usage-limits contract (ported from the
// menubar's ClinePassSubscriptionService.swift):
//
// - GET https://api.cline.bot/api/v1/users/me/plan/usage-limits
// Bearer API key, one 5-hour / weekly / monthly window each.
//
// Credential: ClinePass has no local login file to read; the key is one the
// user supplies. On the Mac that is a CodeBurn Keychain entry, and the CLI's
// equivalent is the environment: CLINEPASS_API_KEY, then CLINE_API_KEY. The
// key is used for one request and never persisted or logged.
import { quotaRequestSignal, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits'
const API_KEY_VARS = ['CLINEPASS_API_KEY', 'CLINE_API_KEY'] as const
const REJECTED_FOOTER = ['ClinePass rejected this API key.']
const RATE_LIMITED_FOOTER = ['ClinePass rate-limited the quota request.']
const UNAVAILABLE_FOOTER = ['ClinePass is temporarily unavailable.']
const PARSE_FOOTER = ['ClinePass quota response was malformed.']
const WINDOW_LABELS: Record<string, string> = { five_hour: '5-hour', weekly: 'Weekly', monthly: 'Monthly' }
export type ClinePassDeps = {
fetch: typeof fetch
env: NodeJS.ProcessEnv
}
function defaultDeps(): ClinePassDeps {
return { fetch: globalThis.fetch, env: process.env }
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'clinepass', connection, primary: null, details: [], planLabel: null, footerLines }
}
export function clinePassApiKey(env: NodeJS.ProcessEnv): string | null {
for (const name of API_KEY_VARS) {
const value = env[name]?.trim()
if (value) return value
}
return null
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* `null` on any malformed row. ClinePass publishes this contract, so unlike the
* looser internal endpoints a field that is present but wrong is a broken
* response rather than something to skip past; only an unknown window type is
* ignored, so a new one does not break the reading.
*/
export function decodeClinePassUsage(body: unknown): QuotaProvider | null {
if (!body || typeof body !== 'object') return null
const root = body as Record<string, unknown>
if (root['success'] !== true) return null
const data = root['data']
if (!data || typeof data !== 'object') return null
const limits = (data as Record<string, unknown>)['limits']
if (!Array.isArray(limits)) return null
const windows = new Map<string, QuotaWindow>()
for (const raw of limits) {
if (!raw || typeof raw !== 'object') return null
const limit = raw as Record<string, unknown>
const type = limit['type']
if (typeof type !== 'string') return null
const label = WINDOW_LABELS[type]
if (label === undefined) continue
const percentUsed = num(limit['percentUsed'])
if (percentUsed === null) return null
let resetsAt: string | null = null
if (limit['resetsAt'] !== undefined && limit['resetsAt'] !== null) {
const parsed = typeof limit['resetsAt'] === 'string' ? Date.parse(limit['resetsAt']) : NaN
if (!Number.isFinite(parsed)) return null
resetsAt = new Date(parsed).toISOString()
}
windows.set(type, { label, percent: Math.min(1, Math.max(0, percentUsed / 100)), resetsAt })
}
const details = ['five_hour', 'weekly', 'monthly']
.map(type => windows.get(type))
.filter((row): row is QuotaWindow => row !== undefined)
if (details.length === 0) return null
return {
provider: 'clinepass', connection: 'connected',
primary: windows.get('weekly') ?? windows.get('five_hour') ?? windows.get('monthly')!,
details,
planLabel: null,
footerLines: [],
}
}
export type ClinePassResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchClinePassQuota(options: Partial<ClinePassDeps> & { signal?: AbortSignal } = {}): Promise<ClinePassResult> {
const deps = { ...defaultDeps(), ...options }
const key = clinePassApiKey(deps.env)
if (!key) return { quota: empty('disconnected') }
try {
const response = await deps.fetch(USAGE_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(options.signal),
headers: { Accept: 'application/json', Authorization: `Bearer ${key}`, 'User-Agent': 'CodeBurn' },
})
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', REJECTED_FOOTER) }
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return {
quota: { ...empty('transientFailure', RATE_LIMITED_FOOTER), rateLimited: true },
retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60),
}
}
if (response.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) }
if (!response.ok) return { quota: empty('transientFailure', PARSE_FOOTER) }
// Never log the body - it carries account data.
const quota = decodeClinePassUsage(await response.json())
return { quota: quota ?? empty('transientFailure', PARSE_FOOTER) }
} catch (error) {
console.warn(`ClinePass quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

407
src/quota/codex.ts Normal file
View file

@ -0,0 +1,407 @@
import os from 'node:os'
import path from 'node:path'
import { atomicWriteSecureFileSync, fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, readSecureFileSync, sanitizeError, trackCredentialWrite } from './security.js'
import type { KeychainOutcome } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://chatgpt.com/backend-api/wham/usage'
const TOKEN_ENDPOINT = 'https://auth.openai.com/oauth/token'
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const EIGHT_DAYS = 8 * 24 * 60 * 60_000
// The CodeBurn menubar caches its ChatGPT-mode Codex OAuth here as a
// `CredentialRecord` JSON blob (accessToken/refreshToken/idToken/accountId/…),
// account "default". Same brand, same machine, already consented - preferred
// over any OpenAI-owned storage.
const MENUBAR_KEYCHAIN_SERVICE = 'org.agentseal.codeburn.menubar.codex.oauth.v1'
type AuthDoc = Record<string, any> & {
auth_mode?: string
tokens?: { access_token?: string; refresh_token?: string; id_token?: string; account_id?: string; [key: string]: unknown }
last_refresh?: string
}
export type CodexDeps = {
fetch: typeof fetch
authPath: string
openaiAuthPath: string
readFile: typeof readSecureFile
// Synchronous by contract, not by convenience: see `persistRotated`.
readFileSync: typeof readSecureFileSync
writeFileSync: typeof atomicWriteSecureFileSync
keychain: (service: string) => Promise<KeychainOutcome>
now: () => number
}
const defaults: CodexDeps = {
fetch: globalThis.fetch,
authPath: path.join(os.homedir(), '.codex', 'auth.json'),
openaiAuthPath: path.join(os.homedir(), 'Library', 'Application Support', 'com.openai.codex', 'auth.json'),
readFile: readSecureFile,
readFileSync: readSecureFileSync,
writeFileSync: atomicWriteSecureFileSync,
keychain: service => readKeychainPassword(service, ['default', null]),
now: Date.now,
}
/** A resolved Codex credential plus how much of its lifecycle we own. Only the
* Codex CLI's own auth.json is `writable` (we may rotate + write it back); the
* menubar keychain and OpenAI app-support copies are read-only. */
type CodexSource = {
name: 'menubarKeychain' | 'authFile' | 'openaiAppSupport'
auth: AuthDoc
writable: boolean
reread: () => Promise<AuthDoc | null>
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'codex', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
async function readAuth(deps: CodexDeps, filePath: string = deps.authPath): Promise<AuthDoc | null> {
const raw = await deps.readFile(filePath, 64 * 1024)
return raw ? JSON.parse(raw) as AuthDoc : null
}
// The menubar stores a Swift `CredentialRecord` (camelCase, Date fields as
// numbers) rather than the CLI's snake_case auth.json. Normalize to AuthDoc and
// mark it chatgpt-mode (the menubar only ever caches ChatGPT subscriptions).
function authFromMenubarRecord(raw: string): AuthDoc | null {
let record: Record<string, unknown>
try { record = JSON.parse(raw) as Record<string, unknown> } catch { return null }
const access = typeof record.accessToken === 'string' ? record.accessToken : ''
if (!access) return null
return {
auth_mode: 'chatgpt',
tokens: {
access_token: access,
refresh_token: typeof record.refreshToken === 'string' ? record.refreshToken : undefined,
id_token: typeof record.idToken === 'string' ? record.idToken : undefined,
account_id: typeof record.accountId === 'string' ? record.accountId : undefined,
},
}
}
async function discoverSource(deps: CodexDeps, allowKeychain: boolean): Promise<CodexSource | 'accessDenied' | null> {
let denied = false
// (a) CodeBurn menubar's own cached Codex OAuth. Read-only: the menubar owns
// rotation, so we never write it back and never proactively refresh it.
if (allowKeychain && process.platform === 'darwin') {
const outcome = await deps.keychain(MENUBAR_KEYCHAIN_SERVICE)
if (outcome.status === 'accessDenied') denied = true
else if (outcome.status === 'found') {
const auth = authFromMenubarRecord(outcome.value)
if (auth) {
return {
name: 'menubarKeychain', auth, writable: false,
reread: async () => {
const next = await deps.keychain(MENUBAR_KEYCHAIN_SERVICE)
return next.status === 'found' ? authFromMenubarRecord(next.value) : null
},
}
}
}
}
// (b) The Codex CLI's own ~/.codex/auth.json. We own rotation + write-back.
const fileAuth = await readAuth(deps)
if (fileAuth) return { name: 'authFile', auth: fileAuth, writable: true, reread: () => readAuth(deps) }
// (c) com.openai.codex App Support, only if it holds a plaintext auth JSON
// with a usable token. Tokens encrypted via "Codex Safe Storage" have no
// plaintext access_token here, so they fall through - we never decrypt.
const openaiAuth = await readAuth(deps, deps.openaiAuthPath).catch(() => null)
if (openaiAuth?.tokens?.access_token) {
return { name: 'openaiAppSupport', auth: openaiAuth, writable: false, reread: () => readAuth(deps, deps.openaiAuthPath).catch(() => null) }
}
return denied ? 'accessDenied' : null
}
function labelForSeconds(value: unknown): string {
const seconds = typeof value === 'number' ? Math.max(0, Math.trunc(value)) : 0
if (seconds < 3600) return 'Hourly'
if (seconds < 7200) return 'Hour'
if (seconds >= 18_000 && seconds < 19_000) return '5-hour'
if (seconds >= 86_400 && seconds < 87_000) return 'Daily'
if (seconds >= 604_800 && seconds < 605_000) return 'Weekly'
const hours = Math.floor(seconds / 3600)
return hours < 24 ? `${hours}-hour` : `${Math.floor(hours / 24)}-day`
}
function windowOf(value: unknown, override?: string): QuotaWindow | null {
if (!value || typeof value !== 'object') return null
const row = value as Record<string, unknown>
const percent = fraction(row.used_percent)
if (percent === null) return null
const reset = typeof row.reset_at === 'number' && Number.isFinite(row.reset_at)
? new Date(row.reset_at * 1000).toISOString() : null
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
}
// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN,
// so blank is rejected or an absent `used` decodes as a confident zero.
function num(value: unknown): number | null {
if (typeof value === 'string' && !value.trim()) return null
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN
return Number.isFinite(parsed) ? parsed : null
}
// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
function normalizePlanType(value: string): string {
return value
.replace(/[_-]usage[_-]based$/, '')
.replace(/^self[_-]serve[_-]/, '')
.replace(/[_-]cbp$/, '')
.replace(/[_-]cbp[_-]/g, '_')
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
const lower = normalizePlanType(raw.toLowerCase())
const known: Record<string, string> = {
guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro',
prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite',
free_workspace: 'Free Workspace', team: 'Team', business: 'Business',
education: 'Education', quorum: 'Quorum', k12: 'K-12', enterprise: 'Enterprise', edu: 'Edu',
}
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
// The admin-set monthly allowance, the only limit a credit-metered workspace
// has. `spend_control` is the live position, the others forward-compat. `any`
// because this walks six optional-chained hops, all validated by `num()`.
function spendControlWindow(data: Record<string, any>): QuotaWindow | null {
// `find`/`num` per alias, not `??`: a non-null garbage value would stop `??`
// and mask a valid alias further down. Object-shaped garbage still wins the
// position, matching Swift, which likewise commits to the first that decodes.
const row = [
data.spend_control?.individual_limit,
data.spend_control?.individualLimit,
data.individual_limit,
data.individualLimit,
data.rate_limit?.individual_limit,
data.rate_limit?.individualLimit,
].find(candidate => candidate && typeof candidate === 'object')
if (!row) return null
const limit = num(row.limit)
if (limit === null || limit <= 0) return null
const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent)
const used = num(row.used)
const rawPercent = num(row.used_percent) ?? num(row.usedPercent)
?? (remainingPercent === null ? null : 100 - remainingPercent)
?? (used === null ? null : (used / limit) * 100)
if (rawPercent === null) return null
const percent = Math.min(1, Math.max(0, rawPercent / 100))
const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt)
// Past 8.64e15 ms `toISOString()` throws RangeError.
const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15
? new Date(resetRaw * 1000).toISOString()
: null
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
const spent = used ?? limit * Math.max(0, rawPercent) / 100
const round = (n: number) => Math.round(n).toLocaleString('en-US')
const reached = data.spend_control?.reached === true
const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits`
return { label: reached ? `${label} · limit reached` : label, percent, resetsAt }
}
export function decodeCodexUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const primaryRaw = windowOf(data.rate_limit?.primary_window)
const secondaryRaw = windowOf(data.rate_limit?.secondary_window)
const primary = primaryRaw ?? secondaryRaw
const details: QuotaWindow[] = []
if (primaryRaw) details.push(primaryRaw)
if (secondaryRaw && secondaryRaw !== primary) details.push(secondaryRaw)
else if (!primaryRaw && secondaryRaw) details.push(secondaryRaw)
if (Array.isArray(data.additional_rate_limits)) {
for (const additional of data.additional_rate_limits) {
if (!additional || typeof additional !== 'object' || typeof additional.limit_name !== 'string') continue
for (const key of ['primary_window', 'secondary_window'] as const) {
const raw = additional.rate_limit?.[key]
const base = windowOf(raw)
if (base && base.percent > 0) details.push({ ...base, label: `${additional.limit_name} · ${base.label}` })
}
}
}
const credits = spendControlWindow(data)
if (credits) details.push(credits)
const balance = num(data.credits?.balance)
// Credit-settled accounts denominate in credits, so no currency symbol.
const hasCredits = data.credits?.has_credits === true
const footerLines: string[] = []
if (balance !== null && balance > 0) {
footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
}
// Uncapped on purpose, so a bar-less card does not read as a failed fetch.
if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited')
return {
provider: 'codex', connection: 'connected', primary: primary ?? credits, details,
planLabel: planLabel(data.plan_type),
footerLines,
}
}
function str(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}
/** The document the rotated tokens are merged into: whatever the Codex CLI has
* on disk right now, so a login it wrote since our own read is not clobbered.
* `null` means the login this rotation belongs to is gone (file removed, or no
* longer ChatGPT mode) and writing would resurrect it. An unreadable or
* unparseable file is NOT one of those cases: it falls back to the document we
* already hold, because dropping a rotated refresh token signs the user out. */
function mergeBase(auth: AuthDoc, deps: CodexDeps): AuthDoc | null {
let raw: string | null
try {
raw = deps.readFileSync(deps.authPath)
} catch {
return auth
}
if (raw === null) return null
let latest: unknown
try {
latest = JSON.parse(raw)
} catch {
return auth
}
if (!latest || typeof latest !== 'object') return auth
const doc = latest as AuthDoc
return doc.auth_mode === 'chatgpt' ? doc : null
}
type RotatedTokens = { access: string | null; refresh: string | null; id: string | null }
/**
* Merges the rotated tokens into auth.json and writes them, synchronously and
* atomically. Synchronous from the moment the token response is parsed to the
* moment the file is renamed into place: the grant has already invalidated the
* refresh token on disk, so an abort, a per-provider timeout or the `quota`
* command's `process.exit` landing in an await here would leave the user with
* nothing but a dead token, which reads as being signed out of Codex.
*/
function persistRotated(auth: AuthDoc, rotated: RotatedTokens, deps: CodexDeps): AuthDoc | null {
const base = mergeBase(auth, deps)
if (!base) {
console.error(`Codex refreshed its login but the credential at ${deps.authPath} no longer holds that ChatGPT login, so the new token was discarded. Run \`codex login\` if Codex signs you out.`)
return null
}
const merged: AuthDoc = {
...base,
tokens: {
...base.tokens,
...(rotated.access ? { access_token: rotated.access } : {}),
...(rotated.refresh ? { refresh_token: rotated.refresh } : {}),
...(rotated.id ? { id_token: rotated.id } : {}),
},
last_refresh: new Date(deps.now()).toISOString(),
}
try {
deps.writeFileSync(deps.authPath, `${JSON.stringify(merged, null, 2)}\n`)
} catch (error) {
// Never silent: the token still on disk is the one the grant just killed,
// so the user has to be told why Codex will ask them to sign in again.
console.error(`Codex refresh token rotated but could not be saved to ${deps.authPath}: ${sanitizeError(error)}. Run \`codex login\` if Codex signs you out.`)
return null
}
return merged
}
/**
* The read-only `codeburn quota` path still refreshes, because the endpoint it
* reads rejects an expired access token: a ChatGPT access token lasts hours,
* the 401 branch below exists for exactly that, and a quota read that never
* refreshed would report Codex as unavailable for anyone who had not used the
* Codex CLI in the last few hours. What is removed instead is the way the
* rotation could be lost: the POST ignores the caller's abort signal, the write
* that follows it is synchronous, and the whole thing is registered with
* {@link trackCredentialWrite} so a command that ends in `process.exit` drains
* it first (src/main.ts, the `quota` action).
*/
async function refresh(auth: AuthDoc, deps: CodexDeps): Promise<AuthDoc | null> {
const refreshToken = auth.tokens?.refresh_token
if (!refreshToken) return null
return trackCredentialWrite(rotate(auth, refreshToken, deps))
}
async function rotate(auth: AuthDoc, refreshToken: string, deps: CodexDeps): Promise<AuthDoc | null> {
// Deliberately not the caller's abort signal, only this request's own
// timeout: the grant retires the refresh token on disk the moment the
// endpoint accepts it, so a per-provider timeout firing while the response
// is still arriving would cancel a rotation we can no longer undo. A slow
// grant costs the caller nothing, since its own race has already given up.
const response = await deps.fetch(TOKEN_ENDPOINT, {
method: 'POST', signal: quotaRequestSignal(),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: CLIENT_ID, grant_type: 'refresh_token', refresh_token: refreshToken, scope: 'openid profile email' }),
})
if (!response.ok) return null
const next = await response.json() as Record<string, unknown>
const rotated: RotatedTokens = { access: str(next.access_token), refresh: str(next.refresh_token), id: str(next.id_token) }
// A rotated refresh token is persisted even when the access token is missing
// or malformed: the grant has already retired the one on disk either way.
if (!rotated.access && !rotated.refresh) return null
const merged = persistRotated(auth, rotated, deps)
return merged?.tokens?.access_token ? merged : null
}
async function usage(auth: AuthDoc, deps: CodexDeps, signal?: AbortSignal): Promise<Response | null> {
const token = auth.tokens?.access_token
if (!token) return null
const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': 'CodeBurn' }
if (auth.tokens?.account_id) headers['ChatGPT-Account-Id'] = auth.tokens.account_id
return deps.fetch(USAGE_ENDPOINT, { method: 'GET', headers, signal: quotaRequestSignal(signal) })
}
export type CodexResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchCodexQuota(options: Partial<CodexDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise<CodexResult> {
const deps = { ...defaults, ...options }
try {
const discovered = await discoverSource(deps, Boolean(options.allowKeychain))
if (discovered === 'accessDenied') return { quota: empty('accessDenied') }
if (!discovered) return { quota: empty('disconnected') }
const source = discovered
let auth = source.auth
if (auth.auth_mode !== 'chatgpt') return { quota: empty('terminalFailure') }
if (!auth.tokens?.access_token) return { quota: empty('disconnected') }
// Proactive staleness refresh only for the source whose rotation we own.
if (source.writable) {
const refreshedAt = typeof auth.last_refresh === 'string' ? Date.parse(auth.last_refresh) : NaN
if (!Number.isFinite(refreshedAt) || deps.now() - refreshedAt > EIGHT_DAYS) {
const next = await refresh(auth, deps)
if (next) auth = next
}
}
let response = await usage(auth, deps, options.signal)
if (!response) return { quota: empty('disconnected') }
if (response.status === 401) {
const reread = await source.reread()
if (reread?.tokens?.access_token && reread.tokens.access_token !== auth.tokens?.access_token) {
auth = reread
} else if (source.writable) {
const next = await refresh(reread ?? auth, deps)
if (!next) return { quota: empty('transientFailure') }
auth = next
} else {
// Read-only source: the owner (menubar) rotates tokens on its own
// cadence, so re-read once and otherwise wait for the next poll.
return { quota: empty('transientFailure') }
}
response = await usage(auth, deps, options.signal)
if (!response) return { quota: empty('transientFailure') }
}
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
let seconds = raw === null ? NaN : Number(raw)
if (!Number.isFinite(seconds) && raw) seconds = (Date.parse(raw) - deps.now()) / 1000
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeCodexUsage(await response.json()) }
} catch (error) {
console.warn(`Codex quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

168
src/quota/copilot.ts Normal file
View file

@ -0,0 +1,168 @@
// Live GitHub Copilot quota via the editor plugins' internal usage endpoint.
//
// - GET https://api.github.com/copilot_internal/user
// Headers mirror observed GitHub Copilot client traffic (Editor-Version /
// Editor-Plugin-Version / X-Github-Api-Version). This is an INTERNAL, UNDOCUMENTED
// API that may drift without notice; every failure must degrade to the
// normal connection states and never crash the panel.
//
// Credential: the GitHub OAuth token already on disk from a signed-in Copilot
// plugin - hosts.json (keyed by host) falling back to apps.json (keyed by app
// name). The plugins write those under ~/.config/github-copilot on macOS and
// Linux but under %LOCALAPPDATA%\github-copilot on Windows, so the directory
// is resolved per platform. Read-only; no new storage.
import os from 'node:os'
import path from 'node:path'
import { fraction, quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://api.github.com/copilot_internal/user'
const HEADERS = {
Accept: 'application/json',
'Editor-Version': 'vscode/1.96.2',
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
'User-Agent': 'GitHubCopilotChat/0.26.7',
'X-Github-Api-Version': '2025-04-01',
} as const
type HostRecord = Record<string, any> & { oauth_token?: unknown }
const CREDENTIAL_FILES = ['hosts.json', 'apps.json'] as const
export type CopilotDeps = {
fetch: typeof fetch
/** Ordered credential directories; the first file that yields a token wins. */
configDirs: string[]
readFile: typeof readSecureFile
}
/**
* Where the signed-in Copilot plugins keep hosts.json / apps.json. Windows has
* no ~/.config: the plugins follow the platform convention and write under
* %LOCALAPPDATA%. The XDG path stays as a second candidate there, because a
* token copied in from a POSIX-style shell (Git Bash, an MSYS home) lands in
* it and is just as valid.
*/
export function copilotConfigDirs(
platform: string = process.platform,
env: NodeJS.ProcessEnv = process.env,
home: string = os.homedir(),
): string[] {
const xdg = path.join(home, '.config', 'github-copilot')
if (platform !== 'win32') return [xdg]
const localAppData = env['LOCALAPPDATA']?.trim() || path.join(home, 'AppData', 'Local')
return [path.join(localAppData, 'github-copilot'), xdg]
}
// Resolved per call rather than once at import so a caller (and the tests) can
// change the platform or the environment the paths are derived from.
function defaultDeps(): CopilotDeps {
return { fetch: globalThis.fetch, configDirs: copilotConfigDirs(), readFile: readSecureFile }
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'copilot', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
function tokenFromMap(raw: string): string | null {
const map = JSON.parse(raw) as Record<string, HostRecord>
// hosts.json keys by host - prefer github.com; apps.json has no canonical
// key, so its first entry wins. Both store the token as `oauth_token`.
const preferred = map['github.com'] ?? Object.values(map)[0]
const token = preferred?.oauth_token
return typeof token === 'string' && token ? token : null
}
async function credentialFromFiles(deps: CopilotDeps): Promise<string | null> {
for (const dir of deps.configDirs) {
for (const name of CREDENTIAL_FILES) {
try {
const raw = await deps.readFile(path.join(dir, name), 64 * 1024)
if (!raw) continue
const token = tokenFromMap(raw)
if (token) return token
} catch {
// A malformed or unreadable file falls through to the next candidate.
}
}
}
return null
}
function windowOf(label: string, snapshot: unknown): QuotaWindow | null {
if (!snapshot || typeof snapshot !== 'object') return null
const row = snapshot as Record<string, unknown>
// The API reports percent REMAINING (0..100); windows render percent USED.
const rawRemaining = row.percent_remaining ?? row.percentRemaining
const remaining = fraction(typeof rawRemaining === 'number' ? rawRemaining : NaN)
if (remaining === null) return null
// A plan without this quota reports entitlement 0 and 0% remaining, which
// would render as 100% used; unlimited windows have no meaningful percent.
if (row.entitlement === 0 || row.unlimited === true) return null
// Round away float dust from the 1-remaining subtraction (1-0.7 !== 0.3).
return { label, percent: Number((1 - remaining).toFixed(6)), resetsAt: null }
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const lower = value.trim().toLowerCase()
const known: Record<string, string> = {
free: 'Free', individual: 'Individual', pro: 'Pro', business: 'Business',
enterprise: 'Enterprise', for_educators: 'Educators', 'for-educators': 'Educators',
}
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
export function decodeCopilotUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
// Field names have shipped both camelCase and snake_case; read each alias
// rather than trusting one spelling.
const snapshots = data.quota_snapshots ?? data.quotaSnapshots
const premium = windowOf('Premium requests', snapshots?.premium_interactions ?? snapshots?.premiumInteractions)
const chat = windowOf('Chat', snapshots?.chat)
const details = [premium, chat].filter((row): row is QuotaWindow => row !== null)
return {
provider: 'copilot', connection: 'connected', primary: premium ?? chat,
details,
planLabel: planLabel(data.copilot_plan ?? data.copilotPlan),
footerLines: [],
}
}
async function request(token: string, deps: CopilotDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(USAGE_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(parent),
headers: { ...HEADERS, Authorization: `token ${token}` },
})
}
export type CopilotResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchCopilotQuota(options: Partial<CopilotDeps> & { signal?: AbortSignal } = {}): Promise<CopilotResult> {
const deps = { ...defaultDeps(), ...options }
try {
let token = await credentialFromFiles(deps)
if (!token) return { quota: empty('disconnected') }
let response = await request(token, deps, options.signal)
if (response.status === 401) {
// An active editor session rotates this token; re-read once before
// giving up so we don't report a failure the disk already fixed.
const reread = await credentialFromFiles(deps)
if (!reread || reread === token) return { quota: empty('transientFailure') }
token = reread
response = await request(token, deps, options.signal)
}
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeCopilotUsage(await response.json()) }
} catch (error) {
console.warn(`Copilot quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

237
src/quota/cursor.ts Normal file
View file

@ -0,0 +1,237 @@
// Live Cursor quota via the app's own usage-summary endpoint (ported from the
// menubar's CursorSubscriptionService.swift):
//
// - GET https://cursor.com/api/usage-summary
// Authenticated with the session cookie Cursor itself sends
// (WorkosCursorSessionToken=<userId>%3A%3A<jwt>), not a bearer header.
//
// Credential: the access token the Cursor app already keeps in its own VS Code
// state database, under the ItemTable key `cursorAuth/accessToken`, opened
// read-only. Nothing is copied and nothing is written; CodeBurn never refreshes
// the session, so an expired token is terminal until the user signs in again.
import { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { quotaRequestSignal, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://cursor.com/api/usage-summary'
const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken'
const SOURCE_FOOTER = ['Source: Cursor app']
const SIGNED_OUT_FOOTER = ['Sign in to the Cursor app, then click Retry.']
const EXPIRED_FOOTER = ['The Cursor app session is expired or invalid. Sign in again, then click Retry.']
const REJECTED_FOOTER = ['Cursor rejected the current app session. Sign in again, then click Retry.']
const UNREADABLE_FOOTER = ["Could not read the Cursor app's local session data. Quit and reopen Cursor, then click Retry."]
const RATE_LIMITED_FOOTER = ['Cursor rate-limited the quota request.']
const UNAVAILABLE_FOOTER = ['Cursor is temporarily unavailable.']
const PARSE_FOOTER = ['Cursor returned an unrecognized quota response.']
/** Cursor is an Electron app, so its state database follows the VS Code layout
* of whichever platform it runs on. */
export function cursorDatabasePath(
platform: string = process.platform,
home: string = os.homedir(),
): string {
if (platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
}
if (platform === 'win32') {
return path.join(home, 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
}
return path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
}
export type CursorDeps = {
fetch: typeof fetch
databasePath: string
/** Resolves the app's access token; `null` when Cursor is not signed in. */
loadAccessToken: (databasePath: string) => Promise<string | null>
now: () => number
}
/**
* Read-only lookup of one row in Cursor's state database. The sqlite driver is
* imported lazily so a machine without Cursor never loads it.
*/
export async function cursorAccessTokenFromDatabase(databasePath: string): Promise<string | null> {
if (!existsSync(databasePath)) return null
const { blobToText, openDatabase } = await import('../sqlite.js')
const db = openDatabase(databasePath)
try {
const rows = db.query<{ value: Uint8Array | string | null }>(
'SELECT CAST(value AS BLOB) AS value FROM ItemTable WHERE key = ? LIMIT 1',
[ACCESS_TOKEN_KEY],
)
const value = blobToText(rows[0]?.value).trim()
return value.length > 0 ? value : null
} finally {
db.close()
}
}
// Resolved per call so a caller can change the platform the path is derived
// from without reloading the module.
function defaultDeps(): CursorDeps {
return {
fetch: globalThis.fetch,
databasePath: cursorDatabasePath(),
loadAccessToken: cursorAccessTokenFromDatabase,
now: Date.now,
}
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'cursor', connection, primary: null, details: [], planLabel: null, footerLines }
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** Provider-reported percentages are 0..100 and never negative; a value above
* the limit clamps rather than overflowing the bar. */
function providerPercent(value: unknown): number | null {
const parsed = num(value)
if (parsed === null || parsed < 0) return null
return Math.min(1, parsed / 100)
}
function ratio(used: unknown, limit: unknown): number | null {
const usedValue = num(used)
const limitValue = num(limit)
if (usedValue === null || limitValue === null || usedValue < 0 || limitValue <= 0) return null
return Math.min(1, Math.max(0, usedValue / limitValue))
}
function resetsAt(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
const known: Record<string, string> = {
pro: 'Pro', pro_plus: 'Pro Plus', 'pro plus': 'Pro Plus', business: 'Business',
enterprise: 'Enterprise', hobby: 'Hobby', free: 'Hobby', ultra: 'Ultra',
}
return known[raw.toLowerCase()] ?? raw
}
/** `null` when the payload carries no usable overall percentage. */
export function decodeCursorUsage(body: unknown): QuotaProvider | null {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const plan = data.individualUsage?.plan
const overall = data.individualUsage?.overall
const pooled = data.teamUsage?.pooled
const auto = providerPercent(plan?.autoPercentUsed)
const api = providerPercent(plan?.apiPercentUsed)
// A capacity gauge must surface the pool that is about to run out, so the
// fallback takes the worse of the two rather than averaging them.
const monthly = providerPercent(plan?.totalPercentUsed)
?? (auto !== null && api !== null ? Math.max(auto, api) : api ?? auto)
?? ratio(plan?.used, plan?.limit)
?? ratio(overall?.used, overall?.limit)
?? ratio(pooled?.used, pooled?.limit)
?? (data.isUnlimited === true ? 0 : null)
if (monthly === null) return null
const reset = resetsAt(data.billingCycleEnd)
const primary: QuotaWindow = { label: 'Monthly', percent: monthly, resetsAt: reset }
const details = [primary]
if (auto !== null) details.push({ label: 'Auto', percent: auto, resetsAt: reset })
if (api !== null) details.push({ label: 'API', percent: api, resetsAt: reset })
const onDemand = data.individualUsage?.onDemand
const onDemandPercent = onDemand?.enabled === false ? null : ratio(onDemand?.used, onDemand?.limit)
if (onDemandPercent !== null) details.push({ label: 'On-demand', percent: onDemandPercent, resetsAt: reset })
const teamPercent = ratio(pooled?.used, pooled?.limit)
if (teamPercent !== null) details.push({ label: 'Team pool', percent: teamPercent, resetsAt: reset })
return {
provider: 'cursor', connection: 'connected',
primary, details,
planLabel: planLabel(data.membershipType),
footerLines: SOURCE_FOOTER,
}
}
function decodeBase64Url(value: string): string | null {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
try {
return Buffer.from(padded, 'base64').toString('utf8')
} catch {
return null
}
}
/**
* Cursor's web session is the JWT plus the user id encoded inside it. Anything
* that is not a live three-part token with a usable subject is an expired or
* invalid session, never a request worth making.
*/
export function cursorSessionCookie(token: string, now: number): string | null {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = decodeBase64Url(parts[1]!)
if (payload === null) return null
let claims: { sub?: unknown; exp?: unknown }
try {
claims = JSON.parse(payload) as { sub?: unknown; exp?: unknown }
} catch {
return null
}
if (typeof claims.sub !== 'string') return null
const userId = claims.sub.split('|').filter(part => part.length > 0).pop()
if (!userId || !/^[A-Za-z0-9._-]+$/.test(userId)) return null
const expiresAt = num(claims.exp)
// 60s skew, matching the menubar: a token about to die is already useless.
if (expiresAt === null || expiresAt * 1000 - now <= 60_000) return null
return `WorkosCursorSessionToken=${userId}%3A%3A${token}`
}
export type CursorResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchCursorQuota(options: Partial<CursorDeps> & { signal?: AbortSignal } = {}): Promise<CursorResult> {
const deps = { ...defaultDeps(), ...options }
let token: string | null
try {
token = (await deps.loadAccessToken(deps.databasePath))?.trim() || null
} catch (error) {
// Cursor holding a write lock, or a database this build cannot open, is a
// local condition that clears itself; never a signed-out user.
console.warn(`Cursor quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure', UNREADABLE_FOOTER) }
}
if (!token) return { quota: empty('disconnected', SIGNED_OUT_FOOTER) }
const cookie = cursorSessionCookie(token, deps.now())
if (cookie === null) return { quota: empty('terminalFailure', EXPIRED_FOOTER) }
try {
const response = await deps.fetch(USAGE_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(options.signal),
headers: { Accept: 'application/json', Cookie: cookie, 'User-Agent': 'CodeBurn' },
})
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', REJECTED_FOOTER) }
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return {
quota: { ...empty('transientFailure', RATE_LIMITED_FOOTER), rateLimited: true },
retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60),
}
}
if (response.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) }
if (!response.ok) return { quota: empty('transientFailure', PARSE_FOOTER) }
// Never log the body - it carries account data.
const quota = decodeCursorUsage(await response.json())
return { quota: quota ?? empty('transientFailure', PARSE_FOOTER) }
} catch (error) {
console.warn(`Cursor quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

206
src/quota/gemini.ts Normal file
View file

@ -0,0 +1,206 @@
// Live Gemini quota via the OAuth-backed Code Assist APIs the Gemini CLI
// itself calls, derived from the Google Code Assist client flow and the CLI's
// own traffic:
//
// - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist
// body { metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }
// → tier (plan label), cloudaicompanionProject (quota project)
// - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota
// body { project } (or {} when unknown) → per-model quota buckets
// - POST https://oauth2.googleapis.com/token (only when the stored token is
// stale and GEMINI_OAUTH_CLIENT_ID/GEMINI_OAUTH_CLIENT_SECRET are set;
// the refreshed token stays in memory - the Gemini CLI owns its file)
//
// Credential: the Gemini CLI's own ~/.gemini/oauth_creds.json, read-only.
import os from 'node:os'
import path from 'node:path'
import { fraction, quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal'
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
type GeminiCredential = {
access_token?: string
refresh_token?: string
id_token?: string
expiry_date?: number
}
export type GeminiDeps = {
fetch: typeof fetch
credentialPath: string
readFile: typeof readSecureFile
now: () => number
}
const defaults: GeminiDeps = {
fetch: globalThis.fetch,
credentialPath: path.join(os.homedir(), '.gemini', 'oauth_creds.json'),
readFile: readSecureFile,
now: Date.now,
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'gemini', connection, primary: null, details: [], planLabel: null, footerLines }
}
function parseCredential(raw: string): GeminiCredential | null {
const parsed = JSON.parse(raw) as GeminiCredential
if (!parsed || typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) return null
return parsed
}
async function credentialFromFile(deps: GeminiDeps): Promise<GeminiCredential | null> {
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
return raw ? parseCredential(raw) : null
}
// Client credentials live inside the installed Gemini CLI bundle; scanning the
// install is out of scope here, so only the CLI's documented env overrides are
// honored. Without them an expired token is used as-is and a 401 degrades.
function clientCredentials(): { clientId: string; clientSecret: string } | null {
const clientId = process.env['GEMINI_OAUTH_CLIENT_ID']
const clientSecret = process.env['GEMINI_OAUTH_CLIENT_SECRET']
return clientId && clientSecret ? { clientId, clientSecret } : null
}
async function refresh(credential: GeminiCredential, deps: GeminiDeps, signal?: AbortSignal): Promise<string | null> {
const client = clientCredentials()
const refreshToken = credential.refresh_token
if (!client || !refreshToken) return null
const body = new URLSearchParams({
client_id: client.clientId,
client_secret: client.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
})
const response = await deps.fetch(TOKEN_ENDPOINT, { method: 'POST', signal: quotaRequestSignal(signal), body })
if (!response.ok) return null
const next = await response.json() as Record<string, unknown>
return typeof next.access_token === 'string' && next.access_token ? next.access_token : null
}
// The `hd` claim (hosted domain) separates Google Workspace logins from
// personal ones, matching the provider's tier labels.
function workspaceClaim(idToken: string | undefined): boolean {
if (!idToken) return false
const [, payload] = idToken.split('.')
if (!payload) return false
try {
const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) as Record<string, unknown>
return typeof json.hd === 'string' && json.hd.length > 0
} catch {
return false
}
}
function tierLabel(body: Record<string, any>, credential: GeminiCredential): string | null {
// paidTier wins whenever present; otherwise whatever tier the account sits on.
const raw = body.paidTier?.name ?? body.currentTier?.name ?? body.currentTier?.id
if (typeof raw !== 'string' || !raw.trim()) return null
const lower = raw.trim().toLowerCase()
if (lower === 'standard-tier') return 'Paid'
if (lower === 'legacy-tier') return 'Legacy'
if (lower.includes('free') && workspaceClaim(credential.id_token)) return 'Workspace'
if (lower.includes('free')) return 'Free'
return raw.trim()
}
function windowOf(bucket: Record<string, unknown>): QuotaWindow | null {
const modelId = bucket.modelId
const remaining = fraction(typeof bucket.remainingFraction === 'number' ? bucket.remainingFraction * 100 : NaN)
if (typeof modelId !== 'string' || !modelId || remaining === null) return null
const resetTime = bucket.resetTime
const resetsAt = typeof resetTime === 'string' && !Number.isNaN(Date.parse(resetTime))
? new Date(resetTime).toISOString() : null
return { label: modelId, percent: 1 - remaining, resetsAt }
}
export function decodeGeminiUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, unknown> : {}
const buckets = Array.isArray(data.buckets) ? data.buckets : []
const details = buckets
.filter((bucket): bucket is Record<string, unknown> => Boolean(bucket) && typeof bucket === 'object')
.map(windowOf)
.filter((window): window is QuotaWindow => window !== null)
.sort((a, b) => b.percent - a.percent)
return {
provider: 'gemini', connection: 'connected',
primary: details[0] ?? null,
details,
planLabel: null,
footerLines: [],
}
}
async function post(token: string, path: string, payload: Record<string, unknown>, deps: GeminiDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(`${CODE_ASSIST_ENDPOINT}:${path}`, {
method: 'POST', signal: quotaRequestSignal(parent),
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': 'CodeBurn' },
body: JSON.stringify(payload),
})
}
// Google flags retired consumer tiers (June 2026 shutdown of individual/AI
// Pro/Ultra OAuth access) with these sentinels instead of ordinary errors.
function migrationFooter(body: unknown): string[] {
const error = body && typeof body === 'object' ? (body as Record<string, any>).error : null
const status = typeof error?.status === 'string' ? error.status : ''
const message = typeof error?.message === 'string' ? error.message : ''
const deprecated = status === 'UNSUPPORTED_CLIENT' || message.includes('IneligibleTierError')
return deprecated ? ['Google retired Gemini CLI OAuth for this account tier. Use Antigravity.'] : []
}
export type GeminiResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchGeminiQuota(options: Partial<GeminiDeps> & { signal?: AbortSignal } = {}): Promise<GeminiResult> {
const deps = { ...defaults, ...options }
try {
let credential = await credentialFromFile(deps)
if (!credential) return { quota: empty('disconnected') }
if ((credential.expiry_date ?? Infinity) - deps.now() <= 5 * 60_000) {
const accessToken = await refresh(credential, deps, options.signal)
if (accessToken) credential = { ...credential, access_token: accessToken }
}
const token = credential.access_token!
let response = await post(token, 'loadCodeAssist', { metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }, deps, options.signal)
if (response.status === 401) {
const reread = await credentialFromFile(deps)
if (!reread || reread.access_token === credential.access_token) return { quota: empty('transientFailure') }
credential = reread
response = await post(credential.access_token!, 'loadCodeAssist', { metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }, deps, options.signal)
}
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
// Retired-tier sentinels ride inside the JSON error body of a non-200
// response, so parse before branching on status.
const assist = await response.json().catch(() => ({})) as Record<string, any>
const migration = migrationFooter(assist)
if (migration.length > 0) return { quota: empty('terminalFailure', migration) }
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
const project = typeof assist.cloudaicompanionProject === 'string' && assist.cloudaicompanionProject
? assist.cloudaicompanionProject : undefined
const quotaResponse = await post(credential.access_token!, 'retrieveUserQuota', project ? { project } : {}, deps, options.signal)
if (quotaResponse.status === 429) {
const raw = quotaResponse.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!quotaResponse.ok) {
return { quota: empty(quotaResponse.status >= 400 && quotaResponse.status < 500 ? 'terminalFailure' : 'transientFailure') }
}
const quota = decodeGeminiUsage(await quotaResponse.json())
quota.planLabel = tierLabel(assist, credential)
return { quota }
} catch (error) {
console.warn(`Gemini quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

225
src/quota/grok.ts Normal file
View file

@ -0,0 +1,225 @@
// Live Grok Build quota with the OAuth login the Grok CLI already owns
// (ported from the menubar's GrokBuildSubscriptionService.swift):
//
// - GET https://cli-chat-proxy.grok.com/v1/billing?format=credits -> usage
// - GET https://cli-chat-proxy.grok.com/v1/settings -> the plan display name,
// optional: a failure there costs the label, not the reading.
//
// Credential: $GROK_HOME/auth.json (default ~/.grok/auth.json), read-only. The
// file maps a login scope to its token; the current OIDC login wins over the
// older sign-in one. CodeBurn never refreshes and never writes, so an expired
// login is terminal until the user runs `grok login` again.
import os from 'node:os'
import path from 'node:path'
import { quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const BILLING_ENDPOINT = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'
const SETTINGS_ENDPOINT = 'https://cli-chat-proxy.grok.com/v1/settings'
const SOURCE_FOOTER = ['Source: Grok Build']
const EXPIRED_FOOTER = ['The Grok Build login has expired. Run `grok login`, then click Retry.']
const UNREADABLE_FOOTER = ["Could not read Grok Build's local login. Run `grok login` again, then click Retry."]
const REJECTED_FOOTER = ['Grok rejected the current Grok Build login. Run `grok login`, then click Retry.']
const RATE_LIMITED_FOOTER = ['Grok rate-limited the quota request.']
const UNAVAILABLE_FOOTER = ['Grok quota is temporarily unavailable.']
const PARSE_FOOTER = ['Grok returned an unrecognized quota response.']
export type GrokDeps = {
fetch: typeof fetch
credentialPath: string
readFile: typeof readSecureFile
now: () => number
}
export function grokAuthPath(env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string {
const configured = env['GROK_HOME']?.trim()
const grokHome = configured
? (configured.startsWith('~') ? path.join(home, configured.slice(1)) : configured)
: path.join(home, '.grok')
return path.join(grokHome, 'auth.json')
}
function defaultDeps(): GrokDeps {
return { fetch: globalThis.fetch, credentialPath: grokAuthPath(), readFile: readSecureFile, now: Date.now }
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'grok', connection, primary: null, details: [], planLabel: null, footerLines }
}
function nonEmpty(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseDate(value: unknown): number | null {
const raw = nonEmpty(value)
if (!raw) return null
const parsed = Date.parse(raw)
return Number.isFinite(parsed) ? parsed : null
}
export type GrokCredential = { accessToken: string; authMode: string | null; expiresAt: number | null }
/** The current OIDC login first, then the older sign-in one, then anything
* else; equal ranks break by scope so the pick never depends on key order. */
function credentialRank(scope: string): number {
if (scope.startsWith('https://auth.x.ai::')) return 0
if (scope.includes('/sign-in')) return 1
return 2
}
/** `'malformed'` separates a file we cannot understand (terminal, the user has
* to log in again) from a file with no token in it at all. */
export function decodeGrokCredential(raw: string): GrokCredential | 'malformed' | null {
let entries: Record<string, unknown>
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return 'malformed'
entries = parsed as Record<string, unknown>
} catch {
return 'malformed'
}
const usable = Object.entries(entries)
.map(([scope, entry]) => ({
scope,
entry: (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>,
}))
.filter(row => nonEmpty(row.entry['key']) !== null)
.sort((a, b) => credentialRank(a.scope) - credentialRank(b.scope) || (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0))
const selected = usable[0]
if (!selected) return null
return {
accessToken: nonEmpty(selected.entry['key'])!,
authMode: nonEmpty(selected.entry['auth_mode']),
expiresAt: parseDate(selected.entry['expires_at']),
}
}
/** Grok bills one credit pool, so the window is named after how long it runs
* rather than by a label the API sends. */
export function grokWindowLabel(resetsAt: number | null, now: number): string {
if (resetsAt === null) return 'Credits'
const days = Math.round((resetsAt - now) / 86_400_000)
if (days >= 4 && days <= 12) return 'Weekly'
if (days >= 20 && days <= 45) return 'Monthly'
return 'Credits'
}
/** "supergrok_heavy" and "SuperGrok Heavy" are the same plan; anything the
* menubar does not know is passed through as sent. */
export function grokPlanLabel(value: unknown): string | null {
const raw = nonEmpty(value)
if (!raw) return null
const letters = raw.toLowerCase().replace(/[^a-z]/g, '')
if (letters === 'supergrokheavy' || letters === 'heavy') return 'SuperGrok Heavy'
if (letters === 'supergrok') return 'SuperGrok'
return raw
}
export type GrokBilling = { window: QuotaWindow; tier: string | null }
/** `null` when the payload carries no usable percentage. */
export function decodeGrokBilling(body: unknown, now: number): GrokBilling | null {
const root = body && typeof body === 'object' ? body as Record<string, any> : {}
const config = root.config
if (!config || typeof config !== 'object') return null
let percent = num(config.creditUsagePercent)
if (percent === null) {
const used = num(config.onDemandUsed?.val)
const cap = num(config.onDemandCap?.val)
if (used !== null && cap !== null && used >= 0 && cap > 0) percent = used / cap * 100
}
if (percent === null) return null
const resetsAt = parseDate(config.currentPeriod?.end ?? config.billingPeriodEnd)
return {
window: {
label: grokWindowLabel(resetsAt, now),
percent: Math.min(1, Math.max(0, percent / 100)),
resetsAt: resetsAt === null ? null : new Date(resetsAt).toISOString(),
},
tier: nonEmpty(config.subscriptionTier) ?? nonEmpty(root.subscriptionTier),
}
}
function headers(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
'x-xai-token-auth': 'xai-grok-cli',
Accept: 'application/json',
'User-Agent': 'CodeBurn',
}
}
/** The plan name is a nicety: any failure leaves the reading intact. */
async function fetchPlan(token: string, deps: GrokDeps, parent?: AbortSignal): Promise<string | null> {
try {
const response = await deps.fetch(SETTINGS_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(parent), headers: headers(token),
})
if (!response.ok) return null
const body = await response.json() as Record<string, unknown>
return nonEmpty(body['subscription_tier_display'])
} catch {
return null
}
}
export type GrokResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchGrokQuota(options: Partial<GrokDeps> & { signal?: AbortSignal } = {}): Promise<GrokResult> {
const deps = { ...defaultDeps(), ...options }
try {
const raw = await deps.readFile(deps.credentialPath, 128 * 1024)
if (!raw) return { quota: empty('disconnected') }
const credential = decodeGrokCredential(raw)
if (credential === 'malformed') return { quota: empty('terminalFailure', UNREADABLE_FOOTER) }
if (credential === null) return { quota: empty('disconnected') }
const now = deps.now()
if (credential.expiresAt !== null && credential.expiresAt <= now) {
return { quota: empty('terminalFailure', EXPIRED_FOOTER) }
}
const response = await deps.fetch(BILLING_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(options.signal), headers: headers(credential.accessToken),
})
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', REJECTED_FOOTER) }
if (response.status === 429) {
const header = response.headers.get('Retry-After')
const seconds = header === null ? NaN : Number(header)
return {
quota: { ...empty('transientFailure', RATE_LIMITED_FOOTER), rateLimited: true },
retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60),
}
}
if (response.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) }
if (!response.ok) return { quota: empty('transientFailure', PARSE_FOOTER) }
// Never log the body - it carries account data.
const billing = decodeGrokBilling(await response.json(), now)
if (billing === null) return { quota: empty('transientFailure', PARSE_FOOTER) }
const plan = await fetchPlan(credential.accessToken, deps, options.signal)
return {
quota: {
provider: 'grok', connection: 'connected',
primary: billing.window,
details: [billing.window],
planLabel: grokPlanLabel(plan) ?? grokPlanLabel(billing.tier),
footerLines: SOURCE_FOOTER,
},
}
} catch (error) {
console.warn(`Grok quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

130
src/quota/index.ts Normal file
View file

@ -0,0 +1,130 @@
// Provider capacity readers for `codeburn quota`.
//
// These adapters were ported from the Electron desktop app's copies under
// `app/electron/quota/*.ts`, which remain the origin and are still what the
// desktop app runs. They are deliberately left untouched by this change; the
// two trees will be deduped in a follow-up once every surface reads the CLI.
import { renderTable } from '../text-table.js'
import { fetchAntigravityQuota } from './antigravity.js'
import { fetchClaudeQuota } from './claude.js'
import { fetchClinePassQuota } from './clinepass.js'
import { fetchCodexQuota } from './codex.js'
import { fetchCopilotQuota } from './copilot.js'
import { fetchCursorQuota } from './cursor.js'
import { fetchGeminiQuota } from './gemini.js'
import { fetchGrokQuota } from './grok.js'
import { fetchKimiQuota } from './kimi.js'
import type { ProviderName, QuotaProvider } from './types.js'
import { fetchZaiQuota } from './zai.js'
export type QuotaCommandWindow = { label: string; usedPct: number; resetsAt?: string }
export type QuotaCommandProvider = {
id: ProviderName
name: string
available: boolean
plan?: string
windows: QuotaCommandWindow[]
error?: string
}
export type QuotaReport = { providers: QuotaCommandProvider[] }
export type ProviderReader = (signal: AbortSignal) => Promise<QuotaProvider>
const READERS: { id: ProviderName; name: string; read: ProviderReader }[] = [
{ id: 'claude', name: 'Claude', read: async signal => (await fetchClaudeQuota({ signal, allowKeychain: true })).quota },
// No keychain for Codex: it would prefer the menubar's read-only cached token
// over `~/.codex/auth.json`, which this process may refresh and write back.
{ id: 'codex', name: 'Codex', read: async signal => (await fetchCodexQuota({ signal })).quota },
{ id: 'gemini', name: 'Gemini', read: async signal => (await fetchGeminiQuota({ signal })).quota },
{ id: 'copilot', name: 'GitHub Copilot', read: async signal => (await fetchCopilotQuota({ signal })).quota },
{ id: 'antigravity', name: 'Antigravity', read: () => fetchAntigravityQuota() },
{ id: 'kimi', name: 'Kimi', read: async signal => (await fetchKimiQuota({ signal })).quota },
{ id: 'cursor', name: 'Cursor', read: async signal => (await fetchCursorQuota({ signal })).quota },
{ id: 'zai', name: 'Z.ai', read: async signal => (await fetchZaiQuota({ signal })).quota },
{ id: 'grok', name: 'Grok', read: async signal => (await fetchGrokQuota({ signal })).quota },
{ id: 'clinepass', name: 'ClinePass', read: async signal => (await fetchClinePassQuota({ signal })).quota },
]
const DEFAULT_TIMEOUT_MS = 5_000
function errorFor(quota: QuotaProvider): string | undefined {
switch (quota.connection) {
case 'accessDenied':
return quota.footerLines[0] ?? 'Access to the stored credential was denied.'
case 'terminalFailure':
return quota.footerLines[0] ?? 'The provider rejected the request.'
case 'transientFailure':
return quota.footerLines[0] ?? 'Temporarily unavailable.'
default:
return undefined
}
}
function toWindows(quota: QuotaProvider): QuotaCommandWindow[] {
const rows = quota.details.length > 0 ? quota.details : quota.primary ? [quota.primary] : []
return rows.map(row => ({
label: row.label,
usedPct: Math.round(row.percent * 1000) / 10,
...(row.resetsAt ? { resetsAt: row.resetsAt } : {}),
}))
}
export function toCommandProvider(id: ProviderName, name: string, quota: QuotaProvider): QuotaCommandProvider {
const error = errorFor(quota)
return {
id,
name,
available: quota.connection === 'connected',
...(quota.planLabel ? { plan: quota.planLabel } : {}),
windows: toWindows(quota),
...(error ? { error } : {}),
}
}
export async function collectQuota(options: {
readers?: { id: ProviderName; name: string; read: ProviderReader }[]
timeoutMs?: number
} = {}): Promise<QuotaReport> {
const readers = options.readers ?? READERS
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const providers = await Promise.all(readers.map(async entry => {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const timedOut = new Promise<'timeout'>(resolve => { controller.signal.addEventListener('abort', () => resolve('timeout')) })
try {
const quota = await Promise.race([entry.read(controller.signal), timedOut])
if (quota === 'timeout') {
return { id: entry.id, name: entry.name, available: false, windows: [], error: 'Timed out.' }
}
return toCommandProvider(entry.id, entry.name, quota)
} finally {
clearTimeout(timer)
}
}))
return { providers }
}
function resetLabel(iso: string | undefined): string {
if (!iso) return ''
const at = new Date(iso)
return Number.isNaN(at.getTime()) ? '' : at.toLocaleString()
}
export function renderQuotaTable(report: QuotaReport, opts: { color?: boolean } = {}): string {
const rows: string[][] = []
for (const provider of report.providers) {
const title = provider.plan ? `${provider.name} (${provider.plan})` : provider.name
if (provider.windows.length === 0) {
rows.push([title, provider.error ?? 'Not connected', '', ''])
continue
}
provider.windows.forEach((window, index) => {
rows.push([index === 0 ? title : '', window.label, `${window.usedPct}%`, resetLabel(window.resetsAt)])
})
}
const columns = [{ header: 'Provider' }, { header: 'Window' }, { header: 'Used', right: true }, { header: 'Resets' }]
return renderTable(columns, rows, { color: opts.color })
}

184
src/quota/kimi.ts Normal file
View file

@ -0,0 +1,184 @@
// Live Kimi Code quota via the CLI's own usage endpoint (ported from the
// menubar's KimiSubscriptionService.swift):
//
// - GET https://api.kimi.com/coding/v1/usages
// Headers mirror observed Kimi Code client responses (X-Msh-Platform /
// X-Msh-Device-Id). Numeric fields have shipped as both JSON numbers and
// strings, and the reset stamp under several spellings, so decode leniently.
//
// Credential: the Kimi CLI's own ~/.kimi-code/credentials/kimi-code.json,
// read-only. Access tokens live ~15 minutes and ONLY the Kimi CLI refreshes
// them - we never refresh and never write, so an expired or rejected token is a
// terminal state whose footer tells the user to run the Kimi CLI once.
import os from 'node:os'
import path from 'node:path'
import { fraction, quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://api.kimi.com/coding/v1/usages'
const EXPIRED_FOOTER = ['Login expired. Run the Kimi CLI once, then refresh.']
const kimiHome = process.env['KIMI_CODE_HOME'] ?? path.join(os.homedir(), '.kimi-code')
export type KimiDeps = {
fetch: typeof fetch
credentialPath: string
deviceIdPath: string
readFile: typeof readSecureFile
now: () => number
}
const defaults: KimiDeps = {
fetch: globalThis.fetch,
credentialPath: path.join(kimiHome, 'credentials', 'kimi-code.json'),
deviceIdPath: path.join(kimiHome, 'device_id'),
readFile: readSecureFile,
now: Date.now,
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'kimi', connection, primary: null, details: [], planLabel: null, footerLines }
}
function num(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
/** `null` = no usable credential; `'expired'` = present but past its life. */
async function freshToken(deps: KimiDeps): Promise<string | null | 'expired'> {
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
if (!raw) return null
const parsed = JSON.parse(raw) as Record<string, unknown>
const token = parsed.access_token
if (typeof token !== 'string' || !token) return null
const expiresAt = num(parsed.expires_at)
// 60s skew, matching the menubar: a token about to die is already useless.
if (expiresAt === null || expiresAt <= deps.now() / 1000 + 60) return 'expired'
return token
}
async function deviceId(deps: KimiDeps): Promise<string | null> {
try {
const raw = await deps.readFile(deps.deviceIdPath, 4 * 1024)
return raw?.trim() || null
} catch {
return null
}
}
/** Reset stamps arrive as ISO-8601 or as epoch seconds (string or number). */
function resetsAt(value: unknown): string | null {
const raw = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : null
if (!raw) return null
// Bare digits are epoch seconds; Date.parse would misread them as a year.
if (/^\d+(\.\d+)?$/.test(raw)) return new Date(Number(raw) * 1000).toISOString()
const iso = Date.parse(raw)
return Number.isFinite(iso) ? new Date(iso).toISOString() : null
}
function windowOf(label: string, detail: unknown): QuotaWindow | null {
if (!detail || typeof detail !== 'object') return null
const row = detail as Record<string, unknown>
const limit = num(row.limit)
if (limit === null || limit <= 0) return null
const remaining = num(row.remaining)
// Rate-limit windows report only limit + remaining - derive used.
const used = num(row.used) ?? Math.max(0, limit - (remaining ?? limit))
return {
label,
// Over-limit usage clamps to 100% rather than overflowing the bar.
percent: fraction(used / limit * 100)!,
resetsAt: resetsAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at),
}
}
/** Window size → human label. The API sends enum-style units
* ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"), so
* normalize first; exact sub-day durations roll up (300 minutes → 5-hour). */
function windowLabel(duration: unknown, timeUnit: unknown): string {
let value = num(duration)
if (value === null || typeof timeUnit !== 'string' || !timeUnit) return 'Rate Limit'
let unit = timeUnit.toLowerCase().replace(/^time_unit_/, '').replace(/s$/, '')
if (unit === 'minute' && value >= 60 && value % 60 === 0) { value /= 60; unit = 'hour' }
const count = Math.trunc(value)
switch (unit) {
case 'minute': return count === 1 ? 'Minutely' : `${count}-min`
case 'hour': return count === 1 ? 'Hourly' : `${count}-hour`
case 'day': return count === 1 ? 'Daily' : count === 7 ? 'Weekly' : `${count}-day`
case 'week': return count === 1 ? 'Weekly' : `${count}-week`
case 'month': return count === 1 ? 'Monthly' : `${count}-month`
default: return `${count} ${unit}`
}
}
/** "LEVEL_INTERMEDIATE" → "Intermediate"; unknown/missing → null so the UI
* falls back to the plain "Kimi Code" title. */
function planLabel(level: unknown): string | null {
if (typeof level !== 'string' || !level.trim()) return null
return level.trim().replace(/^LEVEL_/i, '').replace(/_/g, ' ').toLowerCase()
.replace(/(^|\s)\w/g, match => match.toUpperCase())
}
/** `null` when the payload carries no usable window at all. */
export function decodeKimiUsage(body: unknown): QuotaProvider | null {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
// The top-level usage envelope is the account's weekly quota (its reset lands
// ~7 days out), so label it the way the menubar does.
const primary = windowOf('Weekly', data.usage)
const rest = (Array.isArray(data.limits) ? data.limits : [])
.map((limit: any) => windowOf(windowLabel(limit?.window?.duration, limit?.window?.timeUnit), limit?.detail))
.filter((window: QuotaWindow | null): window is QuotaWindow => window !== null)
const details = primary ? [primary, ...rest] : rest
if (details.length === 0) return null
const parallel = num(data.parallel?.limit)
return {
provider: 'kimi', connection: 'connected',
primary: primary ?? details[0]!,
details,
planLabel: planLabel(data.user?.membership?.level),
footerLines: parallel !== null && parallel > 0 ? [`Parallel sessions: ${Math.trunc(parallel)}`] : [],
}
}
export type KimiResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchKimiQuota(options: Partial<KimiDeps> & { signal?: AbortSignal } = {}): Promise<KimiResult> {
const deps = { ...defaults, ...options }
try {
const token = await freshToken(deps)
if (token === null) return { quota: empty('disconnected') }
if (token === 'expired') return { quota: empty('terminalFailure', EXPIRED_FOOTER) }
const device = await deviceId(deps)
const response = await deps.fetch(USAGE_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(options.signal),
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'User-Agent': 'CodeBurn',
'X-Msh-Platform': 'kimi_code_cli',
...(device ? { 'X-Msh-Device-Id': device } : {}),
},
})
// We never self-refresh, so a rejected token is terminal until the CLI runs.
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', EXPIRED_FOOTER) }
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
// Never log the body - it carries account data.
const quota = decodeKimiUsage(await response.json())
return { quota: quota ?? empty('transientFailure') }
} catch (error) {
console.warn(`Kimi quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

272
src/quota/security.ts Normal file
View file

@ -0,0 +1,272 @@
import { execFile } from 'node:child_process'
import {
chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync,
openSync, readFileSync, renameSync, unlinkSync, writeFileSync, type Stats,
} from 'node:fs'
import { lstat, open } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
const NOFOLLOW = constants.O_NOFOLLOW ?? 0
const execFileAsync = promisify(execFile)
// The macOS keychain prompt (first read of an item `security` is not yet trusted
// for) blocks until the user answers. Give them time to click Allow before we
// give up - the old 10s kill fired while the dialog was still open and got
// misread as "disconnected".
const KEYCHAIN_TIMEOUT_MS = 90_000
/** Outcome of a keychain lookup. `accessDenied` means the item exists but macOS
* needs the user to grant access (dialog dismissed, denied, or not answered). */
export type KeychainOutcome =
| { status: 'found'; value: string }
| { status: 'notFound' }
| { status: 'accessDenied' }
export type KeychainExec = (
file: string,
args: string[],
options: { timeout: number; maxBuffer: number },
) => Promise<{ stdout: string }>
// `security -w` hex-encodes password data it can't hand back as a clean C-string
// (Claude Code line-wraps its JSON blob, which triggers this). Real credential
// JSON always contains non-hex characters like '{' and '"', so an all-hex,
// even-length payload is unambiguously a hex dump we must decode.
function decodeKeychainValue(raw: string): string {
const trimmed = raw.trim()
if (trimmed.length >= 2 && trimmed.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(trimmed)) {
return Buffer.from(trimmed, 'hex').toString('utf8')
}
return raw
}
// exit 44 / "could not be found" is a genuine miss; anything else non-zero
// (interaction not allowed, user canceled/denied, or a timeout kill while the
// dialog waited) means the item is there but access wasn't granted.
function classifyKeychainError(error: unknown): 'notFound' | 'accessDenied' {
const err = error as { code?: number | string; stderr?: string; message?: string }
const code = typeof err.code === 'number' ? err.code : undefined
const text = `${err.stderr ?? ''} ${err.message ?? ''}`.toLowerCase()
if (code === 44 || text.includes('could not be found') || text.includes('specified item could not be found')) {
return 'notFound'
}
return 'accessDenied'
}
/**
* Reads a generic-password keychain item via the Apple-signed `/usr/bin/security`
* (which the item's `apple-tool:` partition trusts, avoiding the partition-list
* prompt on already-consented items). Tries each account candidate in order
* (`null` = service-only). Never returns or logs the secret to any caller but
* the direct one.
*/
export async function readKeychainPassword(
service: string,
accounts: (string | null)[] = [null],
exec: KeychainExec = execFileAsync,
): Promise<KeychainOutcome> {
if (process.platform !== 'darwin') return { status: 'notFound' }
let denied = false
for (const account of accounts) {
const args = ['find-generic-password', '-s', service, ...(account ? ['-a', account] : []), '-w']
try {
const { stdout } = await exec('/usr/bin/security', args, { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 64 * 1024 })
if (stdout && stdout.trim()) return { status: 'found', value: decodeKeychainValue(stdout) }
} catch (error) {
if (classifyKeychainError(error) === 'accessDenied') denied = true
}
}
return denied ? { status: 'accessDenied' } : { status: 'notFound' }
}
export function sanitizeError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error)
return raw
.replace(/\0/g, '')
.replace(/Bearer\s+[^\s,;"']+/gi, '[REDACTED]')
.replace(/sk-ant-[A-Za-z0-9_-]+/gi, '[REDACTED]')
.replace(/sk-[A-Za-z0-9_-]+/gi, '[REDACTED]')
// Google (ya29.) and GitHub (gho_/ghu_/ghp_) OAuth token shapes used by
// the Gemini/Copilot quota providers.
.replace(/ya29\.[A-Za-z0-9._-]+/g, '[REDACTED]')
.replace(/gh[opusr]_[A-Za-z0-9_]+/g, '[REDACTED]')
.replace(/eyJ[A-Za-z0-9._-]+/g, '[REDACTED]')
.slice(0, 240)
}
function assertSafeMode(stats: Stats, filePath: string): void {
if (!stats.isFile()) throw new Error(`Credential path is not a regular file: ${filePath}`)
// POSIX mode bits are meaningless for Windows ACLs, where stats.mode would
// otherwise reject every credential file.
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) throw new Error(`Credential file permissions are too broad: ${filePath}`)
}
export async function readSecureFile(filePath: string, maxBytes = 64 * 1024): Promise<string | null> {
let before: Stats
try {
before = await lstat(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
if (before.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${filePath}`)
assertSafeMode(before, filePath)
if (before.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
const handle = await open(filePath, constants.O_RDONLY | NOFOLLOW)
try {
const after = await handle.stat()
assertSafeMode(after, filePath)
if (after.dev !== before.dev || after.ino !== before.ino) throw new Error(`Credential file changed while opening: ${filePath}`)
if (after.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
return await handle.readFile({ encoding: 'utf8' })
} finally {
await handle.close()
}
}
/** Synchronous twin of {@link readSecureFile}, for the rotation path that must
* not yield to the event loop between reading and writing a credential. */
export function readSecureFileSync(filePath: string, maxBytes = 64 * 1024): string | null {
let before: Stats
try {
before = lstatSync(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
if (before.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${filePath}`)
assertSafeMode(before, filePath)
if (before.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
const fd = openSync(filePath, constants.O_RDONLY | NOFOLLOW)
try {
const after = fstatSync(fd)
assertSafeMode(after, filePath)
if (after.dev !== before.dev || after.ino !== before.ino) throw new Error(`Credential file changed while opening: ${filePath}`)
if (after.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
return readFileSync(fd, { encoding: 'utf8' })
} finally {
closeSync(fd)
}
}
// Rewrite a credential with exactly the owner-only permissions it already had,
// so a refreshed token neither widens the file the user chose nor drops a
// tighter mode like 0o400. Group or world bits (or Windows, where mode bits are
// not the access control) mean there is nothing safe to copy: use owner rw.
function privateModeFor(filePath: string): number {
if (process.platform === 'win32') return 0o600
try {
const stats = lstatSync(filePath)
if (!stats.isFile()) return 0o600
const mode = stats.mode & 0o777
return mode !== 0 && (mode & 0o077) === 0 ? mode : 0o600
} catch {
return 0o600
}
}
/**
* Writes a credential file atomically and synchronously. Synchronous on
* purpose: a caller holding a freshly rotated token has to get it onto disk
* without an await for an abort, a timeout or a `process.exit` to land in.
*/
export function atomicWriteSecureFileSync(filePath: string, contents: string): void {
const mode = privateModeFor(filePath)
const dir = path.dirname(filePath)
mkdirSync(dir, { recursive: true, mode: 0o700 })
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`)
try {
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode)
try {
writeFileSync(fd, contents, 'utf8')
fsyncSync(fd)
} finally {
closeSync(fd)
}
if (process.platform !== 'win32') chmodSync(tempPath, mode)
renameSync(tempPath, filePath)
} catch (error) {
try { unlinkSync(tempPath) } catch { /* nothing to clean up */ }
throw error
}
}
/// How long a quota request, including a token grant, is given to answer.
export const QUOTA_REQUEST_TIMEOUT_MS = 30_000
export function quotaRequestSignal(parent?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(QUOTA_REQUEST_TIMEOUT_MS)
return parent ? AbortSignal.any([parent, timeout]) : timeout
}
/// Refreshes that have left this machine and whose result is not yet on disk.
///
/// A token grant retires the refresh token in the credential file the instant
/// the server accepts it, so the window between the POST and the rewritten file
/// is one where quitting costs the user their login. Nothing cancels that
/// window - the request deliberately ignores the caller's abort signal - but a
/// `process.exit` will still cut through it, so any command that exits outright
/// has to drain this first.
const outstandingCredentialWrites = new Set<Promise<void>>()
/// Past this an exit stops waiting. It has to outlast the request it is waiting
/// on, or the guard expires while the very case it was built for is still in
/// flight: a grant answering at twenty seconds is exactly the slow reply that
/// costs the user their login. Derived from the request timeout rather than
/// written as a number, so the two cannot drift apart, plus a margin for the
/// write that follows the reply.
export const CREDENTIAL_DRAIN_TIMEOUT_MS = QUOTA_REQUEST_TIMEOUT_MS + 5_000
/** Registers a rotation for {@link awaitCredentialWrites} to wait on. Returns
* the same promise, so a caller can keep awaiting it exactly as before. */
export function trackCredentialWrite<T>(work: Promise<T>): Promise<T> {
// Swallowed here only: the tracked copy exists to be waited on, and the
// caller still sees the original promise's rejection.
const settled = work.then(() => undefined, () => undefined)
outstandingCredentialWrites.add(settled)
void settled.then(() => { outstandingCredentialWrites.delete(settled) })
return work
}
export function pendingCredentialWrites(): number {
return outstandingCredentialWrites.size
}
/** Waits for every registered rotation to reach disk, up to `timeoutMs`. Safe
* to call when there is nothing outstanding, which is the usual case. */
export async function awaitCredentialWrites(timeoutMs = CREDENTIAL_DRAIN_TIMEOUT_MS): Promise<void> {
if (outstandingCredentialWrites.size === 0) return
let timer: ReturnType<typeof setTimeout> | undefined
const expired = new Promise<'timeout'>(resolve => {
timer = setTimeout(() => resolve('timeout'), timeoutMs)
timer.unref?.()
})
try {
// Looped rather than snapshotted once: a 401 retry can start a second
// rotation while the first is still draining.
while (outstandingCredentialWrites.size > 0) {
const drained = Promise.all([...outstandingCredentialWrites]).then(() => 'drained' as const)
if (await Promise.race([drained, expired]) === 'timeout') {
// Giving up here can retire a login, so it is said out loud rather than
// left for the user to discover the next time a provider asks them to
// sign in again.
process.stderr.write(
'codeburn: a provider login was still being refreshed after '
+ `${Math.round(timeoutMs / 1000)}s and was not saved. `
+ 'If that provider signs you out, sign in again with its own CLI.\n',
)
return
}
}
} finally {
if (timer) clearTimeout(timer)
}
}
export function fraction(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
return Math.min(1, Math.max(0, value / 100))
}

21
src/quota/types.ts Normal file
View file

@ -0,0 +1,21 @@
export type QuotaWindow = {
label: string
percent: number
resetsAt: string | null
}
export type QuotaProvider = {
provider: 'claude' | 'codex' | 'gemini' | 'copilot' | 'antigravity' | 'kimi' | 'cursor' | 'zai' | 'grok' | 'clinepass'
connection: 'connected' | 'disconnected' | 'accessDenied' | 'loading' | 'stale' | 'transientFailure' | 'terminalFailure'
primary: QuotaWindow | null
details: QuotaWindow[]
planLabel: string | null
footerLines: string[]
/** Set when the provider is in a 429 backoff window (rate limited by the
* upstream quota endpoint), so the UI can say so honestly instead of the
* generic "waiting" copy. */
rateLimited?: boolean
}
export type ProviderName = QuotaProvider['provider']

191
src/quota/zai.ts Normal file
View file

@ -0,0 +1,191 @@
// Live GLM Coding Plan quota via Z.ai's own usage endpoint (ported from the
// menubar's ZaiSubscriptionService.swift):
//
// - GET https://api.z.ai/api/monitor/usage/quota/limit
// The API key goes in a bare Authorization header, not as a bearer.
//
// Credential, in the order the menubar tries them: a key the user supplied
// (there it is a Keychain entry, here the ZAI_API_KEY environment variable),
// then the Z.ai login the Pi CLI already holds in ~/.pi/agent/auth.json.
// Read-only; the key is used for one request and never persisted or logged.
import os from 'node:os'
import path from 'node:path'
import { quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'
const USAGE_ENDPOINT = 'https://api.z.ai/api/monitor/usage/quota/limit'
const SOURCE_FOOTER = ['Source: Z.ai Coding Plan']
const REJECTED_FOOTER = ['Z.ai rejected this API key.']
const RATE_LIMITED_FOOTER = ['Z.ai rate-limited the quota request.']
const UNAVAILABLE_FOOTER = ['Z.ai is temporarily unavailable.']
const PARSE_FOOTER = ['Z.ai returned an unrecognized quota response.']
export type ZaiDeps = {
fetch: typeof fetch
/** The Pi CLI's own login store. */
credentialPath: string
readFile: typeof readSecureFile
env: NodeJS.ProcessEnv
}
function defaultDeps(): ZaiDeps {
return {
fetch: globalThis.fetch,
credentialPath: path.join(os.homedir(), '.pi', 'agent', 'auth.json'),
readFile: readSecureFile,
env: process.env,
}
}
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'zai', connection, primary: null, details: [], planLabel: null, footerLines }
}
function nonEmpty(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/** Numbers have shipped as JSON numbers and as strings. */
function num(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
async function apiKey(deps: ZaiDeps): Promise<string | null> {
const supplied = nonEmpty(deps.env['ZAI_API_KEY'])
if (supplied) return supplied
try {
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
if (!raw) return null
const auth = JSON.parse(raw) as Record<string, unknown>
const entry = auth['zai']
return entry && typeof entry === 'object' ? nonEmpty((entry as Record<string, unknown>)['key']) : null
} catch {
// A malformed or unreadable login file is the same as no login at all.
return null
}
}
/** Reset stamps arrive as ISO-8601, epoch seconds, or epoch milliseconds. */
function resetsAt(value: unknown): string | null {
const epoch = num(value)
if (epoch !== null) {
const seconds = epoch < 1_000_000_000_000 ? epoch : epoch / 1000
return new Date(seconds * 1000).toISOString()
}
const raw = nonEmpty(value)
if (!raw) return null
const parsed = Date.parse(raw)
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
}
/** Z.ai encodes the window as a unit enum plus a count; only the two the plan
* actually meters (5-hour and weekly) have a label. */
function windowLabel(unit: number | null, count: number | null): string | null {
if (unit === 3 && count === 5) return '5-hour'
if (unit === 6 && count === 1) return 'Weekly'
return null
}
/** "coding_pro" → "Coding Pro"; missing or blank → null. */
function planLabel(value: unknown): string | null {
const raw = nonEmpty(value)
if (!raw) return null
return raw.replace(/_/g, ' ').toLowerCase().replace(/(^|\s)\w/g, match => match.toUpperCase())
}
export type ZaiDecoded = QuotaProvider | 'rejected' | null
/** `'rejected'` when the body carries an auth error despite the HTTP status,
* `null` when it carries no usable window. */
export function decodeZaiUsage(body: unknown): ZaiDecoded {
if (!body || typeof body !== 'object') return null
const root = body as Record<string, any>
const code = num(root.code)
if (code === 401 || code === 403) return 'rejected'
if (root.success === false) return null
const payload = (root.data && typeof root.data === 'object' ? root.data : root) as Record<string, any>
if (!Array.isArray(payload.limits)) return null
let fiveHour: QuotaWindow | null = null
let weekly: QuotaWindow | null = null
for (const raw of payload.limits) {
if (!raw || typeof raw !== 'object') continue
const limit = raw as Record<string, unknown>
if (limit.type !== 'CREDIT_LIMIT' && limit.type !== 'TOKENS_LIMIT') continue
const label = windowLabel(num(limit.unit), num(limit.number))
if (label === null) continue
let usedPercent = num(limit.percentage)
if (usedPercent === null) {
const current = num(limit.currentValue)
const total = num(limit.usage)
if (current !== null && total !== null && total > 0) usedPercent = current / total * 100
}
if (usedPercent === null) continue
const window: QuotaWindow = {
label,
percent: Math.min(1, Math.max(0, usedPercent / 100)),
resetsAt: resetsAt(limit.nextResetTime),
}
if (label === 'Weekly') weekly = window
else fiveHour = window
}
const details = [fiveHour, weekly].filter((row): row is QuotaWindow => row !== null)
if (details.length === 0) return null
return {
provider: 'zai', connection: 'connected',
primary: weekly ?? fiveHour,
details,
planLabel: planLabel(payload.level),
footerLines: SOURCE_FOOTER,
}
}
export type ZaiResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchZaiQuota(options: Partial<ZaiDeps> & { signal?: AbortSignal } = {}): Promise<ZaiResult> {
const deps = { ...defaultDeps(), ...options }
try {
const key = await apiKey(deps)
if (!key) return { quota: empty('disconnected') }
const response = await deps.fetch(USAGE_ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(options.signal),
headers: {
Accept: 'application/json',
'Accept-Language': 'en-US,en',
Authorization: key,
'User-Agent': 'CodeBurn',
},
})
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', REJECTED_FOOTER) }
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
const seconds = raw === null ? NaN : Number(raw)
return {
quota: { ...empty('transientFailure', RATE_LIMITED_FOOTER), rateLimited: true },
retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60),
}
}
if (response.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) }
if (!response.ok) return { quota: empty('transientFailure', PARSE_FOOTER) }
// Never log the body - it carries account data.
const decoded = decodeZaiUsage(await response.json())
if (decoded === 'rejected') return { quota: empty('terminalFailure', REJECTED_FOOTER) }
return { quota: decoded ?? empty('transientFailure', PARSE_FOOTER) }
} catch (error) {
console.warn(`Z.ai quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

View file

@ -1846,7 +1846,9 @@ const STATUS_SNAPSHOT_FILE = 'status-snapshot'
// resolution time at which its corpus scan began. The latter is the ordering
// fence for competing writers: max(file mtime) is not monotonic because
// deleting the newest file legitimately makes it decrease.
const STATUS_SNAPSHOT_VERSION = 3
//
// v4: the payload carries `telemetrySnapshot`, which a v3 record predates.
const STATUS_SNAPSHOT_VERSION = 4
type StatusSnapshotRecord = {
version: number

298
src/telemetry-snapshot.ts Normal file
View file

@ -0,0 +1,298 @@
import { isBehavioralTurn } from './behavioral-weight.js'
import { getShortModelName } from './models.js'
import { CATEGORY_LABELS, type ProjectSummary } from './types.js'
import type { MenubarPayload } from './menubar-json.js'
/// The anonymised daily aggregate that both the desktop app and the Windows
/// tray send as the consent-gated `usage_snapshot` telemetry event. It is
/// computed here, in the CLI, so the two surfaces cannot drift into two
/// different shapes.
///
/// Privacy contract, held by construction rather than by a caller's discipline:
/// - Every magnitude is a bucket label. No exact dollar amounts, no exact
/// counts, no exact durations.
/// - Names are configuration or model identifiers only: model names, task
/// category labels, provider display names, MCP server / skill / tool names.
/// Nothing here is derived from a working directory, project name, branch
/// name, file path or message text.
/// - Leaves are only short strings, finite numbers and booleans, nested at most
/// two containers deep, so the desktop's whitelist sanitizer passes the block
/// through unchanged.
/// - Arrays are capped (see the limits below) so the block stays small.
export const TELEMETRY_SNAPSHOT_SCHEMA = 2
const MAX_MODELS = 8
const MAX_TASKS_PER_MODEL = 6
const MAX_CATEGORIES = 12
const MAX_MODELS_PER_CATEGORY = 3
const MAX_PROVIDERS = 8
const MAX_NAMED = 12
const MAX_NAME = 64
const SYNTHETIC_MODEL_NAME = '<synthetic>'
/// Shared with the desktop renderer's fallback builder: 1, 10, 50, 200 and
/// 1000 USD boundaries.
export function costBucket(usd: number): string {
if (!Number.isFinite(usd) || usd < 1) return '<1'
if (usd < 10) return '1-10'
if (usd < 50) return '10-50'
if (usd < 200) return '50-200'
if (usd < 1000) return '200-1k'
return '1k+'
}
/// Occurrence counts (turns, calls, sessions) coarsened the same way dollars
/// are, with one extra label at the bottom: `0`. Nothing happened is not the
/// same claim as "between one and ten", and a model the payload lists but that
/// has no behavioural turns behind it would otherwise report activity it never
/// had. A count that is not a finite number is unknown, which is likewise not
/// evidence of use, so it reads as `0` too.
/// Buckets: `0`, `1-10`, `10-100`, `100-1k`, `1k+`.
export function countBucket(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0'
if (n < 10) return '1-10'
if (n < 100) return '10-100'
if (n < 1000) return '100-1k'
return '1k+'
}
/// Wall-clock session length. Coarse enough that a single session cannot be
/// recognised from its duration.
export function minutesBucket(minutes: number): string {
if (!Number.isFinite(minutes) || minutes < 5) return '<5'
if (minutes < 15) return '5-15'
if (minutes < 60) return '15-60'
if (minutes < 240) return '60-240'
return '240+'
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/// Rates are reported to 2dp, or -1 when the denominator is empty. -1 is the
/// established "not computable" marker for these fields, so a consumer never
/// has to tell a missing rate apart from a genuine zero.
function rate2(numerator: number, denominator: number): number {
if (denominator <= 0) return -1
return round2(numerator / denominator)
}
function safeName(value: unknown): string {
return typeof value === 'string' ? value.slice(0, MAX_NAME) : ''
}
/// One (model, task category) cell of the model x task cross. Raw counts; only
/// their buckets and shares ever reach the snapshot.
export type ModelTaskTurns = {
model: string
category: string
turns: number
editTurns: number
oneShotTurns: number
}
/// What the snapshot needs that the menubar payload does not already carry.
export type TelemetrySnapshotInput = {
modelTasks?: ModelTaskTurns[]
/// Session wall-clock durations in minutes. Only the median's bucket is sent.
sessionMinutes?: number[]
}
export type SnapshotTask = { name: string; turnBucket: string; share: number }
export type SnapshotModel = {
name: string
costBucket: string
turnBucket: string
oneShotRate: number
tasks: SnapshotTask[]
}
export type SnapshotCategory = {
name: string
turnBucket: string
oneShotRate: number
topModels: string[]
}
export type SnapshotProvider = { name: string; costBucket: string }
export type SnapshotNamedCalls = { name: string; callBucket: string }
export type TelemetrySnapshot = {
schema: number
period: string
providerCount: number
costBucket: string
models: SnapshotModel[]
categories: SnapshotCategory[]
providers: SnapshotProvider[]
mcpServers: SnapshotNamedCalls[]
skills: SnapshotNamedCalls[]
tools: SnapshotNamedCalls[]
sessions: { countBucket: string; medianMinutesBucket?: string }
efficiency: { cacheHitRate?: number; retryTaxShare?: number }
}
/// Walks parsed turns once and buckets each behavioural turn under its primary
/// model and its task category. The primary model is the turn's first
/// non-synthetic assistant call, matching how `aggregateModelEfficiency`
/// attributes a turn, and it is shortened with `getShortModelName` so the keys
/// line up with the payload's `topModels[].name`.
export function aggregateModelTaskTurns(projects: ProjectSummary[]): ModelTaskTurns[] {
const rows = new Map<string, ModelTaskTurns>()
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!isBehavioralTurn(turn)) continue
const primary = turn.assistantCalls.find(call => getShortModelName(call.model) !== SYNTHETIC_MODEL_NAME)
if (!primary) continue
const model = getShortModelName(primary.model)
const category = CATEGORY_LABELS[turn.category] ?? turn.category
const key = `${model}\u0000${category}`
let row = rows.get(key)
if (!row) {
row = { model, category, turns: 0, editTurns: 0, oneShotTurns: 0 }
rows.set(key, row)
}
row.turns++
if (turn.hasEdits) {
row.editTurns++
if (turn.retries === 0) row.oneShotTurns++
}
}
}
}
return [...rows.values()]
}
/// Session wall-clock lengths in minutes, for the median duration bucket.
export function sessionDurationMinutes(projects: ProjectSummary[]): number[] {
const minutes: number[] = []
for (const project of projects) {
for (const session of project.sessions) {
const start = Date.parse(session.firstTimestamp)
const end = Date.parse(session.lastTimestamp)
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue
minutes.push((end - start) / 60_000)
}
}
return minutes
}
function median(values: number[]): number | null {
if (values.length === 0) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 1) return sorted[mid]!
return (sorted[mid - 1]! + sorted[mid]!) / 2
}
type ModelRollup = {
turns: number
editTurns: number
oneShotTurns: number
tasks: Map<string, number>
}
export function buildTelemetrySnapshot(
payload: MenubarPayload,
input: TelemetrySnapshotInput = {},
): TelemetrySnapshot {
const current = payload.current
const byModel = new Map<string, ModelRollup>()
const byCategory = new Map<string, Map<string, number>>()
for (const row of input.modelTasks ?? []) {
const model = safeName(row.model)
const category = safeName(row.category)
if (!model || !category || !Number.isFinite(row.turns) || row.turns <= 0) continue
let rollup = byModel.get(model)
if (!rollup) {
rollup = { turns: 0, editTurns: 0, oneShotTurns: 0, tasks: new Map() }
byModel.set(model, rollup)
}
rollup.turns += row.turns
rollup.editTurns += Number.isFinite(row.editTurns) ? row.editTurns : 0
rollup.oneShotTurns += Number.isFinite(row.oneShotTurns) ? row.oneShotTurns : 0
rollup.tasks.set(category, (rollup.tasks.get(category) ?? 0) + row.turns)
const models = byCategory.get(category) ?? new Map<string, number>()
models.set(model, (models.get(model) ?? 0) + row.turns)
byCategory.set(category, models)
}
const models: SnapshotModel[] = []
for (const model of current.topModels ?? []) {
if (models.length >= MAX_MODELS) break
const name = safeName(model.name)
if (!name) continue
const rollup = byModel.get(name)
const tasks: SnapshotTask[] = rollup
? [...rollup.tasks.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_TASKS_PER_MODEL)
.map(([category, turns]) => ({
name: category,
turnBucket: countBucket(turns),
share: rate2(turns, rollup.turns),
}))
: []
models.push({
name,
costBucket: costBucket(model.cost),
turnBucket: countBucket(rollup?.turns ?? 0),
oneShotRate: rate2(rollup?.oneShotTurns ?? 0, rollup?.editTurns ?? 0),
tasks,
})
}
const categories: SnapshotCategory[] = []
for (const activity of current.topActivities ?? []) {
if (categories.length >= MAX_CATEGORIES) break
const name = safeName(activity.name)
if (!name) continue
categories.push({
name,
turnBucket: countBucket(activity.turns),
oneShotRate: activity.oneShotRate == null ? -1 : round2(activity.oneShotRate),
topModels: [...(byCategory.get(name) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_MODELS_PER_CATEGORY)
.map(([model]) => model),
})
}
const providers: SnapshotProvider[] = Object.entries(current.providers ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_PROVIDERS)
.map(([name, cost]) => ({ name: safeName(name), costBucket: costBucket(cost) }))
.filter(entry => entry.name !== '')
const named = <T>(rows: T[] | undefined, name: (row: T) => unknown, calls: (row: T) => number): SnapshotNamedCalls[] =>
(rows ?? [])
.slice(0, MAX_NAMED)
.map(row => ({ name: safeName(name(row)), callBucket: countBucket(calls(row)) }))
.filter(entry => entry.name !== '')
const medianMinutes = median(input.sessionMinutes ?? [])
const cacheDenominator = current.inputTokens + current.cacheReadTokens
const retryTaxUSD = current.retryTax?.totalUSD ?? 0
return {
schema: TELEMETRY_SNAPSHOT_SCHEMA,
period: safeName(current.label),
providerCount: Object.keys(current.providers ?? {}).length,
costBucket: costBucket(current.cost),
models,
categories,
providers,
mcpServers: named(current.mcpServers, row => row.name, row => row.calls),
skills: named(current.skills, row => row.name, row => row.turns),
tools: named(current.tools, row => row.name, row => row.calls),
sessions: {
countBucket: countBucket(current.sessions),
...(medianMinutes === null ? {} : { medianMinutesBucket: minutesBucket(medianMinutes) }),
},
efficiency: {
...(cacheDenominator > 0 ? { cacheHitRate: round2(current.cacheHitPercent / 100) } : {}),
...(current.cost > 0 ? { retryTaxShare: round2(retryTaxUSD / current.cost) } : {}),
},
}
}

View file

@ -12,6 +12,7 @@ import { stat } from 'node:fs/promises'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { aggregateModels } from './models-report.js'
import { aggregateModelTaskTurns, sessionDurationMinutes } from './telemetry-snapshot.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
@ -1203,6 +1204,12 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
})),
]
return {
// Telemetry-only inputs: the model x task cross and session lengths, which
// the payload itself does not carry. Bucketed inside the snapshot builder.
telemetry: {
modelTasks: aggregateModelTaskTurns(scanProjects),
sessionMinutes: sessionDurationMinutes(scanProjects),
},
tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })),
skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })),
subagents: subagents.sort((a, b) => b.cost - a.cost || (b.totalTokens ?? 0) - (a.totalTokens ?? 0)),

View file

@ -20,6 +20,7 @@ import {
flushAntigravityCache,
} from '../src/providers/antigravity.js'
import type { ParsedProviderCall } from '../src/providers/types.js'
import { setHome } from './setup/home.js'
const originalCacheDir = process.env['CODEBURN_CACHE_DIR']
const originalHome = process.env['HOME']
@ -84,8 +85,8 @@ beforeEach(async () => {
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 (originalHome === undefined) setHome(undefined)
else setHome(originalHome)
if (originalCodexHome === undefined) delete process.env['CODEX_HOME']
else process.env['CODEX_HOME'] = originalCodexHome
await rm(root, { recursive: true, force: true })
@ -186,7 +187,7 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => {
}),
].join('\n') + '\n')
process.env['HOME'] = home
setHome(home)
process.env['CODEX_HOME'] = codexHome
process.env['CODEBURN_CACHE_DIR'] = cacheA
const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')

View file

@ -13,7 +13,7 @@ afterEach(async () => {
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})

View file

@ -11,7 +11,7 @@ function runCli(args: string[], home: string) {
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
HOME: home, USERPROFILE: home,
TZ: 'UTC',
},
encoding: 'utf-8',

View file

@ -15,7 +15,7 @@ vi.setConfig({ testTimeout: 30_000 })
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})

View file

@ -11,7 +11,7 @@ function runCli(args: string[], home: string) {
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
HOME: home, USERPROFILE: home,
TZ: 'UTC',
},
encoding: 'utf-8',

View file

@ -18,7 +18,7 @@ function runCli(args: string[], home: string) {
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
HOME: home, USERPROFILE: home,
TZ: 'UTC',
},
encoding: 'utf-8',

View file

@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_TIMEOUT_MS = 10_000
const CLI_TIMEOUT_MS = 30_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {

View file

@ -147,5 +147,7 @@ describe('codeburn models --unpriced public CLI', () => {
expect(json.status, json.stderr).toBe(0)
expect((JSON.parse(json.stdout) as Array<{ model: string }>)[0]?.model).toBe(hostile)
})
}, 15_000)
// Four CLI spawns in one test, and a cold tsx spawn costs several seconds,
// so this one needs more headroom than the 30s config default.
}, 60_000)
})

View file

@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_PLAN_TIMEOUT_MS = 10_000
const CLI_PLAN_TIMEOUT_MS = 30_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {

View file

@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_TIMEOUT_MS = 10_000
const CLI_TIMEOUT_MS = 30_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {

View file

@ -25,7 +25,7 @@ async function makeHome(): Promise<string> {
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})

View file

@ -29,7 +29,7 @@ async function makeHome(): Promise<string> {
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})

View file

@ -25,7 +25,7 @@ async function makeHome(): Promise<string> {
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})

View file

@ -28,7 +28,7 @@ function runCli(args: string[], home: string, extraEnv: Record<string, string |
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
HOME: home,
HOME: home, USERPROFILE: home,
TZ: 'UTC',
...extraEnv,
},
@ -124,6 +124,19 @@ describe('codeburn status --format menubar-json', () => {
const history = payload['history'] as { daily: unknown[] }
expect(Array.isArray(history.daily)).toBe(true)
// The consent-gated telemetry aggregate the desktop app and the tray both
// send verbatim. Bucketed and name-only, so it must never echo a cost.
const snapshot = payload['telemetrySnapshot'] as Record<string, unknown>
expect(snapshot).toBeTruthy()
expect(snapshot['schema']).toBe(2)
// The same period label the payload shows, date suffix and all: the event
// it rides on is already stamped with the calendar day.
expect(snapshot['period']).toBe(current['label'])
expect(snapshot['costBucket']).toBe('<1')
expect((snapshot['models'] as Array<{ name: string; tasks: unknown[] }>)[0]!.tasks.length).toBeGreaterThan(0)
expect(JSON.stringify(snapshot)).not.toContain('myapp')
expect(JSON.stringify(snapshot)).not.toContain(String(current['cost']))
} finally {
await rm(home, { recursive: true, force: true })
}
@ -791,7 +804,9 @@ describe('codeburn status --format menubar-json', () => {
// paths, so it must land group/world-unreadable regardless of umask.
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
expect(statSync(snapshotFiles[0]!).mode & 0o777).toBe(0o600)
// Windows has no POSIX mode bits: NTFS reports 0o666 whatever mode open()
// was handed, so owner-only permission is only assertable on POSIX.
if (process.platform !== 'win32') expect(statSync(snapshotFiles[0]!).mode & 0o777).toBe(0o600)
// Identical query against an unchanged corpus: served from the
// snapshot, byte-identical to the first call.

View file

@ -7,6 +7,7 @@ import type { Server } from 'http'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runWebDashboard } from '../src/web-dashboard.js'
import { setHome } from './setup/home.js'
// Issue #767 item 2: `codeburn context <session>` accepts an 8-char session id
// prefix (findClaudeSession does a startsWith match), but /api/context/tree
@ -27,7 +28,7 @@ describe('web dashboard /api/context/tree: session id prefix', () => {
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'codeburn-context-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-context-cache-'))
process.env['HOME'] = homeDir
setHome(homeDir)
process.env['CODEBURN_CACHE_DIR'] = cacheDir
delete process.env['CLAUDE_CONFIG_DIR']

View file

@ -114,9 +114,16 @@ function makeProject(name: string, sessions: ReturnType<typeof makeSession>[]) {
}
}
// A real TTY always drains, so Ink's render pipeline never waits on a write.
// A PassThrough with no reader stalls once the default 16 KB buffer fills, and
// a few 120x50 frames reach that: the app then never gets to run its exit, and
// the test times out instead of failing. The tests that read stdout keep it
// flowing themselves; the ones that only wait for the exit need the headroom.
const TUI_STDOUT_BUFFER_BYTES = 4 * 1024 * 1024
function makeTui() {
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
const stdout = new PassThrough({ highWaterMark: TUI_STDOUT_BUFFER_BYTES }) as PassThrough & NodeJS.WriteStream
stdin.isTTY = true
stdin.setRawMode = () => stdin
stdin.ref = () => stdin

View file

@ -96,7 +96,7 @@ async function startHydratingDashboard(): Promise<RunningDashboard> {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
HOME: home,
HOME: home, USERPROFILE: home,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: cacheDir,
CODEBURN_DESKTOP_SESSIONS_DIR: join(home, 'desktop-sessions'),

Some files were not shown because too many files have changed in this diff Show more