Repository navigation
[Bug]: Headless orca serve never starts usage polling, so every Claude account row stays blank until a desktop window opens #37256
Description
Operating system: macOS 26 (Apple Silicon). The gate is platform-independent, see "Where it breaks".
Orca version: 1.4.244; verified unchanged on main at 1c40f5404db (2026-11-01).
Details
Summary
On a supervised headless host (orca serve --supervise, packaged macOS, Electron serve runtime) the rate-limit service is never started. orca account list prints weekly — · fable — for every managed Claude account, including the active one, for the whole lifetime of the process. The rows only fill when (a) a Claude session happens to run, which fills the active row through the statusline, or (b) a paired desktop promotes the host and opens a window. Saved accounts never fill on a headless host.
Paired phones and web clients read these rows to decide where to launch or move the next session, so on a headless host they are flying blind.
Related, but not this
- [Bug]: Multi-account switching + usage bars don't work on a headless
orca serveruntime (VPS) #10922 — blank usage bars on a Linux VPS at 1.4.158, bundled with the account-switching problem that Re-land Claude per-account folders (#24434, #26405) with the upgrade and proxy fixes #26801 has since reworked. - [Bug]: Usage poller never runs a cycle after launch; all providers stay null (Claude and Codex usage missing) #27186 — the poller not running on the desktop with a focused window.
- [Feature]: Keep idle saved Claude accounts signed in (refresh lapsed access tokens through Claude Code) #36440 / [Bug]: Windows: Claude usage shows "OAuth access token has expired. Re-authenticate" — Orca never refreshes the managed token (refresh token still valid, no live PTY, no recovery path on win32) #18463 — rows that show a 401 because the stored token expired. That is a different problem; this report is only about the poller never running.
Steps to reproduce
- On a Mac with several managed Claude accounts, run the supervised serve. launchd runs
orca serve --supervise --no-pairing --port 6768; the supervisor spawnsOrca.app/Contents/MacOS/Orca --serve --serve-port 6768. The log confirms[serve] using Electron serve: packaged macOS serve stays on Electron so paired clients can still update it. - Do not open the desktop window.
- Wait longer than the 15-minute poll interval.
- Run
orca account list(or--json) against the host.
Observed: every row is weekly — · fable —; rateLimits.claude is null. No request to the usage endpoint is ever made. There is no log line either, so from the outside it looks like the accounts simply have no data.
Expected: the host polls usage for the active account and for the saved accounts the same way the desktop does.
Where it breaks (read on main at 1c40f5404db)
Two gates stack. Either one alone keeps the host blank.
desktop launch headless serve launch
────────────── ─────────────────────
openMainWindow() launchServeMode()
└─ attachMainWindowCoreServices() └─ (never calls openMainWindow)
├─ rateLimits.attach(window) ◄── gate 1 ── rateLimits never constructed/started
└─ rateLimits.start({fetchImmediately:false})
every 15 min, and on window focus:
shouldBackgroundPoll()
mainWindow null/destroyed? → false ◄── gate 2 ── a headless process can never pass this
!visible || minimized? → false
return mainWindow.isFocused()
- Gate 1.
rateLimits.attach(window)andrateLimits.start()are called only inattachMainWindowCoreServices(src/main/startup/main-window-core-services.ts:163-165), which is reached only fromopenMainWindow(src/main/startup/main-window-controller.ts).launchServeModeinsrc/main/startup/main-process-runtime-launch.tsnever opens a window, so on the serve path the service is never started. - Gate 2. Even if it were started,
shouldBackgroundPoll()(src/main/rate-limits/service/service-polling.ts:56-65) returnsfalsewhenevermainWindowis null, not visible, minimized, or not focused. Both the interval tick (line 27) andrefreshIfWindowActive()(line 178) return early on it. The comment there says the gate exists to save API budget while the UI is inactive, which is right for the desktop and wrong for a host whose only UI is remote.
Suggested fix
We run this in a local build and are happy to open a PR.
sequenceDiagram
participant S as launchServeMode
participant R as RateLimitService
participant U as usage endpoint
S->>R: start({fetchImmediately:true, pollWithoutWindow:true, pollInactiveClaude:true})
S->>S: log "[claude-usage] polling switch: mode=serve pollInactive=true pollWithoutWindow=true"
R->>U: active account usage (no session needed)
R->>U: saved accounts, in one cycle
Note over R: every 15 min, no window required
R->>R: skip? log "[claude-usage] inactive fetch skipped reason=<...>"
- Arm from the serve path. In
launchServeMode, after the browser-identity line and beforeprintServeReady, start the service with explicit options such as{ fetchImmediately: true, pollWithoutWindow: true, pollInactiveClaude: true }. - Let the gate honour the option.
shouldBackgroundPoll()returnstruewhenpollWithoutWindowis set and there is no window. The desktop keeps today's focus gate, so the budget-saving behaviour there does not change. - Do not disarm on a late window. If a desktop promotion opens a window on an already-armed host, the window path must not call
start({ fetchImmediately: false })again, since that would clear the serve flags. Only the subscription that call installed is removed on windowclosed. - Stop on a committed quit, leave running on a vetoed
before-quit(main-process-quit.ts). - Log the decision once, so the state is visible in the field without a debugger:
[claude-usage] polling switch: mode=<desktop|serve> pollInactive=<bool> pollWithoutWindow=<bool>at launch, including when the switch is off.[claude-usage] inactive fetch skipped reason=<debounce|in-flight|no-accounts|not-attached|live-session|retry-after|already-read>when a cycle fetches nothing.
- Fill the active row too.
account listreads the selected host account fromstate.claude, which today is only filled by a live statusline or the active OAuth cycle. The same poll should fetch the selected account ontostate.claudewhen no live reading is fresh, so the active row shows numbers with no session open.
Result on the same host with the fix
- Within a second of
Orca server ready:[claude-usage] inactive fetch start n=9. - 15 minutes later every row carries percentages; the active row fills with no Claude session open.
- Accounts whose token has expired show a 401 state instead of a blank. That is [Feature]: Keep idle saved Claude accounts signed in (refresh lapsed access tokens through Claude Code) #36440's problem and is visible now instead of hidden.
Tests we added (for the PR)
initializeMainProcessReadywith serve options andopenMainWindowthrowing: no window, the inactive fetch runs once at start and again after the poll interval, the log line saysmode=serve pollInactive=true pollWithoutWindow=true.- Serve logs the true switch, desktop logs the false switch, a later window attach leaves an armed poll in place.
- Selected account lands on
state.claude; a fresh live reading and a same-cycle OAuth reading are skipped (live-session,already-read); a stale live reading is replaced;not-attachedandno-accountsare logged.
Activity