Skip to content

Ceiling 1.5.35

Choose a tag to compare

@github-actions github-actions released this 22 Aug 22:58
· 33 commits to main since this release
804f971

[Ceiling] 1.5.35 - 2026-08-22

Charts stops showing numbers it cannot stand behind. A month whose models have no published prices painted as an empty one, the busiest day on the activity heatmap could share a shade with the quietest, and a Codex session carried whatever calendar day it was first parsed on, so a DST change or a trip moved usage onto the wrong day. Two cards scanning at once could overwrite each other's index, and a price refresh landing mid-scan stamped old dollars as current for every card already in the file.

The rest is state a surface reported but could not actually reach. A settings or credentials write could run unserialized on a filesystem that cannot flock, Install and Restart could check a staged installer against a different release's digest, and dismissing an update mid-download un-dismissed itself on the next progress chunk. A machine reporting no home directory no longer reads Gemini credentials from — or writes refreshed Google tokens into — whatever directory it happened to start in.

Ceiling also asks for a GitHub star now. At most twice, ever, and only after a provider has actually reported a reading.

Added

  • Ceiling asks for a GitHub star, at most twice ever. A card in the bottom-right of the dashboard, and the only thing Ceiling has ever asked of anyone using it. The first ask waits until a provider has actually reported a reading and that reading has been on screen for twenty seconds, so it lands after the app has done something useful rather than during setup, when it would be asking to be paid before the work. The second, if the first went unanswered, waits for a later version to have shipped and for a week to have passed since the first, because a version bump the day after is still two asks in one week. Clicking through to GitHub ends it permanently; so does reaching the second ask. Later and the close button mean the same thing and never count as interest. It is not a Windows notification, it takes no focus and traps none, Escape closes it, and the only network call involved is opening the repository in your browser when you ask for it.

Security

  • Gemini treats a missing home directory as not logged in. With no HOME or USERPROFILE, Ceiling used the working directory as ~/.gemini, so a checkout fixture could be read and then written back after a refresh. Both the credential read and the credential write now refuse that fallback, matching client_config.json.

