Skip to content

daemon: pure free-ride on Claude Code's token (read-only, never refresh)#63

Open
nathanjohnpayne wants to merge 12 commits into
HermannBjorgvin:mainfrom
nathanjohnpayne:main
Open

daemon: pure free-ride on Claude Code's token (read-only, never refresh)#63
nathanjohnpayne wants to merge 12 commits into
HermannBjorgvin:mainfrom
nathanjohnpayne:main

Conversation

@nathanjohnpayne

@nathanjohnpayne nathanjohnpayne commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Keeps the desk monitor honest when the Claude OAuth token expires, without freezing on stale numbers — and without the daemon ever touching the OAuth refresh endpoint.

Approach: pure free-ride. The daemon is a read-only consumer of Claude Code's credential. It reads whatever access token Claude Code currently holds (Keychain Claude Code-credentials on macOS, ~/.claude/.credentials.json on Windows/Linux), polls, and never refreshes the token itself. On a 401 it emits {"ok": false} and the device shows its idle "No data" screen until Claude Code — the token's legitimate owner — re-seeds it (i.e. when you next use the CLI, or run claude login).

Why no self-refresh (evidence in-thread): two clients sharing one rotating refresh token is a documented anti-pattern — concurrent rotation trips reuse-detection / exhausts a shared rate-limit bucket, and only a fresh login (a different OAuth grant) resets it (refresh-token rotation BCP, CodexBar #575, opencode #18329). We saw exactly that: the daemon's refresh persistently 429'd while Claude Code's own refresh succeeded and re-seeded the credential — which the daemon then free-rode on to recover. So the daemon stops competing entirely.

Includes:

  • A {"ok": false} "no-data" beat so the device shows idle "No data" promptly when the token's dead (pairs with firmware: flip to idle on the daemon's {"ok": false} no-data beat #65) instead of holding stale numbers
  • macOS + Windows parity; removes ~550 lines of refresh / backoff / write-back machinery
  • test_freeride.py: read-path coverage, a regression guard that neither daemon can refresh, and a loop test that a dead token yields a {"ok": false} beat

Behaviour: while you use Claude Code it keeps the token fresh and the daemon free-rides; when nothing has refreshed it (laptop closed, CLI unused), the device honestly shows "No data" until you next use Claude Code / re-login — a better outcome than a self-inflicted rate-limit.

🤖 Generated with Claude Code

@HermannBjorgvin

Copy link
Copy Markdown
Owner

We had some discussions on this going in #32 and #39

I really am keen to avoid taking such an invasive action as refreshing a token on behalf of users since it interacts directly with Claude Code's credential store both without the user being aware and potentially breaking it in the future even though the chances of that are slim.

I believe the right move is to go into an idle state if the token doesn't get refreshed since if the token is not refreshed by the user then we can at least say for certain the user isn't using Claude Code.

WDYT?

@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Pushed a small follow-on commit (ab94b7a) per the heartbeat discussion on #64: the daemon now sends a {"ok": false} "no-data" beat when it genuinely can't get data — token dead + refresh can't recover, or no token — instead of going silent. The device then shows its idle screen promptly rather than holding stale numbers for the full freshness window. Transient network / rate-limit misses stay silent, so a blip doesn't flicker it to idle. (macOS + Windows.)

Pairs with #65 — a tiny firmware change that interprets the beat. #65 should land first so deployed firmware understands ok:false (without it, firmware would read {"ok":false} as a usage payload and show 0%). Verified end-to-end: beat → idle within a second.

nathanjohnpayne and others added 6 commits June 5, 2026 14:41
The macOS daemon read the access token from the Keychain but never used
the refresh token sitting next to it, so when the ~8h token expired the
API returned 401, no valid payload reached the device, and the screen
froze until a manual re-auth.

Add self-refresh that mirrors Claude Code's own OAuth flow:
- refresh_access_token() POSTs the refresh_token grant to
  platform.claude.com/v1/oauth/token (client id + endpoint lifted from
  the installed CLI), then writes the rotated tokens back to the same
  Keychain item so Claude Code and the daemon stay in sync. Nothing is
  written unless the refresh returns 200, so a failed refresh leaves the
  existing credentials intact.
- Proactive refresh within REFRESH_SKEW (120s) of expiry, plus an
  on-401/403 refresh-and-retry-once in the poll loop.
- Reader now transparently hex-decodes a Keychain blob (`security -w`
  emits hex when the stored secret contains a newline), so a stray
  newline can't hard-fail token extraction.

Tests (daemon/tests/test_macos_refresh.py) cover the 200-response
mapping (rotation / non-rotation / missing token / sibling-field
preservation), hex decode, and expiry parsing.

Windows-daemon parity is tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-refresh added in b4788f6 had no backoff. A data-hungry device
fires a refresh request every few seconds, so an expired token whose
refresh is failing (e.g. a 429) made every device request trigger another
OAuth refresh — driving the token endpoint into a self-sustaining 429 loop
(observed: 85 refreshes in ~9 min while the screen stayed frozen).

Gate every refresh attempt (success or failure) to once per
REFRESH_COOLDOWN (300s) via a module-level timestamp. Normal polling is
untouched — only the refresh endpoint is throttled — so the daemon still
recovers the instant a valid token appears (rate limit clears or the user
re-logs in), just without ever flooding.

Verified live (1 attempt + 23 cooldown-skips in 45s, vs ~6 on the old
code) and unit-tested (two refreshes inside the window make exactly one
network call).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the macOS self-refresh + cooldown (b4788f6, 445564c) to the Windows
daemon so it no longer freezes when the ~8h token expires:

- refresh_access_token() exchanges the stored refresh token at
  platform.claude.com/v1/oauth/token and writes the rotated tokens back to
  the plaintext .credentials.json the Windows client reads (write only on a
  200). Proactive refresh within REFRESH_SKEW, on-401/403 retry, and a tray
  "run claude login" fallback when refresh can't recover.
- REFRESH_COOLDOWN gates every refresh attempt to once per 300s so a
  data-hungry device can't drive the OAuth endpoint into a 429 hammer loop.
- tests/test_windows_refresh.py (24): mock httpx + tmp_path, so nothing hits
  the live endpoint or a real credential store; an autouse fixture resets the
  cooldown timestamp between tests.
- tests/conftest.py: autouse fixture neutralizing the new refresh touchpoints
  for the pre-existing connect_and_run tests (keeps them hermetic).
- tests/test_windows_token.py: skip the two daemon-spawning tests on macOS,
  where running the BLE daemon trips the Bluetooth TCC gate and SIGABRTs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flat 300s cooldown stopped the hammering but still retried every 5 min
forever — against a sustained 429 that left the token frozen for hours
(observed: ~5h of failed refreshes, the device stuck on stale data).

Back off exponentially on consecutive refresh failures (300s → 2× → … →
REFRESH_BACKOFF_CAP 30min), resetting to the base on a success, so a
persistent rate-limit is left alone to clear instead of being poked every
cycle. After REFRESH_GIVEUP_AFTER (5) consecutive failures, log a one-time
"run claude login" notice — the device's amber status dot is the user-facing
signal; this explains why. Recovery is still automatic: a re-login or a
cleared limit yields a fresh token on the next poll, which resets the backoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ity with macOS)

