* Add agent usage collectors that write display-ready data files
One omarchy-agent-usage-scan-<agent> collector per AI coding agent prints a
complete display-ready usage record — identity, tier, status, rate limits,
and today/week/all-time stats. omarchy-agent-usage-update runs every
collector it finds and writes the records atomically to
~/.local/state/omarchy/agents/usage/, so anything that displays usage only
ever reads JSON from there.
The Claude collector absorbs what the shell previously did in-process:
transcript scanning, the stats-cache/history fallback, credentials parsing,
and the OAuth limits probe, now with a probe throttle and last-good limits
kept across network failures. The Codex collector is the existing scanner
reshaped to the shared record contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Redo the model-usage plugin as omarchy.agents watching usage data files
The panel is now strictly a display. It discovers the JSON records that
omarchy-agent-usage-update maintains under
~/.local/state/omarchy/agents/usage/, watches them for changes, and draws
whatever appears — so adding an agent means shipping a collector, never
touching the panel. Marks resolve by convention (assets/<id>.svg with an
optional -light twin), the limits meters read a generic limits array, and
the per-provider QML adapters and in-plugin scanner scripts are gone.
Cross-device sync aggregation stays in the shell and keeps the snapshot
field names older versions wrote, so mixed-version fleets still merge in
both directions.
With the provider fan-out gone, the widget takes its real name: the plugin
id becomes omarchy.agents. A migration renames it wherever a user's config
mentions it — layout entries keep their settings and position, a disabled
widget stays disabled — then primes the data files once and drops the old
scanner cache. The migration test also drops a stale assertion that expected
migrations to restart the shell themselves, which c992cdff moved to
omarchy update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address Codex review: synced-only tabs, limits retry, history fallback
Three data-availability gaps from review. An agent whose records only exist
in synced snapshots — a collector installed on just one machine — now gets
its tab by unioning the synced aggregate into the provider list, with rate
limits blank since those never travel. A Claude limits probe that reaches no
server at all writes retryAdvised into its record, and the shell honors it
with one 30-second retry instead of waiting out the full refresh interval,
restoring the old boot-before-DHCP behavior. And a machine with only
history.jsonl — no transcripts, no stats-cache — still reports today's
prompt and session counts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address second Codex pass: history-only visibility, targeted retries
Today's prompt and session counts now count toward an agent's presence in
the bar, so a machine whose only Claude source is history.jsonl shows up
without waiting for limits. And the 30-second limits retry passes the
advising agent ids to the updater, so an outage at one provider no longer
puts every other collector on a retry treadmill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop omarchy-cmd-present jq guards from the agents migrations
jq ships in the default package set, which makes it a runtime invariant per
AGENTS.md — call it directly. The migration tests lose their now-unused
omarchy-cmd-present stubs with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop the scan infix from the collector command names
Collectors are omarchy-agent-usage-<agent>; the updater skips its own name
when globbing them, and the update test proves it with a decoy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep the credential store out of the printed usage record
The Claude collector now reads .credentials.json once into three scalars —
the access token, its expiry, and the plan label — instead of passing the
parsed store around. The token reaches nothing but the Authorization header
of the limits probe, and only the plan label may travel into the record,
which is what CodeQL's clear-text-logging alert on the record print was
unable to see when the whole dict flowed through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
381 lines
12 KiB
Python
Executable File
381 lines
12 KiB
Python
Executable File
#!/usr/bin/python3
|
|
# omarchy:summary=Print the Codex usage record as JSON
|
|
# omarchy:args=[--force] [--limits-only]
|
|
# omarchy:hidden=true
|
|
"""Collect Codex usage into one display-ready JSON record.
|
|
|
|
Local stats come from native Codex CLI session files (and pi sessions that
|
|
ran through openai-codex); rate limits and the plan come from the Codex
|
|
app-server RPC. The agents panel only ever reads the JSON this prints.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
AGENT_ID = "codex"
|
|
AGENT_NAME = "Codex"
|
|
AUTH_HELP = "Run `codex login` to authenticate."
|
|
|
|
|
|
def local_day(value):
|
|
if value is None:
|
|
return datetime.now().strftime("%Y-%m-%d")
|
|
if isinstance(value, (int, float)):
|
|
# pi message timestamps are milliseconds; Codex timestamps are usually seconds.
|
|
if value > 10_000_000_000:
|
|
value = value / 1000
|
|
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
|
|
text = str(value)
|
|
try:
|
|
if text.endswith("Z"):
|
|
dt = datetime.fromisoformat(text[:-1] + "+00:00")
|
|
else:
|
|
dt = datetime.fromisoformat(text)
|
|
if dt.tzinfo is not None:
|
|
dt = dt.astimezone()
|
|
return dt.strftime("%Y-%m-%d")
|
|
except Exception:
|
|
return datetime.now().strftime("%Y-%m-%d")
|
|
|
|
|
|
def number(value):
|
|
try:
|
|
return int(value or 0)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def model_name(raw):
|
|
value = str(raw or "codex")
|
|
return value if value else "codex"
|
|
|
|
|
|
def runtime_env():
|
|
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_command(name):
|
|
return shutil.which(name, path=ENV.get("PATH"))
|
|
|
|
|
|
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)]
|
|
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
|
|
today_tokens_by_model = {}
|
|
model_usage = {}
|
|
today_sessions = set()
|
|
active_days = set()
|
|
|
|
today_prompts = 0
|
|
today_total_tokens = 0
|
|
total_prompts = 0
|
|
total_sessions = set()
|
|
seen_pi_messages = set()
|
|
|
|
|
|
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
|
|
global today_prompts, today_total_tokens, total_prompts
|
|
total = input_tokens + output_tokens + cache_read + cache_write
|
|
total_prompts += 1
|
|
total_sessions.add(session_key)
|
|
active_days.add(day)
|
|
|
|
bucket = 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 recent:
|
|
recent[day]["messageCount"] += total
|
|
|
|
if day == today:
|
|
today_prompts += 1
|
|
today_sessions.add(session_key)
|
|
today_total_tokens += total
|
|
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
|
|
|
|
|
|
def scan_pi_sessions():
|
|
root = Path.home() / ".pi" / "agent" / "sessions"
|
|
if not root.exists():
|
|
return
|
|
try:
|
|
rg = find_command("rg") or "rg"
|
|
proc = subprocess.Popen(
|
|
[rg, "--json", "-e", '"provider":"openai-codex"', "-e", '"api":"openai-codex"', str(root)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
errors="replace",
|
|
env=ENV,
|
|
)
|
|
except FileNotFoundError:
|
|
return
|
|
|
|
assert proc.stdout is not None
|
|
for raw in proc.stdout:
|
|
try:
|
|
event = json.loads(raw)
|
|
if event.get("type") != "match":
|
|
continue
|
|
line = event.get("data", {}).get("lines", {}).get("text", "")
|
|
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
|
|
entry = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
|
|
if entry.get("type") != "message":
|
|
continue
|
|
message_key = path + ":" + str(entry.get("id") or "")
|
|
if message_key in seen_pi_messages:
|
|
continue
|
|
seen_pi_messages.add(message_key)
|
|
message = entry.get("message") or {}
|
|
if message.get("role") != "assistant":
|
|
continue
|
|
provider = str(message.get("provider") or "")
|
|
api = str(message.get("api") or "")
|
|
if provider != "openai-codex" and not api.startswith("openai-codex"):
|
|
continue
|
|
|
|
usage = message.get("usage") or {}
|
|
if not usage:
|
|
continue
|
|
total = number(usage.get("totalTokens"))
|
|
input_tokens = number(usage.get("input"))
|
|
output_tokens = number(usage.get("output"))
|
|
cache_read = number(usage.get("cacheRead"))
|
|
cache_write = number(usage.get("cacheWrite"))
|
|
if total and not (input_tokens or output_tokens or cache_read or cache_write):
|
|
input_tokens = total
|
|
if not (input_tokens or output_tokens or cache_read or cache_write):
|
|
continue
|
|
|
|
day = local_day(entry.get("timestamp") or message.get("timestamp"))
|
|
session_key = path
|
|
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
|
|
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
proc.kill()
|
|
|
|
|
|
def scan_native_codex_sessions():
|
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
|
|
files = []
|
|
cutoff = time.time() - 30 * 24 * 60 * 60
|
|
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 count the last turn.
|
|
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
|
|
# are included 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)
|
|
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
def rpc_request(proc, request_id, method, params=None, timeout=8):
|
|
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):
|
|
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 and mins % 60 == 0:
|
|
label = f"{mins // 60}h window"
|
|
elif mins:
|
|
label = f"{mins}m window"
|
|
else:
|
|
label = "Limit"
|
|
reset = window.get("resetsAt")
|
|
return {
|
|
"label": label,
|
|
"percent": float(used) / 100.0,
|
|
"resetsAt": datetime.fromtimestamp(number(reset), timezone.utc).isoformat() if reset else "",
|
|
}
|
|
|
|
|
|
def fetch_codex_rpc():
|
|
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
|
|
codex = find_command("codex")
|
|
if not codex:
|
|
result["usageStatusText"] = "Codex unavailable"
|
|
result["authHelpText"] = "codex not found in PATH"
|
|
return result
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[codex, "-s", "read-only", "-a", "untrusted", "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": "omarchy-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 {}
|
|
limits = (limits_msg.get("result") or {}).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)
|
|
except Exception as exc:
|
|
result["usageStatusText"] = "Codex limits unavailable"
|
|
result["authHelpText"] = str(exc)
|
|
finally:
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
# Local stats and limits come from the same cheap scan, so there is no
|
|
# cache to force past and no faster limits-only path. The flags exist so
|
|
# every collector accepts the same invocation.
|
|
parser.add_argument("--force", action="store_true")
|
|
parser.add_argument("--limits-only", action="store_true")
|
|
parser.parse_args()
|
|
|
|
scan_pi_sessions()
|
|
scan_native_codex_sessions()
|
|
rpc = fetch_codex_rpc()
|
|
|
|
record = {
|
|
"schemaVersion": 1,
|
|
"id": AGENT_ID,
|
|
"name": AGENT_NAME,
|
|
"updatedAt": datetime.now(timezone.utc).isoformat(),
|
|
"ready": True,
|
|
"hasLocalStats": True,
|
|
"todayPrompts": today_prompts,
|
|
"todaySessions": len(today_sessions),
|
|
"todayTotalTokens": today_total_tokens,
|
|
"todayTokensByModel": today_tokens_by_model,
|
|
"recentDays": [recent[day] for day in recent_dates],
|
|
"totalPrompts": total_prompts,
|
|
"totalSessions": len(total_sessions),
|
|
# Days with any recorded usage, for the all-time "N days" summary. The
|
|
# dates travel too: merging snapshots from several machines needs their
|
|
# union, which a count alone cannot give.
|
|
"activeDays": len(active_days),
|
|
"activeDates": sorted(active_days),
|
|
"modelUsage": model_usage,
|
|
}
|
|
record.update(rpc)
|
|
print(json.dumps(record, separators=(",", ":")))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|