* 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
Adds a live quota gauge for the z.ai ZCode coding plan on all three surfaces: the CLI plan view, the desktop Plans page, and the mac menu bar. It reads the provider's own remaining-allowance endpoint, so it is quota-only and touches no cost or spend paths. One decoder is shared between Z.ai and ZCode, and ZCode is hidden when a Z.ai login already covers the same endpoint. The fetch is bounded with graceful disconnected, waiting and error states, and no socket is opened without a login. The logo shows on every surface, and ZCode is listed in the desktop supported-provider list to match the menu bar catalog.
The Codex early-reset detector dropped additional-limit windows that were at 0 percent used before checking them, so a per-model window that reset early to 0 percent was never recorded or notified; those windows now reach the detector while the popover stays compact. The Capacity Dock reconnect text was a fixed size while its reserved height scaled with the dock, so it clipped at the 0.9 minimum scale; the text now scales with the dock and the reserve is measured at the scaled font.
Codex hands its quota windows to the same early-reset monitor Claude uses, on refresh and on bootstrap, and a Codex disconnect clears its record. Window identity comes from a pre-localization storage label, so a language switch or the limit-reached suffix cannot drop the stored baseline or flip the key at the boundary. A window with no validated cycle length stays silent, and when a provider reports absolute usage a spend-cap increase no longer reads as a cleared counter.
Every connected provider's windows are checked on the existing quota
refresh. A crossing fires once per (provider, window, reset instant);
the next cycle re-arms it. Authorization is requested on the first real
crossing, not at launch. Settings gains a toggle, default on.
The popover's quota warning painted its text in the same system yellow,
orange or red as its 12% pill. In light mode that measured 1.23:1 to 1.43:1
for yellow and at best 3.04:1 for red, and dark-mode red fell to 3.33:1. The
pill keeps its hue; the text and its glyph now take a per-appearance
foreground from QuotaWarningPalette that measures at least 5.29:1, and a test
holds every severity at WCAG AA against the pill composited over both ends of
the popover's background in both appearances.
The sentence named no window. AggregateQuotaStatus reduced each provider to
its worst window and kept only the percentage, so "Claude 70% of quota used"
was the 5-hour limit read next to a weekly 34%, and looked wrong. Each warning
now carries the winning window's label and reset, and the row reads
"Claude · 5-hour 71% · resets in 3h 12m" with the Capacity Dock's countdown,
one line per provider. The countdown is left off when there is no reset time
or it has passed, and "over limit" is kept for a window at or past 100%
instead of everything the danger band (90% and up) covers. The windows
compared, the 70% threshold and the flame's severity are unchanged.
"%@ of quota used" has no caller left and is retired; "resets in %@" is added
for en and zh-Hans.
Groundwork for #1219. The menubar app hardcoded ~530 English strings across
Views/, the presentation structs, the status-item menu and the update alerts,
so there was nothing for a translator to translate.
Every user-facing literal now goes through `L(_:)` / `L(_:_:)`, which look the
string up in `Localizable.strings` in the SwiftPM target resource bundle. The
key *is* the English copy, so English stays the development language and a
missing translation degrades to correct English instead of a dotted
identifier. `en.lproj` is therefore an identity table; it exists so the bundle
advertises `en` and so a translation can be diffed against it.
Why the lookups are explicit rather than relying on SwiftUI's implicit
`LocalizedStringKey`: SwiftPM emits target resources into a sibling bundle
(`CodeBurnMenubar_CodeBurnMenubar.bundle`) that the packaging scripts copy into
`Contents/Resources`. `Bundle.main` has no `.lproj` at all, so `Text("literal")`
would always miss. Naming `Bundle.module` is the one form that resolves the
same way in `swift run`, in `swift test` and in the packaged `.app`.
Enum raw values that double as identity (`Period`, `MenubarScope`,
`InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gain
a `displayLabel`, so persistence and cache keys are untouched by translation.
Three display-only date formatters that were pinned to `en_US_POSIX` with fixed
patterns now use `setLocalizedDateFormatFromTemplate`, and the calendar
popover's weekday row comes from the locale's own symbols. The `yyyy-MM-dd`
formatter stays POSIX: it parses and builds data keys, not display text.
Not extracted, deliberately: provider, model and plan names; units; currency
codes; shell commands and paths; quota window labels that policy code matches
on by English substring (`QuotaSummary.headlineWindow`); and anything the
`codeburn` CLI produces.
Also copies the SwiftPM resource bundle into the app in build-local.sh, using
the named-bundle + `[[ -d ]]` pattern package-app.sh already uses. Without it
the assembled app finds no strings table (and already trapped on first icon
load, the resource-bundle half of #1262).
Detection rides the refresh lifecycle that already runs: it happens where
each successful Claude fetch is captured into the snapshot store, before
the store is written, so it costs no request. Issue #725 names "no new
network polling loops" a hard non-goal, and this adds none.
Three surfaces, because the user can be looking at any of them. A system
notification names the provider, the window and the lead. The Capacity
Dock carries a band saying the same for twelve hours after the reset was
seen, drawn and reserved exactly like the staleness notice, because the
panel's frame is computed rather than fitted. The quota hover card gains
this Mac's own pattern next to the pace captions.
The state before the fetch decides whether the stored baseline can be
compared against it: a provider coming back from a terminal failure, a
fresh bootstrap or no credentials has a baseline from the far side of a
gap, where stale state looks like a jump. A window the API did not report
this time is passed as absent rather than omitted, so a window that comes
and goes is never read as a reset. Disconnect drops the baseline along
with the snapshots, so a reconnect under another account starts clean.
The Settings switch is on by default, and turning it off withholds only
the notification — the dock band still appears, since it is a caption on
data the user is already looking at rather than an interruption.
A banked reset is the one capacity signal a user can act on before it is spent,
and it arrives silently. CodeBurn already sees it: the reset-credit inventory
rides every successful Codex quota refresh. Comparing that inventory against the
previous reading is all it takes to notice a grant, so there is no new endpoint,
no new cadence and no new data source here.
The detection is a pure function over an observation and the state it is judged
against, which is what makes the awkward cases testable rather than hopeful:
- The first-ever observation is a baseline. Everything the account already holds
is history, not a burst of notifications.
- A credit that disappears was spent or expired, and is never an event. Its id
is kept for the retention window so a payload that lists it again — a flicker,
not a grant — cannot re-announce it.
- An absent or malformed credits payload is *no opinion*: no event, and no state
change at all. Treating it as an empty account would re-baseline on every
failed fetch and re-announce the whole inventory on reconnect.
Seen ids persist to `codex-banked-resets.json` in the CodeBurn cache directory,
written through SafeFile behind an actor exactly the way SubscriptionSnapshotStore
writes its snapshots — the same mechanism, not a second one — so a relaunch does
not repeat a notice. State is written before the post, not after: a notice we
drop is a smaller failure than one that repeats forever. Disconnecting clears the
file, so a reconnect under another account baselines again.
The notification goes out on the existing UpdateNotifier path, one per new
credit, naming what was granted, when, and how many are usable. Never the credit
id, never a token. Settings gains a toggle, default on; with it off the state is
still recorded, so turning it back on does not release a backlog.
Nothing here ever spends a credit. It is a notice.
Reserving the pace slot only while connected made the panel relayout on
every refresh: the slot collapsed and the staleness band appeared. The
reservation is a property of the window shape, so it no longer asks the
connection. Windows block on the real Claude shapes, connected vs stale:
91/77 -> 91/91 at one and two windows, 164/136 -> 164/164 at three.
A Claude or Codex summary now reads stale on sample age alone, and the
manual cadence never refreshes, so "Last known usage - refreshing" and
the Settings "Refreshing..." became permanent lies. Both now ask whether
a fetch is actually in flight.
The "quota remaining" row selected each provider's headline window, which
is a billing horizon: weekly, else monthly, and only then the busiest
window. That is the right number for the Capacity Dock rail and the wrong
one for a row whose whole job is to name the provider nearest its limit.
On a real machine it discarded Cursor's API window at 100% and Claude's
5-hour window at 18% in favour of Monthly 9.5% and Weekly 5%, so the row
read as if nothing were near a limit — while the flame beside it was
tinting off the worst window across every provider. The comment claiming
the row followed the flame was wrong on both counts.
Each provider now contributes its worst window, per-model rows included,
which is exactly the rule aggregateQuotaStatus tints by, so the row and
the flame agree and README, CHANGELOG and the Settings help become true
as written. A provider in transientFailure keeps its last-known window
instead of dropping out, matching the dock, which shows the same data
dimmed during a backoff: the row no longer vanishes and resizes the
status item for the length of a retry.
Three smaller fixes from the same review: the row is capped at 24
characters — about the widest the single row gets at 13pt, since the
second line renders at 9pt — shortening a long provider name rather than
letting "GitHub Copilot 12% left · 6d 3h" more than double the item's
width; the two-line title gets a VoiceOver label that reads as one phrase
instead of a string with a newline in it; and the snapshot feeding the
row is no longer built on refreshes where the feature is off. With the
setting off the title is byte-for-byte the single-row composition it has
always been.
Closes#1310
Main took render revision 6 for `sessionCountBasis` and changed the
provider-slice session fold from a sum to a per-day max. Both had to
survive the merge, so the resolutions are unions rather than sides:
- `addProviderSlice` keeps main's `Math.max` for `sessions` (per-day
ticks are not distinct identities) and adds the cache-read sum beside
it, with a comment saying why one is additive and the other is not.
- `ProviderSliceTotal`, `ProviderCost`, `providerDetails` and the Swift
`ProviderDetail` carry both `sessionCountBasis` and `cacheReadTokens`.
- The snapshot revision stays 7, which is still the next free number
above main's 6. Its doc comment said v6 was PR1265's per-model counts;
it is main's `sessionCountBasis`, so the comment now says that.
The status-menubar suite hard-codes render revisions. Main's assertion
that a rejected v5 snapshot is rewritten as `:render-6:` auto-merged
cleanly while this branch moved the current revision to 7, which would
have failed silently on the next bump too. Those assertions now derive
the tag from `STATUS_SNAPSHOT_RENDER_VERSION`, so the current and
previous revisions follow the constant; the deliberately historical
`:render-5:` and `:render-2:` plants stay literal.
The menu bar item could show one figure, so seeing subscription quota and
today's local usage at the same time meant opening the popover. This adds an
optional second line, off by default, configured from the existing
Settings > General > Display section: a "Second row" toggle plus a picker for
what it shows. All four metrics are figures the app already computes for the
popover, so nothing here adds a fetch or a refresh cadence: quota remaining
with its reset countdown for whichever connected provider is nearest its limit
(the same provider the flame already tints for), today's all-provider cost,
today's total tokens, or running sessions.
The rows are one attributed title. A paragraph style clamps text line height
but not an attachment, so the inline flame at the text's own point size dragged
the title to 24pt and AppKit clipped the second line away; the flame is
requested at 8pt and seated back inside the line, which puts every row
combination at exactly 20pt inside the standard 22pt menu bar, centred with 1pt
of slack above and below. Measured with NSAttributedString.boundingRect and
asserted in the tests, since those four numbers only work together.
With the setting off, or whenever the chosen metric has no data yet (no
connected provider with a quota window, no today payload, a CLI that predates
the liveSessions block), the title falls back to the previous single-row
composition unchanged: same font, same attributes, no paragraph style. The row
formatting is a pure type over the row settings and a plain-value payload
snapshot, so it is covered without an AppStore or a status item.
This is a deliberately reduced slice of the request. Not included: the layout
editor, presets, live preview, a third row, multiple items or aligned columns
per row, per-item provider/period/scope, optional labels, decimal precision,
reordering and text size. Keeping the formatter pure leaves room for those
later without touching the rendering path.
Closes#1252
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.
Switching providers stashed the outgoing payload and rendered it under the new
tab until that tab's own fetch landed. That is not continuity, it is a wrong
number: opening the Hermes tab printed the all-provider total, and the test
suite pinned that behaviour by asserting $12 under Hermes and $12.34 under
Cursor.
The fallback is gone. With no cached data for the selection, `payload` is
empty, `hasCachedData` is false, and the popover renders the loading state it
already has for a cold key, so no dollar figure is shown for a provider whose
data has not arrived. AgentTabStrip and MenuBarContent take the same
hasCachedData gate for the same reason.
When a scoped payload disagreed with its all-provider slice, the code re-ran
the fetch bypassing the resident child and, if it STILL disagreed, threw. That
verified payload is what the CLI actually reports for the provider, so
throwing traded a real number for an error banner and left the tab with
nothing at all.
The payload is now cached with a contradictsAll flag on CachedPayload
(add-only) and served, and the popover carries one subdued secondary line
saying the total may be incomplete. The refresh paths treat an
already-verified contradiction as usable, so a poll cannot re-run the one-shot
parse forever.
To guarantee the all-provider slice was cached before a scoped result could be
accepted, the code simply awaited the all-provider fetch BEFORE starting the
scoped one. That is two full parses back to back on every tab click, and the
ordering it bought is only needed at acceptance time, not at start time.
Both fetches now start together. fetchPayload awaits the all-provider task
after its own fetch has already returned and before the consistency check
reads it, so the evidence is still present when the scoped result is judged.
The resident-bypassing cold fetch was already conditional on a contradiction
actually being detected; it is unchanged.
inFlightKeys held only a timestamp, so the slot had no notion of WHICH fetch
owned it. The watchdog and the display-sleep reset both evict an occupant, and
a later fetch then legitimately claims the same key. When the evicted fetch
finally returned, its defer cleared the new owner's slot and resumed every
task parked on it, so a waiter woke on an empty cache and reported failure
while the real fetch was still running.
The slot now carries a token. A fetch releases it only if it still owns it;
the watchdog and the resets take an explicit force path, because they are
declaring the slot dead rather than reporting a fetch that ended.
The interactive refresh path bailed with false whenever the key was already
being fetched. recoverFromStuckLoading() reads that return value, so a healthy
fetch that was simply still running was announced as a failed recovery, and a
second interactive refresh threw away a result that was moments from landing.
Join the in-flight fetch and report on its outcome, which is what the quiet
path already did.
Adds a DataClient fetch hook under DEBUG: slot ownership and acceptance
ordering are only observable when two REAL fetches overlap, which needs a
fetch whose completion the test controls rather than a spawned CLI.
The Capacity Dock hover panel becomes a glance: a provider header, the
sessions running right now as pills with their context rings, a Today
strip, and a row of quota-window percentages, separated by hairlines.
Window percentages stay plain text until 70 percent so a healthy row
reads as text rather than a wall of colour. The panel stays 350pt wide;
every block height is a whole point and is summed into the panel frame,
since the hosting view does not size itself.
Running sessions are new CLI surface. `liveSessions` is add-only on the
menubar payload: sessions whose transcript was appended inside a 120s
window, carrying the branch, the model that answered last, and the
context that turn reported. Absence means "unknown", so the section
hides rather than claiming nothing is running. The block names projects
and branches, so sharing and MCP output strip it.
Today's totals needed no CLI change; the app simply was not decoding the
cache token counts the payload already carried.
The status-item popover's hosting view is created once and lives for the
app's lifetime, and the flame wordmark inside it ran a repeat-forever
shimmer, so SwiftUI re-rendered the closed popover at display cadence
continuously: 5 to 7 percent idle CPU and roughly 650 wakeups a second
(Activity Monitor showed a 12-hour energy figure near two thirds of
Chrome's). The wordmark sweep and the loading flame pulse now start and
stop with popover visibility via a store flag set by the popover
delegate.
Modern Copilot clients no longer write ~/.config/github-copilot/hosts.json or
apps.json, so the menubar reported "No GitHub Copilot credentials found" even
after signing in through VS Code and the Copilot CLI.
Discovery now walks an ordered read-only chain, first hit wins, every rung
tolerating an absent, locked, or malformed source: the legacy plugin files,
the Copilot CLI keychain item (service copilot-cli, read via /usr/bin/security
so no partition-list prompt can fire), ~/.copilot/config.json and
settings.json, COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN, `gh auth token`,
and finally a token pasted into Settings. The two subprocess rungs are cached
for five minutes so eligibility checks and the 30s refresh cadence never spawn
per call, and hasCredential stays spawn-free.
VS Code's encrypted secret storage is deliberately untouched.
The no-credentials message now says usage tracking still works and names every
way to connect, and the Copilot settings pane gains a token field.
A credential-less adapter left in the dock selection was silently
reconnected by the next scheduled refresh, undoing the user's explicit
disconnect. Cursor is the first adapter that needs no saved credential,
so the disconnect intent now persists: the provider leaves the stored
selection without latching manual-selection mode.
* feat(menubar): add native capacity dock
* fix(menubar): harden dock interaction boundaries
* feat(menubar): polish native Capacity Dock for launch
- Flush top-edge docking via NSHostingView safe-area opt-out; shielding
window level so the rail necks into the physical top like the system notch
- Liquid Glass legibility scrim so labels stay readable on light wallpapers
- Weekly-else-monthly headline window; unified severity colour bands
(green <50 / yellow / orange / red) across rings and detail bars
- Squircle gauge + graphite theme defaults; auto-show connected
subscriptions (up to 5) until the user edits the set, then respect it
- Capacity Dock Settings menu item; re-centre the settings window; info
glyph for the About row
- Trim dead provider-credential override fields down to apiKey only
- Reconcile and extend tests for all of the above
---------
Co-authored-by: iamtoruk <hello@agentseal.org>
Ports the desktop app's Antigravity flow: ps+lsof discovery of the
local language server, Connect-RPC quota/status probes over loopback
HTTPS with the self-signed-cert trust override scoped strictly to
127.0.0.1, disconnected-state UX when the app is not running. Also
swaps all provider sidebar marks to the official LobeHub icon set.
Ports the desktop app's Copilot flow (editor-plugin OAuth token from
~/.config/github-copilot, copilot_internal/user snapshot) read-only,
with github.com-host preference and UI-string sanitization.
Ports the desktop app's Gemini Code Assist quota flow (loadCodeAssist
tier + retrieveUserQuota buckets, ~/.gemini/oauth_creds.json read-only,
in-memory-only refresh) to the menubar. Plan tab and empty-state gating
now treat Gemini as plan-capable.
disconnect() cleared the usage block before anyone knew whether the delete
had worked, and AppStore then returned early on failure — so a failed
disconnect cleared some state, left the rest, and still posted
subscriptionDisconnected. It also carried a second !isSuccess branch that the
early return had already made unreachable.
Both services now return the delete result and only clear the usage block on
success, so a failure changes nothing at all: the provider stays connected,
Disconnect stays available, and the banner asks for a retry. That matches the
success path's ordering instead of half-applying it.
Errors reaching the generic catches now render localizedDescription rather
than String(describing:), so a Keychain failure shows its message instead of
an enum dump with the raw item name in it.
Extra High MERGE AFTER FIX on 252ea92. Pathname chmod was
unverified. Disconnect hid retry when only the legacy file
survived. Secure read stopped at exactly maxBytes.
Tighten leftovers via opened-fd fchmod+fstat. Keep bootstrap
unless both Keychain and legacy deletes succeed. Read
maxBytes+1 so growth past the limit is rejected.
A valid Keychain item plus a leftover JSON used to skip chmod, so a
failed unlink could leave 0644 secrets on disk. Failed Disconnect also
cleared bootstrap and hid the retry. Repair leftover files to 0600,
keep bootstrap when Keychain delete fails, revalidate the opened fd,
and loop the secure read.
Stop writing OAuth caches as Application Support JSON. Persist
CodeBurn-owned items in Keychain, secure-read and migrate leftover
0644 files only after read-back verification, and keep Claude from
storing a refresh token.
The desktop dollar-balance footer used toFixed, which drops thousands
separators, while the new menubar footer groups via a currency
NumberFormatter, so the two clients showed $12500.00 vs $12,500.00 for
the same balance. The menubar credit footer also rounded half-even while
the desktop uses Math.round (half-up), disagreeing on exact-half values.
Render the desktop dollar path through the en_US currency locale, and pin
the menubar footer formatter to half-up. Adds a grouping assertion (TS)
and an exact-half rounding test (Swift).