* feat(desktop): one-click updates through electron-updater on the update-feeds release
* feat(tray): signed one-click updates through tauri-plugin-updater; feed publishing and release docs
* ci(tray): build without the updater key until it is configured
* chore(windows): set the tray updater public key
* ci(windows): skip updater signing in the release build smoke
* ci(windows): build without updater artifacts when no signing key is available
* ci(windows): turn off updater artifacts through TAURI_CONFIG, not CLI flags pwsh mangles
* ci(windows): run tauri build under bash so --no-sign reaches the CLI (pwsh drops the -- separator)
* feat(app): transient project selector in the desktop top bar
Scopes every report to one project path for the current session through
the same narrowing the VS Code workspace scope uses. The saved project
filter is never written; a restart is back on all projects.
* fix(app): let the name pickers ellipsize before the top bar overflows
* fix(app): list only selectable, non-zero projects in the top bar picker
Rows with no session cwd (the Cursor and Grok Bot imports, provider label
fallbacks) report their label as the path, so the picker offered names the
bridge refuses and dash-to-slash guesses like eywa/lab. Keep only absolute
paths, and drop rows whose lifetime cost rounds to $0.00. The old test that
rooted a slashless Codex path is replaced: Codex now records the absolute cwd,
and a slashless path is always the label fallback.
* fix(app): show a toast when a project pick is refused
The pick was dropped silently. Also pin that an encoded -Users-... label
never reaches the picker list.
* 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.
* feat(cursor): show the cursor.com sync in the apps, with an off switch
status --format menubar-json gains an additive cursorSync block (enabled,
state, lastSuccessAt, and a fixed errorCode/error on failure), read from
the sync sidecar, the config and the Cursor app's login. It never syncs,
never makes a request and never carries the token, the account hash or
server text. It is absent when Cursor is not signed in and has never
synced, and it is attached after the snapshot and per-device payloads.
With the Cursor provider selected, the desktop overview, the macOS menu
bar and the Windows tray show one line under the total: "Synced from
cursor.com 12 min ago", or the error in a muted warning colour. A
"Sync Cursor usage from cursor.com" switch writes config cursorSync:
Settings > Providers > Cursor on the desktop, the Cursor settings pane
on macOS and Windows. Desktop and mac strings are in all six locales;
the tray stays English until its localization lands.
* fix(cursor): guard config writes, show the env override in the switches
The tray's shared config writer now refuses to write over a config.json
that does not parse or is not an object (it used to replace it with an
empty map plus the one key), which also protects the currency, budget
and Claude config directory writers. The desktop writer rejects null
and array configs the same way.
With CODEBURN_CURSOR_SYNC=0 set, the desktop, mac and tray switches show
"Turned off by CODEBURN_CURSOR_SYNC=0" and are disabled. A failed mac
save now says so under the switch. The tray hero note uses the warning
ink and maps errorCode to its own messages.
* 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
* feat(windows): configure the menu bar and Capacity Dock as a Plugins card
Windows now configures its tray companion from a card on the Plugins page, the
same place macOS does, instead of two switches in the sidebar corner.
- WindowsCompanionCard mirrors the macOS MenuBarCard: Install/Reinstall,
Update, Open (show the tray popover), Settings (opens the desktop Menu bar
settings pane), Quit (stop without uninstalling), Uninstall, plus the
Capacity Dock toggle, a Running dot, the version, and an info popup.
- MenubarCompanion.status() now reports installed/running/version/outdated/
canInstall, and new discrete install/open/quit/uninstall actions back the
card. uninstall only forgets the tray once the CLI removal succeeds, and a
cancelled elevation prompt is treated as a no-op rather than a failure.
- isNewerVersion sorts a dev build below any release, so a staged release still
lights Update and a dev build never does.
- codeburn menubar --uninstall reads the product code from the install marker
or the uninstall registry and runs msiexec /x.
- Remove the bespoke sidebar CompanionSwitches and their dead CSS.
- A dev-only CODEBURN_MENUBAR_RESOURCES override lets the tray install run
without packaging; the CLI still validates the staged path in full.
* feat(desktop): fold the Capacity Dock settings into the Menu bar pane
One tray rail entry instead of two: MenuBarPane and CapacityDockPane render
together in the Menu bar pane with a clear gap between the two cards, so the
card's Settings button opens both. Drops the now-unreachable 'dock' pane value.
* fix(desktop): stop the Spend cost-flow Sankey from re-rendering forever
The label-fit effect measured the already-truncated text, so a capped label
read as short, un-capped, overflowed the cap, and re-capped without end
(Maximum update depth exceeded). It now estimates each label from its full
name via data-name, and caps only tighten per layout, so the measure, cap and
remeasure cycle is guaranteed to settle instead of oscillating.
* feat(desktop): bundle Inter and JetBrains Mono for one UI on every OS
The app used each platform's system font (SF Pro on macOS, Segoe UI on
Windows), so the two looked different. Bundle Inter for text and JetBrains
Mono for figures and lead the --sans and --mono stacks with them, keeping the
per-locale CJK fallbacks, so Windows and macOS render the same faces.
* desktop: gate tray installed state on support; drop strings and channels the sidebar switches left behind
The desktop app and the macOS menu bar can now run in English, French, Japanese, Korean, Simplified Chinese and Traditional Chinese. A Language setting in the desktop Settings picks the language or follows the system, and the choice is shared so the menu bar switches with it, relaunching to pick up the language with a retry so a switch never leaves it down.
Every user-facing string in the desktop renderer goes through a local lookup table, 1,249 keys per language, no framework and no network. The menu bar gains four .lproj to join English and Simplified Chinese, 626 keys each. A completeness gate fails the build if any language is missing a key, in both the desktop catalogs and the menu bar coverage suite. Fonts fall back per language so Japanese, Korean and both Chinese render their correct glyph forms, and dates and numbers format for the locale with values unchanged. English output is byte-identical.
Strings produced by the CLI engine stay English until the CLI is localized, a separate effort. Validated live in all six languages: no missing text, no clipping, correct fonts, numbers unchanged, and the menu bar switching for every language.
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.
Desktop cohorts mode talks to the new cohort-json format through two
typed bridge channels (facets + report) with argv validation for models,
repeatable project patterns, and categories. The section keeps the
classic mode side by side; the cohorts view shows the population before
any metric (included observations, distinct sessions, excluded mixed-
model turns with their combined cost, unknown-cost count), an Inspect
samples list of the declared population through a minimal navigation
adapter (sampleNavigation.ts), cost median/P90/mean with a compact
histogram whose top bucket is open-ended, one-shot/retry rates over the
declared population, and token volume with input + cache-read labeled as
a context proxy. The volume band recomputes renderer-side over the
report's own observation list (cohortStats.ts mirrors the core
convention, pinned to the same test vector), excludes and counts
observations without a token measure, and never reads missing data as
small. CODEBURN_USER_DATA_DIR opts into an isolated Electron profile so
parallel dev runs get their own userData/serve.pid.
Two bridge channels follow the existing envelope + argv-validation pattern:
codeburn:getPeriodCompare spawns compare-periods --format json with two
REQUIRED local YYYY-MM-DD range pairs (validated per range), and
codeburn:getPeriodCompareSessions spawns the sessions drill-down for one
project/model contribution key. Contribution keys ride the --key VALUE
position, so a dash-leading slug (Claude sanitizes /work/pricing to
-work-pricing) passes vContributionKey where vToken would wrongly reject it.
Preload and the renderer CodeburnBridge types mirror the two calls.
New getBranchSpend bridge channel (codeburn:getBranchSpend) spawning
'spend --format branch-json' with the same period/provider/range flags and
validation as the other first-click reports, plus background-priority
prefetch handling. The renderer keeps the typed contract in lib/types.ts.
BranchBreakdown panel: project picker (auto top project, explicit All
projects view labeled project/branch), expandable branch rows with token
components, worktree evidence and per-session contributions, a real session
inspect detail, and the coverage note (known / Unknown / no-branch-data,
identity-based distinct sessions). Reuses ListRow/Panel/Dropdown/stale-safe
usePolled memo keys; selection is derived so a refresh or a vanished project
falls back instead of mixing data.
Every report grows real entries: Overview day bars, expensive sessions
(provider+id matched), model names, task categories, Spend projects,
and PR cards. The Sessions destination renders the selection chips with
per-chip removal and Clear, fetches the contributions report once per
population (widened to cover the selected days so a day outside the top-bar
period still reconciles), computes every chip combination client-side over
the full population, and shows each row's contribution beside its full
cost. Row clicks open the side drawer (Escape closes, focus returns); the
top bar gains Back/Forward (Cmd+[ / Cmd+] on macOS, Alt+arrows elsewhere)
over the in-app history; the position survives a renderer restart. IPC
gains getSessionsContributions with the same argv validation as the plain
report.
getUnfilteredProjects passed the period on screen, and the CLI builds
that list from the days the period covers. A project filter has no
period: it scopes every screen and every horizon at once. So an exclude
pattern for a project with no activity in the current period rendered as
"matches nothing detected" next to a Remove button, while the footer read
"Nothing is hidden." Clicking Remove there unhid the project on every
screen, from a control that had just called the pattern dead.
The list is lifetime now, and the period argument is gone from the
channel rather than left for a caller to get wrong. `all` is capped at
six months, so lifetime is the only horizon that can answer for the whole
filter.
The pane's memo key goes through reportMemoKey like every other pane, and
the fetch is off the poll interval: it is the heaviest read in the app, a
toggle cannot change an unfiltered list, and the only thing that can is a
project appearing, which the next open picks up.
Screen-sharing or demoing the app exposed every project on the machine,
with no way to narrow it. Settings now has a Projects pane: one switch
per detected project, plus a field for hiding a pattern that matches
nothing detected yet.
The filter lives in ~/.config/codeburn/app-filter.json rather than
renderer storage, because the main process is where the CLI argv is
assembled. No file, or empty lists, means no filter, so the default is
unchanged for everyone who never opens the pane. CODEBURN_APP_FILTER
overrides the path and an empty value disables it, which is what the app
test suite sets so a real filter file cannot reach the assertions.
Details worth knowing:
- The pane's own project list is fetched WITHOUT the filter. Scoping it
would hide the projects the checklist exists to switch back on.
- Patterns are passed as --project=<value>, never as two argv entries. A
Claude project directory is encoded as "-Users-me-Web-thing", and a
leading dash in its own argv slot parses as a flag.
- Combined scope and a project filter cannot both hold. The CLI rejects
--scope combined next to --project/--exclude because paired devices
report their usage unfiltered, and dropping the filter instead would
put the hidden projects back inside the combined total, which is the
one thing the pane exists to prevent. So the filter wins: Settings
offers Local only while something is hidden (and says why), the
renderer derives its effective scope from the filter so even the first
poll of a filtered session is local, and buildOverviewArgs keeps the
same rule as a guard for a stale caller.
- A read that fails on an existing file keeps the last known filter
instead of falling back to the empty one. Unhiding every project on a
torn write or a typo mid-edit is the one failure mode this pane cannot
have. Only a missing file means "show everything" again.
- The pane's matcher lives in renderer/lib/projectMatch.ts, and
projectMatch.test.ts pins it against the CLI's matchesProjectPattern
over every pattern shape. A switch that disagrees with the argv it
writes would lie about what is on screen.
- Writes are serialized: every write is computed from the filter on
screen, so a second click before the first landed would be built from
the pre-click value and drop it.
- export carries the filter too, since it writes project names into a
file that gets shared. getPlans stays unfiltered, like combined scope:
a plan is billed on every project, so scoping it would misreport the
cycle.
The tray app keeps its preferences in two files it reads from Rust before any of
its windows exist, so those files are the interface rather than any running
process: windows-settings.json for what only it cares about, windows-dock.json
for the Capacity Dock. The desktop app can now read and write both, which is
what lets its Settings carry the tray app's own panes.
Every write is a merge. Both files hold keys this side has no business touching,
the rail's placement above all, and a whole-file write would take them with it.
And every value is checked against the same closed set the tray app parses it
with: a value it cannot read collapses to a default there, so sending one would
silently lose the setting rather than store it. The check is also what keeps a
renderer from reaching a key it was not offered.
Two rules come with the dock file. Writing the provider set latches
manualSelection, because editing the set by hand is what stops the tray app
seeding it from whatever is connected. And a set that no longer holds the
resting provider moves it, since the rail can only rest on a row it is showing.
Showing the rail is the Sidebar switch under another name, so it goes through
that path rather than straight to the file, and keeps the rule that the rail
cannot outlive the tray app. Launch at login is the one setting that is not in
either file: it is the Run value, which this app already owns.
After every write the running tray app is nudged with --reload-settings, which
makes it re-read both files and broadcast to its own windows. With the tray app
off there is nothing to tell and the files are enough.
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.
Part 1: Add --json output flags to three CLI commands for machine-readable output
- codeburn plugin list --json: array of plugin objects with status and capabilities
- codeburn plugin info <name> --json: manifest with dir and onDiskSections
- codeburn sync auto status --json: status object with configured, accepted, killed, receipts
Part 2: Implement Electron bridge with read and mutation handlers
- Read handlers: pluginList, pluginInfo, syncAutoStatus (return parsed JSON)
- Mutation handlers: pluginAdd, pluginRemove, pluginVerify, syncAutoEnable, syncAutoDisable
- All handlers follow existing Envelope pattern with proper error handling
- Special case: syncAutoEnable captures disclosure text when accept=false
Part 3: Renderer UI components (minimal implementation D1-D3 shell)
- Add Plugins section to nav rail at bottom (quiet entry, plugin icon)
- Implement PluginsSection component with list rendering for loaded/rejected plugins
- Update Section type and routing to include plugins navigation
- Add test coverage for Plugins panel row states
- Implemented as dormant Team tab registry placeholder for future plugin sections
Tests: All CLI tests (3839 passed), app tests (613 passed), E2E gates all passing
- CLI: tsc clean, vitest passes with 26 pre-existing jsdom errors
- App: typecheck clean, npm test green, npm run build succeeds
Extends the existing borrowed-credential quota module (claude/codex) with
three providers, per-provider settings toggles, and a provider display map:
- gemini: Code Assist loadCodeAssist/retrieveUserQuota via the Gemini CLI's
on-disk OAuth creds; optional env-gated token refresh; retired-tier
responses degrade to terminalFailure with migration guidance
- copilot: copilot_internal/user with editor-plugin headers; token read-only
from hosts.json/apps.json with one re-read on 401; marked as an internal
API that may drift, failures degrade honestly
- antigravity: loopback-only Connect-RPC probe of the local language server
(ps/lsof discovery via execFile, csrf token for the app variant, agy CLI
accepted tokenless); TLS relaxation scoped to 127.0.0.1; no OAuth fallback
- settings: per-provider quota switches (default on) persisted renderer-side,
honored in the main-process service so disabled fetchers never run; IPC
disabled-list filtered against the provider allowlist
- sanitizeError extended for Google (ya29.) and GitHub (gh*_) token shapes
Kimi audited, not duplicated: the macOS menubar already fetches Kimi quota
live (KimiSubscriptionService.swift); the electron app not surfacing it is a
pre-existing gap, unchanged here.
The release's new capabilities were unevenly surfaced: the Spend
punchcard existed only on the web dashboard, and the menubar decoded
nothing of the PR-attribution block the payload already carries.
- Desktop app: new Punchcard component (hour-of-day x weekday spend
matrix, ported from the dash and restyled to the app's tokens) on the
Spend page. Fed by a dedicated getTimeline bridge channel that fetches
the payload WITH history.timeline; every other fetch keeps the lean
--no-timeline path, and the serve child makes the extra fetch cheap.
Hides gracefully when the payload has no timeline (older CLI).
- Menubar: PullRequestsSection renders the top three PRs by attributed
spend under the Workflow strip; MenubarPayload now decodes the
pullRequests block (decodeIfPresent, so older payloads are unchanged).
Hidden when absent or empty.
Deliberately NOT ported: codex Tok/s (reads rollout files per session -
too heavy for payload cadence; stays a TUI/report analytics view) and
the punchcard in the menubar (a 7x24 matrix has no legible place in a
compact popover).
App suite 468 green (bridge channel pinned, mocks extended), swift
build + 156 tests green, CLI suite green.
Fixes#795: the desktop Dashboard showed only local-device cost while the
menubar and web GUI aggregated across paired devices.
Desktop app:
- Add a Local/Combined Scope setting (Settings > General), mirroring the
macOS menubar. Combined passes `--scope combined` to the CLI, forces the
provider filter to all, and persists to localStorage.
- Overview hero shows the paired-device aggregate (cost/calls/sessions) with
a "Combined · N devices" caption and per-device breakdown when Combined is
selected; detailed panels remain local (the combined payload carries totals
only).
Menubar:
- The badge figure now reflects Combined scope instead of always showing the
local total: refreshMenubarBadge fetches the combined payload for the badge
period and the badge renders the cross-device aggregate, falling back to
local when no combined payload is available.
The update Download button opened the GitHub release page; it now
downloads the right artifact for the running platform directly: arm64
or x64 dmg on macOS (preload newly exposes process.arch), the Setup exe
on Windows. Linux keeps the release page since it ships three formats
and the user picks. Unknown platforms and preloads without arch fall
back to the page. URL mapping is unit-tested per platform.
Co-authored-by: reviewer <review@local>
Checks the GitHub releases for newer desktop-v tags once per launch
and every 24h (15s timeout, no identifying headers, offline = silent
retry). A dismissible banner and a Settings About row surface the
newer version and link the release page; dismissal persists per
version so each release nags at most once. No auto-install: unsigned
builds cannot, by platform constraint; the checker only notifies.
App 364/364 (+24), typechecks + build green.
Performance and coherence:
- Settings > General 'Refresh every' (Manual/30s/1m/3m/5m/10m), live via
RefreshCadenceContext; Manual polls only on demand
- usePolled memoKey LRU: provider/period switches paint the last-good
result instantly with a switching hairline while refreshing quietly
- quota TTL 5min + honest rate-limited copy on 429 backoff
- version-suffixed cache files (session-cache.v5.json, daily-cache.v12,
auto-minted on future bumps); legacy files never written or deleted,
adopted once when versions match: old and new binaries coexist
without clobbering (field-observed menubar-vs-desktop ping-pong)
- advisory hydration lock: concurrent cold starts share one scan
(wait-then-read-warm), stale/dead locks self-heal, never a
correctness gate
Telemetry v1 + onboarding (desktop only, per owner decisions):
- first-launch onboarding (3 feature screens + consent); region-split
default (EU/EEA/UK/CH off, elsewhere on, unknown off); nothing sends
before consent completion or while off; dev builds never send
- anonymous install UUID, rotated on opt-out; day-granularity events,
cost buckets only; whitelisted names; 200-event queue, 5min flush
- Settings > Privacy live toggle replaces the static claim
Zero computed-number changes. App 316/316, root 1805.
Wire contract targets api.codeburn.app/v1/telemetry (Worker follows).
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.
- Models gains an Audit lens: raw provider token fields vs displayed
totals and cost derivation per model, est badge where pricing is
missing or recomputed cost diverges from attributed
- Settings Pricing pane: list/add/remove price overrides (USD per 1M),
inline confirm on remove, only provided rates sent; rates validated
finite and strictly positive on both sides of the bridge
- four new IPC channels with the established validation pattern
Root 1617/1617, app 235/235, build green.
- TopBar picker (shown only when >1 Claude config dir exists) threads
--claude-config-source through getOverview; synthetic 'All Claude
configs' entry returns to the aggregate default
- config id validation accepts the real <kind>:<16hex> id shape while
still rejecting flag-shaped input (leading char anchored)
- both incompatibility directions handled: selecting a config resets a
non-Claude provider filter, and picking a non-Claude provider clears
the config scope, so the CLI rejection is unreachable
- choice persisted (localStorage); active config shown in the scope
line and read-only in Settings/General
Verified live against 2 real config dirs. 205/205, build green.
- 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).
- formatUsd applies the payload currency {code,symbol,rate} once at
display; formatConverted (symbol only) for CLI-preconverted plan
values so nothing converts twice
- provider picker built from providerDetails: label shown, internal id
sent as --provider (fixes filters for providers whose display name
differs, e.g. Grok Build); falls back to map keys on older CLIs
- getYield threads the active provider through preload/main/sections
App suite 170/170, root 1615/1615.
Port the menu bar's OAuth quota fetch to the Electron main:
- app/electron/quota/{claude,codex,index,security}.ts reads the local Claude/
Codex credentials, hits the usage APIs, normalizes to QuotaProvider (5h/weekly
windows, auto-detected tier, resets-at) with 429 backoff + single-flight
- getQuota IPC bridge (main + preload + renderer types); needs a full relaunch
Security: tokens are never returned or logged (redact Bearer/sk-ant/sk-/JWT,
strip NUL, truncate 240); credential reads refuse symlinks (O_NOFOLLOW/lstat) and
insecure modes; Anthropic refresh is never POSTed (single-use, shared with CLI).
typecheck clean; 147/147 tests pass; Electron build passes.
- add a shell.openExternal bridge (main ipc handler + preload + renderer type)
so links open in the default browser (requires a full relaunch)
- wire the sidebar social icons to real URLs and add LinkedIn
- AboutModal: flame + wordmark, version, tagline, the five social links, and a
"Check for updates" link to the GitHub releases page
typecheck clean; 128/128 tests pass.
- Plans tab: add/remove budget presets via `plan set`/`plan reset`, lists
configured plans, links to the full Plans screen
- Export tab: format + provider + native folder picker (electron dialog)
→ runs `codeburn export`, reports the result
- Devices: real Remove (`devices rm`) + Refresh; visibility is read-only
from `share status`; pairing shows an honest "pair from terminal" note
instead of a dead Approve button; combine row is read-only status
- Apply the persisted theme at app startup (not only when General mounts)
- Settings gets onNavigate for the "Open Plans" link
Only one-shot, non-interactive CLI commands are wired as buttons; the
interactive share daemon and pairing flow stay honestly read-only.
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.
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.
- Period seg now uses the 5 real CLI periods (Today/7D/30D/Month/6M);
6M maps to 'all' (matches the menubar).
- Provider control is a real dropdown listing only DETECTED providers
(accumulated from current.providers so it never shrinks) + All.
- New RangeCalendar: month grid with click-and-drag range selection
(+ two-click fallback, future-date disabling) behind a calendar button;
commits a custom {from,to} threaded to Overview/Spend/Models/Optimize
via new --from/--to CLI args (Plans/Settings stay period-only).
- typecheck clean, 87 vitest pass.
macOS uses titleBarStyle hiddenInset so the traffic lights float over
the sidebar (Linear/Hermes-style), with the sidebar top inset to clear
them and the top chrome marked -webkit-app-region: drag (interactive
controls stay no-drag). Windows/Linux keep their native frame + controls.
Exposes process.platform through the bridge and tags <html data-platform>
so CSS adapts per OS. typecheck + 82 tests green.
Settings rail (Devices active) with This device (getIdentity), Discovered nearby
(new getDevicesScan bridge → `codeburn devices scan --format json`), and Paired
(getDevices perDevice). Pairing/approve/pull/visibility/combine are M1 visual
affordances (mutations = M2); share status is process-local so not shown as
authoritative. Settings uses a title-only bar (no period/provider).
Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
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.
- main.ts: 1200x820 BrowserWindow (contextIsolation:true,
nodeIntegration:false, sandbox:true), loads the Vite dev URL or the
built index.html, registers one ipcMain.handle per CodeburnBridge
channel, and starts a 30s tick that emits `codeburn:refresh`.
createBridgeHandlers() is a pure, injectable channel→argv map so the
spawn→IPC wiring is unit-testable without a GUI (main.test.ts).
- preload.ts: exposes window.codeburn (CodeburnBridge, exactly) and
window.codeburnEvents.onRefresh via contextBridge. Handlers return an
{ok,value|error} envelope so the structured CliError kind survives the
world boundary.
- types.ts: mirrors MenubarPayload/DailyHistoryEntry/DailyModelBreakdown
and the other CLI payload types verbatim, plus the exact CodeburnBridge
interface and Period union.
- ipc.ts: typed 1:1 wrapper + normalizeCliError.