From c8fb5be42edebd82098c1d912fecc0084845a490 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 11:13:07 +0200 Subject: [PATCH] Explain an expired Claude sign-in instead of hiding the limits (#6795) Only the Claude Code CLI can refresh the OAuth token it saves; the collector just reads it. A machine left alone long enough finds the token lapsed, and that branch returned an empty limits list with no status text at all, so the panel hid its whole limits section and explained nothing. Say what is wrong, and fall back to the cached limits already on disk rather than discarding them. Cached windows are kept only until they reset: a percentage from a window that has rolled over describes a period that is over, and pinning a stale 78% on an allowance that is now untouched would be worse than showing nothing. The probe-failure path gets the same filtering for the same reason. Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-agent-usage-claude | 61 +++++++++++--- .../shell.d/agent-usage-claude-limits-test.sh | 83 +++++++++++++++++++ 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index 49a6561a..8d3b32ed 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -765,24 +765,65 @@ def probe_limits(access_token: str) -> dict[str, Any]: return {"ok": True, "limits": limits} +# A cached percentage outlives the probe that measured it, but only until its +# window rolls over: once a window has reset, the figure describes a period +# that is over, and a stale 78% would misreport an allowance that is now +# untouched. A window with no reset time, or one that will not parse, is kept +# — an unreadable timestamp is no reason to throw away a real number. +def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: + raw = str(entry.get("resetsAt") or "") + if raw == "": + return True + try: + resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return True + if resets_at.tzinfo is None: + resets_at = resets_at.replace(tzinfo=dt.timezone.utc) + return resets_at > now + + +def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + entries = cached.get("limits") + if not isinstance(entries, list): + return [] + now = dt.datetime.now(dt.timezone.utc) + return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] + + def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]: result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} - if access_token == "": - result["usageStatusText"] = "Waiting for auth" - return result - if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000: - return result - # A panel that is opened and shut repeatedly must not turn into a request # per flick, so recent probe results are reused for a short window — and # kept as the answer of record when a later probe fails. probe_cache = cache_root() / "claude-limits.json" cached = read_fresh_json(probe_cache, float("inf")) or {} + fallback = usable_cached_limits(cached) + + # Probing needs a live token and only the Claude Code CLI can mint one: it + # refreshes the credential file when it runs, so a machine left alone long + # enough finds the saved token lapsed. Say so — an empty limits list with + # nothing else set hides the whole section and explains nothing — and keep + # showing the last numbers whose window has not since reset. + if access_token == "": + result["limits"] = fallback + result["usageStatusText"] = "Waiting for auth" + return result + if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000: + result["limits"] = fallback + result["usageStatusText"] = "Sign-in expired" + result["authHelpText"] = ( + "Claude Code's saved sign-in expired" + + (" — showing the last known limits." if fallback else ".") + + " Start Claude Code, or run `claude auth login`, to refresh it." + ) + return result + fetched_at = number(cached.get("fetchedAtMs")) / 1000 min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS - if cached.get("limits") and time.time() - fetched_at < max(min_interval, PROBE_MIN_INTERVAL_SECONDS): - result["limits"] = cached["limits"] + if fallback and time.time() - fetched_at < max(min_interval, PROBE_MIN_INTERVAL_SECONDS): + result["limits"] = fallback return result probe = probe_limits(access_token) @@ -795,8 +836,8 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s # route. Ask the shell to try again sooner than its regular interval. if probe.get("transport"): result["retryAdvised"] = True - if cached.get("limits"): - result["limits"] = cached["limits"] + if fallback: + result["limits"] = fallback else: result["usageStatusText"] = "Claude limits unavailable" result["authHelpText"] = probe["helpText"] diff --git a/test/shell.d/agent-usage-claude-limits-test.sh b/test/shell.d/agent-usage-claude-limits-test.sh index fa21eef0..0b20ca3e 100644 --- a/test/shell.d/agent-usage-claude-limits-test.sh +++ b/test/shell.d/agent-usage-claude-limits-test.sh @@ -72,6 +72,89 @@ for payload in '{"five_hour":{"utilization":78.0},"limits":[{"kind":"session","p done pass "Claude collector adds no limit when the payload scopes none" +# Only the Claude Code CLI refreshes the saved token, so between its runs the +# collector can find a lapsed one. Drive collect_limits over a planted cache +# with the network unreachable, so nothing but the credential state decides +# the answer. +CACHE_HOME=$(mktemp -d) +trap 'rm -rf "$CACHE_HOME"' EXIT + +collect_limits() { + COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" TOKEN="$1" EXPIRES_AT="$2" CACHED="$3" \ + XDG_CACHE_HOME="$CACHE_HOME" python3 - <<'PY' +import importlib.machinery, importlib.util, json, os, pathlib + +loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"]) +spec = importlib.util.spec_from_loader(loader.name, loader) +collector = importlib.util.module_from_spec(spec) +loader.exec_module(collector) + +cache = collector.cache_root() / "claude-limits.json" +cached = os.environ["CACHED"] +if cached: + cache.write_text(cached, encoding="utf-8") +elif cache.exists(): + cache.unlink() + +def unreachable(request, timeout=None): + raise OSError("no route to host") + +collector.urllib.request.urlopen = unreachable +print(json.dumps(collector.collect_limits(os.environ["TOKEN"], int(os.environ["EXPIRES_AT"]), False))) +PY +} + +# An open window and one that already reset, cached long enough ago that a live +# token would re-probe rather than reuse them. +open_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=3)).isoformat())') +past_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=3)).isoformat())') +cache=$(jq -nc --arg open "$open_at" --arg past "$past_at" '{ + fetchedAtMs: 1, + limits: [ + { label: "Session (5-hour)", percent: 0.31, resetsAt: $past }, + { label: "Weekly (7-day)", percent: 0.11, resetsAt: $open } + ] +}') + +# An expired token used to return an empty limits list and no status at all, +# which hides the panel's whole limits section without saying why. +expired=$(collect_limits "token" 1000 "$cache") +[[ $(jq -r '.usageStatusText' <<<"$expired") == "Sign-in expired" ]] || + fail "Claude collector reports an expired sign-in" "$expired" +[[ $(jq -r '.authHelpText' <<<"$expired") == *"claude auth login"* ]] || + fail "Claude collector says how to refresh an expired sign-in" "$expired" +pass "Claude collector reports an expired sign-in instead of hiding the section" + +# The window that has not reset is still true; the one that has is not. +[[ $(jq -c '[.limits[].label]' <<<"$expired") == '["Weekly (7-day)"]' ]] || + fail "Claude collector keeps only cached windows that have not reset" "$expired" +pass "Claude collector keeps only cached windows that have not reset" + +# Nothing worth showing: the status still explains the silence. +stale=$(collect_limits "token" 1000 "$(jq -c '.limits |= [.[0]]' <<<"$cache")") +[[ $(jq -c '.limits' <<<"$stale") == "[]" && $(jq -r '.usageStatusText' <<<"$stale") == "Sign-in expired" ]] || + fail "Claude collector drops a wholly reset cache but keeps explaining itself" "$stale" +[[ $(jq -r '.authHelpText' <<<"$stale") != *"last known"* ]] || + fail "Claude collector promises no last-known limits when it has none" "$stale" +pass "Claude collector drops a wholly reset cache but keeps explaining itself" + +# A signed-out machine says so, and still shows what it last knew. +signed_out=$(collect_limits "" 0 "$cache") +[[ $(jq -r '.usageStatusText' <<<"$signed_out") == "Waiting for auth" ]] || + fail "Claude collector still reports a missing token" "$signed_out" +[[ $(jq -c '[.limits[].label]' <<<"$signed_out") == '["Weekly (7-day)"]' ]] || + fail "Claude collector serves open cached windows without a token" "$signed_out" +pass "Claude collector serves open cached windows without a token" + +# A live token that cannot reach the endpoint keeps the old contract: the open +# window stands in, and the shell is asked to retry sooner than its interval. +unreachable=$(collect_limits "token" 0 "$cache") +[[ $(jq -c '[.limits[].label]' <<<"$unreachable") == '["Weekly (7-day)"]' ]] || + fail "Claude collector falls back to cache when the probe cannot connect" "$unreachable" +[[ $(jq -r '.retryAdvised' <<<"$unreachable") == "true" ]] || + fail "Claude collector advises a retry after a transport failure" "$unreachable" +pass "Claude collector falls back to cache when the probe cannot connect" + # The panel reads a window out of a label, and that guess cannot survive a # model name — "Opus 5 (1M context)" parses as a one-minute window. A collector # that states the title outright is taken at its word.