Mirror ca06f4e on the Windows daemon: back off exponentially on consecutive
refresh failures (300s → 2× → … → REFRESH_BACKOFF_CAP 30min), reset to base on
success, and log a one-time "run claude login" notice after REFRESH_GIVEUP_AFTER
(5) failures — so it can't get stuck in a multi-hour flat-5-min retry loop
either. The tray's set_error toast remains the prominent user-facing signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the token is dead and a refresh can't recover it (or there's no token at
all), send {"ok": false} to the device instead of going silent — the firmware
then shows its idle screen now instead of holding stale numbers for the full
~90s freshness window. Transient network / rate-limit misses fall through
silently and keep the existing retry behaviour, so a blip doesn't flicker the
device to idle. macOS + Windows.

Pairs with firmware ok:false handling (separate PR): that must land first so the
device interprets the beat. Verified end-to-end: beat -> idle within a second.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proactive refresh competed with the Claude app's own refreshes for the same
token endpoint and tripped its rate limit (429), blocking recovery and forcing a
manual `claude login`. Free-ride instead: use whatever access token Claude Code
currently holds, and refresh only reactively (on a 401) as a last resort. While
you use Claude, the app keeps the token fresh and the daemon never touches the
refresh endpoint; if it lapses while Claude is fully closed, the device shows an
honest idle "No data" (the {"ok":false} beat) until next use / re-login.

