feat: menu bar / system tray companion app (macOS, Windows, Linux) - #231
Draft
VasiHemanth wants to merge 12 commits into
Draft
feat: menu bar / system tray companion app (macOS, Windows, Linux)#231VasiHemanth wants to merge 12 commits into
VasiHemanth wants to merge 12 commits into
Conversation
Adds a small Tauri v2 desktop app under src-tauri/ that starts the local server on login and keeps today's spend in the menu bar. It is a launcher, not a replacement for the dashboard — every menu item opens the same web UI, because the trace view and analytics need real screen space. It spawns one child, `node bin/cli.js`, rather than reimplementing venv creation, the SHA-stamped pip install, npm ci and the port pre-flight in Rust. One source of truth for how the services start. Notes on the parts that are easy to get wrong: - Today's figure comes from /analytics?from=<local date>&to=<same>, read off total.cost. The date is built from local Y/M/D, and `to` is a bare YYYY-MM-DD because the backend decides whether to merge today's in-flight sessions with a lexicographic string compare. by_day is empty on a zero-spend day, so total.cost is the only safe field. - Liveness is GET /. Not /version (it makes an outbound call to api.github.com) and not /budgets (reading it writes notification records). - Interpreters are resolved through a login shell and stored as absolute paths. A login-launched GUI app inherits a minimal PATH, so a bare `node` resolves under `tauri dev` and fails on the first real login. - If a server is already listening it attaches instead of starting a second one, and leaves it running on quit. A server left by a previous tray process is reaped via a pidfile before the ports are touched. - Tray title text is macOS-only, so the spend figure is the first menu item on every platform and the title/tooltip are additive. The reqwest client is built without TLS: this app only ever talks to 127.0.0.1, and dropping TLS makes an outbound HTTPS call impossible rather than merely discouraged. Verified on macOS: total.cost matches an independent re-bucketing of /sessions by local day to 7e-15 (float summation order), the app polls the documented URL and nothing else, and the .app/.dmg bundle builds with LSUIElement set. Windows and Linux are type-checked and bundled in CI but not yet run on real hardware. Also adds .rs to the pre-push reviewer's suffix list, which would otherwise have let all of this ship unreviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review pass over the previous commit surfaced 33 candidate defects; 22 survived verification. The ones with a real failure mode: Deadlock. refresh_tray held the `menu` mutex across set_text/set_enabled, which dispatch to the main thread and block on a reply. If the main thread was itself in refresh_tray (a menu click) waiting for that same mutex, neither side could progress and nothing timed out. MenuItem is an Arc newtype, so the handles are cloned out and the guard dropped first. IPv6 port probe never ran. "::1:8000" is not a valid SocketAddr — v6 literals need brackets — so the parse failed and the loop silently `continue`d every time. The probe is now built from IpAddr, which is what made the cross-stack check the comment claims worth having. Test added, plus a bound-port test. reap_stale killed by PID alone. The record routinely survives a reboot, and PIDs are recycled aggressively on Windows and wrap at pid_max on Linux, so this could SIGTERM (or on Windows tree-kill) an unrelated process. Now: skip entirely when neither recorded port is listening, and confirm the process is really our supervisor before signalling. Windows pid lookup also parses the CSV rather than substring-matching, which false-positived on the memory column. Windows quit stalled. A graceful `taskkill /T` only posts WM_CLOSE, and a CREATE_NO_WINDOW node process has no window — so it could never succeed and just burned the whole stop budget before escalating. Goes straight to the tree kill, matching bin/cli.js. Stale figure after midnight. The cached spend carried no date, so the last value of the day kept being labelled "Today" — indefinitely if the backend was down. It now stores (local date, cost) and every reader goes through a date check. Tray icon invisible off macOS. The template asset is pure black plus alpha; Windows and Linux ignore the template flag, so it was a black glyph on a dark taskbar. Ships the colour icon there. Data store split. A GUI process at login inherits no shell exports, so a user with TOKENTELEMETRY_DATA_DIR in their rc file got a second empty store. Both it and TOKENTELEMETRY_HOME are read from the login shell and forwarded. No way to quit without a tray. Stock GNOME shows no icon at all without an AppIndicator extension, and closing Preferences only hid it. Adds a Quit button and command. Also: single-instance guard (a second tray reaped the first one's live services); detection moved off the startup path (it runs the login shell up to four times per command at 6s each, delaying the icon at login); a POSIX-sh fallback so csh/tcsh users get any result at all; CREATE_NO_WINDOW on helper spawns; bounded wait in stop() so Quit cannot hang the UI thread; restart and checkout-clearing guarded against states where they strand a running child; CSP widened for Tauri's IPC origin; and the CI tag trigger fixed — a `paths` filter applies to tag pushes too, so releases would never have fired. Re-verified on macOS: today's total still matches an independent re-bucketing of /sessions by local day (66.72878289259259 vs ...258 across 28 sessions), the single-instance guard drops the second process, and clippy -D warnings, fmt, 12 tests and the bundle all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-ups from a review of the previous fix commit. Autostart pointed at a stale path. All three platforms record an ABSOLUTE binary path when the login item is created, so dragging the .app out of target/release/bundle into /Applications leaves the LaunchAgent dangling. is_enabled() keeps returning true, so Preferences would show "launch at login: on" while login silently did nothing — the headline feature failing with the UI insisting it works. Re-asserted on every start. reap_stale could skip a real orphan. The port-free early return assumed "no listener means nothing to reap", but a supervisor that died during its install phase (npm ci / pip install) has not bound anything yet and will bind both ports seconds later. Keyed on the pid being dead instead, which is the condition that actually means there is nothing to signal. tasklist_row abandoned the whole lookup on one unparseable row: `?` inside the loop returns None from the function rather than skipping the line, so pid_alive and pid_is_our_supervisor could report a live process missing for reasons unrelated to it, silently disabling reaping on Windows. Windows identity check now reads the real command line via Get-CimInstance and looks for cli.js, matching the strength of the Unix `ps` check, with the image-name check kept as a fallback. tasklist alone cannot tell our node from any other node the user is running. Verified the spawn path end to end on macOS for the first time (everything before this exercised attach mode only): cold start built the venv, ran the installs and bound both ports; tray-runtime.json recorded the real pid; `ps` confirms it is `node .../bin/cli.js` with its own process group (setsid worked) and node resolved to an absolute /opt/homebrew path; SIGKILLing the tray left cli.js running as designed; relaunching reaped that orphan and both its children, leaving exactly one cli.js; and SIGTERM frees both ports with no leftovers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI builds installers on all three platforms, but nobody has run the Windows or Linux binaries. "Built in CI" and "tested" are not the same claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Left-clicking the tray icon now opens a 380px panel instead of only a text menu; the menu moves to right-click. A native menu can only render text rows, which is why the first version looked sparse next to what the data supports. The panel shows today's spend as the headline, a 7-day bar chart, tokens / cache-hit / agent-count tiles, per-agent and per-model breakdowns with share bars, and chips for skills, MCP servers, subagent types and energy. All of it already existed in /analytics and was simply not surfaced. Cost: two /analytics calls, issued ONLY when the panel is opened. The 30s background poll is unchanged, so idle cost is exactly what it was. Deliberately not used: - /projects takes 5.3s and is all-time, not today. - /budgets emits persistent notification records as a side effect of being read, which must never sit behind a tray click. The sparkline is built from ONE multi-day response rather than N single-day queries, and that is load-bearing. A cached single-day query for 2026-08-02 returned $152.76 while a fresh scan of the same window returned $6.72 — a 22x error, reproducible, caused by a cache entry built while that date was still "today" (and so carried the live-scan merge) continuing to be served as a historical figure. The multi-day response is internally consistent and agrees with an independent re-bucketing of /sessions. Worth fixing in the backend separately; this avoids depending on it. Charting decisions: every bar is one hue and identity is carried by the text label beside it, so there is no categorical palette that could fail a colour-vision check. Today's bar is a darker step of the same hue plus the only direct label — labelling all seven would make it a table. Rounded data-ends anchored to the baseline, a 2px surface gap between bars, recessive axes, tabular figures so the amount does not jitter as it ticks, and dark-mode steps chosen against the dark surface rather than auto-flipped. Routes from the panel go through a fixed allow-list rather than string interpolation, so nothing the webview sends can steer the browser. macOSPrivateApi is on because the panel is a transparent window; without it the rounded sheet renders on an opaque rectangle. Verified on macOS: clippy -D warnings, fmt, 17 tests (5 new covering window maths across a month boundary, spend-ordered slices, zero-spend days and today-flagging), and the .app/.dmg bundle. Panel markup rendered against live data for today ($133.44) and the week ($1,316 / 27.7M tokens). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lf-hiding Three defects found by running it on a real dual-monitor Mac (main 2940x1912 @2x at (0,0), external 2560x1440 @1x at (1470,0)). Coordinate spaces were mixed. Tauri reports Monitor::position() in POINTS but Monitor::size() in PHYSICAL pixels, and the tray rect arrives tagged `Physical` while its numbers are already points. Scaling the anchor by the panel window's own scale factor — which is the scale of whatever monitor the panel currently sits on, not the one holding the icon — computed a 760pt wide window for an icon on the 1x display and placed the panel 190pt to its left. It also self-corrected on the second click, once the window had migrated to the other monitor, which made it look random. Everything is now computed in points, the width comes from the config rather than the live window, and the result is clamped to the monitor containing the icon. The panel hid itself the instant it appeared. macOS keeps focus on the status item when you click it, so the popover receives a blur immediately; acting on that is indistinguishable from the panel never opening. There is now a grace period, a re-check after the focus settles, and a `panel-shown` event from Rust so a reopened panel refreshes rather than showing stale numbers. Content taller than the window rubber-banded the whole sheet, dragging the rounded card away from its own window edge. Only the breakdown lists scroll now, with the headline, chart, tiles and footer pinned, overscroll disabled, and the window raised to 620pt so there is usually nothing to scroll. Also adds a "Show panel" menu item. The tray click event is not deliverable on every desktop (on Linux a left click commonly opens the menu regardless), so the panel must not be reachable only that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tray rect arrives as points multiplied by the scale of the monitor the icon sits on. With displays of different scale those ranges OVERLAP in that space, so x alone cannot identify the display. Measured on a real dual setup: main 0..2940 @2x, external 1470..4030 @1x — an anchor at x=1784 is a valid coordinate on BOTH. The icon height resolves it. A menu-bar item is ~22-40pt tall on every Mac, so only the correct scale yields a plausible height. Both observed anchors on the same machine, each ambiguous by x and unambiguous by height: raw (1784,0) 176x66 -> /2 = 88x33pt main display -> panel (746,39) raw (3462,0) 76x30 -> /1 = 76x30pt external display -> panel (3310,36) Previously the second case worked and the first placed the panel on the wrong display entirely, which is why it looked like it opened somewhere random: the answer depended on which monitor the panel happened to be on at the time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mixed-DPI anchor fix computed the right coordinates, logged them, and then never called set_position — the call was dropped while reworking the monitor-selection logic. The panel kept opening wherever it last sat, which looks exactly like the placement bug it was meant to fix, and the log line read "-> panel at (x,y)" for a move that never happened. Move the coordinate maths into a pure `panel_origin` so it is covered by tests instead of by clicking the icon and looking. The cases are real measurements from a 2x built-in plus a 1x external whose x ranges overlap in the space the tray anchor is reported in, including one anchor taken verbatim from a session log, plus the clamp and fallback paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e OS Two gaps in the previous fix: An anchor that matches no monitor fell back to 1x AND skipped the clamp. macOS reports a zero-size or full-menu-bar rect while the status item is still being drawn, so this path is reachable, and on a 2x-only Mac an icon near the right edge resolved to a point past the right edge of the only screen — the panel opens off-screen, which is the "clicking does nothing" symptom. Fall back to the primary display for both the scale and the clamp. The placement tests could only prove the maths was stable, not that it was applied: they assert values derived from the same formula the function uses, so the build that never called set_position would have passed them. Add a TT_TRAY_DEBUG read-back that logs the position the OS reports after showing the window and flags a MISMATCH against the computed one. Placement depends on the user's monitor arrangement, so a wrong-place report needs a way to capture the raw numbers from the machine that saw it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The move is applied asynchronously. Reading the position straight back after set_position returns the value from the PREVIOUS show, which made the verification added in the last commit report a MISMATCH on placement that was in fact correct — the read-back was one show behind, not the placement. Set the position again after show() and read back once it has settled. The second call also runs after the window has adopted the scale factor of the display it landed on, which is what LogicalPosition is interpreted against. Add TT_TRAY_SELFTEST="x,y,w,h;…", which replays synthetic anchors through the real placement path and logs computed vs settled for each. Placement depends on the live monitor arrangement, so it cannot be unit-tested and otherwise needs one click per display per attempt; this makes it a loop that runs unattended. Verified on a 2x built-in plus a 1x external: 6/6 MATCH across both displays, alternating between them, and both edge clamps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irror A comment claimed Monitor::position() was in points while Monitor::size() was in physical pixels. tao builds BOTH with from_logical(.., scale_factor()) — see platform_impl/macos/monitor.rs — so each is points times that monitor's own scale, exactly like the tray anchor rect. The code was already right; only the stated reason was wrong, which is worse than no comment because the next person reasons from it. The measured desktop cannot tell the two readings apart: the 2x display sits at x=0, where doubling is a no-op, and the 1x display's scale is 1. Add the arrangement that can — a Retina to the RIGHT of a 1x primary, where its 2560pt origin is reported as 5120. Under the wrong reading every anchor on it falls out of range and clamps to the wrong screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tray polled every 30s. The backend's session cache TTL is also exactly 30s, so every poll arrived as the cache expired, missed it, and forced a full rescan of every session across every harness plus a history write. One scan costs ~25-35 CPU-seconds on a ~1,200 session history, so the tray asked for scans faster than a scan could finish and the backend never returned to idle. Measured on that machine, backend CPU per 45s of wall clock: 56.7 CPU-seconds with the tray running, and a clean 60s baseline with no tray reads 0. The tray was the entire load — about 86% of a core, continuously, and the laptop ran hot. With the new default the same window costs one scan at startup and then nothing until the next interval. - default interval 30s -> 300s; spend accumulates slowly and opening the panel always refreshes on the spot, so this only governs the idle case - a 60s floor: Preferences offered 10s with nothing to say what it cost - values under the floor fall back to the DEFAULT, not the floor. Every existing tray.json says 30, so raising the default alone would leave every install on the old value, and snapping to 60s would still rescan for a large part of every minute - the interval-vs-TTL relationship is a compile-time assertion rather than a test: the failure mode is silent, nothing breaks, the machine just gets hot - panel in-page refresh 30s -> 120s, and the Preferences hint now states the cost of a refresh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VasiHemanth
force-pushed
the
feat/tray-companion
branch
from
August 10, 2026 14:03
edc8a4d to
5a62394
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a menu bar / system tray companion under
src-tauri/. It starts the localserver on login so there is no terminal to keep open, keeps today's spend in the
menu bar, and opens the existing dashboard in the browser.
It is a launcher, not a replacement. Every menu item opens the same web UI —
the trace view in particular is where you backtrack through a whole agent run,
which does not collapse into a dropdown, and keeping it on the web keeps it
reachable from any device.
How it works
It spawns one child,
node bin/cli.js, rather than reimplementing venvcreation, the SHA-stamped
pip install --require-hashes,npm ciand the portpre-flight in Rust. That keeps one source of truth for how the services start;
a Rust reimplementation would have been a second one that drifts.
The parts that were easy to get wrong
/analytics?from=<local date>&to=<same>, readoff
total.cost. The date is built from local Y/M/D, andtois a bareYYYY-MM-DDbecause the backend decides whether to merge today's in-flightsessions using a lexicographic string compare.
by_dayis[]on azero-spend day, so
total.costis the only safe field to read.GET /. Not/version, which shells out to git and makes anoutbound HTTPS call to api.github.com. Not
/budgets, where reading statusemits persistent notification records.
paths. A login-launched GUI app inherits a minimal PATH, so a bare
nodeworks under
tauri devfrom a terminal and fails on the first real login —the single most likely way this would have broken in the field.
that one and leaves it running on quit. A server left behind by a previous
tray process is reaped via a pidfile before the ports are touched, because
bin/cli.jshard-exits on a busy port and a tray app has no console to printthat to.
~/.tokentelemetry/, not Tauri'sapp_data_dir(), sotray state sits beside every other TokenTelemetry file rather than in
~/Library/Application Support.127.0.0.1, and dropping TLS makes an outbound HTTPS call impossible rather
than merely discouraged.
Platform reality
Tray title text is macOS-only, so the spend figure is the first menu item on
every platform and the title/tooltip are additive. Linux needs
libayatana-appindicator3-1; on stock GNOME there is no tray at all without anextension, so tray creation failure logs and degrades instead of aborting — it
keeps supervising and still opens the dashboard.
Verification
total.costfor today matched an independent re-bucketing of/sessionsby local day to 7e-15 (float summation order):
$55.28579432962963vs$55.28579432962964across 27 sessions.GET /analytics?from=2026-08-02&to=2026-08-02&granularity=dayandGET /,and nothing else — confirmed in the backend access log.
cargo fmt --check,cargo clippy --all-targets -- -D warnings, and 9 testspass. The
.appand.dmgbundle, withLSUIElementpresent.Tested on macOS only. Windows and Linux pass
fmt,clippy -D warningsandthe test suite in CI, and a dispatched run produced real installers on all three
(
tray-macos-latest3MB,tray-windows-latest1MB,tray-ubuntu-22.0478MB) —but nobody has run them on Windows or Linux hardware. Builds are unsigned, so
macOS needs right-click → Open and Windows will show a SmartScreen warning.
Also here
.rsadded to the pre-push reviewer's suffix list — without it every Rustfile in this PR would have shipped past that gate unread.
src-tauri/target/andgen/gitignored.Cargo.lockis committed: thisis a binary, not a library. That cuts against the repo's npm lockfile
convention, which exists for the
security-audit.ymljob, so it is adeliberate exception rather than an oversight.
@tauri-apps/clientry in the rootpackage.json— that file is a pathtrigger for
security-audit.yml, whosenpm ciwould fail against thecurrent zero-dependency lock. CI drives the bundle through
npxinstead.Follow-ups not in this PR
KILL_ON_JOB_CLOSEwould beat the pidfile reap forcrash cleanup, but it is unsafe code I cannot exercise here.
cheaper; it needs a
--no-frontendmode inbin/cli.jsto do cleanly.Review pass (commit
32c3cdb)A multi-agent adversarial review over the first commit produced 33 candidate
findings; 22 survived an independent refutation pass. Fixed in the follow-up
commit — the ones with a real failure mode:
refresh_trayheld the menu mutex acrossset_text, whichdispatches to the main thread and blocks on a reply. A menu click landing in
that window hung both threads with no timeout.
"::1:8000"is not a validSocketAddr(v6 literals need brackets), so the parse failed and the loop silently
skipped it — the cross-stack check the code claimed to do was never happening.
reap_stalekilled by PID alone. That record survives reboots and PIDsget recycled, so it could have SIGTERM'd (or tree-killed, on Windows) an
unrelated process.
taskkill /Tonly posts WM_CLOSE, which awindowless node process can never receive.
taskbar.
TOKENTELEMETRY_DATA_DIRwas dropped at login, splitting the data store.POSIX-sh fallback for csh/tcsh users, and a CI fix: a
pathsfilter appliesto tag pushes too, so the release job would never have fired.
Verification of the spawn path
The numeric checks above all ran against attach mode. The spawn half was
exercised separately on macOS, end to end:
tray-runtime.jsonrecorded the real PID;psconfirms it isnode .../bin/cli.js, in its own process group (sosetsidtook effect),with
noderesolved to an absolute/opt/homebrewpath rather than a barename — which is the whole point of the login-shell probing.
kill -9on the tray leftcli.jsrunning, as designed.cli.js— this is the code path that exercisesterminate()+wait_ports_free()for real.SIGTERMfrees both ports with nothing left listening.Not yet exercised: a clean Quit through the tray menu (it needs a click I
can't script), and the Windows/Linux teardown paths.
Dropdown panel (commit
ae95a58)Left-click the tray icon opens a 380px panel; the menu moves to right-click. A
native menu can only render text rows, which is why v1 looked sparse next to
what the data already supports.
Shows today's spend as the headline, a 7-day bar chart, tokens / cache-hit /
agent-count tiles, per-agent and per-model breakdowns with share bars, and chips
for skills, MCP servers, subagent types and energy. All of it already existed in
/analytics.Costs two
/analyticscalls, issued only when the panel is opened — the 30sbackground poll is unchanged. Deliberately avoids
/projects(5.3s, all-time)and
/budgets(reading it writes notification records).A backend bug this works around. The sparkline is built from one multi-day
response rather than seven single-day queries, because a cached single-day query
for
2026-08-02returned $152.76 while a fresh scan of the same windowreturned $6.72. Reproducible, a 22× error: a cache entry built while that
date was still "today" (carrying the live-scan merge) keeps being served as a
historical figure. The multi-day response is internally consistent and agrees
with an independent re-bucketing of
/sessions. Worth fixing in the backend onits own.
Charting: every bar is one hue with identity carried by the text label beside
it, so there is no categorical palette that could fail a colour-vision check.
Today's bar is a darker step of the same hue and the only direct label.
Panel placement on mixed-DPI desktops
Everything macOS reports here — the tray anchor rect,
Monitor::position()andMonitor::size()— is points multiplied by the scale of the monitor itdescribes, despite the
Physicaltype name (tao builds all of them withfrom_logical(.., scale_factor())). Each value is consistent within its ownmonitor but not comparable across monitors of different scale.
On a 2x built-in at (0,0) plus a 1x external to its right, the two displays
therefore overlap in that space, so an anchor's x alone cannot say which screen
it is on. The icon height disambiguates: a menu-bar item is ~22-40pt tall, so
only the correct scale yields a plausible height. An anchor matching nothing —
macOS reports a zero-size rect while the status item is still being drawn —
falls back to the primary display for both the scale and the clamp, so the panel
always lands on a real screen.
The move is applied asynchronously, and reading the position straight back
returns the value from the previous show, so the position is set again after
show().TT_TRAY_SELFTEST="x,y,w,h;…"replays synthetic anchors through the realplacement path and logs computed vs settled for each;
TT_TRAY_DEBUG=1printsthe read-back on ordinary clicks. Placement depends on the live monitor
arrangement, so it cannot be unit-tested and otherwise needs one click per
display per attempt. Verified 6/6 on a 2x built-in plus a 1x external — both
displays, alternating between them, and both edge clamps.
Note: this workflow stopped running on pushes
Tray appran onpull_requestfor the first pushes to this branch and thenstopped firing on later ones, leaving the head commit with no checks at all
despite
src-tauri/**changes. Runs triggered byworkflow_dispatchstillwork, and that is how the cross-platform check was run here. Worth working out
before merge, otherwise tray pushes go unchecked silently.
The poll interval is a CPU decision, not a freshness one
Caught by the machine running hot with the tray installed.
/analyticsis served from the backend's session cache, whose TTL is 30s. Amiss runs
_scan_sessions_syncacross every session of every harness plus ahistory write — on a ~1,200 session history that measured 25-35 CPU-seconds.
There is no serve-stale-and-refresh path, so a client polling at or slower than
30s misses every single time, and the poll interval is really "how often do
I force a full rescan".
The original 30s default therefore asked for scans faster than one could finish
and the backend never returned to idle. Measured per 45s of wall clock:
The backend is completely idle otherwise, so this was entirely the tray's doing.
Now: default 300s, a 60s floor, and any value below the floor falls back to the
default rather than the floor — every existing
tray.jsonsays 30, so raisingthe default alone would have left installs on the old value, and snapping to 60s
would still rescan for a large part of every minute. The relationship between
the interval and the cache TTL is a compile-time assertion, not a test, because
the failure mode is silent: nothing breaks, the machine just gets hot.
Freshness is unaffected in practice — opening the panel always refreshes on the
spot, so the interval only governs the menu bar number while nobody is looking
at it.
Worth fixing in the backend separately: serving stale data while refreshing
in the background would make the cost of a poll proportional to how often the
data actually changes, rather than to how often anyone asks.