Fixed

  • Grok project names read from a Windows path no longer come back as the whole path. A session's cwd is written by whichever machine ran Grok, so a Windows path reaches Ceiling on Linux and inside WSL. Path::file_name only treats \ as a separator on Windows, so everywhere else a cwd of C:\projects\personal\ceiling was reported as a project literally named C:\projects\personal\ceiling, and the URL-encoded session folder split the same way. Both now split on either separator on every host. A Codex sessions directory joined onto a Windows root also keeps \ instead of picking up the host's separator. Windows behaviour is unchanged.
  • A corrupted Claude de-duplication key no longer silently inflates Charts and Estimated API value. The usage index treated invalid UTF-8 in a present dedup_key as "this record has no key", so the same transcript event could be counted twice if its counterpart still had one. Invalid UTF-8 now rejects the whole index the way a required string already does, and the next scan rebuilds it from the transcripts. A key that was never written is still encoded as the length sentinel, not as a failed decode.
  • A filesystem that cannot flock no longer writes settings unserialized. The state-write lock treated every flock errno other than contention as "no lock needed" and continued, so an NFS/FUSE/SMB home without lockd let the tray and the CLI replace api_keys.json over each other. Only ENOTSUP — the filesystem saying it does not implement locking at all — now falls back, read from the errno rather than from how a given Rust version happens to decode it, and the fallback is an exclusive-create sibling that a second writer cannot take. ENOLCK is no longer treated as a broken filesystem: it also means the kernel lock table is full or lockd failed for that one call, while another process still holds a real flock, so falling back to the sibling would have let both writers through. It fails the write instead. An unknown errno already did. Because the sibling has no kernel-backed release, a leftover from a killed process still has to age out, but the age it has to reach is now two minutes rather than the ten seconds an acquirer waits — a holder doing slow work can no longer be outlasted and have its lock taken, and a lock file stamped in the future by a skewed server clock reads as held rather than as expired. A holder unlinks the sibling on release only while it is still the same file it created, so a takeover cannot cascade into deleting a third writer's lock. A state-write.lock the current user cannot open — left by a sudo run, or by another account — now fails the write and names the path to remove. It used to be unlinked and recreated, which put the running holder and the new one on two different inodes and let both writes proceed. A directory in the lock path still fails the write. Releasing that sibling also hands the lock cleanly to the next writer on Windows, which reports a name whose deletion has begun as access-denied rather than as already-existing; a waiter that arrived in that window used to get a hard failure instead of the lock a moment later.
  • The 1.5.33 notes no longer claim the incident badge is Ceiling's only non-provider request. That sentence was false: models.dev (prices) and GitHub (update checks) already run without that opt-in. The badge is still off by default. The replacement makes no exclusive claim at all: the badge adds each enabled provider's public status page to the hosts Ceiling talks to, on top of the usage requests those providers already receive, while models.dev and GitHub are fixed hosts that run either way. A copy test bans the exclusive phrasings so neither version can come back.
  • The activity heatmap no longer paints the busiest day the same shade as the quietest. Quartile cuts that collapsed to one value still held every active cell at mid-scale, including a day orders of magnitude above the rest, and with four or fewer active cells the 75th-percentile cut sat on the maximum so the legend's top swatch never appeared. An outlier now reaches the darkest step, a genuinely flat month stays mid-scale, and the busiest cell of a small set reaches the top of the ramp. The top swatch is decided by the busiest cell rather than by the 75th-percentile cut, which was wrong in both directions: when the upper half tied, the busiest cell fell into the mid band, and with four distinct cells the cut sat low enough that the second-busiest matched the busiest.
  • A single light day no longer becomes the spend-anomaly baseline. After a week away, one $1.50 session was "the recent daily median", so the first ordinary $12 day back toasted as an 8x spike and marked the day sent. A real runaway later that evening then said nothing. The detector now needs three floor-clearing days before it will compare; fewer is the same as a zero baseline, which already stays quiet. Closes SBS-965.
  • Install and Restart no longer checks the downloaded installer against a different release. Clicking Download and Check for updates at the same moment could let the check finish second and store a newer release digest next to the installer that had just completed. Install then failed closed and discarded a valid download. Download now takes the updater lock for the whole start, a check that lost the slot does not overwrite an in-flight download, and apply verifies the digest captured when that download began. Dismissing an update while its download is still running now sticks: a later progress chunk used to write the Downloading state back, which let the finishing download store Ready and bring Install & Restart back after the banner had been dismissed. That discarded download's installer is deleted rather than left in the staging directory.
  • Two Charts scans no longer wipe each other's usage index. The API-value card and the heatmap can finish at the same time, and each commit encoded its snapshot then released the lock before writing the file. The slower writer could put the older snapshot back on disk, so files the faster scan had just indexed were gone after a restart and had to be parsed again. The lock now covers the write, so the file on disk is always the latest encode. In-memory totals were already correct.
  • A light user whose every day is under $1 can now receive a spend-anomaly alert. Days under a dollar still do not build the recent-median baseline — a five-cent leftover next to a real day would otherwise manufacture a false spike. When that leaves no median at all, the detector used to go silent, so a $0.60/day user never heard about a $180 loop. It now falls back to a $20 absolute trigger (20x the $1 floor, the largest spike factor the setting accepts) so a runaway still toasts and an ordinary first day does not. The budget alert is a separate opt-in and is no longer this feature's only answer for that user. The trigger is compared at cent precision, so a day worth exactly $20.00 cannot be missed because a sum of floating-point estimates landed a fraction under it while the toast still printed "$20.00".
  • The activity heatmap follows the UI language. The rest of Charts already translated; this card had been written after locale landed and never joined. Titles, empty states, axis labels, screen-reader text, and dollar/token formatting now go through the same bundles as the rest of the app, so a Chinese language setting no longer leaves this card in English. A language without its own bundle formats in English rather than picking up the machine's locale, so English copy no longer appears beside another language's dates and separators. Switching language re-renders the card in place instead of re-running the transcript scan behind a spinner, and a failed scan is reported in the selected language rather than passing the backend's English sentence through.
  • The first-run cleanup of old temp-folder logs no longer repeats forever on a shared /tmp. Earlier versions wrote codexbar_launch_*.log into the system temp directory. The upgrade pass that removes those files refused to mark itself done whenever anything it did not delete was still there, and on a multi-user machine /tmp is sticky, so another account's leftover is PermissionDenied. Every later codexbar command then walked the whole temp directory before doing any work. Another account's file this process cannot unlink is no longer treated as unfinished work, so the pass writes its marker and stops looking. Logs this account still owns — including a Windows leftover that fails with PermissionDenied after an elevated run in the same %TEMP% — and logs still inside the 24-hour age bound, keep the pass alive until they are gone. An in-age log belonging to another account is treated the same way, rather than holding the marker open until it ages out. A log that disappears under the sweep, or that is replaced by a directory, is not counted as unfinished either.
  • codexbar serve no longer opens an unbounded number of connections, and no longer re-reads settings on every request. The accept loop spawned a task per TCP client with no cap, and each /usage or /cost call loaded settings.json and account dirs again. A script polling /usage could multiply into one live provider fetch per connection, and a settings file that still carried legacy credentials serialized those tasks on the state write lock. Settings and configured accounts are loaded once at start, and at most eight connections run at once — the same cap the desktop refresh already uses. A ninth client is answered HTTP/1.1 503 straight away rather than queued, so a caller that polls hard sees backpressure it can retry rather than a stall. The listening line is now printed only once the server is actually accepting. Restart the server to pick up a Preferences change. --refresh-interval is still unused; that is a separate ticket.
  • Codex charts no longer freeze a local date onto a session. Each Codex usage record used to store the machine calendar day at parse time. After a DST change, a trip, or an OS timezone switch, an already-indexed rollout stayed on the old day: a one-day window could include usage a fresh parse would drop, and the daily bars could disagree with the heatmap. The index now keeps only the UTC timestamp and derives the local day on every scan. A log line whose timestamp is not RFC3339 (no offset) is left out of every bucket, instead of landing in the daily total and vanishing from hourly and reset-window totals. Closes SBS-944. Rebuilding the Codex index no longer discards the Claude one: the two files sat under one shared format tag, so a Codex-only layout change failed Claude's tag check and made the next scan reparse every Claude transcript for nothing. Each record type now versions its own index.
  • A first Charts scan no longer holds every newly parsed transcript in RAM until the index is written. The parse path already worked a batch at a time so a 90-day walk would not keep gigabytes of records live; the index path undid that by collecting every Store into one updates vec and committing after the last file. Claude and Codex now apply each parse batch to the live index before the next batch is read, and write the snapshot once at the end of the scan. A user with a large corpus opening Charts after a cold start, or after the index is discarded, no longer pays for the whole corpus twice before a single byte is written.
  • A provider with no model prices no longer paints the activity heatmap as an idle month. Unpriced models have always contributed tokens and zero dollars, which is correct for the dollar figure. The heatmap only received those dollars, so a busy Codex month on a newly released model id painted every cell at the empty shade, printed a zero total, and dropped the busiest-hour line. Hour rows now carry priced vs total token counts, the same coverage the Estimated API value card already uses, and the card names the gap instead of looking unused. Unknown prices are their own hatch, not the idle shade. The hatch marks the Estimated API value view only: under Tokens the count is fully known, so hatching it there would have blanked the intensity of a genuinely busy cell and called a known number unknown. The coverage note is stated once, in the card header, rather than repeated in the By-day line where a screen reader read it out twice.
  • Windows stops handing an npm shim or a directory to the PTY as the Codex or Claude binary. where.exe prints every match on PATH and the resolver took the first line, so an extensionless POSIX shim named codex sitting ahead of codex.exe went straight to CreateProcessW and failed with error 193. Every candidate is now examined, a native .exe or .com wins over a .cmd or .bat shim wherever the shim sits in the list, and directories and extensionless files are refused — for PATH results, for the CODEX_BINARY and CLAUDE_BINARY overrides, and for an explicit path. A .cmd or .bat shim now runs through %COMSPEC% /d /c instead of being launched directly, and its arguments are escaped for the interpreter rather than for CreateProcessW, because the two do not agree on &, |, or >. The few tails cmd.exe cannot express at all fail with a message rather than launch something other than what was asked for. Closes #270. Thanks @ITSMERNB!
  • A price change that lands mid-scan is no longer stamped as current. Charts can take tens of seconds over a large corpus. If models.dev refreshes while that walk is still running, the usage index and the Estimated API / heatmap cache used to write the old-price dollars under the new fingerprint, then serve them as current until a file changed or the 14-day TTL expired. Both writes now capture the fingerprint when the scan starts and drop the persist if prices have moved. Unknown (never captured) is its own state, not "current". The reverse case is covered too: a scan that starts wholly under the new prices may be written, but the values already in the cache file were priced under the old ones, so they are dropped rather than relabelled current alongside it.

