547 lines
24 KiB
Bash
Executable File
547 lines
24 KiB
Bash
Executable File
#!/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/<pid>/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" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "\$*" >>"$work/curl-argv"
|
|
exit 1
|
|
STUB
|
|
chmod +x "$stub/curl"
|
|
: >"$work/curl-argv"
|
|
|
|
credentials() {
|
|
local expires="$1"
|
|
cat >"$work/credentials.json" <<CREDS
|
|
{"claudeAiOauth":{"accessToken":"$SECRET","refreshToken":"refresh-fixture",
|
|
"expiresAt":$expires,"rateLimitTier":"default_claude_max_5x","subscriptionType":"max"}}
|
|
CREDS
|
|
}
|
|
|
|
# The collector prints its record; the fan-out is what writes files. Both are
|
|
# exercised, so both are seamed.
|
|
run_claude() {
|
|
PATH="$stub:$PATH" \
|
|
CLAUDE_CONFIG_DIR="$work/claude" \
|
|
PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \
|
|
PANAMA_AGENT_USAGE_ENDPOINT="${FIXTURE_ENDPOINT:-$endpoint}" \
|
|
PANAMA_AGENT_USAGE_CACHE="$work/cache" \
|
|
"$claude" "$@" 2>"$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" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf 'claude\n' >>"$work/ran"
|
|
printf '%s\n' '{"schemaVersion":1,"id":"claude","name":"Claude Code","ready":true,"limits":[]}'
|
|
STUB
|
|
cat >"$work/collectors/panama-agent-usage-codex" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf 'codex\n' >>"$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'
|