Skip to content

Menubar never blank, Cursor string numbers (#151), Codex reset credits (#152) - #153

Merged
prakersh merged 56 commits into
mainfrom
fix/menubar-never-blank-cursor-codex-resets
Oct 22, 2026
Merged

prakersh merged 56 commits into
mainfrom
fix/menubar-never-blank-cursor-codex-resets

Conversation

@prakersh

@prakersh prakersh commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Menubar never goes blank

  • The daemon keeps the last good snapshot and the last good card per provider/account. A slow or failed build never empties the tray or the popover.
  • Snapshots are rebuilt in the background shortly after each poll (only while a menubar client is active) and served from memory. An aged snapshot is answered immediately while a rebuild runs, with an X-Onwatch-Refreshing hint so the popover re-fetches once.
  • Tray (macOS/Windows/Linux): a single refresh worker keeps the last title and menu. A failed refresh only dims the title. Opening the popover also refreshes the tray, so both show the same snapshot.
  • Popover:
    • Each card's age counts up from its own capture time, and stale cards show an amber age and a hollow dot (tooltip only, no inline text).
    • The last snapshot renders instantly from localStorage, and the DOM is rewritten only when the markup changes.
    • On macOS the panel fades in after WebKit repaints, with no black flash.
  • Companion process: single-instance lock, its own log file, pid ownership published by the lock holder, and an identity check before treating a pid as running.
  • GNOME extension: keeps the last good state, uses a 15s timeout with one retry, and cancels requests on disable.

Cursor numeric fields as JSON strings (Fixes #151)

  • Numbers, quoted numbers, "" and null all decode. A bad non-quota field degrades to zero; an unreadable limit or percent drops that quota for the poll instead of storing a fake 0%.
  • Team is no longer inferred from pooledLimit; the last known plan name is reused when the plan lookup fails.

Codex banked reset credits, phase 1 (Refs #152)

  • Read-only. Shows the per-account reset count, the same number the Codex CLI shows, as a compact "1 reset" chip on the dashboard and the menubar Codex card. The next expiry is in the tooltip.
  • One notification is sent 24h before a credit expires. onWatch never redeems a credit; a behavioural test fails on any request other than the expected GETs.

Peak hours

  • The Anthropic peak chip is removed. A DeepSeek peak/off-peak chip (peak 01:00-04:00 and 06:00-10:00 UTC, Mon-Fri) is added to Command Code, OpenCode Go (dashboard and menubar) and DeepSeek (dashboard).

Command Code and OpenCode Go dashboard

  • 7d/30d charts cover the full range (they previously showed only the newest ~3h). Usage samples keep units, caps and reset times. The billing cycle overview shows peak usage and reset times instead of Total Delta, and insights show reset pacing once 20 minutes of data exist.
  • Trackers no longer count the sample after a reset in the closing cycle, and they handle delayed API rollovers and idle windows without duplicate notifications. A one-time migration repairs existing inverted and churn cycles structurally. It never recalculates stored peaks and never touches raw snapshots.
  • Chart history never blanks on a failed fetch (all providers), and a failed refresh marks the kept chart stale.

Performance

  • Full-table DISTINCT scans replaced with index seeks, adding the missing codex_quota_values(quota_name) index.
  • Per-snapshot N+1 value loads batched in every provider store, and chart ranges sampled in SQL from the captured_at index.
  • Dashboard aggregates (insights, history, cycle overview, logging history) are cached until the provider they cover polls again. Settings writes clear the cache, partial or failed answers are never cached, and the cache is bounded at 60s, 128 entries and 8MB.
  • The dashboard fetches settings once per load.
  • Measured on a 430MB production database:
    • All-providers dashboard: 3.5s to 0.46s.
    • All-providers insights: 3.2s to 0.11s cold.
    • 30-day history per provider: up to 729ms to 39-119ms.
    • Popover open with an aged cache: up to 450ms to under 1ms.

Test plan

  • ./app.sh --test (race), full suite
  • gofmt clean
  • go vet -tags menubar on darwin (cgo), linux and windows
  • CI green on all platforms, including E2E on macOS, Linux and Windows
  • Repair migration dry-run on a copy of a real database: only the cycle tables and one settings marker change, raw snapshots are identical, and a second run is a no-op
  • Installed and running locally on macOS
  • Windows: quick view theme switch, and hide/show during a daemon outage
  • GNOME: disable/re-enable while a request is pending
  • Cursor: polls work on a free plan

…y age

A single slow /api/menubar/summary request blanked the tray title to
"--" and left the popover empty or black. Every client now keeps its
last good data and shows staleness only through each card's age label.

Daemon:
- Cache the built snapshot with singleflight; serve it immediately when
  fresh (12s) or when a rebuild exceeds a 3s budget, and let the build
  finish in the background. Settings and provider writes invalidate it
- Keep the last good card per provider/account and serve it with its
  original capture time on storage read errors; never fabricate fresh
  zero cards; staleness no longer raises severity
- Add latest_at and per-card updated_at_time so ages count from real
  capture time

Tray (macOS/Windows/Linux):
- Keep the last title, menu and preferences on failure; only the icon
  tint and "last updated" tooltip change
- Single refresh worker, so requests never block, overlap or get lost
- 15s timeout with one retry

Popover:
- Ages count up from each provider's capture time
- Connection issues move to the status dot (ring + hover tooltip)
- Render the last snapshot from localStorage instantly on open; ignore
  out-of-order responses and corrupt cache entries
- Host background follows the page theme to avoid the black flash

GNOME extension: keep last good state, 15s timeout with one retry,
cancel in-flight requests on disable.
Cursor now returns spend fields such as spendLimitUsage.pooledLimit as
strings ("50000"), which aborted the whole usage decode and dropped the
provider on every poll, including free plans.

- Tolerant int/float types accept numbers, quoted numbers, "" and null
- A field that still cannot be parsed degrades to zero with one warning
  (count only, no values) instead of failing the response
- Team is no longer inferred from pooledLimit; it requires a team or
  business plan name or limitType "team"
Read-only tracking of Codex "reset credits". onWatch never redeems them.

- Parse rate_limit_reset_credits (available and applicable counts) from
  wham/usage; an absent count stays unknown instead of 0
- Fetch the credit list (GET wham/rate-limit-reset-credits) every 30m
  per account, or when the count changes; failures never touch usage
  polling, pause state or auth counters, and never refresh tokens
- Store credits per account with gone-marking; additive, idempotent
  migrations; account merges keep notification and live state
- Dashboard: "N resets" chip with next-expiry tooltip, plan-unsupported
  note, and a muted dot when the list is unavailable
- One notification per credit 24h before expiry, deduped across restarts;
  credits with no or nonsensical expiry are never notified
- Test guard that no consume/redeem code path exists
Cards whose data is older than a few poll intervals now get an amber
age label and a hollow status dot with a hover reason (for example
"Last updated 14m ago - network error"); the tray title dims and its
tooltip lists stale providers. Ages now come from honest capture times:
statusline data uses the file mtime, multi-quota cards use their oldest
quota, and OpenCode uses its cache fill time. The menubar companion now
writes its own log so a hung daemon cannot block it.

Also stop serving stale summaries as live data:
- respondJSON now sends Cache-Control: no-store on every API response.
  The dashboard needed it too and no API response here is cacheable.
- Every menubar fetch uses {cache: "no-store"}.
- The popover fetches on native show (macOS and Windows), window focus
  and visibilitychange, coalesced through the existing generation guard.
  The localStorage instant paint still happens first.
- A companion holds an OS-level single-instance lock, and the daemon no
  longer relays the companion's stdout into the log it already writes.
…panion restart waits

Oldest-quota ages now take each quota's current time when it is present
and only remember a quota while it is missing, so a refreshed card moves
forward. Anthropic hybrid cards judge staleness on their primary windows,
not the every-tenth-poll API supplements.

The macOS title applies systray's plain SetTitle first so fyne switches
the button out of image-only mode, then the attributed (dimmed) title,
and mirrors the image position. The daemon waits for an exiting companion
and the replacement retries the lock for 10s. The companion logs to
stderr when it cannot open its log, and an unchanged statusline file no
longer inserts duplicate rows.
…a (CI)

The popover called init() from the middle of its IIFE, before the snapshot
generation counters were initialized. The first render and cache restore
therefore threw "Cannot access 'snapshotGeneration' before initialization":
the page stayed on "Loading" and no provider card rendered, which failed the
four Playwright menubar tests on every platform. Start init() at the end of
the IIFE and guard the ordering with a static test.

The E2E fixture now seeds Anthropic statusline data in its isolated HOME.
Synthetic (real API, 401) and Anthropic (no statusline file) have no stored
data, and omit-on-no-data correctly drops them; main only passed because it
fabricated zero cards. Real statusline data gives the order and card tests a
second provider without any network call.

Make the invalidation-burst cache test deterministic: a non-positive freshness
window forced late waiters to start extra builds after a successful one, so
the at-most-two assertion depended on scheduling. Use a positive window, and
replace the sleep-based staleness in the slow-build test with a negative one.
Comment thread cmd/onwatch/menubar_runtime.go Fixed
Lock a byte range past the pid text so the lock file stays readable on
Windows, where LockFileEx locks are mandatory. Latest-snapshot providers
(Cursor) drop quotas missing from the payload immediately instead of
pinning the card stale for 24h. Apply the parent-pid guard on unix only
and recognise renamed builds by comparing the executable base name.
…DeepSeek; drop Anthropic peak chip

Command Code, OpenCode Go and DeepSeek cards now show a peak/off-peak chip
for the DeepSeek schedule, peak 01:00-04:00 and 06:00-10:00 UTC Mon-Fri.
The schedule is computed once in a shared helper with exact UTC math and a
fractional countdown; the 30s tickers update the chip without refetching.
The Anthropic promo chip and its ET-based logic are removed from the
dashboard response, the popover and the menubar snapshot.
…harts

Sample the selected range within the 200-row budget instead of returning only the newest polls, preserving the earliest and latest samples in individual and combined views.
Return stored format and reset metadata and render credit or currency usage against its cap instead of dropping units and limits. Keep valid zero balances and percentage-only OpenCode meters intact.
Honor the OpenCode cycle filter and dashboard JSON contract, preserve stored reset times and limits, and replace irrelevant duration and total-delta metrics with peak utilization and reset times for both window providers. Add contract and Node rendering regressions covering samples, cycles, chart datasets, and fetch-failure preservation.
A non-OK status, an error or non-array body, and a network failure now warn with the provider and range and leave the existing datasets untouched. Only a successful empty response clears the chart. The guard is shared by every provider.
The poll that detected a reset was folded into the cycle being closed, so a completed cycle could store the next window's peak and delta. Close on the last sample of the old window and give the new cycle only a future reset timestamp, avoiding stale resets and inverted cycle windows.
…mmand Code

Require a 20-minute measurement window before projecting a rate, replace the misleading Idle/0.0% placeholder with an omitted stat or a measured Flat result, and add reset-pacing cards with exhaustion risk per window. Exclude Command Code's uncapped dollar balance from percent forecasts and bound the payload at 50.
A poll that still carried the previous, already-passed reset created a cycle
with no reset, so the next poll read that stale reset as a fresh window. Each
poll then closed a one-minute cycle and fired a duplicate reset notification;
a time-based close could also set cycle_end before cycle_start.

Treat a reset that is not after the sample timestamp as absent everywhere, and
attach a newly published future reset to the open cycle instead of closing it
unless utilization dropped. Clamp cycle_end to cycle_start.
rate.Rate is utilization percent per hour while Used and Limit are credits, so
adding the rate straight to Used overstated consumption (2.8 of 14 credits at
10%/hr over 4h became 42.8 instead of 8.4) and showed the card as already
exhausted. Convert the rate through the cap before comparing to the limit.
OpenCode's equivalent card is already percent based.
The OpenCode and Command Code overview handlers used the generic 500-row page
limit, and each cycle runs an extra quota-values query. Cap them at the
project's 200-cycle budget so one request cannot fan out past the guardrail.
A limit of 500 now returns at most 200 newest cycles.
Existing databases carry derived cycles written before the reset-boundary fix:
inverted windows, peaks borrowed from the next window, and one-minute churn
cycles. Run a one-off repair guarded by a settings marker, inside one
transaction per provider table, without touching raw snapshots.

Inverted cycles merge into an adjacent successor or are clamped to the last
sample inside the window; short null-reset cycles fold into their successor;
peak and positive-only delta are recomputed from in-window samples; active
cycles lose a reset that is not after their start. Each pass reads only the
newest 200 cycles per quota and logs counts.
One drifted count on the main usage payload failed the whole wham/usage
decode, leaving Codex usage stale for every account. Accept numbers, quoted
numbers and integral floats; degrade anything else to unknown.
Team and Business members report limitType "user", so the plan name is the
only Team signal. A failed plan-info fetch classified the account as
Individual for that poll, flipping total_usage from dollars to percent.
Keep the last known plan name in memory; never infer Team from pooledLimit.
A future mtime (clock skew, or a file synced from another host) was clamped
to now, so the timestamp changed on every poll and the same unchanged file
inserted a row per poll while being labelled fresh. Clamp only the displayed
captured time; dedupe on the raw filesystem mtime.
The expiry sanitizer reports an untrustworthy expiry (already past on a fast
local clock, or equal to granted_at) as unknown. The next sync then wrote
NULL over a stored, valid expires_at and set expiry_unknown, so a credit lost
its real expiry. Keep the valid stored value; a credit genuinely reported as
never-expiring still clears it.
UPDATE OR REPLACE kept the source row's status and last_seen even when the
target's row was newer, so a merge could resurrect a stale status. Take both
from the row with the newer last_seen; notified_at, MIN(first_seen) and the
gone_at live-wins rule are unchanged.
The source-string guard could be routed around by URL building. Every Codex
client request method now runs against an httptest server that fails on any
non-GET, on a path containing consume, and on unexpected paths. The guard's
skip list is narrowed to exact file paths, and the agent's fake server fails
on any non-GET or unexpected path. The string test stays.
A non-numeric limit or percent ("N/A") degraded to zero and was stored and
shown as a fresh 0% or no-limit reading, violating never-fabricate. Drop that
quota so the last good card is kept; non-defining fields still degrade to
zero, and numbers, quoted numbers, null and "" still decode as before.
The all-accounts overview replaced a failed per-account history fetch with an
empty array, then destroyed and redrew the chart without that account's lines.
Treat non-OK responses, error or non-array bodies and network errors as
failures: warn and leave the existing chart untouched.
A failed range or account switch left the previous lines on screen under the
newly active label with only a console warning. Record the provider, account
and range each chart was drawn for; when a later failure does not match, keep
the lines but show an amber hollow dot whose tooltip names what is displayed.
The next successful load clears the mark.
Command Code, OpenCode and DeepSeek rendered a chip in the All Providers card
header and again on their first KPI card. Individual views only put it on the
first card, so drop the header chip for providers that have KPI cards and keep
it only where there is no KPI card to carry it (DeepSeek balance).
if err != nil {
return 0, false
}
defer file.Close()
Limit the unshipped v1 repair to structural corrections, preserving stored peaks and deltas despite incomplete or ambiguous samples.
Recover awaiting-rollover state from persisted cycle boundaries so stale samples cannot seed the new peak or cause another reset after a restart.
The delayed-rollover bug seeded churn cycles with the old window's sample, so folding or merging them forward wrote a spurious 100% peak into the real successor. Let the successor keep its own stored statistics instead.
Stop holding samples once the grace window ends, restart an idle OpenCode window at the next session instead of stretching it across days, and count usage recorded between the boundary and the API acknowledgment.
An idle 5h window that waited past the rollover grace kept its old start when the next session opened, stretching the cycle across days. Mirror OpenCode's idle restart and keep the next session's first sample. Fold churn rows without dragging a distant successor's start, and seed usage in a new window whose predecessor barely registered any.
Only restart an idle Command Code window when it never learned its reset, so a boundary published during the grace period is not moved forward. Seed the restart with its first sample so the delta matches OpenCode.
historyBoth silently dropped providers whose read failed and still returned
200, so the All Providers view saw a missing provider and replaced its drawn
rows with "Collecting data...". The handler now lists per-provider read
failures in an errors map, and the dashboard merges successful providers into
the previous snapshot, keeping failed providers' last rows behind an amber
stale dot with the data age in its tooltip. A successful empty read still
clears a chart.

A repeated failure of the chart's current selection also kept its stale mark
now: the render key records the last successful fetch time, and every failure
of the current request marks the retained data with its age. Failures from
obsolete requests no longer touch the marker.
The peak test helpers interpolated a filesystem path straight into a JS
string literal. On Windows filepath.Join produces backslashes, JavaScript
read them as escapes, and require() failed with a mangled module name
(D:aonWatchonWatchinternalwebstaticpeak-hours.js). Render the path with
JSON string quoting instead, which is valid JavaScript on every platform,
and unit-test the quoting with a Windows path so the logic runs on macOS.

The other node-driven web tests read their sources through staticFS, so no
path is interpolated and nothing else needed changing.
TestDaemonChildRun_DebugModeAllProvidersWithCopilot failed on Windows only
because t.TempDir's cleanup reported 'unlinkat ...: The directory is not
empty'. The child is terminated abruptly there (Windows cannot deliver
os.Interrupt), and a write from it can land between the cleanup's directory
scan and rmdir; Go retries sharing violations on Windows but not ENOTEMPTY.
The daemon subprocess helper now removes its sandbox home itself, retrying
until it converges, before t.TempDir's own removal runs.
The dashboard rendered applicable_available_count (0 while a reset is banked)
as a stray grid cell. Use available_count, the number the Codex CLI shows, and
render a compact chip in the first card header, the account header, and the
menubar Codex card, with the next expiry in the tooltip.
DISTINCT over the per-snapshot value tables scanned 80k-230k rows per call
(100-200ms each, several per insights and menubar build). Seek between values
through an index instead, adding the missing codex_quota_values(quota_name)
index. Antigravity range loads ran one query per snapshot; batch them, sample
chart ranges in SQL, and compute the coverage insight from COUNT/MIN/MAX.
Build the menubar snapshot shortly after each poll (debounced, only while a
menubar client is active) and serve aged snapshots immediately while a
rebuild runs, with an X-Onwatch-Refreshing hint so the popover re-fetches
once. The popover now writes DOM only when its markup changes and repaints
once per refresh, opening the popover refreshes the tray from the same
snapshot, and the macOS panel fades in after WebKit repaints instead of
flashing the bare background.
Dashboard init read /api/settings three times in sequence (hidden insights,
timezone, provider settings) before any data request could start. Share one
request and clear it before any settings write.
Insights, history, cycle overview and logging history are pure functions of
stored data and settings, yet were recomputed on every load, tab switch and
refresh tick. Cache each 200 response until the provider it covers polls
again (any poll for the combined view), drop everything on a settings write,
cap entries at 60s, 128 entries and 8MB, and never cache partial answers.
Range loads ran one SELECT per snapshot on every provider's value table,
so a 30-day history read issued thousands of tiny queries. Load each range's
values with batched WHERE snapshot_id IN (...) queries (400 ids per query),
ordered by snapshot_id then the existing inner order, and attach rows through
an id->snapshot map. The same batching removes the per-cycle value queries
from every provider's cycle overview.

Chart callers also get ...RangeSampled methods that pick about 200 snapshots
evenly across a range in SQL, using windowChartQuery, plus a variant that
carries an extra account predicate.
History endpoints and historyBoth loaded a full range and then downsampled
to maxChartPoints, so the query cost grew with every poll in the window.
Switch the chart handlers to the store's ...RangeSampled methods, which pick
about 200 points in SQL. Logging-history endpoints, which need every row,
and Cursor/Ollama/Muse (already capped at the 200 most recent) are unchanged.
The window sampler numbered full rows, so a 30-day range materialized every
snapshot (raw_json included) to keep about 200. Pick the sampled ids from
(captured_at, id), which the index covers, then read only those rows:
30 days of Command Code drops from about 0.7s to about 10ms.
…d reads

Responses over 1MB are served uncached and stored bodies are exact copies,
so the 8MB budget holds. Agents publish a poll outcome just before writing
the snapshot, so invalidate the provider again 3s later to drop answers
computed in that gap. Grok, Kimi and Antigravity history now answer an error
status on a failed read instead of an empty 200, which the dashboard keeps
its last chart for and the cache never stores.
…nswers

Every settings write (including hiding an insight) clears the response cache,
and insight builders that swallow a read error mark their answer partial so it
is served but not cached. Background menubar rebuilds keep a 5s minimum gap,
deferring poll-driven prebuilds and never retrying a failing build per
request; the popover's refresh follow-up cannot chain. DeepSeek and Moonshot
now report poll outcomes after their writes, and the dashboard reset chip's
expiry tooltip updates on the 30s ticker.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cursor: pooledLimit now returned as a JSON string, so every quota fetch fails ("cannot unmarshal string into ... of type int")

1 participant