macOS + Windows; connect-loop wiring test now asserts no proactive refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nathanjohnpayne nathanjohnpayne changed the title daemon: auto-refresh expired OAuth token so the screen never freezes daemon: survive OAuth token expiry without freezing the screen Jun 5, 2026
@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Refined the refresh strategy (commit 956516c): dropped proactive refresh in favor of free-riding on Claude Code's token.

Proactive refresh turned out to compete with the Claude app's own refreshes for the token endpoint and trip its rate limit (429) — which then blocked recovery and forced a manual claude login (the exact thing this PR was meant to avoid). Now the daemon just uses whatever token Claude Code currently holds and only refreshes reactively on a 401. While you use Claude, the app keeps the token fresh and the daemon never touches the refresh endpoint.

Updated the title/description to match. Verified live: token recovered and steady data flow resumed.

@nathanjohnpayne

nathanjohnpayne commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

Update: switched the refresh strategy to "free-ride" — validated on macOS over ~16h

TL;DR: proactive token refresh turned out to be the wrong call — it competed with Claude Code / the Claude app for the same OAuth endpoint and tripped its rate limit (429), which then blocked recovery and forced a manual claude login. The fix is to stop competing: free-ride on Claude Code's token and only refresh reactively. Proven on my Mac (details below); still needs a Windows tester.

What was tried

  1. Proactive + reactive refresh — renew ~2 min before expiry every cycle, plus refresh-and-retry on a 401.
  2. Cooldown + exponential backoff + give-up notice — added when a failing refresh hammered the endpoint (300 s floor, doubling to a 30 min cap, a "re-login" notice after 5 consecutive failures).
  3. Root-caused the actual problem: the proactive refresh was the source of the 429. Claude Code (and the app) already refresh the shared credential; the daemon refreshing on top of that pushed the token endpoint over its rate limit — and the cooldown/backoff were only treating the symptom.

How recovery worked before this change

The cooldown/backoff kept the daemon from worsening the 429, but they couldn't clear it — they just spaced out retries that kept failing (I watched one throttle persist ~5 hours). With no way to wait it out, recovery was manual every time: run claude login to re-seed a fresh credential into the Keychain (the daemon reads the token fresh each poll, so it picks the new one up within ~60 s; launchctl kickstart -k "gui/$(id -u)/com.user.claude-usage-daemon" forces an immediate re-read).

So the screen would sit on stale numbers — and, once the idle screen existed, "No data" — for an unbounded stretch until I noticed, re-authenticated by hand, and kicked the daemon. Removing that manual claude login loop for the normal (laptop-open) case is the whole point of this change.

What worked: free-ride (reactive-only)

Drop proactive refresh entirely. The daemon now uses whatever access token Claude Code currently holds (Keychain item Claude Code-credentials on macOS) and refreshes only reactively, on a 401, as a last resort. While you use Claude Code, the app keeps that token fresh and the daemon never touches the refresh endpoint → no self-inflicted 429, no forced login.

