daemon: auto-refresh OAuth token via Claude Code CLI#32
Conversation
The daemon was reading accessToken from Keychain on every poll but had no refresh path, so after ~8h the cached token expired and every poll started hitting 401 until the user manually ran claude in a terminal. Piggyback on the CLI's own OAuth flow: when expiresAt is within 2 minutes (or already past), spawn `claude -p ping`, wait up to 60s, then re-read Keychain to pick up the freshly-persisted token. Costs ~1 message per refresh.
|
Hey @HermannBjorgvin — when you have a moment, would love a review on this one. It's the OAuth-refresh follow-up I called out in the "Known follow-ups" section of #22. Self-contained daemon-only change (~140 lines on |
|
I think we should still leave the refreshing of the token to the user's Claude Code, feels like too much of an unexpected intervention to manage this on behalf of people. What about just looking at the token expiry time and schedule a re-read of the If the access token expires without refreshing I think we should just stop displaying data until the token is updated, maybe poll it every ~15 minutes or so? |
Hear you on the "unexpected intervention" framing, and I want to gently push back because I think the trade-off lands the other way for this case. The cost of passive polling is more user-visible than it sounds. When the token expires and the user isn't actively using Claude Code, the device freezes on stale data — Activity stops updating, Usage bars stop moving. There's no on-device signal that anything's wrong, it just looks wrong. That state can persist for hours, sometimes overnight or over a weekend. For a desk-side monitor whose entire job is "show me current state at a glance," that's a meaningful failure mode. The active refresh just sidesteps it — the user never sees the broken state at all. On the intervention concern, I'd argue the daemon is already pretty intervention-y by design:
Spawning Middle ground if you'd prefer a guarantee: make it opt-out via env var ( Happy to add the opt-out + docs if that lands it. But I'd really prefer not to lose the active refresh path entirely — it's the kind of "this just works" detail that's the whole point of having a dedicated device. WDYT? |
|
The access token has a lifetime of something like ~8 hours, at least on my machine that's the time left until it expires. Thing is the usage window is 5 hours and every call to That would only leave us sending an API request for a new access token an updating the credentials.json file as far as I can see and that makes me uncomfortable about breaking it for people in the future, we'd need to have a json schema guard around it to make sure we were never messing something up for users. That's actually a feature idea that I have had for the meter is the ability to schedule a small call to If the concern is stale data I would suggest just showing 0% instead of --- and hiding the "resets in" text while not receiving data, the end result should be the same that you would start receiving data as soon as the user opens claude code or desktop and starts a usage cycle. Unless I am missing something? |
Follow-up from #22 (third item under "Known follow-ups" in the description).
What
The daemon currently reads
accessTokenfrom the macOS Keychain /~/.claude/.credentials.jsonon every poll, but has no refresh path. After ~8h the cached token expires, every poll starts hitting 401, and the user has to manually runclaudein a terminal to renew it.This PR piggybacks on Claude Code CLI's own OAuth flow rather than reverse-engineering Anthropic's token endpoint:
expiresAtout of the credentials blob (both keychain and file shapes).accessTokenis within 120s of expiry (or already past), spawnclaude -p pingand wait up to 60s. The CLI auto-refreshes and writes the new token back to the same store.poll_apicall site.Why this approach (vs direct refreshToken use)
client_id/ OAuth secret to embed or reverse-engineer from the CLI bundle.Failure modes (all fail-open — the daemon never crashes)
expiresAt→ never trigger proactive refresh (callers tolerate the 401).Test plan
read_token()→read_credentials()parser still handles all 4 credential shapes (direct, nested underclaudeAiOauth, regex-fallback, raw).python3 -c 'import ast; ast.parse(open("daemon/claude_usage_daemon.py").read())'clean.claudeon PATH and a near-expiry token:[HH:MM:SS] Token expires in 87s; triggering CLI refresh→[HH:MM:SS] Token refreshed; new expiry in +8.0h→ subsequent polls succeed.clauderemoved from PATH: refresh attempt logs "claude CLI not found on PATH; cannot refresh token", returns stale token, next API call returns 401 as before (no regression vs current behavior).Notes
activity-stream(the staging branch where Activity screen: live Claude Code session state from hooks #22 will land) rather thanmainbecause both share the same base for the Python daemon today; no conflict either way./api/oauth/usageendpoint #29 (the/api/oauth/usageendpoint switch) — they touch independent layers.🤖 Generated with Claude Code