Skip to content

Recover from an expired OAuth token instead of idling until someone runs Claude Code - #147

Open
nathanjohnpayne wants to merge 1 commit into
HermannBjorgvin:mainfrom
nathanjohnpayne:claude/daemon-token-selfheal
Open

Recover from an expired OAuth token instead of idling until someone runs Claude Code#147
nathanjohnpayne wants to merge 1 commit into
HermannBjorgvin:mainfrom
nathanjohnpayne:claude/daemon-token-selfheal

Conversation

@nathanjohnpayne

Copy link
Copy Markdown
Contributor

The gap

The daemon is a deliberate free-ride: Claude Code owns the OAuth token and does all refreshing, because refreshing here would race its rotation and feed the OAuth endpoint's rate limit (429). That's the right design — but it leaves one hole. When the token expires while nobody is at the keyboard, every poll 401s, the daemon correctly sends {"ok":false}, and the device shows "No data" for as long as it takes a human to run Claude Code.

I hit this on my own desk unit: 4,535 consecutive no-data beats, the longest stretch ~16 hours overnight. Nothing was broken — the refresh token was valid the whole time. The access token just needed someone to run the CLI.

What this does

Keeps the free-ride, closes the hole. When no configured config dir has a usable token, the daemon runs one deliberately tiny headless Claude Code call so the CLI refreshes its own token as a side effect:

claude -p ok --model claude-haiku-4-5-20251001 --output-format text

The next poll finds a live token. The daemon still never mints or refreshes a token itself — it just nudges the process that owns it.

Guard rails

This spends a small but non-zero amount of the user's quota, so it is opt-in and hedged:

  • Off by defaulttoken_refresh = on in the config enables it.
  • Only on all-dead — fires when every config dir lacks a usable token. A transient poll failure (network blip, DNS, 5xx, 429) is explicitly not a trigger; nudging there would spend quota on a hiccup.
  • Rate-limited to one attempt per 15 minutes, stamped before the attempt so a missing CLI or a dead refresh token can't become a spawn-per-poll loop.
  • Timeout-bounded (120s) with a kill path, so it can never wedge the single-threaded poll loop.
  • Never fatal — any failure leaves the device idle exactly as it is today.
  • Pinned to the cheapest model with 1 output token, so the recovery costs far less than the visibility it restores.

CLI lookup

claude is resolved via an optional claude_cli config override → PATH → known install locations. This matters: launchd/systemd hand the daemon a minimal PATH that usually omits ~/.local/bin, which is exactly where the official installer puts the binary. If it can't be found, the daemon logs the actionable message once rather than retrying.

Scope

All three daemons stay in lockstep: claude_usage_daemon.py, claude_usage_daemon_windows.py, claude-usage-daemon.sh. daemon/config.example documents both new options.

Verified

  • 21 new tests (daemon/tests/test_token_selfheal.py), both Python daemons: default-off gate, single spawn with the cheap model, rate limiting (including after a missing CLI), non-zero exit, spawn OSError, the hung-subprocess kill path, CLI lookup precedence, and that the nudge fires only on all-dead — never on a transient failure or a healthy poll. Suite: 140 passed, 2 skipped. No real subprocesses are spawned in tests.
  • On hardware: the exact command above, run against a genuinely expired token on my Mac, refreshed it (new expiry ~8h out) and the device resumed live numbers on the next poll. The daemon's own invocation path was then exercised end-to-end against the real CLI — spawn, clean exit, rate limit held on the immediate retry.
  • Bash daemon syntax-checked (bash -n).

Independent of #143 — this is daemon-side only and touches no firmware.

🤖 Generated with Claude Code

…uns Claude Code

The daemon is a pure free-ride: Claude Code owns the OAuth token and does all
refreshing, because refreshing here would race its rotation and feed the OAuth
endpoint's rate limit. The gap is what happens when the token expires while
nobody is at the keyboard — every poll 401s, the device shows "No data", and it
stays that way for as long as it takes for a human to run Claude Code. In one
observed case that was 16 hours.

This keeps the free-ride but closes the gap: when NO configured config dir has
a usable token, the daemon runs one deliberately tiny headless Claude Code call
(`claude -p ok`, cheapest model, 1 output token) so the CLI refreshes its own
token as a side effect. The next poll finds it alive. The daemon still never
mints or refreshes a token itself.

Because this spends a small amount of the user's quota it is opt-in
(`token_refresh = on`), fires only when everything is dead, is rate-limited to
one attempt per 15 minutes, is bounded by a 120s timeout, and can never raise
into the poll loop — a failure leaves the device idle exactly as before. A
transient poll failure (network blip, 5xx) is explicitly NOT a trigger.

The CLI is located via an optional `claude_cli` config override, then PATH,
then the known install locations — service managers hand the daemon a minimal
PATH that usually omits ~/.local/bin, which is where the installer puts it.

All three daemons stay in lockstep. 21 new tests cover the default-off gate,
the single spawn with the cheap model, rate limiting (including after a missing
CLI), non-zero exit, spawn errors, the hung-subprocess kill path, CLI lookup
precedence, and that the nudge fires only on all-dead and never on a transient
failure or a healthy poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HermannBjorgvin

Copy link
Copy Markdown
Owner

The problem is the usage window starts when you poll. People can be particular about when they start their usage windows so they might end for example right after lunch and allow them to have two full usage windows in one 9-5 work day.

Could you perhaps modify this so that the daemon adds an option for starting a usage window? That would be a genuinely useful feature.

Just polling to get fresh data doesn't really make sense in my opinion because:

A) We can pretty much guarantee a usage of 0% for the current session, unless you're using your phone or another device

B) We can cache the weekly limit so we can always display a 0% on the current session and keep the weekly limit displaying the last known value.

Perhaps making the main loading bar boxes slightly opaque and adding your no data animation overlay on top of that could be a good indicator for stale data?

The install.sh and install-mac.sh scripts could ask the user to set when they want usage windows started for them by the daemon perhaps? An option to keep a Claude usage window alive at all times could be useful for people though I admit so we could keep that option as well. But would be nice to give people the option to schedule a usage window like they schedule an alarm on their phones.

What do you think?

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.

2 participants