Tradeoff: if nothing refreshes the token — laptop closed, or you only use the desktop app (which keeps a separate credential the daemon doesn't read) — the device shows an honest "No data" until you next use Claude Code. That's a better failure mode than the old self-inflicted rate-limit.

Proof (macOS)

Environment:

  • macOS 26.5 (build 25F71), Apple M1 Pro
  • Python 3.14.5, httpx 0.28.1, bleak (CoreBluetooth backend)
  • Device: Waveshare ESP32-S3 AMOLED-2.16 (CO5300, 480×480)

One continuous ~16.6 h daemon session (15:37 → 08:16), 0 proactive refreshes:

Window Mac state Device
15:37 → ~23:00 open, using Claude Code 🟢 live — a fresh reading every minute, continuously, past the ~8 h token lifetime
~00:00 → 07:50 lid closed / asleep (on AC; Maintenance-Sleep + DarkWake cycles) 😴 honest "No data" — token went invalid (wall-to-wall API HTTP 401: Invalid authentication credentials), nothing running to refresh it
07:51 (lid opened) → live reopened; Claude Code resumes 🟢 self-healed to live — no claude login

The overnight gap lines up exactly with the macOS sleep log (lid wake UserActivity at 2026-06-06 07:51:37). The daemon kept polling during the Mac's dark-wakes and correctly emitted {"ok":false} each time — so the screen was never lying, just honest about a sleeping host.

⚠️ Needs Windows testing

The Windows daemon got the same change (parity: no proactive refresh, reactive-only, same {"ok":false} beat) and the unit tests pass — but I can only validate the macOS / CoreBluetooth path on my hardware. Someone on Windows should confirm the WinRT/BLE daemon free-rides on Claude Code's Windows credential and behaves the same way.

Relationship to the firmware change (#65)

This PR's {"ok":false} "no-data" beat is consumed by the firmware change in #65 — that's what flips the device to the idle "No data" screen immediately instead of holding stale numbers for the freshness window. They're complementary:

Merge order: #65 should land before this PR, so deployed firmware understands ok:false — without it, firmware would read the beat as a usage payload and render 0%.

nathanjohnpayne and others added 2 commits June 6, 2026 16:35
_refresh_failures only reset on a successful *refresh*, but under free-ride the
daemon recovers via Claude Code's refresh / a re-login, almost never its own — so
the backoff stayed pinned at the 30-min cap and blocked the next reactive refresh
that might have succeeded. Reset it on any successful poll (the token works) via a
new _note_poll_success() in the poll loop. macOS + Windows, with tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the daemon a pure read-only consumer of Claude Code's credential: read the
stored access token, poll, and on a 401 emit {"ok": false} (idle "No data") and
wait for Claude Code (the token owner) to re-seed it. Never refresh — two clients
rotating one refresh token trips reuse-detection / a shared 429 bucket; the daemon's
refresh always 429'd while Claude Code's succeeded and the daemon free-rode on it.

Removes ~550 lines of refresh/backoff/write-back machinery (macOS + Windows) and
replaces the self-refresh suites with test_freeride.py (read-path + a regression
guard that neither daemon can refresh + a no-data-beat loop test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nathanjohnpayne nathanjohnpayne changed the title daemon: survive OAuth token expiry without freezing the screen daemon: pure free-ride on Claude Code's token (read-only, never refresh) Jun 7, 2026
@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Final pivot (and I think the right one): pure free-ride — the daemon now never touches the OAuth refresh endpoint at all.

Earlier this PR did reactive refresh (on a 401). But validated on hardware + cross-checked against the literature, that was still wrong: the daemon's refresh always 429'd (the endpoint rate-limits a piggybacking second client), while Claude Code's own refresh succeeded and re-seeded the credential — which the daemon then free-rode on to recover. So the daemon's refresh was pure dead weight that only kept its own throttle warm.

Evidence that two clients sharing one rotating refresh token is the anti-pattern: refresh-token rotation reuse-detection (Auth0), and a near-identical menu-bar tool that hit the exact "poll/refresh → 429-bucket exhaustion → manual re-login" cycle (CodexBar #575).

So the daemon is now a pure read-only consumer: read Claude Code's token, poll, and on a 401 emit {"ok": false} (idle "No data") and wait for Claude Code to re-seed it. Removed ~550 lines of refresh/backoff machinery; added test_freeride.py (read-path + a regression guard that neither daemon can refresh + the no-data-beat loop test). macOS validated live; still needs a Windows tester.

@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Why this went all the way to "never refresh": the failure and the research

The failure I hit

Even after switching to reactive-only refresh (refresh only on a 401, never proactively), the device still got stuck on "No data" and couldn't recover on its own. Captured live on macOS:

  • The access token was minted at 07:52 and expired a clean ~8h later at 15:52 (last good reading 16:00, then dead).
  • Nothing refreshed it at that moment, so the daemon hit 401s and tried its reactive refresh — which came back HTTP 429 "Rate limited" every time. Failures accumulated until it sat in a 30-minute backoff, unable to recover.
  • At 16:26 the credential was rewritten by Claude Code itself (the CLI's own refresh, triggered by normal use) and the daemon's next poll free-rode straight back to live — no daemon refresh involved.
  • One minute later, at 16:27, I forced the daemon's refresh by hand → 429 again.

That last pair is the whole story: the daemon's refresh is persistently rate-limited, while Claude Code's own refresh succeeds. Same endpoint, same token, ~same minute. So the daemon's refresh was dead weight — it never worked, and every attempt only kept its own throttle warm. The thing that actually recovered the device was free-riding on the CLI's refresh.

The research (why a second refresher can't win)

  • Refresh-token rotation + reuse detection. Refresh tokens are single-use and rotated; presenting an already-rotated one trips reuse detection, which revokes the token family / exhausts a rate-limit bucket. Two clients sharing one refresh token and both rotating it is the documented anti-pattern — the correct design is "one owner refreshes, everyone else reads." (Auth0 refresh-token rotation, token rotation overview)
  • A near-identical tool hit the identical wall. CodexBar (a macOS menu-bar Claude usage indicator) documents the exact chain: it fell back to polling/refreshing the OAuth endpoint → "token exhaustion through retry spam" → the rate limiter blocks the exhausted token bucket with 429, and the documented fix is claude logout && claude login to reset the bucket. That's our failure verbatim, including the recovery. (CodexBar #575)
  • The endpoint is just aggressively limited. On OpenCode, the console.anthropic.com/v1/oauth/token 429 persisted even after changing the User-Agent and reproduced across multiple accounts — a genuinely tight limit, so the fewer non-owner calls hit it, the better. (opencode #18329)
  • Even the tool that self-refreshes hedges. opencode-claude-auth proactively refreshes + writes back — but "falls back to the Claude CLI if the direct refresh fails." Nobody fully trusts a piggybacking client's own refresh.

The fix

Stop competing. The daemon is now a pure read-only consumer of Claude Code's credential: read the stored access token, poll, and on a 401 emit {"ok": false} (the device shows an honest idle "No data") and wait for Claude Code — the token's owner — to re-seed it (next CLI use, or claude login). The daemon never touches /oauth/token, so it can't trip the rotation race or feed the 429 bucket. ~550 lines of refresh/backoff machinery removed; a regression guard now asserts neither daemon can ever refresh.

Validated on macOS; still needs a Windows tester.

@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Let's see how long this fix holds. It's complex, but I really can't even with manual refreshes. 😄

@HermannBjorgvin

Copy link
Copy Markdown
Owner

Let's see how long this fix holds. It's complex, but I really can't even with manual refreshes. 😄

Thanks for the help Nathan, I'm on holiday until the 10th, just been sneaking code reviews on my phone when I get the chance, ping me if you need any more input on this or more testing, @kvenanzi opened a draft pull request on this topic as well hoping he'll be able to help us test on the windows side if he's able

nathanjohnpayne and others added 2 commits June 9, 2026 13:41
The daemon sends a {"ok":false} "no-data" beat when it can't fetch usage
(dead/rotating token), expecting the device to show its idle screen. The
firmware half of that contract was missing on main: ui_update gated only
on `valid`, so an ok:false beat — which parses fine but carries no s/w
fields — rendered as 0% on the usage screen instead of flipping to idle.

Restore the three-part gate (dropped when the heartbeat commit was
reshaped into PRs on origin/main, then again by a local reset --hard):
  - track the last payload's ok flag in `data_ok`
  - ui_update early-returns on ok:false (keep last numbers, don't refresh
    the freshness clock)
  - update_view_state requires data_ok for the live-usage view

Verified on the 2.16 in a live ok:false window: device shows the sleeping
creature + "No data…/Listening…" instead of 0%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HermannBjorgvin

Copy link
Copy Markdown
Owner

I'm back in action now @nathanjohnpayne sorry you caught me almost as soon as I started my vacation so I was not very active this week :)

I will test this tomorrow on my end and hopefully we can merge this, thanks for working on this hopefully I wasn't too hard to handle in the review.

BTW a tip on Claude Code I noticed that I hope you don't mind me mentioning, I have this happen to me as well that sometimes it will update tickets titles and descriptions and pull request descriptions after you do a pivot like you did it will reword it based on the current session context "(read-only, never refresh)" which only makes sense if you were there at the time the pivot happened but not if you come to it later.

It's a good instruction to add to have titles and descriptions reflective of the work performed and not the journey the chat session took.

@nathanjohnpayne

Copy link
Copy Markdown
Contributor Author

Now I have been on vacation, home for a few days, and back to traveling. Thanks for the tip! Claude does do that, and it makes it hard to follow along.

PR and issue titles/descriptions: describe the work, not the session

Titles and descriptions—for both pull requests and issues—must describe the
final state of the change (what it does and why) for a reader who arrives with
no knowledge of the session that produced it.

  • Do not narrate the session's path: no pivots, abandoned approaches,
    "originally did X, then switched to Y," or commentary on how the plan evolved.
  • When a pivot changes what the work is, update the title/description to
    reflect the new end state—not the fact that a pivot happened.
  • Once a title or description already describes the work accurately, treat it as
    read-only. Do not reword or "refresh" it to fold in later session context.

The daemon now polls every configured Claude config dir (upstream's
multi-plan active-plan support) while keeping the pure free-ride contract:
it reads whatever token each dir currently holds and never refreshes. When
no dir has a usable token — file/Keychain empty, or a 401/expired token —
it emits a {"ok": false} beat so the device shows "No data" instead of
stale numbers; a live token that merely fails transiently stays silent.

poll_active() carries the (payload, all_dead) result the loop needs and
catches TokenExpired per dir so a 401 can't crash the poll; poll_active_payload()
remains a thin dict|None wrapper. _read_token_keychain() is retained (used by
read_token_for's macOS default-dir fallback) and now applies the hex-dump
Keychain decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR shifts the usage-tracker daemons to a “pure free-ride” model: they only read Claude Code’s existing access token (Keychain / .credentials.json), never refresh it, and explicitly emit a {"ok": false} “no data” beat when the token is dead so the device can switch to its idle “No data” screen promptly.

Changes:

  • Firmware: treat ok:false payloads as “no fresh data” (idle immediately, keep last rendered numbers).
  • Daemons (macOS + Windows): stop any OAuth refresh behavior; on 401/403 (or missing token) write {"ok": false} instead of holding stale numbers.
  • Tests: add free-ride regression coverage and adjust Windows token tests to avoid macOS CoreBluetooth/TCC crashes.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
firmware/src/ui.cpp Tracks last ok flag and gates “live usage” view on ok:true so ok:false yields immediate idle.
daemon/claude_usage_daemon.py Adds Keychain hex-decode handling, introduces TokenExpired, and emits {"ok": false} when all configured tokens are dead.
daemon/claude_usage_daemon_windows.py Emits {"ok": false} for missing/expired token and removes any refresh behavior.
daemon/tests/test_windows_token.py Skips Windows-daemon-spawn tests on macOS to avoid CoreBluetooth/TCC SIGABRT in CI.
daemon/tests/test_freeride.py New tests covering Keychain blob decoding, “no refresh machinery” regression, and ok:false emission on auth failure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +768 to 771
log("No usable token; signalling no-data to device — run "
"`claude login` or use the CLI to let Claude Code renew it")
await session.write_payload({"ok": False})
last_poll = time.time()
Comment on lines +582 to 587
log("No token; signalling no-data to device")
await session.write_payload({"ok": False})
last_poll = time.time()
if tray_state:
tray_state.set_error("token expired — run claude login")
else:
Comment on lines +618 to +620
log("No data (token dead); signalling idle to device")
await session.write_payload({"ok": False})
last_poll = time.time()
@cosjef

cosjef commented Jul 20, 2026

Copy link
Copy Markdown

@nathanjohnpayne Have we considered these alternative approaches:

  1. Parse Claude Code's local JSONL transcripts instead of calling the API at all. Claude Code writes per-session usage data (tokens, model, timestamps) to ~/.claude/projects/**/*.jsonl on disk. This is exactly how the popular ccusage tool computes usage stats: zero network calls, zero tokens, zero auth to expire. The daemon would just tail files. The limitation is that it reconstructs usage from local records rather than reading the account's official quota/limits endpoint, so if the device specifically displays "% of 5-hour window remaining" per Anthropic's server-side counters, local parsing approximates rather than mirrors it. But it eliminates the entire class of auth problems, and for a desk gadget, an approximation that never goes dark may beat exact numbers that die at 3am.

  2. A pragmatic middle ground within the current design might be: on a 401, instead of only waiting, the daemon could invoke the CLI itself, e.g. shelling out to a trivial claude command, so the token owner performs its own refresh through its own code path. That keeps "one owner refreshes" intact while removing the wait-for-human dependency. It's slightly gross (spawning a CLI to side-effect a credential), and it burns a tiny bit of usage, but it directly fixes the overnight/idle gap. One project cited in the thread already hedges this way: opencode-claude-auth falls back to the Claude CLI if its direct refresh fails.

Thoughts?

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.

4 participants