No error is a dead end: crash, click, and your agent is already looking

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 12:50:09 -04:00
parent ada0faf1d1
commit cc7d91d09c
43 changed files with 4648 additions and 327 deletions
@@ -1,118 +0,0 @@
#!/usr/bin/env bash
# How much of the Claude subscription this account has used.
#
# Writes one display-ready record to $XDG_STATE_HOME/panama/agent-usage.json.
# The bar widget only ever reads that file, so adding a second agent later is a
# collector rather than a change to any QML.
#
# ── What this deliberately does NOT do ───────────────────────────────────────
#
# It never refreshes the OAuth token, and it never writes to
# ~/.claude/.credentials.json.
#
# That token expires about hourly and Claude Code refreshes it on demand. If
# this refreshed it too, two processes would be rotating one credential: a
# refresh that rotates the refresh token invalidates the other holder's copy,
# and the failure mode is being silently logged out of Claude Code by a status
# widget. No bar indicator is worth that.
#
# So this reads the token, uses it if it is still valid, and reports
# "unavailable" if it is not. In practice that covers the case that matters --
# while you are actually using Claude Code the token is fresh, and while you
# are not, there is nothing to watch.
#
# ── The token ────────────────────────────────────────────────────────────────
#
# Never reaches argv. `curl --config -` takes the Authorization header on
# stdin, because a header passed as an argument is world-readable in
# /proc/<pid>/cmdline for as long as the request takes -- the same rule
# panama-pick follows for passwords and panama-sudo for the MOK hash.
#
# Never reaches the output either. The record below carries percentages and
# timestamps and nothing else; the widget has no business seeing a credential
# and neither does anyone reading the state file.
set -uo pipefail
CREDENTIALS="${PANAMA_AGENT_CREDENTIALS:-$HOME/.claude/.credentials.json}"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
OUTPUT="$STATE_DIR/agent-usage.json"
ENDPOINT="${PANAMA_AGENT_USAGE_ENDPOINT:-https://api.anthropic.com/api/oauth/usage}"
mkdir -p "$STATE_DIR"
# Written whatever happens, so the widget can distinguish "no data yet" from
# "collector never ran" and hide itself for the right reason.
emit() {
local status="$1" detail="${2:-}" body="${3:-null}"
local tmp
tmp="$(mktemp "$OUTPUT.XXXXXX")"
jq -n --arg status "$status" --arg detail "$detail" \
--argjson usage "$body" --arg at "$(date -Is)" \
'{status: $status, detail: $detail, collectedAt: $at, usage: $usage}' \
>"$tmp" 2>/dev/null || printf '{"status":"error","detail":"could not write","usage":null}' >"$tmp"
mv "$tmp" "$OUTPUT"
}
command -v jq >/dev/null 2>&1 || exit 0
[[ -r "$CREDENTIALS" ]] || { emit unavailable "Claude Code is not signed in on this machine."; exit 0; }
expires="$(jq -r '.claudeAiOauth.expiresAt // 0' "$CREDENTIALS" 2>/dev/null)"
[[ "$expires" =~ ^[0-9]+$ ]] || expires=0
now="$(( $(date +%s) * 1000 ))"
# Thirty seconds of headroom: a token about to expire will have expired by the
# time the request lands, and a 401 is a worse answer than an honest wait.
if (( expires <= now + 30000 )); then
emit stale "Waiting for Claude Code to refresh its session."
exit 0
fi
config="$(mktemp)"
cleanup() { rm -f "$config"; }
trap cleanup EXIT
chmod 600 "$config"
jq -r '"header = \"Authorization: Bearer \(.claudeAiOauth.accessToken)\"\nheader = \"anthropic-beta: oauth-2025-04-20\"\nsilent\nshow-error"' \
"$CREDENTIALS" >"$config" 2>/dev/null \
|| { emit unavailable "Could not read the Claude Code session."; exit 0; }
response="$(curl --max-time 10 --config "$config" "$ENDPOINT" 2>/dev/null)" || {
emit unavailable "Could not reach the usage service."
exit 0
}
rm -f "$config"
jq -e . >/dev/null 2>&1 <<<"$response" || { emit unavailable "The usage service returned something unreadable."; exit 0; }
if jq -e '.error' >/dev/null 2>&1 <<<"$response"; then
emit unavailable "$(jq -r '.error.message // "The usage service refused the request."' <<<"$response")"
exit 0
fi
# Reshaped into a small, stable record rather than passed through, so the
# widget does not depend on the shape of an endpoint nobody documents. Every
# field is optional: an endpoint that stops reporting one should cost that
# number, not the whole indicator.
usage="$(jq -c '
# The endpoint reports utilisation as a percentage already -- 15 means 15%.
# This multiplied by 100 on the assumption it was a 0..1 fraction, which is
# how the bar came to read 1500%. Clamped as well as rounded, because a
# readout is a number you glance at and trust; one that can exceed 100
# teaches you not to.
def pct: if type == "number" then ([[(. | round), 0] | max, 100] | min) else null end;
{
tier: (.rate_limit_tier // .rateLimitTier // null),
subscription: (.subscription_type // .subscriptionType // null),
fiveHour: {
used: ((.five_hour.utilization // .fiveHour.utilization // null) | pct),
resetsAt: (.five_hour.resets_at // .fiveHour.resetsAt // null)
},
week: {
used: ((.seven_day.utilization // .week.utilization // null) | pct),
resetsAt: (.seven_day.resets_at // .week.resetsAt // null)
}
}' <<<"$response" 2>/dev/null)"
[[ -n "$usage" ]] || { emit unavailable "The usage service returned an unfamiliar shape."; exit 0; }
emit ok "" "$usage"
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
"""Print one display-ready Claude Code usage record as JSON.
Everything the usage panel shows for Claude comes from here: local transcript
statistics from the Claude Code projects directory, the stats-cache and history
fallbacks for machines with no transcripts, and the authoritative rate limits
from Anthropic's OAuth usage endpoint. The panel reads only the JSON this
prints; it never learns a disk format or an endpoint shape.
Adapted from Omarchy (bin/omarchy-agent-usage-claude).
Copyright (c) David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
── What this deliberately does NOT do ────────────────────────────────────────
It never refreshes the OAuth token, and it never writes to the credentials file.
That token expires about hourly and Claude Code refreshes it on demand. If this
refreshed it too, two processes would be rotating one credential: a refresh that
rotates the refresh token invalidates the other holder's copy, and the failure
mode is being silently signed out of Claude Code by a status widget. No bar
indicator is worth that. So this reads the token, uses it while it is valid, and
reports an honest waiting state when it is not.
── The token ─────────────────────────────────────────────────────────────────
It never reaches argv. The request is made in this process with urllib, so there
is no child process whose /proc/<pid>/cmdline could carry a credential -- the
same rule panama-pick follows for passwords and panama-sudo for the MOK hash,
kept by having no subprocess at all rather than by hiding an argument.
It never reaches the output either. The record below carries percentages,
timestamps and token counts; the only thing from the credential store that may
travel into it is the display-safe plan label.
── Seams ─────────────────────────────────────────────────────────────────────
CLAUDE_CONFIG_DIR Claude Code's config directory (~/.claude)
PANAMA_AGENT_CREDENTIALS the credentials file, for tests
PANAMA_AGENT_USAGE_ENDPOINT the usage endpoint, for tests
PANAMA_AGENT_USAGE_CACHE the scan/probe cache directory
The output path is not a seam here: this prints, and panama-agent-usage-update
owns the atomic write into $XDG_STATE_HOME/panama/agents/usage/.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
AGENT_ID = "claude"
AGENT_NAME = "Claude Code"
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
# A panel that is opened and shut repeatedly must not turn into a request per
# flick, so a recent probe result is reused for this long.
PROBE_MIN_INTERVAL_SECONDS = 15
# A normal run reuses a scan only long enough to dedup concurrent collectors
# (the fan-out backs one off per agent). --limits-only promises fresh limits and
# nothing else, so it may reuse a scan for far longer.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value)))
def config_dir() -> Path:
return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude")
def credentials_path(claude_dir: Path) -> Path:
override = os.environ.get("PANAMA_AGENT_CREDENTIALS")
return expand_path(override) if override else claude_dir / ".credentials.json"
def endpoint() -> str:
return os.environ.get("PANAMA_AGENT_USAGE_ENDPOINT") or DEFAULT_ENDPOINT
def cache_root() -> Path:
override = os.environ.get("PANAMA_AGENT_USAGE_CACHE")
root = expand_path(override) if override else (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "panama" / "agent-usage"
)
root.mkdir(parents=True, exist_ok=True)
return root
def date_string(value: dt.date) -> str:
return value.strftime("%Y-%m-%d")
def recent_date_strings() -> list[str]:
today = dt.datetime.now().date()
return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
def local_date_string() -> str:
return date_string(dt.datetime.now().date())
def local_date_from_timestamp(value: Any) -> str:
if value is None:
return local_date_string()
if isinstance(value, (int, float)):
try:
seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
return date_string(dt.datetime.fromtimestamp(seconds).date())
except Exception:
return local_date_string()
raw = str(value).strip()
if not raw:
return local_date_string()
# Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but
# not a trailing Z until it is normalized to +00:00.
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return date_string(parsed.date())
except Exception:
return local_date_string()
def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int:
value = usage.get(snake_key, usage.get(camel_key, 0))
try:
return round(float(value or 0))
except Exception:
return 0
def number(value: Any) -> int:
try:
n = float(value or 0)
return round(n) if n == n else 0
except Exception:
return 0
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
# ─────────────────────────────────────────────────────────────── local scan ──
def scan_projects(projects_path: Path) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing keeps files with
# unrelated lines inexpensive.
if '"usage":' not in line:
continue
try:
entry = json.loads(line)
except Exception:
continue
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
# One assistant message can be written more than once (a
# resumed session replays it). The message id is what tells
# a replay from a second answer.
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else (
f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
)
if unique_key in seen:
continue
seen.add(unique_key)
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# recentDays.messageCount is a token total despite the
# legacy name; the panel draws it as one.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"panama-agent-usage-claude: ignoring unreadable {path}: {exc}", file=sys.stderr)
return {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]:
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache's freshness cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path: Path, payload: dict[str, Any]) -> None:
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once (the fan-out backgrounds one per agent), and a
# shared temp path means the second replace finds the first one's file
# already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
# mkstemp opens at 0600; nothing in this cache is a secret.
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
"""Local stats, with the cache as a pure optimization.
A cache-layer failure (unwritable cache root, lock errors, a full disk) must
never take the collector down: it degrades to a direct scan. The printed
record is the contract; the cache is not.
"""
try:
cache_file, lock_file = scan_cache_paths(projects_path)
cached = read_cached_scan(cache_file, max_age_seconds)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_scan(cache_file, max_age_seconds)
if cached is not None:
return cached
summary = scan_projects(projects_path)
write_json(cache_file, {"schemaVersion": 1, "scanDate": local_date_string(), "stats": summary})
return summary
except Exception as exc:
print(f"panama-agent-usage-claude: cache unavailable ({exc}); scanning directly", file=sys.stderr)
return scan_projects(projects_path)
# The cache payload is a versioned envelope around the stats dict, so a
# corrupted or foreign-shaped file is a miss (rescan and rewrite) rather than a
# crash or a garbage record.
def read_cached_scan(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None:
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned. A cache from
# another local date (midnight passed, or the clock moved) is a miss, not
# merely old, whatever its mtime says.
if cached.get("scanDate") != local_date_string():
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
# ────────────────────────────────────────────────────────── local fallback ──
#
# A machine without transcripts on disk can still know its history: Claude Code
# keeps aggregate counters in stats-cache.json and per-prompt history in
# history.jsonl. Only consulted when the project scan comes back empty.
def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None:
try:
data = json.loads((claude_dir / "stats-cache.json").read_text(encoding="utf-8"))
except Exception:
return None
today = local_date_string()
daily_model_tokens = data.get("dailyModelTokens") or []
today_tokens = {}
for entry in daily_model_tokens:
if isinstance(entry, dict) and entry.get("date") == today:
today_tokens = entry.get("tokensByModel") or {}
break
daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)]
active_dates = sorted({
str(day.get("date")) for day in daily_activity
if number(day.get("messageCount")) > 0 and day.get("date")
})
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
return {
"todayPrompts": today_prompts,
"todaySessions": today_sessions,
"todayTotalTokens": sum(number(v) for v in today_tokens.values()),
"todayTokensByModel": today_tokens,
"recentDays": daily_activity[-7:],
"modelUsage": data.get("modelUsage") or {},
"totalPrompts": number(data.get("totalMessages")),
"totalSessions": number(data.get("totalSessions")),
"activeDays": len(active_dates),
"activeDates": active_dates,
}
def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
prompts = 0
sessions: set[str] = set()
start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000
try:
with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
except Exception:
return 0, 0
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except Exception:
continue
if number(entry.get("timestamp")) < start_of_day:
break
prompts += 1
if entry.get("sessionId"):
sessions.add(str(entry.get("sessionId")))
return prompts, len(sessions)
# ───────────────────────────────────────────────────────────────── limits ──
# The access token, its expiry, and the display-safe plan label from the CLI's
# login. Nothing else leaves the credential store: the token goes nowhere but
# the Authorization header of the limits probe, and only the plan label may
# travel into the printed record.
def oauth_login(credentials: Path) -> tuple[str, int, str]:
try:
data = json.loads(credentials.read_text(encoding="utf-8"))
except Exception:
return "", 0, ""
login = data.get("claudeAiOauth")
if not isinstance(login, dict):
return "", 0, ""
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
def plan_label(tier: str, subscription: str) -> str:
if tier:
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def parse_utilization(value: Any) -> float:
try:
return float(str(value).strip().replace("%", ""))
except Exception:
return float("nan")
def normalize_utilization(value: Any, percent_scale: bool) -> float:
n = parse_utilization(value)
if not (n >= 0):
return -1.0
# The OAuth usage endpoint currently reports percentages (37.0, or 1.0).
# Older payloads sometimes used fractions (0.37). A payload containing any
# value >= 1 is percent-scaled, so 1.0 renders as 1%, not 100%. Clamped as
# well as normalized: a readout that can print 1500% is one you learn to
# ignore.
if percent_scale or n > 1:
return min(1.0, n / 100.0)
return min(1.0, n)
def normalize_reset_at(value: Any) -> str:
if value is None:
return ""
raw = str(value).strip()
if raw == "":
return ""
if raw.isdigit():
ts = int(raw)
if ts < 1e12:
ts *= 1000
try:
return dt.datetime.fromtimestamp(ts / 1000, dt.timezone.utc).isoformat()
except Exception:
return raw
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
return parsed.isoformat()
except Exception:
return raw
def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None:
bucket = payload.get(key)
return bucket if isinstance(bucket, dict) else None
# An entry's `kind` names its window the way the flat buckets' keys do
# ("weekly_scoped", "five_hour_scoped"). Reading a window out of free text
# cannot survive a model name like "Opus 5 (1M context)" -- the "1M" reads as a
# one-minute window -- so the window is settled here and travels as an explicit
# title, capitalized the way the flat windows title themselves so "Fable Weekly"
# sits beside "Weekly" rather than under it.
def scoped_window(kind: str) -> str:
text = kind.lower()
if "month" in text:
return "Monthly"
if "week" in text or "day" in text:
return "Weekly"
if "hour" in text or "session" in text:
return "Session"
return ""
# Alongside the flat buckets the payload carries a `limits` array, and that
# array is the only place a model-scoped allowance shows up -- a weekly window
# only one model draws from, say. The matching legacy keys (`seven_day_opus`,
# `seven_day_sonnet`, ...) stayed behind at null, so a collector reading buckets
# alone silently drops a limit the account is actually spending against. A model
# can hold more than one scoped window, and only the pair of model and window
# tells them apart, so both make the title and both make the dedupe key.
def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]:
entries = payload.get("limits")
if not isinstance(entries, list):
return []
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
model = scope.get("model") if isinstance(scope, dict) else None
if not isinstance(model, dict):
continue
# A display name is what the panel wants, but an entry carrying only an
# id still names a window worth showing.
name = str(model.get("display_name") or model.get("id") or "").strip()
kind = str(entry.get("kind") or "").strip()
if name == "" or (name, kind) in seen:
continue
percent = normalize_utilization(entry.get("percent"), percent_scale)
if percent < 0:
continue
seen.add((name, kind))
window = scoped_window(kind)
title = name + " " + window if window else name
out.append({
"label": title,
"percent": percent,
"resetsAt": normalize_reset_at(entry.get("resets_at")),
})
return out
def probe_limits(access_token: str) -> dict[str, Any]:
# The token travels in a header on a request made in this process. There is
# no child process, so there is no argv to leak it through.
request = urllib.request.Request(
endpoint(),
headers={
"Authorization": "Bearer " + access_token,
"anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as error:
retry_after = error.headers.get("retry-after", "") if error.headers else ""
if error.code == 429:
help_text = "Anthropic's usage endpoint is rate limiting checks right now" + (
f" (retry after {retry_after}s)" if retry_after else ""
) + ". Local Claude Code stats are still shown."
else:
help_text = (
f"Anthropic's usage endpoint returned status {error.code}. "
"Local Claude Code stats are still shown."
)
return {"ok": False, "helpText": help_text}
except Exception:
# A transport failure reached no server at all -- no route, no DNS. Any
# real answer, including an error status, is a server worth not
# pestering; this is not.
return {
"ok": False,
"transport": True,
"helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.",
}
if not isinstance(payload, dict):
return {"ok": False, "helpText": "Anthropic's usage endpoint returned an unfamiliar shape."}
weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day")
session = usage_bucket(payload, "five_hour")
raw = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None]
# One payload speaks one convention, so the scoped entries settle the scale
# alongside the buckets rather than assuming their own.
entries = payload.get("limits")
if isinstance(entries, list):
raw += [entry.get("percent") for entry in entries if isinstance(entry, dict)]
percent_scale = any(parse_utilization(v) >= 1 for v in raw)
limits = []
if session is not None:
percent = normalize_utilization(session.get("utilization"), percent_scale)
if percent >= 0:
limits.append({
"label": "Session (5-hour)",
"percent": percent,
"resetsAt": normalize_reset_at(session.get("resets_at")),
})
if weekly is not None:
percent = normalize_utilization(weekly.get("utilization"), percent_scale)
if percent >= 0:
limits.append({
"label": "Weekly (7-day)",
"percent": percent,
"resetsAt": normalize_reset_at(weekly.get("resets_at")),
})
limits.extend(scoped_limits(payload, percent_scale))
if not limits:
return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown."}
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: dict[str, Any] = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
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
# --force is a person asking for fresh numbers, so it skips the reuse window
# entirely; the interval absorbs repeated panel opens, it does not overrule
# someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback
return result
probe = probe_limits(access_token)
if probe["ok"]:
result["limits"] = probe["limits"]
try:
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
except Exception as exc:
print(f"panama-agent-usage-claude: could not cache limits ({exc})", file=sys.stderr)
return result
# The first probe after login often fires before DHCP has handed out a
# route. Ask the shell to try again sooner than its regular interval.
if probe.get("transport"):
result["retryAdvised"] = True
if fallback:
result["limits"] = fallback
else:
result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"]
return result
# ───────────────────────────────────────────────────────────────── record ──
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Claude Code usage record as JSON")
parser.add_argument("--force", action="store_true",
help="rescan transcripts and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true",
help="reuse any recent transcript scan; only the limits probe must be fresh")
args = parser.parse_args()
claude_dir = config_dir()
scan_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = cached_scan(claude_dir / "projects", scan_age)
if number(stats.get("totalPrompts")) <= 0:
fallback = stats_cache_fallback(claude_dir)
if fallback is not None:
stats = fallback
else:
# No transcripts and no aggregate cache, but history.jsonl alone can
# still put numbers on today.
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
if today_prompts or today_sessions:
stats = dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions)
access_token, expires_at_ms, plan = oauth_login(credentials_path(claude_dir))
limits = collect_limits(access_token, expires_at_ms, args.force)
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
"ready": number(stats.get("totalPrompts")) > 0 or len(limits["limits"]) > 0,
"hasLocalStats": True,
"tierLabel": plan,
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"limits": limits["limits"],
}
if limits.get("retryAdvised"):
record["retryAdvised"] = True
record.update(stats)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+535
View File
@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""Print one display-ready Codex usage record as JSON.
Local statistics come from the Codex CLI's own session files; the plan and the
rate limits come from the Codex app-server over JSON-RPC. The usage panel reads
only the JSON this prints.
Adapted from Omarchy (bin/omarchy-agent-usage-codex).
Copyright (c) David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
── Secrets ───────────────────────────────────────────────────────────────────
This never reads the Codex credential store. The app-server is asked for the
account and its limits over a pipe, and it authenticates itself; no token
reaches this process, its argv, or the record. The app-server is spawned
read-only and untrusted so a usage query can never change anything.
── Seams ─────────────────────────────────────────────────────────────────────
CODEX_HOME Codex's home directory (~/.codex)
PANAMA_AGENT_CODEX_BIN the codex binary, for tests
PANAMA_AGENT_USAGE_CACHE the session-scan cache directory
The output path is not a seam here: this prints, and panama-agent-usage-update
owns the atomic write into $XDG_STATE_HOME/panama/agents/usage/.
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import select
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
AGENT_ID = "codex"
AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate."
# A scan this recent is only reused to dedup concurrent collector runs (the
# fan-out backgrounds one per agent). --limits-only promises only fresh limits,
# so it may reuse a scan for far longer.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
# Sessions older than this are not what anyone is looking at, and walking them
# on every refresh is the difference between a scan and a stall.
SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value)))
def codex_home() -> Path:
return expand_path(os.environ.get("CODEX_HOME") or "~/.codex")
def runtime_env() -> dict[str, str]:
# Codex is commonly a user-level npm or mise install, and a collector run
# from the shell's environment does not always inherit those directories.
home = str(Path.home())
path_parts = [
os.environ.get("PATH", ""),
f"{home}/.local/bin",
f"{home}/.npm-global/bin",
f"{home}/.local/share/mise/shims",
]
env = os.environ.copy()
env["PATH"] = os.pathsep.join(part for part in path_parts if part)
return env
ENV = runtime_env()
def find_codex() -> str | None:
override = os.environ.get("PANAMA_AGENT_CODEX_BIN")
if override:
return override if os.access(override, os.X_OK) else None
return shutil.which("codex", path=ENV.get("PATH"))
def cache_root() -> Path:
override = os.environ.get("PANAMA_AGENT_USAGE_CACHE")
root = expand_path(override) if override else (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "panama" / "agent-usage"
)
root.mkdir(parents=True, exist_ok=True)
return root
def number(value: Any) -> int:
try:
return int(value or 0)
except Exception:
return 0
def model_name(raw: Any) -> str:
value = str(raw or "codex")
return value if value else "codex"
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
def local_day(value: Any) -> str:
if value is None:
return today
if isinstance(value, (int, float)):
# Codex timestamps are usually seconds; anything larger is milliseconds.
if value > 10_000_000_000:
value = value / 1000
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
text = str(value)
try:
parsed = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return parsed.strftime("%Y-%m-%d")
except Exception:
return today
class Tally:
"""Everything the session scan accumulates, in one place."""
def __init__(self) -> None:
self.recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
self.today_tokens_by_model: dict[str, int] = {}
self.model_usage: dict[str, dict[str, int]] = {}
self.today_sessions: set[str] = set()
self.total_sessions: set[str] = set()
self.active_days: set[str] = set()
self.today_prompts = 0
self.today_total_tokens = 0
self.total_prompts = 0
def add(self, day: str, session_key: str, model: str,
input_tokens: int, output_tokens: int, cache_read: int, cache_write: int) -> None:
total = input_tokens + output_tokens + cache_read + cache_write
self.total_prompts += 1
self.total_sessions.add(session_key)
self.active_days.add(day)
bucket = self.model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in self.recent:
# recentDays.messageCount is a token total despite the legacy name.
self.recent[day]["messageCount"] += total
if day == today:
self.today_prompts += 1
self.today_sessions.add(session_key)
self.today_total_tokens += total
self.today_tokens_by_model[model] = self.today_tokens_by_model.get(model, 0) + total
def stats(self) -> dict[str, Any]:
return {
"todayPrompts": self.today_prompts,
"todaySessions": len(self.today_sessions),
"todayTotalTokens": self.today_total_tokens,
"todayTokensByModel": self.today_tokens_by_model,
"recentDays": [self.recent[day] for day in recent_dates],
"totalPrompts": self.total_prompts,
"totalSessions": len(self.total_sessions),
"activeDays": len(self.active_days),
"activeDates": sorted(self.active_days),
"modelUsage": self.model_usage,
}
def scan_native_sessions(tally: Tally) -> None:
home = codex_home()
roots = [home / "sessions", home / "archived_sessions"]
files = []
cutoff = time.time() - SESSION_MAX_AGE_SECONDS
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError:
pass
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(
payload.get("model") or payload.get("model_slug") or current_model
)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding
# every snapshot makes usage grow quadratically, so only the
# last turn is counted.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens and reasoning
# tokens in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
tally.add(day, str(path), current_model,
input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
# ───────────────────────────────────────────────────────────── scan cache ──
def scan_cache_paths() -> tuple[Path, Path]:
digest = hashlib.sha1(str(codex_home()).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
def read_fresh_json(path: Path, max_age_seconds: float) -> Any:
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path: Path, payload: dict[str, Any]) -> None:
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once, and a shared temp path means the second
# replace finds the first one's file already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
# The cache payload is a versioned envelope around the stats dict, so a
# corrupted or foreign-shaped file is a miss (rescan and rewrite) rather than a
# crash or a garbage record.
def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None:
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned.
if cached.get("scanDate") != today:
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
def local_stats(max_age: float) -> dict[str, Any]:
"""Local stats, with the cache as a pure optimization.
A cache-layer failure must never take the collector down: it degrades to a
direct scan. The printed record is the contract; the cache is not.
"""
try:
cache_file, lock_file = scan_cache_paths()
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
tally = Tally()
scan_native_sessions(tally)
stats = tally.stats()
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
return stats
except Exception as exc:
print(f"panama-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
tally = Tally()
scan_native_sessions(tally)
return tally.stats()
# ──────────────────────────────────────────────────────────────── app-server ──
def rpc_request(proc: subprocess.Popen, request_id: int, method: str,
params: dict[str, Any] | None = None, timeout: float = 8) -> dict[str, Any]:
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
def limit_window(window: Any, prefix: str = "") -> dict[str, Any] | None:
if not isinstance(window, dict):
return None
used = window.get("usedPercent")
if used is None:
return None
mins = number(window.get("windowDurationMins"))
if mins == 10080:
label = "Weekly (7-day)"
elif mins == 300:
label = "Session (5-hour)"
elif mins and mins % 60 == 0:
label = f"{mins // 60}h window"
elif mins:
label = f"{mins}m window"
else:
label = "Limit"
if prefix:
label = f"{prefix} {label}"
reset = window.get("resetsAt")
try:
percent = min(1.0, max(0.0, float(used) / 100.0))
except Exception:
return None
return {
"label": label,
"percent": percent,
"resetsAt": datetime.fromtimestamp(number(reset), timezone.utc).isoformat() if reset else "",
}
# The account's own windows come back under `primary` and `secondary`.
# `rateLimitsByLimitId` repeats those under the account's limit id and adds the
# model-scoped ones beside them -- a window only one model draws from, named by
# `limitName`. An entry with no name is the account limit again, so only the
# named ones are worth a row of their own.
def scoped_limits(limits: dict[str, Any]) -> list[dict[str, Any]]:
by_id = limits.get("rateLimitsByLimitId")
if not isinstance(by_id, dict):
return []
out: list[dict[str, Any]] = []
for entry in by_id.values():
if not isinstance(entry, dict):
continue
name = str(entry.get("limitName") or "").strip()
if name == "":
continue
for window in (entry.get("primary"), entry.get("secondary")):
row = limit_window(window, name)
if row:
out.append(row)
return out
def fetch_rpc() -> dict[str, Any]:
result: dict[str, Any] = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
codex = find_codex()
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex was not found on PATH."
return result
try:
# Read-only, and never asking for approval: a usage query has no
# business being able to change anything on this machine, and nothing is
# watching a prompt it might raise. (`-a untrusted` was the flag Omarchy
# used; codex 0.149 takes on-request or never.)
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "never", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=ENV,
)
except Exception as exc:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize",
{"clientInfo": {"name": "panama-agent-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
account = (account_msg.get("result") or {}).get("account") or {}
payload = limits_msg.get("result") or {}
limits = payload.get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["tierLabel"] = str(plan) if plan else ""
for window in (limits.get("primary"), limits.get("secondary")):
entry = limit_window(window)
if entry:
result["limits"].append(entry)
result["limits"].extend(scoped_limits(payload))
except TimeoutError as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = (
f"The Codex app-server did not answer `{exc}` in time. "
"Start Codex once, or run `codex login`, and the limits come back."
)
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc) or "The Codex app-server could not be reached."
finally:
try:
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
return result
# ───────────────────────────────────────────────────────────────── record ──
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Codex usage record as JSON")
parser.add_argument("--force", action="store_true",
help="rescan sessions and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true",
help="reuse any recent session scan; only the limits probe must be fresh")
args = parser.parse_args()
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = local_stats(max_age)
rpc = fetch_rpc()
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
# Honest about having nothing to say: a Codex that is not signed in and
# has never run leaves the panel's tab empty rather than showing zeros.
"ready": number(stats.get("totalPrompts")) > 0 or len(rpc["limits"]) > 0,
"hasLocalStats": True,
}
record.update(stats)
record.update(rpc)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Refresh the agent usage records the bar panel watches.
#
# Each panama-agent-usage-<agent> collector prints one display-ready JSON
# record; this runs the enabled ones in parallel and writes their output to
# $XDG_STATE_HOME/panama/agents/usage/<agent>.json. Adding an agent is adding a
# collector -- the panel picks up any record that appears in that directory, and
# no QML changes.
#
# Adapted from Omarchy (bin/omarchy-agent-usage-update).
#
# Copyright (c) David Heinemeier Hansson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# ── Why this reads settings.json rather than being told ──────────────────────
#
# Per-agent collection is a preference, and a preference is read at the moment
# it matters -- the same rule the power button follows. A collector the user
# turned off must not run at all: it is the thing that reads a credential file
# and makes a network call, so "off" has to mean "no process", not "a process
# whose output is discarded".
#
# ── Why a disabled agent's record is deleted ─────────────────────────────────
#
# The panel watches the directory. Leaving yesterday's record behind after the
# collector is switched off would keep showing numbers nothing is refreshing,
# which is worse than an empty tab.
set -uo pipefail
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Seams. The contract points all three somewhere hermetic; nothing else does.
COLLECTOR_DIR="${PANAMA_AGENT_USAGE_COLLECTORS:-$self_dir}"
USAGE_DIR="${PANAMA_AGENT_USAGE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/panama/agents/usage}"
SETTINGS="${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
command -v jq >/dev/null 2>&1 || {
printf 'panama-agent-usage-update: jq is required\n' >&2
exit 1
}
mkdir -p "$USAGE_DIR" || exit 1
flags=()
only=()
while (( $# > 0 )); do
case "$1" in
--force | --limits-only) flags+=("$1") ;;
--help | -h)
printf 'usage: panama-agent-usage-update [--force] [--limits-only] [agent...]\n'
exit 0
;;
-*)
printf 'panama-agent-usage-update: unknown option %s\n' "$1" >&2
exit 2
;;
*) only+=("$1") ;;
esac
shift
done
# `.key // true` is wrong here: jq's alternative operator fires on false as well
# as on null, so a collector the user switched off would read as enabled. Absent
# is the only case that means "use the default".
enabled() {
local agent="$1" key answer
key="agentUsage$(tr '[:lower:]' '[:upper:]' <<<"${agent:0:1}")${agent:1}"
[[ -r "$SETTINGS" ]] || return 0
answer="$(jq -r --arg key "$key" \
'if has($key) then (.[$key] | tostring) else "true" end' "$SETTINGS" 2>/dev/null)" || return 0
[[ "$answer" == "true" ]]
}
requested() {
local agent="$1" candidate
(( ${#only[@]} == 0 )) && return 0
for candidate in "${only[@]}"; do
[[ "$candidate" == "$agent" ]] && return 0
done
return 1
}
# A record is replaced only by a whole, parseable one. A collector that dies
# halfway, or prints a stack trace, leaves the previous numbers standing rather
# than blanking the panel: mktemp + mv makes the swap atomic, so a reader never
# sees a half-written file, and the jq gate makes sure the thing being moved
# into place is a record at all.
collect() {
local collector="$1" agent="$2" record tmp
if ! record="$("$collector" "${flags[@]}" 2>/dev/null)" \
|| [[ -z "$record" ]] \
|| ! jq -e . >/dev/null 2>&1 <<<"$record"; then
printf 'panama-agent-usage-update: the %s collector produced no usable record\n' "$agent" >&2
return 1
fi
tmp="$(mktemp "$USAGE_DIR/.$agent.XXXXXX")" || return 1
printf '%s\n' "$record" >"$tmp" || { rm -f "$tmp"; return 1; }
chmod 644 "$tmp"
mv "$tmp" "$USAGE_DIR/$agent.json"
}
pids=()
status=0
for collector in "$COLLECTOR_DIR"/panama-agent-usage-*; do
[[ -x "$collector" ]] || continue
agent="${collector##*/panama-agent-usage-}"
[[ "$agent" == "update" ]] && continue
requested "$agent" || continue
if ! enabled "$agent"; then
rm -f "$USAGE_DIR/$agent.json"
continue
fi
collect "$collector" "$agent" &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"