#!/usr/bin/env bash # How much of each agent subscription is gone, in the bar. # # The Claude collector is the only thing in Panama that reads an authentication # token, so most of what is pinned here is about that rather than about the # number: # # 1. The token never reaches argv. A credential passed as an argument is # world-readable in /proc//cmdline for as long as the request takes, # which is the same rule the password and MOK paths already follow. The # collector makes its request in-process, so there is no child to leak it # through -- and this proves no child is spawned. # 2. The token never reaches the output. Neither the panel nor anyone reading # the state directory has any business seeing a credential, and neither # does the collector's own cache. # 3. It NEVER refreshes the token and never writes to the credentials file. # That token expires hourly and Claude Code refreshes it on demand; two # processes rotating one credential means being silently signed out of # Claude Code by a status widget, and no bar indicator is worth that. # 4. An expired token is reported honestly and no request is made with it. # 5. The widget hides unless it was asked for AND there are real numbers. An # indicator reading "unknown" is worse than an empty space. # # And, since the collectors became a fan-out over a directory of records: # # 6. A collector the user switched off does not run, and its stale record # does not linger. # 7. A record is replaced only by a whole, parseable one. # 8. The replacement is atomic -- mktemp and mv, never a redirect into the # file a reader is watching. # # Hermetic throughout. The usage endpoint is a local HTTP fixture, the codex # app-server is a stub, and the fan-out runs stub collectors: nothing here # reaches Anthropic or starts a real agent. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" scripts="$repo_dir/config/dot/quickshell/scripts" claude="$scripts/panama-agent-usage-claude" codex="$scripts/panama-agent-usage-codex" updater="$scripts/panama-agent-usage-update" service="$repo_dir/config/dot/quickshell/services/AgentUsage.qml" widget="$repo_dir/config/dot/quickshell/modules/bar/AgentUsageWidget.qml" panel="$repo_dir/config/dot/quickshell/modules/bar/AgentUsagePanel.qml" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" aliases="$repo_dir/config/dot/quickshell/config/Settings.qml" findings=() note() { findings+=("$1"); } for required in "$claude" "$codex" "$updater"; do [[ -x "$required" ]] || { printf 'agent usage contract: %s is not executable\n' "$required" >&2 exit 1 } done [[ -f "$panel" ]] || { printf 'agent usage contract: missing %s\n' "$panel" >&2; exit 1; } work="$(mktemp -d)" server_pid="" cleanup() { [[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null rm -rf "$work" } trap cleanup EXIT stub="$work/bin" mkdir -p "$stub" "$work/claude" "$work/cache" "$work/collectors" "$work/usage" readonly SECRET='sk-fixture-token-must-never-appear' # ── The endpoint, served locally ──────────────────────────────────────────── # # A real request to a real socket, so what the collector puts in the header is # observable -- and so nothing in this file can accidentally reach Anthropic. cat >"$work/server.py" <<'PY' import http.server import os import sys work = sys.argv[1] class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): with open(os.path.join(work, "headers.txt"), "a") as handle: handle.write((self.headers.get("Authorization") or "") + "\n") try: status = int(open(os.path.join(work, "status")).read().strip()) except Exception: status = 200 body = open(os.path.join(work, "payload.json"), "rb").read() self.send_response(status) self.send_header("Content-Type", "application/json") if status == 429: self.send_header("Retry-After", "30") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass server = http.server.HTTPServer(("127.0.0.1", 0), Handler) with open(os.path.join(work, "port"), "w") as handle: handle.write(str(server.server_address[1])) server.serve_forever() PY printf '200\n' >"$work/status" : >"$work/headers.txt" cat >"$work/payload.json" <<'JSON' { "five_hour": {"utilization": 42, "resets_at": "2099-08-22T14:00:00Z"}, "seven_day": {"utilization": 71, "resets_at": "2099-08-27T00:00:00Z"}, "rate_limit_tier": "default_claude_max_5x", "limits": [ {"kind": "weekly_scoped", "percent": 31, "resets_at": "2099-08-27T00:00:00Z", "scope": {"model": {"display_name": "Fable 5", "id": "claude-fable-5"}}} ] } JSON python3 "$work/server.py" "$work" & server_pid=$! for _ in $(seq 1 50); do [[ -s "$work/port" ]] && break sleep 0.1 done [[ -s "$work/port" ]] || { printf 'agent usage contract: the endpoint fixture did not start\n' >&2; exit 1; } endpoint="http://127.0.0.1:$(cat "$work/port")/usage" # A recording curl. Nothing should ever invoke it: the collector makes its # request in-process. If it is called, the argv it was called with is evidence. cat >"$stub/curl" <>"$work/curl-argv" exit 1 STUB chmod +x "$stub/curl" : >"$work/curl-argv" credentials() { local expires="$1" cat >"$work/credentials.json" <"$work/claude-stderr" } future_ms=$(( ($(date +%s) + 3600) * 1000 )) # ── 4. An expired token is honest, and makes no request ───────────────────── credentials 1000 record="$(run_claude --force)" [[ "$(jq -r .usageStatusText <<<"$record")" == "Sign-in expired" ]] \ || note "an expired token did not report as expired (got: $(jq -r .usageStatusText <<<"$record"))" [[ -s "$work/headers.txt" ]] \ && note 'a request was made with an expired token' # A credential file that is not there at all is a different sentence. mv "$work/credentials.json" "$work/credentials.away" record="$(run_claude --force)" [[ "$(jq -r .usageStatusText <<<"$record")" == "Waiting for auth" ]] \ || note "a missing sign-in did not report as waiting (got: $(jq -r .usageStatusText <<<"$record"))" [[ -s "$work/headers.txt" ]] \ && note 'a request was made with no token at all' mv "$work/credentials.away" "$work/credentials.json" # ── 1, 2 & 3. What happens to the token ───────────────────────────────────── credentials "$future_ms" before="$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" record="$(run_claude --force)" [[ "$(jq -r .ready <<<"$record")" == "true" ]] \ || note "a valid token did not produce a reading (status: $(jq -r .usageStatusText <<<"$record"))" grep -qF "Bearer $SECRET" "$work/headers.txt" \ || note 'the token did not reach the Authorization header, so the request was not authenticated' [[ -s "$work/curl-argv" ]] \ && note 'the collector shelled out to curl, where /proc makes an argument world-readable' grep -qF "$SECRET" <<<"$record" \ && note 'the token appears in the record the panel reads' grep -qrF "$SECRET" "$work/cache" \ && note "the token appears in the collector's own cache" grep -qF "$SECRET" "$work/claude-stderr" \ && note 'the token appears on stderr, where the journal keeps it' [[ "$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" == "$before" ]] \ || note 'the collector modified the credentials file' # Comments stripped: the header explains at length that it does not refresh, # and matching that is matching documentation. uncommented() { python3 - "$1" <<'PY' import re, sys text = open(sys.argv[1], encoding="utf-8").read() # Module docstring first, then whole-line comments. text = re.sub(r'^\s*(?:"""|\'\'\')(?:.|\n)*?(?:"""|\'\'\')', "", text, count=1) print("\n".join(line for line in text.split("\n") if not line.lstrip().startswith("#"))) PY } uncommented "$claude" | grep -qE 'refreshToken|refresh_token|grant_type' \ && note 'the collector touches the refresh token, which can sign Claude Code out' uncommented "$claude" | grep -qE 'credentials.*\.write_text|open\([^)]*credentials[^)]*"w"' \ && note 'the collector writes to the credentials file' # ── The reading it produced ───────────────────────────────────────────────── # # The endpoint reports percentages already. Treating them as 0..1 fractions and # multiplying is how the bar once came to read 1500%. # Whole percents: jq prints 1 and 1.0 for the same number, and a contract that # fails on the spelling of a float teaches nothing. percent_for() { jq -r --arg label "$1" '.limits[] | select(.label == $label) | .percent * 100 | round' <<<"$record"; } [[ "$(percent_for 'Session (5-hour)')" == "42" ]] \ || note "the five-hour reading is $(percent_for 'Session (5-hour)')% for a reported 42%" [[ "$(percent_for 'Weekly (7-day)')" == "71" ]] \ || note "the weekly reading is $(percent_for 'Weekly (7-day)')% for a reported 71%" # The model-scoped window lives only in the `limits` array. A collector reading # the flat buckets alone drops a limit the account is really spending against. [[ "$(percent_for 'Fable 5 Weekly')" == "31" ]] \ || note 'the model-scoped limit from the limits array was not read' [[ "$(jq -r .tierLabel <<<"$record")" == "Max 5x" ]] \ || note "the plan label is $(jq -r .tierLabel <<<"$record"), not the Max 5x the credential names" # Nothing the panel shows may fall outside the range a percentage has, whatever # the endpoint says. cat >"$work/payload.json" <<'JSON' {"five_hour": {"utilization": 1500}, "seven_day": {"utilization": -4}} JSON rm -f "$work/cache/claude-limits.json" record="$(run_claude --force)" [[ "$(percent_for 'Session (5-hour)')" == "100" ]] \ || note "an out-of-range reading was not clamped (got: $(percent_for 'Session (5-hour)'))" [[ -z "$(percent_for 'Weekly (7-day)')" ]] \ || note 'a negative reading was reported as a percentage rather than dropped' # A payload that speaks the older fraction convention is read as fractions. cat >"$work/payload.json" <<'JSON' {"five_hour": {"utilization": 0.42}, "seven_day": {"utilization": 0.71}} JSON rm -f "$work/cache/claude-limits.json" record="$(run_claude --force)" [[ "$(percent_for 'Session (5-hour)')" == "42" ]] \ || note "a fraction-scaled payload was misread as $(percent_for 'Session (5-hour)')%" # ── A cached limit outlives its probe, but not its window ─────────────────── # # Once a window has reset, a cached figure describes a period that is over. A # stale 78% on a fresh week is a lie the panel must not tell. dead_endpoint="http://127.0.0.1:1/usage" cat >"$work/cache/claude-limits.json" <<'JSON' {"fetchedAtMs":0,"limits":[{"label":"Weekly (7-day)","percent":0.78,"resetsAt":"2099-01-01T00:00:00+00:00"}]} JSON record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)" [[ "$(percent_for 'Weekly (7-day)')" == "78" ]] \ || note 'a cached limit whose window is still open was thrown away' cat >"$work/cache/claude-limits.json" <<'JSON' {"fetchedAtMs":0,"limits":[{"label":"Weekly (7-day)","percent":0.78,"resetsAt":"2000-01-01T00:00:00+00:00"}]} JSON record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)" [[ -z "$(percent_for 'Weekly (7-day)')" ]] \ && [[ "$(jq -r .usageStatusText <<<"$record")" == "Claude limits unavailable" ]] \ || note 'a cached limit was reported after its window had already reset' # ── A transport failure is not an answer ──────────────────────────────────── # # Nothing reached a server: retry sooner than the interval. An HTTP status IS a # server, and pestering it is how a rate limit becomes a ban. rm -f "$work/cache/claude-limits.json" record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)" [[ "$(jq -r '.retryAdvised // false' <<<"$record")" == "true" ]] \ || note 'a transport failure did not ask the shell to retry sooner' printf '500\n' >"$work/status" record="$(run_claude --force)" [[ "$(jq -r '.retryAdvised // false' <<<"$record")" == "false" ]] \ || note 'an HTTP error asked for a fast retry, which turns a bad status into a hammer' printf '429\n' >"$work/status" record="$(run_claude --force)" grep -q 'retry after 30s' <<<"$(jq -r .authHelpText <<<"$record")" \ || note "a 429 did not carry its retry-after into the help text (got: $(jq -r .authHelpText <<<"$record"))" printf '200\n' >"$work/status" # ── The Codex collector asks read-only, and reads the right field ─────────── cat >"$stub/codex" <<'STUB' #!/usr/bin/env python3 import json import os import sys with open(os.environ["CODEX_STUB_ARGV"], "w") as handle: handle.write(" ".join(sys.argv[1:])) RESULTS = { "initialize": {"userAgent": "stub"}, "account/read": {"account": {"type": "chatgpt", "planType": "prolite"}}, "account/rateLimits/read": { "rateLimits": { "planType": "prolite", "primary": {"usedPercent": 55, "windowDurationMins": 10080, "resetsAt": 4102444800}, "secondary": None, }, "rateLimitsByLimitId": { "codex": {"limitName": None, "primary": {"usedPercent": 55, "windowDurationMins": 10080}}, "codex_spark": { "limitName": "Spark", "primary": {"usedPercent": 12, "windowDurationMins": 300, "resetsAt": 4102444800}, "secondary": None, }, }, }, } for line in sys.stdin: line = line.strip() if not line: continue try: message = json.loads(line) except Exception: continue if "id" not in message: continue print(json.dumps({"id": message["id"], "result": RESULTS.get(message.get("method"), {})}), flush=True) STUB chmod +x "$stub/codex" session_day="$(date +%Y/%m/%d)" session_date="$(date +%Y-%m-%d)" session_offset="$(date +%:z)" mkdir -p "$work/codex/sessions/$session_day" { printf '%s\n' '{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}' jq -nc --arg timestamp "${session_date}T10:00:00${session_offset}" \ '{type:"token_count",timestamp:$timestamp,payload:{type:"token_count",info:{total_token_usage:{input_tokens:999999,output_tokens:999999},last_token_usage:{input_tokens:1200,cached_input_tokens:1000,output_tokens:300}}}}' jq -nc --arg timestamp "${session_date}T10:05:00${session_offset}" \ '{type:"token_count",timestamp:$timestamp,payload:{type:"token_count",info:{total_token_usage:{input_tokens:1999999,output_tokens:1999999},last_token_usage:{input_tokens:1200,cached_input_tokens:1000,output_tokens:300}}}}' } >"$work/codex/sessions/$session_day/session.jsonl" codex_record="$( PATH="$stub:$PATH" \ CODEX_HOME="$work/codex" \ CODEX_STUB_ARGV="$work/codex-argv" \ PANAMA_AGENT_CODEX_BIN="$stub/codex" \ PANAMA_AGENT_USAGE_CACHE="$work/cache" \ "$codex" --force 2>/dev/null )" grep -q -- '-s read-only' "$work/codex-argv" \ || note "the codex app-server was not asked for read-only ($(cat "$work/codex-argv" 2>/dev/null))" grep -q -- 'app-server' "$work/codex-argv" \ || note 'the codex collector did not start the app-server' [[ "$(jq -r '.limits[] | select(.label == "Weekly (7-day)") | .percent * 100 | round' <<<"$codex_record")" == "55" ]] \ || note 'the codex weekly limit was not read from the app-server' [[ "$(jq -r '.limits[] | select(.label == "Spark Session (5-hour)") | .percent * 100 | round' <<<"$codex_record")" == "12" ]] \ || note 'the codex model-scoped limit was not read' [[ "$(jq -r .tierLabel <<<"$codex_record")" == "prolite" ]] \ || note 'the codex plan was not read' # total_token_usage is cumulative for the session. Counting it instead of # last_token_usage makes usage grow quadratically: these two snapshots are # 1500 tokens each, not four million. [[ "$(jq -r .todayTotalTokens <<<"$codex_record")" == "3000" ]] \ || note "the codex scan counted $(jq -r .todayTotalTokens <<<"$codex_record") tokens where the last-turn rule gives 3000" # ── 6, 7 & 8. The fan-out ─────────────────────────────────────────────────── cat >"$work/collectors/panama-agent-usage-claude" <>"$work/ran" printf '%s\n' '{"schemaVersion":1,"id":"claude","name":"Claude Code","ready":true,"limits":[]}' STUB cat >"$work/collectors/panama-agent-usage-codex" <>"$work/ran" printf '%s\n' '{"schemaVersion":1,"id":"codex","name":"Codex","ready":true,"limits":[]}' STUB chmod +x "$work/collectors"/panama-agent-usage-* run_update() { PANAMA_AGENT_USAGE_COLLECTORS="$work/collectors" \ PANAMA_AGENT_USAGE_DIR="$work/usage" \ PANAMA_SETTINGS="$work/settings.json" \ "$updater" "$@" 2>"$work/update-stderr" } # Both on: both records appear. printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":true}' >"$work/settings.json" : >"$work/ran" run_update [[ -s "$work/usage/claude.json" && -s "$work/usage/codex.json" ]] \ || note 'the fan-out did not write a record for every enabled collector' # One off: it must not run at all. "Off" has to mean "no process" -- the # collector is the thing that reads a credential and makes a request. printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":false}' >"$work/settings.json" : >"$work/ran" run_update grep -qx 'codex' "$work/ran" \ && note 'a collector the user switched off still ran' [[ -e "$work/usage/codex.json" ]] \ && note "a disabled collector's stale record was left behind for the panel to show" # Absent means default, and the default is on. `.key // true` would read false # as absent, which is how a switched-off collector comes back to life. printf '%s\n' '{}' >"$work/settings.json" : >"$work/ran" run_update grep -qx 'codex' "$work/ran" \ || note 'an unset per-agent preference was not treated as its default of on' # 7. A record is replaced only by a whole, parseable one. printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":true}' >"$work/settings.json" run_update good="$(sha256sum "$work/usage/codex.json" | cut -d' ' -f1)" cat >"$work/collectors/panama-agent-usage-codex" <<'STUB' #!/usr/bin/env bash printf '%s\n' 'Traceback (most recent call last):' exit 1 STUB chmod +x "$work/collectors/panama-agent-usage-codex" run_update [[ "$(sha256sum "$work/usage/codex.json" | cut -d' ' -f1)" == "$good" ]] \ || note 'a collector that died mid-run replaced a good record with rubbish' jq -e . >/dev/null 2>&1 <"$work/usage/codex.json" \ || note 'the usage directory holds something that is not JSON' # 8. Atomic, and tidy: no temp file survives a failure for the panel to read. compgen -G "$work/usage/.*.??????" >/dev/null \ && note 'the fan-out left a temporary record behind in the directory the panel watches' grep -qE '>[[:space:]]*"\$USAGE_DIR/\$agent\.json"' "$updater" \ && note 'the fan-out redirects straight into the file a reader is watching' grep -q 'mktemp' "$updater" && grep -q 'mv "\$tmp"' "$updater" \ || note 'the fan-out does not write through mktemp and mv, so a reader can catch a half-written record' # ── 5. The widget hides itself, and the panel is what it opens ────────────── qml_uncommented() { grep -v '^[[:space:]]*//' "$1"; } grep -q 'Settings.showAgentUsage && AgentUsage.available' "$widget" \ || note 'the widget does not gate on both the preference and having real numbers' qml_uncommented "$widget" | grep -q 'interactive: false' \ && note "the widget disables Pill's mouse area, so its click handlers never fire" qml_uncommented "$widget" | grep -q 'onActivated' \ || note 'left-clicking the widget does nothing' qml_uncommented "$widget" | grep -q 'onSecondaryActivated: ShellState.openSettings' \ || note 'right-clicking the widget no longer opens the settings that govern it' qml_uncommented "$widget" | grep -q 'AgentUsagePanel' \ || note 'the widget does not open the usage panel' grep -q 'key: "showAgentUsage"' "$schema" || note 'there is no showAgentUsage preference' grep -A2 'key: "showAgentUsage"' "$schema" | grep -q 'def: false' \ || note 'the usage widget is on by default; it is a coding-tool readout, not general-purpose desktop furniture' for key in showAgentUsage agentUsageClaude agentUsageCodex agentUsageRefreshMinutes; do grep -q "\"$key\"" "$aliases" \ || note "Settings.qml does not alias $key, so the binding reads undefined" done # The two-tier colouring is better than one threshold and is not to be lost in # a refactor: amber before it hurts, red when it is about to. qml_uncommented "$widget" | grep -q 'AgentUsage.headline >= 90' \ || note 'the widget lost its red tier at 90%' qml_uncommented "$widget" | grep -q 'AgentUsage.headline >= 75' \ || note 'the widget lost its amber tier at 75%' # The collectors run on a timer measured in minutes, never on a repaint, and # only while the readout was asked for. python3 - "$service" <<'PY' || note 'the collectors are not run on a minute-scale timer driven by the preference' import re import sys text = open(sys.argv[1], encoding="utf-8").read() if not re.search(r"interval:\s*root\.refreshMinutes\s*\*\s*60\s*\*\s*1000", text): raise SystemExit(1) if "Settings.agentUsageRefreshMinutes" not in text: raise SystemExit(1) if not re.search(r"running:\s*Settings\.showAgentUsage", text): raise SystemExit(1) PY # The panel and the service read records; neither has any business knowing what # a credential looks like. for file in "$service" "$panel" "$widget"; do grep -qE 'credentials|accessToken|Authorization' "$file" \ && note "$(basename "$file") mentions a credential; only the collector may" done # Nothing in the panel repaints on a clock. The one timer it owns advances a # displayed time and only runs while the panel is open. python3 - "$panel" <<'PY' || note 'the panel animates or ticks while it is closed' import re import sys text = open(sys.argv[1], encoding="utf-8").read() for block in re.findall(r"Timer\s*\{(.*?)\n \}", text, re.S): if "running: root.visible" not in block: raise SystemExit(1) if re.search(r"\b(SequentialAnimation|loops:\s*Animation\.Infinite)\b", text): raise SystemExit(1) PY # ── The retired collector stays retired ───────────────────────────────────── [[ -e "$scripts/panama-agent-usage" ]] \ && note 'the single-agent collector is still on disk; its consumers moved to the fan-out' grep -rqF 'scripts/panama-agent-usage"' "$repo_dir/config" "$repo_dir/bin" 2>/dev/null \ && note 'something still calls the retired single-agent collector' if (( ${#findings[@]} > 0 )); then printf 'agent usage contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'agent usage contract: PASS\n'