Internal

  • The no-glow prebuild check now sees a glow behind a CSS variable, and one written as an inline boxShadow. isGlow stopped at the first non-length token, so box-shadow: var(--halo) was waved through even when --halo was 0 0 10px. The check now resolves var(--x) against custom properties declared in the same file before judging the layer, and it reads boxShadow: "..." string literals in .ts / .tsx, including the el.style.boxShadow = "..." assignment form that is how an inline shadow is usually set. Regex literals are no longer mistaken for comments — /^[a-z][a-z0-9+.-]*:\/\// blanked the rest of its line, and a regex containing /* blanked the rest of the file, so any glow after one went unread. The property name written inside a string no longer counts as a painted glow. A custom property holding several comma-separated layers is re-split after substitution, so a glow sitting in the second layer of --card: 0 1px 3px red, 0 0 8px red is no longer read as the first layer's drop shadow. The live box-shadow: var(--panel-shadow) stays allowed: every declaration of --panel-shadow is an offset drop shadow.

What's Changed

  • Ask for a GitHub star, at most twice ever by @tsouth89 in #332
  • fix: choose launchable Windows PTY executable by @ITSMERNB in #331
  • Reject Charts custom ranges that start more than a year back by @tsouth89 in #334
  • Rotate a leaked serve token instead of chmodding it by @tsouth89 in #335
  • Ignore unavailable Cursor Plan readings in reset detection by @tsouth89 in #337
  • Write rotated Claude tokens back to the OS keyring by @tsouth89 in #338
  • Trim pace and primary labels before hiding placeholder pace by @tsouth89 in #339
  • Honor the 15-minute incident-check promise on failures by @tsouth89 in #340
  • Ignore path-adjacent tokens in terminal title matching by @tsouth89 in #342
  • Fail when no providers are enabled instead of returning [] by @tsouth89 in #343
  • Keep a corrupt settings.json as a backup instead of overwriting it by @tsouth89 in #344
  • Let Check for Updates run while an installer is already staged by @tsouth89 in #346
  • Keep a counted GitHub star ask on the empty tray by @tsouth89 in #347
  • Keep at least one provider on a custom taskbar strip by @tsouth89 in #345
  • Stop rebuilding usage indexes on a no-op models.dev refetch by @tsouth89 in #333
  • Redact MCP account identity unless --include-identity by @tsouth89 in #336
  • Honor enabled providers in MCP spend listing by @tsouth89 in #341
  • Reject a corrupt usage-index dedup_key instead of treating it as absent by @tsouth89 in #360
  • Use as_chunks for the constant-size chunk walks by @tsouth89 in #365
  • Stop writing settings unserialized when flock is unavailable by @tsouth89 in #348
  • Refuse Gemini OAuth cwd fallback when home is missing by @tsouth89 in #361
  • Correct the incident-badge only-outbound claim by @tsouth89 in #353
  • See glows hidden behind CSS variables and inline boxShadow by @tsouth89 in #356
  • Stop painting the busiest heatmap day the same shade as the quietest by @tsouth89 in #352
  • Require three usable days before a spend-anomaly baseline by @tsouth89 in #350
  • Pin Node to the version CI tests against by @tsouth89 in #366
  • Pair a staged installer with the digest it was downloaded from by @tsouth89 in #364
  • Keep the later usage-index snapshot when two Charts scans commit by @tsouth89 in #359
  • Fall back to a $20 trigger when spend-anomaly has no usable median by @tsouth89 in #358
  • Retry a lock sibling whose deletion has begun by @tsouth89 in #368
  • Split Windows paths the same way on every host by @tsouth89 in #367
  • Localize the activity heatmap card by @tsouth89 in #357
  • Retire the legacy temp sweep when a leftover cannot be unlinked by @tsouth89 in #363
  • Cap serve connections and load settings once by @tsouth89 in #362
  • Stop two CI flakes blocking the merge queue by @tsouth89 in #369
  • Drop mid-scan price writes instead of stamping them current by @tsouth89 in #349
  • Derive Codex day buckets from UTC at fold time by @tsouth89 in #355
  • Apply usage-index Stores per parse batch by @tsouth89 in #351
  • Stop painting an unpriced heatmap month as idle by @tsouth89 in #354
  • Put the unreleased entries back in the right sections by @tsouth89 in #370
  • Release 1.5.35 by @tsouth89 in #371

New Contributors

Full Changelog: v1.5.34...v1.5.35