diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index d8443094..e615c583 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -1031,11 +1031,8 @@ 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. +# Cached windows reset to zero when their measured period ends. Without a +# fresh probe, the next rolling window has no known reset time yet. def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: raw = str(entry.get("resetsAt") or "") if raw == "": @@ -1054,7 +1051,10 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: 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)] + return [ + entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="") + for entry in entries if isinstance(entry, dict) + ] def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]: @@ -1079,7 +1079,7 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: # 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. + # showing the last numbers, resetting elapsed windows to zero. if access_token == "": result["limits"] = fallback result["usageStatusText"] = "Waiting for auth" @@ -1168,24 +1168,6 @@ def registered_accounts() -> list[dict[str, Any]]: return accounts -# A parked account whose sign-in lapsed can't be probed, and once every window -# it last saw has reset, the cache has nothing current to offer. That is not -# "unknown" — a reset window is an untouched allowance, and switching needs to -# know this account is the one with the most room. Each window it last saw -# comes back at 0%. -def replenished_limits(cache_name: str) -> list[dict[str, Any]]: - cached = read_json(cache_root() / cache_name) or {} - entries = cached.get("limits") - if not isinstance(entries, list): - return [] - now = dt.datetime.now(dt.timezone.utc) - return [ - dict(entry, percent=0.0, resetsAt="") - for entry in entries - if isinstance(entry, dict) and not limit_window_open(entry, now) - ] - - def current_account_id(home: Path) -> str: try: account = json.loads(profile_path(home).read_text(encoding="utf-8")).get("oauthAccount") or {} @@ -1211,8 +1193,6 @@ def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]: else: cache_name = f"claude-limits-{key}.json" limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, force, cache_name) - if not limits["live"] and not limits["limits"]: - limits["limits"] = replenished_limits(cache_name) return { "id": account["id"], "label": str(account.get("label") or account["id"]), diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index a209ddfc..67563f62 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -24,7 +24,7 @@ Panel { property bool cursorActive: false - // Countdowns and "as of" ages read this instead of Date.now() so the + // Countdowns and "last updated" ages read this instead of Date.now() so the // panel keeps telling the truth while it sits open. property double nowMs: Date.now() @@ -419,7 +419,7 @@ Panel { function otherTrouble(item) { var status = String(item && item.usageStatusText || "") - return status !== "" && !needsSignIn(item) ? status : "" + return status !== "" && status !== "Limits paused" && !needsSignIn(item) ? status : "" } // Sign an account that's already here in again, following along in the @@ -499,10 +499,12 @@ Panel { // titled after its model, and a name like "Opus 5 (1M context)" would parse // as a one-minute window. function limitWindow(label, percent, resetAt, title) { + var reset = new Date(String(resetAt || "")).getTime() + var expired = isFinite(reset) && reset <= root.nowMs return { title: String(title || "") !== "" ? String(title) : windowTitle(label), - percent: Number(percent), - resetAt: String(resetAt || "") + percent: expired ? 0 : Number(percent), + resetAt: expired ? "" : String(resetAt || "") } } @@ -1837,10 +1839,9 @@ Panel { component CompactLimit: Item { id: compact property var window: null - // Numbers kept past a failed check dim, and say how old they are on hover. + // The age of numbers kept past a failed check is shown only on hover. property bool stale: false property real fetchedAt: 0 - opacity: stale ? 0.5 : 1.0 readonly property var scoped: window && window.scoped ? window.scoped : [] readonly property bool alarming: window && window.percent >= 0.9 readonly property real resetMs: root.resetMsFor(window) @@ -1858,8 +1859,8 @@ Panel { } if (compact.stale) lines.push(compact.fetchedAt > 0 && root.nowMs - compact.fetchedAt > 60000 - ? "As of " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago" - : "Last known") + ? "Last updated " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago" + : compact.fetchedAt > 0 ? "Last updated less than a minute ago" : "Last updated time unavailable") for (var i = 0; i < compact.scoped.length; i++) lines.push(compact.scoped[i].title + ": " + Math.round(compact.scoped[i].percent * 100) + "% of its " + String(compact.window ? compact.window.title : "").toLowerCase() + " allowance") diff --git a/test/shell.d/agent-usage-claude-limits-test.sh b/test/shell.d/agent-usage-claude-limits-test.sh index 7c2b469c..5372075a 100644 --- a/test/shell.d/agent-usage-claude-limits-test.sh +++ b/test/shell.d/agent-usage-claude-limits-test.sh @@ -171,18 +171,20 @@ expired=$(collect_limits "token" 1000 1000 "$cache") 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" +# The elapsed session resets to zero while the weekly usage stays intact. +[[ $(jq -c '[.limits[].label]' <<<"$expired") == '["Session (5-hour)","Weekly (7-day)"]' ]] || + fail "Claude collector keeps cached windows and resets elapsed ones" "$expired" +[[ $(jq '[.limits[].percent] == [0,0.11]' <<<"$expired") == "true" && $(jq -r '.limits[0].resetsAt' <<<"$expired") == "" ]] || + fail "Claude collector resets just the elapsed window" "$expired" +pass "Claude collector keeps cached windows and resets elapsed ones" -# Nothing worth showing: the status still explains the silence. +# A wholly reset cache still shows the empty allowance. stale=$(collect_limits "token" 1000 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" +[[ $(jq '.limits | map(.percent) == [0]' <<<"$stale") == "true" && $(jq -r '.usageStatusText' <<<"$stale") == "Sign-in expired" ]] || + fail "Claude collector resets a wholly elapsed cache to zero" "$stale" +[[ $(jq -r '.authHelpText' <<<"$stale") == *"last known"* ]] || + fail "Claude collector keeps the timestamp and guidance for a reset cache" "$stale" +pass "Claude collector resets a wholly elapsed cache to zero" # The access token lives about eight hours; the refresh token behind it lives # about thirty days, and the CLI mints a new access token from it on its next @@ -194,7 +196,7 @@ paused=$(collect_limits "token" 1000 "$live_refresh" "$cache") fail "Claude collector calls a lapsed access token paused, not signed out" "$paused" [[ $(jq -r '.authHelpText' <<<"$paused") != *"claude auth login"* ]] || fail "Claude collector does not send a signed-in machine back through login" "$paused" -[[ $(jq -c '[.limits[].label]' <<<"$paused") == '["Weekly (7-day)"]' ]] || +[[ $(jq -c '[.limits[].label]' <<<"$paused") == '["Session (5-hour)","Weekly (7-day)"]' ]] || fail "Claude collector keeps open cached windows while the access token is stale" "$paused" pass "Claude collector calls a lapsed access token paused, not signed out" @@ -202,7 +204,7 @@ pass "Claude collector calls a lapsed access token paused, not signed out" signed_out=$(collect_limits "" 0 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)"]' ]] || +[[ $(jq -c '[.limits[].label]' <<<"$signed_out") == '["Session (5-hour)","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" @@ -210,14 +212,14 @@ pass "Claude collector serves open cached windows without a token" # time, not by the file's age. A backwards clock correction must not discard # an otherwise open fallback when no token is available to replace it. future_fallback=$(collect_limits "" 0 0 "$cache" 3600) -[[ $(jq -c '[.limits[].label]' <<<"$future_fallback") == '["Weekly (7-day)"]' ]] || +[[ $(jq -c '[.limits[].label]' <<<"$future_fallback") == '["Session (5-hour)","Weekly (7-day)"]' ]] || fail "Claude collector keeps open fallback limits after a backwards clock correction" "$future_fallback" pass "Claude collector keeps open fallback limits after a backwards clock correction" # 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 0 "$cache") -[[ $(jq -c '[.limits[].label]' <<<"$unreachable") == '["Weekly (7-day)"]' ]] || +[[ $(jq -c '[.limits[].label]' <<<"$unreachable") == '["Session (5-hour)","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" @@ -321,10 +323,11 @@ pass "Claude collector caches a successful probe" run_node_test <<'JS' const fs = require('fs') const source = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'utf8') +const panelRoot = { nowMs: Date.now() } const start = source.indexOf('function windowIsLong') const end = source.indexOf('// The window that decides') assert(start > 0 && end > start, 'agents panel exposes its limit-window helpers') -eval(source.slice(start, end)) +eval(source.slice(start, end).replaceAll('root.nowMs', 'panelRoot.nowMs')) assertDeepEqual( limitWindows({ limits: [ @@ -344,6 +347,13 @@ assertDeepEqual( 'agents panel still reads a window out of a label that carries no title' ) +const resetAt = new Date(panelRoot.nowMs + 1000).toISOString() +const measured = { limits: [{ label: 'Session (5-hour)', percent: 0.78, resetsAt: resetAt }] } +assertEqual(limitWindows(measured)[0].percent, 0.78, 'usage remains until the reset time') +panelRoot.nowMs += 1000 +assertDeepEqual(limitWindows(measured), [{ title: 'Session', percent: 0, resetAt: '' }], 'usage resets at the deadline while the panel stays open') +assertEqual(measured.limits[0].percent, 0.78, 'display reset leaves the measured record intact') + // A model's weekly allowance rides under the Weekly row; one whose window has // no row of its own keeps a row. assertDeepEqual( diff --git a/test/shell.d/agents-panel-test.sh b/test/shell.d/agents-panel-test.sh index d9e742a2..e79ca340 100755 --- a/test/shell.d/agents-panel-test.sh +++ b/test/shell.d/agents-panel-test.sh @@ -27,7 +27,7 @@ assert(/target\.kind === "signin"\) signInAgain\(/.test(panelSource) && /target\ assert(/target\.kind === "autoswitch"\) setSwitchMode\(/.test(panelSource), 'Enter on Autoswitch flips the switch mode') assert(/keyColumn = use >= 0 \? use : /.test(panelSource), 'moving up or down onto an account lands on Use') assert(/Qt\.callLater\(function\(\) \{ if \(picking\) pointAt\("choice", 0\) \}\)/.test(panelSource), 'picking an agent to add starts with the first one focused') -assert(/opacity: stale \? 0\.5 : 1\.0/.test(panelSource) && /"As of " \+ root\.formatDuration/.test(panelSource), 'limits kept from an earlier check dim and say how old they are on hover') +assert(!/opacity: stale \?/.test(panelSource) && /"Last updated " \+ root\.formatDuration/.test(panelSource), 'stale limits say how old they are only on hover') assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes') assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in') assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop')