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.