4669 -> 4739 primary models (+71, -1), 18 repriced; fallback table
200 -> 203 (+4, -1), 4 repriced. Notable: kimi-k2.5/k2.6 and the Azure
DeepSeek V4 rows gain cache-write prices, Mistral Medium and both Magistral
tiers repriced, Qwen3.8-2.4T cheaper, gemini-3.8-flash and grok-build-latest
new. bundle-litellm flags deepseek-v4-flash, deepseek-v4-pro and
claude-mythos-5 as now upstream (manual entries kept for this release).
An omitted creditUsagePercent inside an open billing window was read as 0%
for every Grok account. That contract only holds for unified-billing users;
for anyone else it now stays unknown instead of showing a fabricated 0%.
The wireframe mockup still carried the one device name #1250 replaced
everywhere else.
The test ran 100 rounds a side, both taking the lock, while a reader thread
hammered the same file. Production allows a 2s wait with a 50ms poll, which
suits a UI that writes on a click but starves under that much contention: a
waiter that loses the race after each poll can exceed the budget, and the
acquire then fails by design. That is what turned main red on the merge,
not a lost update.
24 rounds a side keeps the property under test and the harness stops being
the thing that fails. Measured on this machine, ten runs each way: the test
still fails 10 out of 10 with CB_TRAY_XPROC_NOLOCK=1 and passes 10 out of
10 with the lock, so it detects the bug exactly as reliably as before.
The production budget and poll are untouched. If sustained contention ever
becomes a real workload rather than a test, the poll is the thing to
revisit, not the budget.
An audit of everything on main since v0.9.23 turned up five places where
real detail had been used where an invented value would have done. None of
them is a credential and none identifies a customer, but a public
repository is the wrong home for any of it.
perf/README.md cited an absolute path into a private audit workspace on a
contributor's own machine, naming their home directory, the workspace's
naming scheme and a run timestamp accurate to the second, as "evidence" no
reader can open. The number stays; the path goes, and the text now says
plainly that the artifact is not published. The same file described the
measurement corpus as the founder's 35 GB of session history, which is a
personal fact about one identifiable person, so it is now described by what
it is rather than by whose it is.
The remaining three are fixtures: a private product name as a working
directory, the maintainer's real operating-system username inside the very
tests that assert such a path is redacted, and their two machine names in
the sharing tests. Using the real values proved nothing the invented ones do
not, and the redaction tests read better when the string being scrubbed is
obviously synthetic.
The rename retry landed, but its test kept the watcher reading in a
tight loop, and on real Windows that lost 8 runs in 12: a reader holding
the file open every instant is a race no bounded retry can win, because
an open handle blocks a rename until it closes. That is not a reader
either app has. The watcher now polls at 1ms, which still samples the
file thousands of times and still catches a lost update, while leaving
the writers room to rename. The locked path passes 15 of 15 on Windows,
and the unlocked path still reports a rolled-back write.
Also restores the lineage case the review asked for: a refresh where the
account id and refresh token are unchanged and only the id_token subject
moves, so the subject comparison is what discards the write. Both copies.
The Windows CI leg failed on the cross-process test with EPERM from
renameSync. It is not a test artifact. Windows refuses a rename over a
file another process holds open rather than waiting, with
ERROR_ACCESS_DENIED or ERROR_SHARING_VIOLATION, and reads of these two
files are deliberately lock-free on both sides. So any tray or app read
that overlaps a writer's replace fails that write, and since this branch
made writers report a failure instead of swallowing it, the outcome is
exactly the lost update the lock exists to prevent, reached by another
route. The test's reader thread only made it visible by reading the way
both apps really do, continuously and without the lock.
The replace is now retried on that error class, ten attempts about 20ms
apart, on both sides: replace_file in settings.rs, used by settings patch
and by the dock's write_state_at, and renameOnto behind writeFileAtomic
in tray-settings.ts. The lock is already held at that point, so the wait
can only ever be on a reader, never on another writer, and a read of one
of these files is microseconds of open file. Once the budget is out the
original error is returned unchanged and the caller cleans up as before.
On POSIX, where a rename over an open file cannot fail this way, it
compiles out in Rust and short-circuits on process.platform in Node.
Reads stay lock-free, which is the right trade and the reason the files
can be read at all without coordination; both read paths now say what it
costs and where that cost is absorbed. The reader thread stays as it is,
and is now the regression test for the retry as well: it is what puts a
replace under a continuous reader.
The retry itself is covered on the Node side by faking the platform and
the rename, since a rename that fails this way cannot be produced on a
Mac: a reader waited out and the write landing, the budget running out
with the old file still whole, and an error a reader cannot cause not
being waited out at all. The Rust half is compiled out off Windows, so
CI is its only proof.
writeDockEnabled still did its own read, mutate and atomic write of
windows-dock.json, outside the lock the rest of the module takes. It is
the Node side of the dock's enabled switch, reached from the desktop
app's Sidebar toggle, so a toggle there could still land between the
tray app's read and its rename and erase whatever it had just stored,
which in practice is the rail's placement. It now merges through
patchTrayFile, which takes the lock for the whole cycle; enabled is
already an accepted dock key, so nothing is dropped on the way.
Stale-lock recovery could hand out two owners at once. Both sides did
"if abandoned, unlink and retry", and nothing stood between the check
and the unlink: contender A could already have removed the stale lock
and created a fresh one of its own, and B's unlink then deleted A's
fresh lock, leaving both of them holding the file. Windows' share mode
hides this most of the time, because a lock a live holder still has open
will not unlink at all, but the same protocol runs on Linux and macOS,
where the unlink always succeeds. Removing an abandoned lock is now
itself arbitrated, by the same exclusive create the lock uses, on a
sibling <lock>.takeover file. Only its winner may unlink, and only after
re-checking staleness under that right, so a rival that reclaimed first
keeps the lock it now holds. A takeover file is dropped on every path
out, never held across the wait, and one left behind by a reclaimer that
died is freed by the same staleness rule as the lock itself.
The cross-process test asserted only each side's final value, so it
noticed a lost update only when the last writes happened to collide: on
a deliberately broken lock it passed about one run in three, which is
weak evidence for the property it exists to prove. Each side now writes
a counter that only rises, and a reader thread watches the file for the
whole run. A separate reader is the point: neither writer can see its
own counter rolled back, because in the unlocked cycle each carries the
other side's value forward itself, so what it reads back is never older
than what it last read. With the lock both counters only ever rise in
the file; without it one falls. It now fails on every unlocked run.
Also: a placement that cannot be saved is logged instead of discarded
silently, since losing the lock under contention is a new way for the
rail to revert at the next launch; and the Codex sameLogin comment now
states outright that a replacement document carrying none of the three
identifying fields reads as the same login and is written over, which is
the deliberate side to err on.
The new lock test starts a real Node process alongside the Rust one so both
write the same settings file at once, which is the only way to prove a
cross-process lock. The Node half is TypeScript run through tsx, and tsx is
a repo-root devDependency, while this job only runs `npm ci` inside
windows/. The worker exited 1 with "Cannot find package 'tsx'" and the test
failed on both legs.
The test's own skip path covers a node binary that will not spawn, not a
worker that starts and dies, and widening it would let the guard disappear
in silence, which is worse than a red job. Install the dependency instead.
TEAMS-HANDOFF.md and .gstack/ are local scratch, not product files, and
were committed by an over-broad `git add -A` in eb992ff6. The handoff note
carries internal deployment detail that does not belong in a public repo.
Removed from the tree and added to .gitignore so the mistake cannot repeat.
They remain in this repository's history; treat anything they named as
disclosed.
The glance's Today row was scoped to the hovered provider by matching one
catalog id against one payload row. Cursor read $0.00 with 14 calls
recorded, because the CLI splits that one subscription into `cursor` for
the editor and `cursor-agent` for the agent, and the tile saw only the
first. Droid read zero for a plainer reason: the tile carries the account's
id, `factory`, while the CLI records the product, `droid`.
A tile now maps to a list of payload rows and sums them: Cursor over both
its surfaces, ClinePass over `cline` and `cline-cli`, Droid onto `droid`.
Every other tile still stands for exactly one row, and no row belongs to
two tiles, so the per-tile figures still reconcile to the machine total.
A summed tile keeps a token field absent unless some row reports it, so an
older CLI that sends no per-provider tokens still reads as "no breakdown"
rather than as a zero the account did not earn.
Checked every one of the 69 tiles against the provider registry by id and
by display name; these three were the only mismatches. Mistral and Qwen
Cloud look similar but are not confirmed to be the same account as the
CLI's `mistral-vibe` and `qwen`, so they are left alone deliberately.
The Capacity Dock's glance popover rendered the machine-wide Today block in
every provider's card, so hovering Claude showed the total across every
provider as if it were Claude's.
CLI (add-only): ProviderCost and providerDetails now carry optional
inputTokens, outputTokens and sessions, summed per provider in all three
branches that build the provider list. They stay ABSENT when no day in the
period reported a breakdown, so a consumer can tell "no data" from zero.
STATUS_SNAPSHOT_RENDER_VERSION 4 -> 5, since a v4 snapshot's payload predates
the new fields; the daily cache already holds the tokens, so its version is
untouched.
App: the Today row reads the hovered provider's row out of providerDetails
(matched on the CLI provider id, which the dock's kimi entry bridges to
kimicode). Against a CLI that omits per-provider tokens the arrows drop out
rather than falling back to the global pair; a provider absent from a
breakdown that IS present reads as a real $0.00. The row keeps its fixed
frame, so the panel's computed height is unchanged either way.
The desktop app and the tray app both read, merge and rename the same
preference files. The atomic rename stopped a torn file but not a lost
update: both read state A, each changed a different field, and the last
rename erased the other's change, so dock placement, enabled state,
provider choice or scale silently reverted.
One cross-process lock now covers the whole read-modify-write cycle,
designed once and implemented in both Rust and Node against the same
protocol: a sibling .lock file taken by exclusive create, carrying the
holder's pid and timestamp, taken over only when abandoned by a dead pid
or a stale mtime, and failing loudly rather than writing behind a live
holder. It follows the CLI's existing cache-refresh lock. All three
sites use it, including the dock's enabled, provider and placement
writes. A test drives real Node and Rust processes at the same file and
fails without the lock.
The refresh reread auth.json before writing the rotated tokens but only
checked it was still ChatGPT mode, not that it was still the same login.
A `codex login` account switch, or a second CodeBurn process refreshing
the same credential, landing while our refresh was in flight let the late
response splice one account's tokens into another's document: a new
account id over an old refresh token, or an older refresh token over a
newer one. Either signs the user out or binds them to the wrong account.
The login the grant belongs to is now captured before the grant and
compared against the reread document on account id, refresh token and
id-token subject. A demonstrated mismatch discards the write and says so.
An unreadable or key-sparse file is not a mismatch and still writes, so a
rotated token is never dropped. Both the CLI and the Electron copy.
The line reading "Last known usage · refreshing" sat crammed against the
header with the blocks below pushed into it. Its height was reserved, but
only as 16 points: a bare 10pt line box with no padding, no side inset of
its own and no separator, while every other block in the glance carries
8 or 10 points of padding and a 16 point inset.
It is a section now, with the same inset, its own padding and a divider,
and the reservation grows from 16 to 27 to match so nothing below is
squeezed. Only the two in-progress states change; disconnected and
reconnect keep their own blocks with the action button.
The new test pins both halves: the reservation equals the band's height
for every in-progress connection, and the total stays whole at every
scale, since a fractional panel height re-runs the window's layout at
display cadence for as long as the dock is up.
The glance popover replaced the row-counted one while this branch was
parked, so `detailHeight` grew three parameters and this branch's copy of
the old body went with main's. The branch's assertion that the popover's
height is whole is now covered better by CapacityDockGlanceTests, across
every scale and against the new signature, so it goes rather than being
ported.
What the branch's remaining test caught is a live regression on main. Every
dock metric must be a whole number of points: a fractional panel length
re-runs the window's layout at display cadence for as long as the dock is
up, which is the drain #1210 fixed. `points` still rounds each base metric,
but the eased flare compensation added afterwards put the fraction straight
back, so the rail padding measured 30.6 and the panel 111.2 and 225.2
through the whole attach animation. Round the sum, not the base.
Nothing on main tests this; the parked branch is the only guard, which is
how the regression reached the release candidate unseen.
Every window belongs to a set of spaces. The rail belongs to all of them;
the detail panel had been reduced to the desktop space alone, so hovering a
pill inside a full-screen app positioned the popover, faded it in and
ordered it front exactly as it does on the desktop, in a space the user was
not looking at. Measured on a running instance: the rail reported
spaces=[3, 63, 24, 137] with 137 active, the panel reported spaces=[3].
The membership is written once in init and never again. The window server
drops it across a display sleep or a display reconfiguration, and the rail
survives only because it is never ordered out. Re-assert it in showDetail,
the one funnel every presentation passes through. It has to be written, not
read and compared: assigning collectionBehavior is what re-registers the
window, and it must happen while the window is ordered out.
Also drop a duplicate `isFloatingPanel = true` that ran after the shielding
level was set. NSPanel.isFloatingPanel writes level = .floating, so the
second assignment silently discarded the level the comment above it asks
for: the dock measured at layer 3 instead of 2147483628, low enough for any
floating window to cover it. Both panels now report the shielding level.
The onboarding screen, the privacy settings and the README each said the
daily report includes the names of the models, tools, skills and MCP
servers in use, and then in the next breath that it never includes
anything identifying. A server named after an employer is identifying,
so the second half was not true. All three now use the wording the tray
notice already had, which names what is withheld rather than claiming
more than the product does: never your prompts, your code, or your
project and file names.
The reboot-pending flag lived in memory with a comment claiming the
restart it waits for also clears it. Quitting the desktop app and
opening it again cleared it too, and that is not the restart being
waited for, so the next launch started the old tray binary under the new
version's name, which is the confusion the flag exists to prevent.
It is saved as a timestamp now and read back by comparing it against
when Windows last booted, so only a real restart clears it. The test
that asserted the old behaviour crossed the boundary by restarting the
app; it now moves the boot time, and a second test covers restarting the
app without restarting Windows.
Two doors still skipped the credential drain. A closed pipe, which is
how `codeburn quota | head` ends, exited straight from the stdout error
handler; it drains first now, and resolves immediately for every command
with nothing outstanding. A throw from any provider left through the
same door as success without draining at all; the drain moved into a
finally, so one provider failing cannot strand another's rotation.
The drain also waited fifteen seconds for a request allowed thirty, so a
grant answering at twenty was abandoned by the very guard built for it.
The wait is now derived from the request timeout plus a margin, so the
two cannot drift, and giving up says so on stderr instead of leaving the
user to discover it the next time that provider asks them to sign in.
- Tray preference patches from the window were used with `in` before
any type check; every patch handler now refuses anything that is not
a plain object.
- The tray install passes `--staged-msi <path>` to the CLI instead of
an environment variable.
- A timeout or a missing CLI no longer counts as a declined install,
so it is retried at the next launch; only a cancelled prompt is
remembered. After msiexec reports a pending restart the old binary is
not launched, and the sidebar says a restart finishes the install.
- The telemetry opt-in event fired before the toggle was applied and
could never be sent; it fires after a successful write.
- The ARM64 installer carries the x64-named tray, since the CLI accepts
only that asset name; the distribution notes say so plainly.
- The README, the onboarding screen and the privacy settings say that
the daily report includes the names of the models, tools, skills and
MCP servers in use, and the renderer's fallback builder reports zero
turns as zero.
- codeburn quota could still exit with a rotated Codex token in flight:
the command ended with process.exit and the per-provider abort could
cut the grant after the server had rotated. The refresh stays, since
the usage endpoint rejects an expired access token, and every rotation
registers itself; the command awaits outstanding credential writes,
bounded to fifteen seconds, before it exits. Tests abort mid-grant and
time the provider out and still find the new token on disk.
- CODEBURN_MENUBAR_MSI let one environment variable send msiexec at any
file whose checksum the same party wrote. It is replaced by
`codeburn menubar --staged-msi <path>`, which must be absolute,
resolved, named as the release asset, inside a resources\menubar
directory whose grandparent holds CodeBurn.exe, a regular file with a
regular .sha256 beside it; the variable is refused with the reason.
- countBucket(0) reported one to ten turns for a model with none. Zero
is its own bucket.
- A rail dropped while expanded reappeared elsewhere: the drop
normalised against the live rail's travel while layout denormalised
against the resting one, 71 px of drift at four rows on this work
area. The drop now walks back to the resting start the rail grew
from and normalises over the resting travel; a test drops an
expanded rail and asserts it lands exactly where it was released.
- The click-through state was set before the call that applies it and
a failure was discarded, so one failed call left an always-on-top
window hit-testable forever. The state records only a successful
call and the next tick retries; the same fail-open in begin_drag is
fixed.
- Any button held anywhere on the desktop pinned the poll at 62 Hz;
only a press that began on the rail counts now.
- The dock state file the desktop app also writes is replaced through
a temp file and rename.
- The consent notice and the settings hint say that the daily report
includes the names of the models, tools, skills and MCP servers in
use, as the owner decided, and no longer claim more than that.
- The non-Windows random fill read /dev/urandom with fs::read, which
never returns on a character device, and had a borrow error beside it
that failed clippy on the ubuntu leg. It opens the device and reads
exactly sixteen bytes, keeping the fallback; the reader is compiled
for tests on Windows too, and the Linux target was checked from an
extracted copy with clippy against x86_64-unknown-linux-gnu.
- The telemetry commands were synchronous and every tracked event
rewrote the whole queue file. They are async now and the save is
debounced by two seconds, with a save before each flush and on quit.
- The queue is capped by bytes as well as count, drops events older
than seven days on load, backs off exponentially with jitter from
five to thirty minutes while the endpoint fails, and keeps an
in-flight batch in the file until it lands so app_close can no
longer overwrite it.
- One reqwest client for the process, a 0600 queue temp file on Unix,
the hidden window station warning in the release log, and
open_terminal_command limited to a named read-only subcommand.
The tray had no telemetry. It now sends the same envelope as the desktop
app to the same endpoint, with app name codeburn-menubar. Consent comes
from the desktop app's telemetry state file when that app is installed,
so one decision covers both apps and events share one anonymous id;
otherwise the tray asks once, defaulting off for EU, EEA, UK and Swiss
locales and for an unknown locale, and keeps its own toggle under
Settings, General, Privacy. Switching off rotates the id.
Events, all name-only or bucketed: app_open, app_close with session
minutes, popover_open, settings_open with the pane, update_click with
the action, glance_open, dock_enabled and dock_disabled with the edge
and a scale bucket, dock_provider_switch, dock_drag_end with the edge,
and the CLI's usage_snapshot once a day, skipped when the desktop app is
the consent source so nothing is counted twice. The queue persists under
the app's local data directory, capped at two hundred events, and flushes
every five minutes and on quit; a 4xx drops the batch, anything else
keeps it. Debug builds never send.
The daily usage_snapshot event now carries the payload's
telemetrySnapshot verbatim, with the old renderer builder kept as the
fallback for an older CLI. Five name-only events join it: optimize_apply
with the fix kind, plan_set with the provider, export with the format,
compare_view with the two models, and settings_change with the setting
and its enum or boolean value.
The props sanitizer had allowed one level of arrays of flat objects and
would have stripped the snapshot's nested model and task rows; it walks
plain objects recursively now, five levels deep at most and a thousand
leaves per event, leaves still short strings, finite numbers and
booleans only. The README gains a Telemetry section listing every event
and field and how to turn it off.
The desktop app and the Windows tray both send a once-a-day usage
aggregate when the person has opted in. Each built its own, which meant
two schemas to keep honest. The CLI now computes one snapshot at the end
of the menubar payload and both apps send it verbatim.
Everything is bucketed and name-only: cost and count buckets, up to
eight models each with its top six task categories and their share of
turns, twelve categories with their top models, providers, MCP servers,
skills and tools by identifier with call buckets, a session count and
median length bucket, and cache hit and retry tax shares to two
decimals. Nothing derived from paths, project or branch names, or
message text can enter it; the builder only emits primitives and capped
containers. The raw model by task cross rides in through the options
bag and never lands in the payload itself.
7-Zip compresses ARM64 executables with its ARM64 branch filter by
default. The decoder inside the NSIS installer predates that filter, so
extraction dropped every .exe and .dll while the data files landed, and
the installer exited zero with no program in the directory. A new
package:win:arm64 script pins the BCJ filter through the electron-builder
override, and the distribution notes say why.
The Run value was seeded once per machine. Uninstalling the desktop app
removes the tray and, with it, the Run value; reinstalling put the tray
back but the seeded flag survived in the companion settings, so launch
at login was silently off afterwards. A tray app the current launch has
just installed now seeds the value as a first run does.
Two faults in the uninstall hook, found by running it for the first time:
- NSIS has no doubled-quote escape, so the '' written for PowerShell's
single quotes ended the NSIS string at the first one. The plugin got a
truncated command PowerShell could not parse, and the hook silently
did nothing: uninstalling the desktop app left the tray, its Run value
and its marker behind. Each inner quote is now the NSIS escape.
- An upgrade runs the previous version's uninstaller first, with the
updated flag, and the hook removed the tray there too, so every desktop
upgrade uninstalled the tray and reinstalled it with an elevation
prompt. The hook now leaves the tray alone during an upgrade.
The parity test builds the payload twice over the same warm cache and
expects the two to match. Since liveSessions landed, each session
carries idleSeconds, whole seconds since its last activity, so two
builds that straddle a second boundary differ by one and the test
fails at random. Seen on the macOS runner. stableShape now zeroes it
the way it already blanks the generated stamp.
The parity test builds the payload twice over the same warm cache and
expects the two to match. Since liveSessions landed, each session
carries idleSeconds, whole seconds since its last activity, so two
builds that straddle a second boundary differ by one and the test
fails at random. Seen on the macOS runner. stableShape now zeroes it
the way it already blanks the generated stamp.
The Store package is signed at submission and updates itself. The tray
.msi under windows-v* and the desktop setup exe are a developer preview:
unsigned, SmartScreen warns on first run, and they do not update
themselves; the tray app reports a newer version and links to it. Said
in the README, the distribution notes, the tray development notes and
the release body.
Outside a Store package the tray app no longer runs `npm install -g
codeburn@latest --force` and `codeburn menubar --force` on one click.
The MSI is unsigned and its checksum is published in the same release,
so an automated install has nothing to verify; a manual install at
least passes through SmartScreen. The check still runs and reports a
newer version, the badge and About now open the windows-v* release
page, and the manual command is shown as copyable text. Inside a Store
package the checker stands down as before. One-click install returns
once the MSI is signed and the installer verifies the signature.