The never-lose contract carried a (day, provider) slice forward only when
the re-derivation found nothing for it. Transcripts expire per file, so a
mostly-aged-out day still gets a few turns from surviving later files: the
fresh slice came back non-empty but truncated and replaced the full cached
one (a real cache lost $2,765.75 / 19,209 calls / 520 sessions over 13 days
on the 17 -> 19 upgrade).
A fresh slice now replaces a settled baseline slice only when it carries at
least as many calls. Comparison is on calls alone - cost and tokens are
re-priced accounting on the same evidence, and session counts drift down on
healthy days. Days inside a 7-day settle window stay authoritative. The
tz-change re-derive gets the exact form of the rule: the subtraction residual
is added on top of a data-carrying fresh slice instead of being dropped. The
cross-file adoption union is unchanged.
node:sqlite enables SQLITE_OPEN_URI from Node 22.15 on. Below that -- 22.13 is
the package floor -- a `file:...` location is a literal filename, so the
immutable open failed as CANTOPEN and the copy quietly stood in for it. That
was the right outcome by accident; the test asserted the newer behaviour and
failed on the floor.
The support question is now asked once per process, with an in-memory URI that
touches no filesystem whichever answer comes back, and the immutable open is
attempted only when the answer is yes. The test asks the same question rather
than skipping, so both CI lines assert something: rows are correct either way,
in place where URI filenames work and from a copy where they do not.
Four things the copy fallback got wrong.
A database whose -wal is absent or empty has no un-checkpointed frames, so
there is nothing to go stale and nothing worth copying: immutable=1 opens the
source in place and SQLite skips the -shm it cannot create. The copy is now
taken only when a non-empty -wal exists, which is the case where dropping it
would lose rows.
A copy is published under a name carrying its fingerprint, so refreshing one
never has to unlink a file another process may still hold open, which Windows
does not allow. The -wal is published before the database so a reader can
never see the database without the sidecar holding its newest rows, and losing
a publish race to an identical copy is not an error. That removes the metadata
sidecar: the name is the fingerprint.
Superseded copies are evicted rather than overwritten -- the one in use plus at
most one predecessor, and anything untouched for a day, which is also what a
source path that no longer exists looks like. Reuse touches the copy, so its
mtime is last use.
A cache directory that cannot be written no longer fails the same way the bug
did. It emits the once-per-database notice naming the database and the reason
before the database is skipped, instead of going quiet.
Both multi-model tests assert that chooseAuthoritativeModel skips a
modelUsage id it cannot price and falls back to a priced one. They got that
"cannot price" from the bundled LiteLLM snapshot happening not to carry
grok-4.6-build, so `npm run build` - which re-fetches the snapshot - flipped
both assertions the moment an xai/grok-4.6 entry appeared upstream and the
prefix match started pricing the id.
Stub getModelCosts for that one id instead. The reporter's real ids stay in
the fixtures, so the tests still document the #998 case, and calculateCost is
left alone: module-internal calls are not intercepted, so cost assertions
keep pricing off the real tables.
Verified by re-running both files against a refreshed snapshot that does
carry xai/grok-4.6; 26/26 pass where they previously failed.
The daily-cache re-derivation test seeded v18, a version that only ever
existed as an unreleased draft of this change. Seed the shipped v17 so the
test models the 17 -> 19 upgrade path users actually hit, and rename it: the
bump re-derives every day for every provider, not just Grok, because the
daily cache has no per-provider invalidation. The Grok day stays as the
fixture since Grok is what the bump exists to correct.
The changelog entry now says outright that Grok totals change materially on
upgrade (150K -> 96.3M cache-read tokens on a 568-session corpus), that a
turn without a turn_completed record inside an otherwise-covered session is
dropped rather than estimated, and that the one-time daily re-derivation
reads the warm session cache and keeps the superseded file. The
context-bloat denominator fix moves to Fixed and names the providers it
corrects.
docs/providers/grok.md gets the same undercount warning in the token model
and a matching entry under Quirks.
A read-only parent with a -wal but no -shm fails as SQLITE_CANTOPEN (14),
not SQLITE_READONLY (8), so the fallback never ran and the un-checkpointed
rows in the -wal stayed invisible. openReadonlyCache already re-throws the
original error when the database itself is missing, which is the other
CANTOPEN, so widening the trigger keeps that case distinguishable.
Also stops copying the source -shm: SQLite rebuilds the wal-index from the
-wal in the writable cache directory, so the copy is dead weight.
The unpriced-models pointer told every user to run `codeburn model-alias`. A
subscription or flat-rate model is correctly $0, and mapping it onto another
model's per-token rate invents spend that was never billed (#968), so the hint
now states the condition instead of the instruction.
`sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every
other command and format rendering provider-supplied IDs unfiltered. It moves
to the `formatModel` closures in models-report and audit-report, the two sites
every renderer routes through, covering the raw-ID fallback as well. The
`--unpriced` override stays: it bypasses that path deliberately, because
`model-alias` keys on the raw ID, not the friendly name. README says so.
Below 45 columns of panel the dashboard warning dropped its marker and count
and printed a bare command, so a narrow terminal gave no signal that anything
was wrong. `! N: codeburn models --unpriced` is 31 characters and fits.
Only duplicate-reads has a structural reason to skip them: a subagent
starts on a fresh context, so re-reading what its parent read is a
necessary read, not a repeat. Reading node_modules or editing without
reading is the same waste whoever does it, and the CLAUDE.md rule both
findings suggest binds subagents too - filtering them there discarded
most of the evidence on a subagent-heavy corpus.
The prefix alone could not tell a claude.ai connector from a local MCP
server that happens to be named claude_ai_*, so the latter lost its
removal command and its apply plan. A namespace is now a connector only
when no readable local config claims that exact name; localMcpServerNames
supplies the set from the same files the remove plan edits (settings.json,
.mcp.json, and ~/.claude.json top-level plus per-project mcpServers).
A prefixed name that local config does own stays local: normal removal
command, apply plan, class fix, full savings attribution. Because the
transcript still cannot rule out a same-name connector, the finding adds a
manual note about it instead of asserting the server is one. Config that
cannot be read contributes no names, which leaves every prefixed namespace
on the conservative connector path.
The rewritten cost pass charges each session only for the schemas that
session loaded, which changes local-only estimates too (on a real corpus
the mcp-low-coverage estimate roughly halves). Pin it so the change is
deliberate rather than a side effect of the connector split.
A mixed local + claude.ai connector finding is class `fix`, but `--apply`
only mutates the local servers. classTotals now credits the `fix` group
with `applyTokensSaved` when present, so the "Fix now (apply-able)"
subtotal, the "apply-able: ~$X" headline and `summary.byClass.fix` (CLI,
TUI and desktop all read these) describe what apply can actually recover.
The finding keeps the whole opportunity in its own `tokensSaved`.
Also fixes the desktop connector fixture, which predated the class/basis
fields, and adds class-level coverage: connector-only findings resolve to
`nudge` (no apply payload), a local server named like a connector stays
manual-only, and local-only findings keep their full subtotal.
The menubar told System Events to make its login item, so macOS asked for
Automation access on first launch. SMAppService.mainApp does it in-process
with no Automation grant. No AppleScript fallback: a failure must not bring
the prompt back. Package floor is macOS 14, so the 13+ API needs no
availability guard.
Fixes#1026
Shares the mac release resolution behind a per-platform spec (tag prefix, asset name, error text), so the Windows path reuses the pinned-version URL, the release-API fallback scan, the retrying download and the sha256 verify unchanged. Windows then runs msiexec out of %SystemRoot%\System32 with /i /passive /norestart, treats 3010 and 1602 as non-failures, and launches the exe named by the product's Uninstall registry key.
The spend badge is a second tray icon carrying the number as its bitmap, which
only the Tauri tray backend provides; Linux runs its own SNI tray and has no
equivalent. The module was compiled there anyway, so every item in it - and the
two tray ids in lib.rs - tripped dead_code under clippy -D warnings on the
ubuntu leg.
- `mod tray_badge` and TRAY_ID / BADGE_TRAY_ID are now cfg(not(linux)), so the
code is absent on Linux rather than present and unused.
- `set_tray_badge` reports the badge as unsupported on Linux instead of
returning a success that never happened.
- The frontend hides the control wherever it is unsupported, behind
TRAY_BADGE_SUPPORTED in lib/platform.ts - one constant, three call sites
(the settings row, the footer menu item, and the effect that would otherwise
invoke the command).
- AppState's `linux_tray` field is dropped: it was written and never read, which
is the same lint one file over. init_tray_linux already owns the handle.
Checked rather than reasoned: `cargo clippy --all-targets -- -D warnings` passes
on macOS, and a scratch copy of the crate with the linux/macos cfg arms swapped
(so a normal macOS clippy selects everything a Linux build would keep, and drops
everything it would drop) also passes. Reinstating the ungated `mod tray_badge`
in that copy reproduces the exact CI failure, so the check is real. It stubs
tray_linux.rs, whose ksni and png deps do not build here - that file is still
only covered by CI's ubuntu leg.
The 5s default failed three unrelated real-I/O tests on the CI Node-22 runner
in two days (dashboard optimize scan, codex resume differential, OTel DB
prune) purely from runner load. Hung tests still fail at 30s.
README gains a Windows section next to the macOS and GNOME ones, a download
badge on the menubar card, and an honest note that the Tauri tray builds on
Linux but is unreleased there. docs/architecture.md picks up windows/ in the
surfaces diagram and gets a section covering the crate layout, the PATH and
System32 spawn rules, and the Claude quota parity.
`codeburn menubar` on Windows now points at the windows-v release page instead
of failing with "macOS only". The generalized installer from the source branch
is not brought over: it is 1182 commits behind this file and would drop the
proxy support, retry/backoff, checksum and bundle verification, and persistent
CLI path handling that landed since.
windows-menubar-ci.yml runs on windows/** changes: tsc, clippy with -D warnings,
and cargo test on both windows-latest and ubuntu-latest, plus a release-profile
`tauri build --no-bundle` on Windows. The Linux leg exists because most of the
crate's cfg(windows) code cannot be compiled anywhere else, so the reverse -
keeping the ksni paths and every shared helper clean off Windows - has to be
checked somewhere too.
release-menubar-windows.yml mirrors release-menubar.yml: a `windows-v*` tag (or
a manual dispatch) builds the MSI and publishes it, with a sha256, to a
"Windows Menubar vX" release.
The two release-desktop-*.yml files on the source branch are deliberately not
imported: their tag names and "Desktop" release titles would collide with the
Electron app in app/, which already owns `desktop-v*`.
Process spawning (Windows searches the current directory before PATH):
- reg.exe and cmd.exe are now spawned by absolute System32 path via
cli::system_command, which also carries the CREATE_NO_WINDOW flag the three
previous copies each set by hand. The tray badge re-ran `reg query` on every
refresh, so this was the most reliably reachable planted-binary path.
- "Connect Claude" resolves the claude binary itself instead of handing a bare
name to the console shell.
- CLI lookup ignores empty and relative PATH entries. `;;` or a trailing `;`
used to yield PathBuf::from("").join("codeburn.cmd"), a current-directory
lookup, at startup. The filter lives in one place (find_in_dirs) that every
search - codeburn, claude, and the Linux terminal probe - goes through.
- The Linux terminal path re-validates the whole command against the argument
allowlist before joining it into the string `bash -lc` parses; anything that
fails falls through to the argv-only detached spawn.
CLI version gate:
- MIN_CLI_VERSION moves from 0.7.0 to 0.9.9, the first release accepting
`status --format menubar-json --no-optimize` (every quiet refresh passes it)
and emitting all the payload fields the popover reads.
- The gate is probed on mount, before the first fetch. It previously only ran
when a fetch failed with the literal "CLI not found", so an old CLI produced
a payload whose missing history.daily threw and blanked the popover.
- A successful fetch no longer flips an incompatible CLI to compatible, and the
settings panel no longer probes on its own - App owns the verdict, so a
transient probe failure there cannot drop a working app onto the setup screen.
- The payload reads App makes are optional now, so a surprising payload lands on
an empty state rather than a blank window.
Refresh cadence, mirroring mac RefreshCadence.swift: 60s with optimize findings
while the popover is visible, 120s today/all without them while it is hidden,
and an immediate refresh on show when the visible key is stale. Every hide path
in lib.rs now goes through mark_hidden so the debounce stamp and the frontend
signal cannot drift. Previously it was 60s with optimize regardless of
visibility - about 2880 CLI spawns a day.
Claude quota (plan.rs) stops calling the token refresh endpoint. Claude's
refresh token is single-use and rotates, so spending it invalidated the token
Claude Code itself holds and broke the user's login. Parity with
ClaudeCredentialStore.refreshAfter401: re-read Claude's own store for a token it
has already rotated, and report a transient failure when there is not one yet.
Smaller:
- Snapshot writes refuse a symlinked target and are 0600 on unix, mirroring
mac SafeFile; no home directory now means no snapshots rather than a file
dropped in whatever directory the tray was launched from.
- The Windows config lock keeps its file handle open, so the stale sweep can
only ever unlink a lock whose owner is gone. The doc comment now says what the
lock actually buys (the CLI never takes it).
- "updated Xs ago" is only stamped by a fetch of the key on screen.
- External links go through tauri-plugin-opener instead of target=_blank.
- The tray badge font is loaded once into a OnceLock instead of read and parsed
on every render.
- autostart's shared import and constant are cfg-gated so clippy is clean on
every OS.
Adds unit tests for the PATH filter and the version gate.
Brings the Tauri 2.x tray popover from #1022 onto main as windows/, mirroring
mac/. Product name, bundle identifier, and version line up with the macOS
menubar (org.agentseal.codeburn-menubar, 0.9.20); the crate is renamed off
"desktop" so it no longer collides with the Electron app in app/.
Linux (ksni) stays compiled and dev-usable but is documented as experimental:
gnome/ is the shipping Linux surface.
The five src/ CLI commits on that branch are dropped - they re-implement a
daily-bucketing fix main already carries.