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:
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user