cli_error gains duration, exit, a locally classified reason and the scoped provider. New provider_read_fail (desktop) and update_result (desktop, menubar, tray) events, enums only.
* refactor(app): move the bridge handlers out of main.ts
createBridgeHandlers and the argv builders now live in
app/electron/bridge-handlers.ts with no electron import, so a host other
than Electron can run the same channel to argv mapping. main.ts keeps its
default deps and re-exports, so the desktop app and its tests are unchanged.
Two additive hooks for that host: scopeProjectFilter narrows the saved
project filter before it becomes argv, and CODEBURN_NODE_BIN runs the
bundled CLI with a named Node instead of Electron-as-Node.
* feat(app): let the renderer run inside an editor
The bridge can now say it is the VS Code host. When it does, the chord
shortcuts and their hints step aside for the editor's own, Settings hands
theme, language, default period and refresh to the editor's settings,
the top bar shows a this workspace / all projects switch, and the editor
can send a section, a period or a refresh to an open dashboard.
formatResetTime moves to lib/format.ts so the status bar and the summary
use the Plans wording. New strings are in all six locales. Nothing
changes in the desktop app.
* feat(app): build the renderer for VS Code webviews
app/vite.vscode.config.ts builds two webview entries into
vscode/dist/webview: the whole dashboard and the activity bar summary.
renderer/vscode/bridge.ts implements window.codeburn over postMessage,
forwarding only the channels in channels.ts; telemetry, update checks and
the desktop companions are answered in the webview. localStorage is
prefixed per scope so one workspace's cached views never paint in
another's, and the theme follows the editor's light, dark and high
contrast kinds through its --vscode-* tokens.
* feat(vscode): CodeBurn extension for VS Code and its forks
A status bar item with today's spend (~ when estimated) and a tooltip
with the week, the month, this workspace, the top model and plan quotas;
an activity bar summary; the desktop dashboard in an editor tab, scoped
to the open workspace; eight commands and eight settings.
The bundled CLI runs as the desktop's resident serve child through the
shared client. Current VS Code, Cursor and Antigravity ship Node 24, so
it runs on the editor's own Node; an older editor gets a system Node
22.13+ or a degraded run that says what it cannot read. A workspace
folder maps to a rooted --project the way the CLI keys sessions,
worktrees folding into their main repository.
npm run package:vscode builds the .vsix. Unit tests cover the bridge
host, status bar formatting, workspace mapping, settings and runtime
choice; test:smoke drives a real VS Code against a fixture home.
* ci, docs: build, test and package the VS Code extension
A vscode job typechecks, unit tests, packages the .vsix, runs the smoke
test in VS Code under xvfb and uploads the .vsix. docs/vscode.md covers
the architecture, the measured editor runtimes and how to test.
* fix(vscode): keep the currency setting out of workspace settings
Changing codeburn.currency runs the CLI's own currency command, which
every CodeBurn app shares, so a repository's .vscode/settings.json must
not be able to set it: the setting is application scoped now.
The smoke test also checks that the dashboard's renderer booted inside
the webview and called the host, not only that its tab opened.
* feat(vscode): version the extension 1.0.0, apart from the CLI
The extension carried the CLI's 0.9.25. It now has its own version,
1.0.0. The host passes it to the webview, and the dashboard's nav
footer, About dialog and Settings show it there instead of the
renderer's package version; Settings adds "Bundled CLI 0.9.25" beneath
it. The desktop app is unchanged. Nothing in the build or packaging
writes the extension's version from the CLI.
* fix(vscode): run the smoke test in Cursor, keep the demo shim out
Cursor 3 opens its Agents window on a fresh profile, which starts no
extension host, so the smoke run against Cursor.app hung; it now passes
--classic for Cursor. The webview build no longer copies
renderer/public/demo-shim.js, a browser demo harness, into the .vsix.
* feat(vscode): a quiet star line in the summary, and a Star on GitHub command
From the third day of use, the activity bar summary ends with the
desktop's "Enjoying CodeBurn? Star us on GitHub" line, in the same six
languages, with a close button. Clicking it or closing it hides it for
good (kept in the extension's globalState). It is never a notification.
The new "CodeBurn: Star on GitHub" command opens the repository page.
No tracking parameters, no telemetry.
* docs(vscode): marketplace screenshots in the README
Five screenshots of the extension on real data (Overview in dark and
light, the why it cost view, the status bar hover and the activity bar
summary), 1600 px wide, each with a one-line caption. They live in
vscode/media/screenshots, stay out of the .vsix, and the package script
points vsce at the raw GitHub URLs on main.
* fix(vscode): review fixes for settings trust, serve lifecycle and the tooltip
- codeburn.provider and codeburn.quotaProviders are application scoped,
like currency, so a repository's settings cannot turn quota requests
back on, even in Restricted Mode.
- deactivate removes the serve pid file once the child is stopped, and
changing codeburn.nodePath restarts the serve child on the new Node.
- codeburn.currency is applied at startup when the CLI's currency
differs, not only when the setting changes.
- The dashboard's Settings no longer shows per-plan quota switches in the
editor, where the editor setting rewrites them on every boot; it points
to the editor settings instead.
- Workspace and model names are escaped in the status bar tooltip.
- The README lists the dashboard actions that reach the network.
- The staged CLI drops its dependencies' tests and TypeScript sources
(466 files, 9.9 MB to 9.2 MB).
* feat(sessions): explain why one session cost what it did
`codeburn sessions --id <id> --why` reads one Claude Code transcript on
demand and returns where the money went: a verdict, findings ranked by
dollars, spend by prompt with helper (sub-agent) cost stacked, and every
prompt's model calls and tool steps with timing, cost parts and step detail.
Money comes from the parser's own dedupe, turn grouping and calculateCost,
so the session total matches its `sessions` row.
The desktop app opens this as a focused session view when a Claude Code
session is clicked. Transcript content is read on demand only: never cached,
never routed through serve's output memo, never synced or sent in telemetry.
* fix(sessions): audit fixes for the session cost view
Recovery after a failed step stays inside its prompt; neutral note when the
view and the row differ (forked/resumed transcripts); starting-context
estimate compares against the cached prefix; local dates with the end date
when the session spans days; idle gaps that end with a helper reporting back
say so; savings are the difference of the shown figures; singular counts;
more secret patterns (sk_live_/sk_test_, aws_secret_access_key, URL
credentials); a note when the prompt count differs from the row's turns.
The desktop serve client counted every death, watchdog kills included,
against a 3-death budget that never reset, so three sporadic kills over an
app run disabled serve for good. The budget now resets on any healthy
answer: three generations in a row that never answer still disable it.
One-shot CLI runs only heartbeated inside a parse, so a run waiting on
another process's cold hydration lock (up to 10 min) went silent and was
killed at 45s on every poll. Start the keepalive at command entry.
* 0.9.25: correctness + provider fixes, energy guard, logo, default DMG
CLI/engine fixes: local-model savings count reasoning; ZCode reasoning billed;
codex-credits version-boundary tiering; GLM alias repointed to z.ai discounted rate;
openclaw/pi/qwen timestamp mtime fallback; hermes delta timestamp;
sqlite/warp/hermes busy+permission reclassified as failed (no sealed empty);
codex-cache dev+ino; cursor-cache WAL; antigravity server TTL; quota timeout+
grokbot footer+claude near-expiry; serve RSS guard env+hysteresis; CSV export
subfolder; providers pane fixed window; Warp credit-based cost.
Desktop/menubar: unified flame logo; default DMG; docs signed+notarized.
* menubar: one running instance, self-relaunch on language change
The single-instance guard only retired strictly older copies, so two login
items starting in the same instant both survived, and a copy whose bundle
identity did not survive install was never seen at all. The decision is now
total (newest launch wins, ties break on pid) and peers are matched on bundle
id or executable name. --keep-both opts one launch out.
Language changes no longer have the desktop app quit and reopen the menu bar,
which made macOS attribute the new process to the desktop app and re-ask for
the data-access grant. The menu bar relaunches itself via the existing remote
command key; older menu bars that do not watch the key fall back to the old
path after 5s.
* desktop: export toast shows the saved path; resource buckets on app_close
The export toast showed the folder that was picked, not the dated subfolder
the files land in. The handler already parsed the CLI's saved line; it now
returns that path and the toast uses it.
app_close carries cpu/mem (and serveCpu/serveMem when serve reported) as bucket
labels, sampled on the existing flush interval. serve adds cpuSec and peak rssMb
to its answer frames. Unmeasured figures are omitted, never sent as zero.
* menubar guard never leaves zero instances; installer replaces in place; JSON export into folder
Guard: an unreadable launch date on either side could make two copies retire
each other. Unreadable peers now count only when their pid is lower, and a copy
that cannot read its own date retires lower pids only, so a disagreement keeps
at least one instance.
Installer: an existing bundle in /Applications or ~/Applications is replaced
where it lives instead of a second copy being added. The old bundle is moved
aside and restored on any failure; a cross-volume move falls back to a copy that
is re-verified with codesign. An unwritable location is never escalated: the
install goes to ~/Applications and the leftover is named.
Export: -o pointing at an existing folder writes the JSON inside it. Short
sessions omit the cpu buckets. setLanguage reopens the menu bar if its own
relaunch never comes back. AppRelaunch passes the bundle path as an argument.
* menubar guard orders by kernel start time; installer opens the copy that exists and recovers a killed install
Guard: launch dates could be unreadable and pid order can disagree with start
order after pid reuse, so two copies could still retire each other. Ordering now
uses the kernel-reported process start time, which both sides read identically.
A copy whose start time cannot be read is left out, so the worst case is two
menu bars, never none.
Installer: with the only copy in an unwritable /Applications, a plain run tried
to open a path that did not exist. It now opens the installed copy. A placement
killed mid-way is recovered on the next run, a concurrent install is refused,
and a failed restore names where the previous bundle is. Process matching uses
the exact executable name.
Export: a trailing separator names a folder. Telemetry cpu is measured from
telemetry start.
* installer: a recycled pid can no longer block installs; language change waits for the replacement process
An aside left by an earlier install counted as an active install whenever its
pid was live, so once that pid was reused every install was refused for good.
It now also has to be recent (ctime within 10 minutes), a pid we may not signal
is not ours, the refusal names the file, and an orphan that cannot be cleared is
reported instead of aborting the install.
setLanguage confirmed the relaunch with a matcher the dying process still
satisfied, so the reopen fallback never fired. It now waits for a pid that was
not running before.
* desktop: only the headline refreshes live; act, yield and the optimize scan move to slower tiers
An open window ran act report (a fresh CLI process each time), yield and an
optimize scan on every refresh. The headline poll now passes --no-optimize and
takes the snapshot path; act report refreshes on mount, on manual refresh and
every 10 minutes, yield every 5. The optimize scan runs at most once a day per
query scope, is stored in userData with its time, and is forced fresh by the
Optimize page or a manual refresh. Figures that are not live show their age,
and no stored scan means no savings clause rather than a zero.
On battery the live interval doubles; the chosen cadence stays the base.
* desktop: a stored optimize scan is only valid on the day it was computed; cadence changes no longer refetch
The scan cache is keyed on the argv, which says 'today' or '30days', not a date,
so yesterday's scan was served as today's. It is now reused only on the same
local day. Stored findings in Signals carry their age, a failed scan shows the
error instead of scanning forever, and the cold-boot label uses the time the app
received the payload.
usePolled fetched immediately whenever its interval changed, so every power
transition (and launching on battery) refetched every live hook and could spawn
an extra act report. Fetching and timer arming are now separate effects.
Optimize's yield is on the same 5 minute tier as Overview.
* desktop: re-ask for the optimize scan on the first render of a new local day
An app left open across midnight kept yesterday's stored savings under Today,
because the scan was only requested on mount, scope change or manual refresh.
The local day is now one of the hook's triggers; the previous day's figure is
dropped while the new scan runs.
* serve: a menubar poll is never answered from the status snapshot
Inside its settle window the status snapshot returns the pre-change payload on
purpose. The resident serve process then memoized that answer as clean, so after
a single write followed by quiet the headline stayed on the old figure until the
next write or the five minute memo cap. The snapshot is for one-shot runs; the
resident process already holds the parsed state, so it now skips it.
* tests stop writing the real serve pid file; superseded caches are swept; installer notes reach the desktop card
ServeConnection takes its pid file as a parameter, so the Swift tests no longer
share ~/.cache/codeburn/menubar-serve.pid with a running menu bar, and the
reaper only signals a record that is a serve --stdio command.
Superseded session-cache, codex-results and cursor-results files (100MB+ each)
are removed once untouched for 14 days, at most once a day. daily-cache and the
hermes ledger are never swept: they carry sealed days a version bump adopts.
The menu bar installer's leftover-copy advice is emitted as a marked line under
CODEBURN_PROGRESS and shown on the desktop card. The skipped-refresh NSLog is
debug only. The app's vitest workers turn off Node's built-in web storage.
* menubar: anonymous usage statistics, under the same consent as the desktop and the Windows tray
The Swift menu bar sent nothing, so every codeburn-menubar row was a Windows
tray. It now follows the desktop's telemetry.v1.json when that exists (read
only: same install id, same on/off, onboarded required) and otherwise keeps its
own state with the same region default (off in EU/EEA/UK/CH and when the region
is unknown). Settings gains one Privacy toggle, disabled when the desktop owns
the choice. Events use the tray's names; the daily usage snapshot is the CLI's
block passed through a sanitizer and is left to the desktop when it is the
consent source. Debug builds never send.
A resident CLI that gets disabled for the run now says so in the log.
* menubar statistics: private network session, abandoned batch on opt-out, a desktop No survives the desktop going away
The sender used URLSession.shared, so a server or CDN cookie and the default
User-Agent rode along and would have outlived an opt-out. It now owns an
ephemeral session with no cookies, no cache, a fixed agent string and no
redirects. A batch that is out when the toggle goes off is dropped rather than
re-queued under the fresh id. The desktop's answer is recorded locally, so a
missing or damaged state file never falls back to the region default, and the
standalone id is persisted when minted. The toggle stays usable as a local off
switch under desktop consent. Copy states what this app actually sends.
* vercel gateway spend is shown on its own row and kept out of totals by default
The gateway reports daily aggregates per model, with nothing to match against
the local tools that were pointed at it, so every headline counted that traffic
twice. Totals now leave the gateway slice out when all providers are shown; the
row keeps its full amount and is marked not in total, --provider vercel-gateway
is unchanged, and codeburn gateway-totals include folds it back in. The daily
cache still stores the slice, so the choice applies retroactively. Reports with
no gateway credential are byte-identical.
The gateway's request_count now feeds the call count instead of each daily row
counting as one call. Hermes: a mid-session price change lands on the day it is
observed and sealed days are never rewritten; stated in the code and changelog.
* gateway exclusion applies to every report through the parse, and the detail panels follow the headline
The first pass narrowed only the day totals, so By tool, top models, top
projects, activities, status json projects and any project-filtered table still
carried gateway spend next to a headline that did not, and By tool shares could
pass 100%. The rule now lives in parseAllSessions: with all providers in scope
and the opt-in off, aggregate-only sessions are dropped after the memo, and the
callers that derive cached days opt out so stored slices are unchanged.
day.projects is reduced like models. The gateway stays visible once, as a
labelled not-in-total row with no share, and standalone reports note the
excluded amount on stderr so machine output is unchanged.
* dashboard: the background history index follows the gateway exclusion too
The index keeps the whole corpus so it can fill the daily cache, but its
per-period projection handed that unfiltered list to the project, model and
activity panels, so they regained gateway spend once the index landed.
* ci: a hung lock suite fails in 90s and retries once with a warning; slow Windows tests get measured limits
The cache-lock step failed 6 of the last 60 runs with no output for five minutes
after the vitest banner; passing runs take 12-18s on every Node version. Each
attempt is now capped at 90s and retried once, with a warning whenever that
happens. On windows-latest a single CLI spawn takes 1.4-12s, so the four-spawn
status snapshot test and the c6 parser suite get limits taken from those runs,
each with its reason. Both test jobs print their ten slowest cases.
* desktop: Privacy & data uses the same rows as the rest of Settings
The pane mixed two static claims with two controls in its own layout. It now
uses the about-row pattern every other pane uses: title and hint on the left,
one control on the right. The claims became the subtitle. Rows: anonymous usage
statistics (toggle, link to what is sent), device sharing state with Manage,
local report snapshots with Clear, export with Open export.
* desktop: Privacy & data keeps one bounded column and rows that never squeeze
The card stretched with the window, stranding each control hundreds of pixels
from its text. This pane's heading, subtitle and card now share a 760px column
and one text measure. Rows stack on their own width, because the window can
never be narrow enough for the old viewport rule to fire; row buttons do not
wrap. The what-is-sent link sits on its own line, and the snapshot and export
descriptions fit their measure and say this machine.
* menubar tests clean up their scratch defaults and leave the real Keychain alone
Four test files never removed their UserDefaults suites and the rest removed the
domain but left the plist, so thousands of files piled up in
~/Library/Preferences. A shared helper forgets a suite and deletes its file, and
every test that makes a suite now uses it. cfprefsd still rewrites an empty plist
when the test process exits, so a run leaves about a hundred 42-byte files with
no test data in them. The real-Keychain round trip only runs with
CODEBURN_TEST_REAL_KEYCHAIN=1.
* desktop: the resident parser retires after 15 idle minutes and restarts on demand
The renderer stops polling while the window is hidden, but the serve child stayed
resident with its parsed cache, one to two GB on a large corpus. After 15 minutes
without a request it is stopped the way a settings restart stops it, without
spending the unexpected-death budget, and the next request starts a new resident
through the existing lazy path, never a one-shot. CODEBURN_SERVE_IDLE_MS tunes or
disables it.
* desktop: every settings pane keeps a bounded column, and dark buttons have an edge
Cards stretched with the window, so on a wide display controls sat up to 1300px
from their labels and intro lines ran past 140 characters. All panes now share
one left-aligned column (760px, 980px for projects, aliases and pricing, which
hold wide rows) and one text measure. Rows stack on their own width instead of a
viewport rule the 900px minimum window could never reach, and a long path in
Projects no longer overflows the pane.
In dark mode the secondary button matched or sat below its surface, and was
invisible on the plugin install dialog. It gets a face and a border from two new
tokens; light is unchanged. The onboarding consent label matches Settings.
* desktop: this device shows its fingerprint on its own line
The local name and the fingerprint are two adjacent inline spans, so they read as
one run of text.
* menubar: the resident parser retires after 15 idle minutes and restarts on demand
The menu bar kept its serve child, and the parsed cache with it, for the whole
run, even with the popover closed and nothing changing. After 15 minutes without
a request the child is retired without counting as a death, and the next request
starts a new resident through ensureStarted, never the one-shot fallback, whose
snapshot settle window would make the figure lag a whole tick. The window sits
above the longest gap between real requests, so a child stays warm while work is
happening. CodeBurnServeIdleSeconds tunes or disables it.
* serve: a poll re-checks only what the watcher saw change, and shard writes are coalesced
With a session being written, every poll re-listed about 2,400 directories and
re-fingerprinted about 6,000 transcripts, then rewrote the whole current-month
shard (49MB on a large corpus) and read it back on the next poll. The resident
process now reuses the previous listing and fingerprints for paths its root
watcher proves untouched, and falls back to the full sweep whenever the watcher
is missing, unhealthy, overflowed, armed after the sweep, does not cover the
path, the memo is older than 4.5 minutes, or the process was suspended. Shard
publication is held for up to 90s and flushed when stdin closes; a held write is
dropped rather than written over something another process published. One-shot
runs are unchanged. Active poll cost 0.71 to 0.33 CPU-s at a 30s cadence;
time-to-reflect unchanged at about 1.2s across append, new dir, new file, delete,
rename-over, in-flight, watcher overflow and unhealthy watcher.
Changelog: state the telemetry default as it is in this version.
* a retired parser gets to publish its held shards, and durable providers are never held
Both apps retired the serve child with SIGTERM before its stdin-close flush could
run, so every retire discarded the coalesced window. They now close stdin, give
the child a bounded grace to publish and exit, and signal it only if it is still
running; a settings restart does the same. Measured: flushed 0/5 before, 5/5
after, for both clients.
copilot's cache entries are the only record once Copilot prunes its own files, so
a held window that was dropped (retire, crash, kill -9) before the source was
pruned lost that spend for good. A window that a durable provider dirtied is
published on the poll that parsed it, as before coalescing existed. The RSS guard
publishes a held window before it clears the caches instead of discarding it.
* serve: release the lifetime parse after the fill, and make the collection real
The RSS guard dropped its memos and then called globalThis.gc, which exists only
under --expose-gc; no launcher passes it, so the collection had never run and the
freed pages never went back. On a cold start the resident idled at 2.2GB with
364MB of live heap: clearing every memo moved RSS by 0MB, a collection alone by
700MB.
After the background fill has sealed its summaries the process now publishes any
held shard window, drops the memos it can re-read from disk (the cold start had
loaded the cache with scope 'all', which then served every later poll), and
collects; it collects again a minute later for as long as a pass returns 64MB,
at most four times. Cold idle 2198MB to 700MB, warm 900MB to 781MB. Reports are
leaf-identical cold and warm, time-to-reflect unchanged, a released month scope
reloads in 0.3-0.5s.
* warp: the billed cost reaches reports and survives the cache
The provider derived a dollar cost from Warp's own billing record (provider cents,
charged usage, or credits), but warp was missing from the reported-cost
pass-through, so the cached call kept no cost and every report, cold or warm,
priced the conversation from its tokens. A fixture billed at $1.22 reported
$0.15. A call whose cost came from a billing record now keeps it; a call that
only has the token floor stores none and is still re-priced when prices change.
Warp entries re-parse once (provider parse version), and the daily cache moves to
v34 so days whose Warp source still exists re-derive; call counts do not move, so
nothing is double counted or dropped.
* openclaw and pi: a reported cost survives the cache like every other reported cost
Both providers read the cost their tool recorded for a message, and both were
missing from the reported-cost pass-through, so reports priced every call from
tokens instead. Fixtures reported $0.011 against $0.056 (openclaw) and $0.013
against $0.410 (pi). A call with a reported cost now keeps it; a call with none,
or an explicit zero, stores none and is still re-priced when prices change. omp
had been added to the list in #1150 and pi, the same parser, was never revisited.
The provider list is one named set, the unreachable providerCallToTurn is gone,
and both providers re-parse once; the v34 daily cache re-derives their days with
call counts unchanged.
* test: pin parse count per report and per resident poll
Fresh installs now default to the light theme; an explicit theme choice is still honored. Type and spacing move one step up (larger body, titles, sidebar rows and page padding), sidebar shortcut chips move to hover tooltips, and primary text gets full contrast in both themes.
The Plugins page shows the Menu bar and Teams plugins as two cards side by side with embossed background art. The Menu bar card carries the Capacity Dock switch and Settings, Quit and Uninstall as icon buttons with tooltips, an info modal describing the menu bar and the Capacity Dock with the real supported-provider list, and it can install, update, open, quit and uninstall the menu bar app, versioned so an older one shows Update, with a Settings action that opens the menu bar app's own settings. The Teams card shows what is planned in an info modal and a Register for beta testing link.
The Plans page is redesigned as provider cards in a responsive grid, each with its own logo embossed. Connection status is now stable: a single empty or slow poll no longer flips a connected provider to disconnected, a connected provider keeps its quota bars through a transient refresh, and a stuck waiting resolves to an actionable state after twenty seconds. A login-expired error offers Connect, and Gemini's expired login is now a terminal actionable state instead of an endless wait, matching the menu bar.
CLI report, models and menubar-json numbers are identical to the cent before and after; only the Gemini quota error string changes.
The Projects pane's list is `report --format json`, the heaviest read in
the app, and report was in neither routing table: not in SERVE_ROUTED on
the client, not in SERVE_OPTIONS on the server. Every open of the pane
paid a fresh node boot plus the full session-cache parse the resident
child exists to avoid, at interactive priority, holding one of the two
run slots for its whole duration and stalling the panels polling behind
it.
report is servable in its JSON form only: on every other format it is the
interactive dashboard, which cannot run in a child whose stdout is the
wire. allowed() refuses those and the client falls back to a one-shot
spawn, which is where a dashboard belongs. --refresh only paces that
dashboard, so it stays off the served surface.
taskkill only ran for children spawned through the .cmd shim, and the
shipped desktop build runs the bundled CLI through Electron's node, so
it never ran at all and provider subprocesses were orphaned on every
stop. On the shim route it was also fire-and-forget followed by killing
the top of the tree, which reparents the children before taskkill can
find them. Every Windows child now goes through taskkill /T /F from
System32 and the handle is only ended if taskkill fails.
Windows has no shebang. CreateProcess starts executables, and both shapes a
codeburn install takes here are scripts: dist/cli.js in dev, and the codeburn.cmd
that a global npm install puts on PATH. Spawning either with shell: false is
EFTYPE, so nothing the desktop app ran ever ran on this platform. The throw came
out of startServe inside whenReady and took the rest of it with it, which is why
`npm run dev` never even opened a window, and why 49 of this file's own tests
failed here.
Each script now gets a runner that can start it. A .js goes through this
process's own binary as Node, the same shape the bundled CLI already used. A .cmd
goes through cmd.exe as one command line, because that is the only way to start
a batch file, and the line is built rather than handed to shell: true, which
does not escape anything: two parsers read it, cmd first and then whatever splits
a command line back into argv, and a .cmd re-expands its own arguments through
%*, so the arguments are escaped for both readings. That is cross-spawn's rule,
restated here rather than depended on, since the main process ships no npm
dependencies. An .exe and a POSIX codeburn are spawned exactly as before.
Two things follow from the cmd.exe route. The PATH lookup asks for codeburn.cmd
before bare codeburn, because npm writes both into the same directory and the
bare one is a shell script Windows cannot start. And killing such a child means
killing the tree: the shim exits as soon as it has started node, so the handle we
hold is the shim's, and taskkill /T is what reaches the generation below.
Five tests do not run on Windows now rather than failing there. They assert
catchable SIGTERM: a grace window, a cleanup handler, a child that ignores the
first signal, a resident closing its own stdin. child.kill on Windows is
TerminateProcess, so those are things the platform does not have rather than
things this code gets wrong, and the POSIX expectations are untouched. Three
assertions that hard-coded ":" as the PATH separator now use the platform's.
Installing the desktop app should give a person the tray icon and the Capacity
Dock without their ever learning that `codeburn menubar` exists. So it carries
the matching windows-v* build and puts it there itself, at first launch and at
every launch after, which is also what covers a desktop-app update: the staged
.msi travels with the app.
The install itself is not reimplemented here. The CLI already owns the registry
read, the checksum, the msiexec call and the never-downgrade rule, so this side
stages the file, names it in CODEBURN_MENUBAR_MSI and reads the one JSON line
that comes back. What it does own is the tray app's process and its Run value,
because the two sidebar switches have to be able to stop and start it: the tray
app is reached through its own argv, --quit to stop and --reload-settings to
make it re-read the dock preference this module writes.
The Store route shares almost none of that. msiexec is not available to a
packaged app and would fight the package manager anyway, so the tray exe ships
inside the package and is launched from there, and no Run value is written
because the manifest's own startup task covers launch at login.
Two rules worth naming. The defaults are seeded once, so a person who later
turns the rail off in the tray app's own settings does not find it back on at
the next launch of this one. And every setter answers with the whole status
rather than an acknowledgement, so a switch shows what took: an install
cancelled at the UAC prompt leaves it where it was.
Each surface holds its OWN serve child: the Swift menubar (mac/), GNOME and
Windows clients spawn `codeburn serve --stdio` too, and their payload
decoders ignore an unknown key — so they would have shown a partial total
with no way to say so. The paint is now opt-in via
CODEBURN_SERVE_PROGRESSIVE, which the Electron app sets on its own serve
child because its renderer shows the indexing banner. Unset means the child
answers only with complete data, so no surface can present partial totals
unlabelled.
G1a - the stdin-EOF drain is now bounded (Promise.race against 45s, overridable
via CODEBURN_SERVE_DRAIN_MS for the test). Generous enough that no legitimate
request answered at EOF comes near it, and an async-wedged one releases the
child instead of turning it into the orphan the drain was added to prevent. The
comment names the ceiling: a SYNCHRONOUSLY wedged parse never yields to this
timer or to any other JS path, and only a signal can end that process.
G1b - Windows is the majority of the desktop fleet and had no orphan escape at
all: no ps, and no stdin-close recourse once the app has crashed. The reap now
works there. tasklist cannot report argv (image name and window title only) and
wmic is gone from current Windows, so the command line comes from
Get-CimInstance Win32_Process. Match discipline is unchanged - exact argv - with
quoting normalized on both sides, because we record a plain space-joined argv
while Windows reports the real quoted command line; a path with spaces still
round-trips. serveCommandMatches is extracted and tested directly.
G2 - the grace test raced node's cold boot (a 300ms window). The child now emits
one byte the moment it is ready, so the watchdog window is measured from
READINESS, not from spawn: boot cost cannot eat it at any machine speed. Timeout
raised on top of that. 6/6 green under 8 busy loops.
G3 - a served request heartbeats for its whole duration now, not just its parse:
the wrap moved to the one seam (runCaptured) that also covers aggregation and
payload serialization, whose ~8s tail lands back-to-back with the parse's own
quiet stretches. The one-shot path keeps the parse-only wrap, where an 8s tail
is comfortably inside the window on its own, and the CHANGELOG now says exactly
that instead of claiming every phase.
G4 - stillCold() anchors to coldStartBegan ?? bootedAt.
Text: the cold-timeout bound no longer claims to restore the "locate the CLI"
recovery (that is a not-found state, never cold-flagged), and the CHANGELOG
states the >15min case honestly - the ceiling still ends it, but partial cache
saves mean successive polls converge rather than each rescanning from nothing.
F1 - `codeburn serve` lost an in-flight request's response when stdin closed
mid-flight: runStdioServe returned before its queue drained, and the explicit
process.exit(0) then hit runCaptured's monkeypatched exit, throwing ExitSignal
and exiting 1 with the frame never written. The finally now awaits the queue.
F2 - the SIGTERM rationale was false. armSignalCleanup unlinks the refresh lock
and re-raises; it publishes no partial cache, and a lock left by SIGKILL already
self-heals through the stale-pid takeover. SIGTERM-first is kept for the real
(smaller) benefit - a clean lock release instead of a takeover - and every
comment plus the CHANGELOG now says only that.
F3 - the cold gate had no exit. overviewWarmed only flips on success, so an
install that can never hydrate sat behind an indexing splash forever with no
error and no route to the CLI recovery. The cold claim now expires with the cold
window itself.
F4 - the real CLI does not heartbeat the way the demo did: a cold parse's
inter-provider cache save measured 31.6s of total silence, which a 45s window
survives only until the machine is 1.5x slower. Under CODEBURN_PROGRESS a
running parse now emits a keepalive every 10s regardless of phase, so silence
genuinely means stopped. Consumers that do not know the event ignore it.
F5 - the orphan-reap identity check matched any `cli.js` running `serve`. The
pidfile now records the exact argv and `ps -ww` must match it exactly.
F6 - bump() re-armed the watchdog after settle, leaving a timer finish() never
clears when a killed child's buffered output landed.
F7 - kill paths dropped the child from activeChildren before SIGTERM and the
SIGKILL backstop was unref'd, so a quit inside the 5s grace orphaned a child
that ignores SIGTERM. It now stays registered until it actually dies.
N8 - the silence test wrote its only byte at t~0, so it passed without the
re-arm. The byte now lands mid-window and the kill is asserted from it.
N9 - documented why mutations keep a plain total cap.
A settings mutation replaces the resident serve child, which may be mid-write:
the same lock hazard a timed-out read has, so it gets the same treatment.
restartAfterMutation and destroy now go through killGracefully instead of
SIGKILL. reapAll's one-shot loop stays a hard kill and now says why - quit has a
1.5s flush budget, shorter than the grace, so waiting one out would wedge the
quit, and a one-shot read holds no lock worth releasing.
The desktop app capped every read at 45s of TOTAL runtime, so a slow machine's
warm `optimize` (52.5s on 0.9.20, 39.6s on main) was SIGKILLed mid-parse and the
panel painted a red "timed out" that a 60s poll then reproduced forever.
- cli.ts: the read timeout is now a no-output watchdog. The window restarts on
every byte the child writes (serve: every frame carrying that request's id),
so only a genuinely silent child times out. A 15-minute absolute ceiling still
reaps a livelocked child. Every read spawn sets CODEBURN_PROGRESS=1 so a long
parse heartbeats through it; progress lines are stripped from any surfaced
stderr so they can never become the error message.
- cli.ts: a timed-out child gets SIGTERM first and SIGKILL only after a 5s
grace, on both the spawn and the serve branch. That lets the CLI's signal
cleanup publish its partial parse and release the cross-process refresh lock
instead of leaving both stale.
- main.ts: the 10-minute cold floor now covers EVERY read while the first
hydration is still running, not just the overview. Sections start polling the
moment `ready` flips (which an overview error also does), and a 45s section
spawn queued behind the cold parse was killed on arrival.
- main.ts/renderer: a timeout raised while the hydration is still running is
flagged `cold`, and the renderer keeps its indexing splash (and keeps the
sections gated) instead of latching ready and painting CliErrorPanel. Only
timeouts are flagged; a permission or nonzero failure still surfaces.
- cli.ts/main.ts: the resident serve child's pid is recorded under userData and
a serve orphaned by a previous crash is reaped at launch, after `ps` confirms
the pid still belongs to a codeburn serve.
- src/main.ts: `codeburn serve` exits explicitly once its stdin closes, so a
handle outliving the transport cannot keep the child alive as an orphan.
Clear the per-directory Codex and Antigravity memo maps in the resident RSS
guard; document the single cache-dir rule (XDG_CACHE_HOME no longer
consulted, ledger migrated); stop output-overflow terminations from spending
the resident's unexpected-death budget.
Every CLI spawn on a large corpus pays seconds of fixed cost before any
query work: node boot, a 100MB+ session-cache JSON.parse, the discovery +
fingerprint sweep, and serve-time classification. The desktop app spawns
one CLI per panel fetch, so it pays that cost per panel.
codeburn serve --stdio is the same CLI kept warm: the app holds one child,
sends {id, args} per line, and gets the command's stdout back. Three layers
make it fast, each disabled outside serve so one-shot runs stay byte-exact:
- loadCache memo (session-cache.ts): the parsed cache object is reused
while a stat() shows the file unchanged; saveCache updates it
write-through. A rewrite by another process still forces a fresh read.
- burst reuse (parser.ts, CODEBURN_PARSE_BURST_MS, serve sets 10s): panel
bursts anchor their range ends at their own new Date(), so the exact-key
memo never hits in real traffic; within the window a re-anchored range is
served by trimming the previous parse instead of re-running discovery.
- fresh commander program per request (main.ts buildProgram factory),
because commander option state is sticky across parses.
The server allows only the app's read queries (status/overview/models/
sessions/compare/yield/spend/optimize/audit), refuses everything else
(client falls back to a spawn), serializes requests, and converts
process.exit into a caught signal. The app starts the child once at
startup; requests route through it only when warm, cold-start keeps the
spawn path with its progress events, any serve failure falls back to a
spawn, and three child deaths disable serve for the app run.
Measured on a real 17B-token corpus: panel fetches drop from ~7.4s per
spawn to 5-900ms warm (sessions/spend 5ms, status 898ms). One-shot CLI
output verified byte-identical against the pre-branch baseline.
* test(electron): isolate dev-resolution fixtures via CODEBURN_DEV_REPO_ROOT
Rework of the #681 fix per review: instead of stubbing the real repo
dist/cli.js with snapshot-restore, the Vite-dev resolution branch gains a
CODEBURN_DEV_REPO_ROOT override (matching the CODEBURN_PATH_DIRS precedent)
so the whole fixture lives in a per-test tmpdir. With the env unset the
resolution order is byte-identical to before.
- covers all three fresh-clone failures on current main, including the
resolveTarget dev-repo-beats-bundled case
- deletes the snapshot-restore machinery, the parent-path precondition and
the beforeAll repo-scanning guard
- tests assert both override-set (tmpdir wins) and override-unset behavior
Closes#681
* test(electron): drop environment-coupled null assertion for unset dev-root override
The override-unset case resolved <repo>/dist/cli.js whenever a build had
run, so the toBeNull() assertion depended on the absence of a gitignored
artifact. The override-set tests already prove the new branch and the
non-Vite test covers the null path.
path.startsWith('/') POSIX guard rejected the Windows bundled-CLI path
(C:\...\launch.js), so 100% of Windows installs got not-found
(confirmed in telemetry: 84 events, all win32/x64). Use path.isAbsolute
everywhere. cli_error now carries {cmd, kind, detail?} for
self-diagnosing errors, never paths/args/stderr.
Root cause of the field-reported 45s timeout + perpetual slowness: on
a cold cache all six sections spawned different CLI subcommands at
once, each running its own full-history parse (~11-31s, ~3GB RSS),
contending past the 45s kill so the rebuilt cache never persisted and
every poll restarted from zero.
- the first overview fetch runs as a warmup: 10-minute timeout,
re-arms until it succeeds, reverts to 45s after; section polls gate
on that first resolution (usePolled gains enabled), so cold
hydration happens exactly once
- warmup streams the CLI's progress protocol; the splash shows 'First
run: indexing your usage history' with a per-provider ingest list
(logo, live counts, check on done), static under reduced motion,
generic fallback without events; warm launches unchanged
- overview spawns pass --no-timeline (desktop never renders it)
Measured on real data: cold 31s once (was: killed at 45s forever),
warm 2.6s. 295/295 app, 1797 root.
The packaged app resolved whatever codeburn was on PATH — for real
users the published npm release, which predates every JSON surface the
app calls. The app now ships its own CLI copy under resources/cli
(staged production node_modules tree; tsup output is not
self-contained) and spawns it with Electron's own binary via
ELECTRON_RUN_AS_NODE. No install prerequisite remains.
- resolution order: CODEBURN_BIN, dev repo CLI, bundled, persisted
path, PATH search
- launch.js shim strips the extra argv element commander mis-slices
under packaged Electron-as-node (process.versions.electron is set),
which reproduced the 'too many arguments' class of error
- afterPack hook copies the staged tree (extraResources runs
node_modules through the production-dep filter and ships it empty);
lands before signing, signature verified intact
- packaging always restages from src via root build:cli (tsup only,
no network); ~12MB compressed per artifact
Verified on the built app: Electron-as-node CLI emits current JSON
(providerDetails, currency), and a minimal-PATH GUI launch spawns the
bundled CLI with zero external dependencies. 290/290.
GUI-launched apps inherit a minimal PATH without the user's node
install, so spawning the codeburn npm shim (#!/usr/bin/env node) failed
with 'env: node: No such file or directory' in packaged builds. Spawn
now augments PATH with the resolved binary's own directory (node sits
beside the shim in nvm, Homebrew, and npm-prefix layouts) plus the
resolver's search dirs. Dev never hit this because the terminal PATH
was inherited.
- will-navigate/window-open denied; openExternal restricted to http(s);
production CSP drops 'unsafe-inline' scripts (dev-only Vite plugin
re-adds it for the Fast Refresh preamble)
- single-instance lock; before-quit reaps in-flight CLI children; 16MB
output cap rejects with CliError 'too-large'
- read-only CLI spawns coalesce: concurrent identical calls share one
child, a 5s result cache absorbs same-cadence pollers (six sections
polled getOverview independently); actions bypass and flush the cache
on completion so post-action refetches are fresh
- every renderer-supplied argv string validated (period/provider/range/
currency allowlists, no flag-shaped tokens, absolute export path) with
new CliError kind 'bad-args'; envelope + action stderr routed through
sanitizeError
- quota force-refresh: manual refresh (refreshToken) passes force which
invalidates QuotaService's cache; steady polls do not
- POSIX credential mode check gated off on win32
App suite 190/190, build green (production index.html verified strict).
Settings was a dead shell (rail didn't switch, all buttons disabled).
Now the rail navigates and four tabs are wired to the real CLI:
- action bridge: spawnCliAction + runAction (text output, not JSON) with
getAliases/getProxyPaths reads and setCurrency/resetCurrency/addAlias/
removeAlias actions
- General: theme switcher (System/Light/Dark, persisted) + live currency
(reads status, changes via `currency`) + default-period preference
- Providers: real detected-tools list with spend
- Model aliases: full add/remove against real config
- Privacy: honest local-only info
Devices content preserved as-is; Plans/Export are placeholders for Part 2.
The dev-mode repo dist/cli.js fallback ran AFTER the persisted-path file,
so a stale globally-installed codeburn (e.g. a Homebrew build lacking the
new sessions/compare commands) shadowed the repo build and every Sessions
call errored. Move the Vite-dev fallback ahead of the persisted-path
lookup; CODEBURN_BIN still overrides, and production (no VITE_DEV_SERVER_URL)
is unchanged.
Adds getSessions / getCompareModels / getCompare IPC channels (argv for
the new CLI JSON emitters), mirrors SessionRow + compare-stats types into
the renderer, and — in Vite dev — resolves the repo's own dist/cli.js so
newly-added commands work without setting CODEBURN_BIN.
Review of T0 (Electron scaffold) approved with one Important finding + cheap
Minors. All fixes scoped to app/.
- usePolled: replace per-call cancel closure with a generation/epoch counter so
an orphaned in-flight fetch (from refresh() or an interval tick, discarded
cancel handle) can't resolve after a newer fetch and clobber fresh data. New
TDD race test (slow deps-A resolves after fast deps-B; keeps B).
- cli: nvm resolution now scans version dirs descending and takes the first
whose bin actually contains codeburn (was lexicographic max, unverified),
matching CodeburnCLI.swift. Export nodeManagerDirs + hermetic nvm test.
- index.html: tighten CSP connect-src ws: -> ws://localhost:5173.
- preload: import type { Envelope } from main instead of redeclaring it.
- main.test: table-driven channel->argv assertion over all 9 codeburn:* channels
plus a keys check and a non-spawning cliStatus case.