diff --git a/bin/omarchy b/bin/omarchy index 2c59cae4..bd5ac435 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -26,6 +26,7 @@ declare -A ROUTE_IS_ALIAS declare -A BINARY_TO_KEY declare -A GROUP_DESCRIPTIONS +GROUP_DESCRIPTIONS[agent]="AI coding agent usage data" GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[battery]="Battery status helpers" diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude new file mode 100755 index 00000000..b0a72ec6 --- /dev/null +++ b/bin/omarchy-agent-usage-claude @@ -0,0 +1,530 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Claude Code usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Claude Code usage into one display-ready JSON record. + +Everything the agents panel shows for Claude comes from this one +command: local transcript stats from ~/.claude/projects, the stats-cache and +history fallbacks for machines without transcripts, and the authoritative +rate limits from Anthropic's OAuth usage endpoint. The panel itself only ever +reads the JSON this prints; it never talks to disk formats or endpoints. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import re +import sys +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." +USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" +PROBE_MIN_INTERVAL_SECONDS = 15 + + +def config_dir() -> Path: + return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude") + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(value))).resolve() + + +def cache_root() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "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 we normalize it 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 + + 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 actually a token total, despite the + # legacy name shared with synced snapshots. + 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"Ignoring unreadable Claude project file {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), + # 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), + } + + +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: + if time.time() - path.stat().st_mtime <= 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: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]: + cache_file, lock_file = scan_cache_paths(projects_path) + + cached = read_fresh_json(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_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + summary = scan_projects(projects_path) + write_json(cache_file, summary) + return summary + + +# ------------------------------------------------------------- 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(claude_dir: Path) -> tuple[str, int, str]: + try: + data = json.loads((claude_dir / ".credentials.json").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 + # Anthropic's OAuth usage endpoint currently reports percentages (for + # example 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%. + 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 + + +def probe_limits(access_token: str) -> dict[str, Any]: + request = urllib.request.Request( + USAGE_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 we should stop + # 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.", + } + + 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] + 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"))}) + + 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} + + +def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]: + result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} + + if access_token == "": + result["usageStatusText"] = "Waiting for auth" + return result + if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000: + return result + + # A panel that is opened and shut repeatedly must not turn into a request + # per flick, so recent probe results are reused for a short window — and + # kept as the answer of record when a later probe fails. + probe_cache = cache_root() / "claude-limits.json" + cached = read_fresh_json(probe_cache, float("inf")) or {} + fetched_at = number(cached.get("fetchedAtMs")) / 1000 + min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS + if cached.get("limits") and time.time() - fetched_at < max(min_interval, PROBE_MIN_INTERVAL_SECONDS): + result["limits"] = cached["limits"] + return result + + probe = probe_limits(access_token) + if probe["ok"]: + result["limits"] = probe["limits"] + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]}) + 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 cached.get("limits"): + result["limits"] = cached["limits"] + else: + result["usageStatusText"] = "Claude limits unavailable" + result["authHelpText"] = probe["helpText"] + return result + + +# -------------------------------------------------------------------- record + + +def main() -> int: + parser = argparse.ArgumentParser() + 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") + parser.add_argument("--cache-seconds", type=float, default=20) + args = parser.parse_args() + + claude_dir = config_dir() + scan_age = 0 if args.force else (900 if args.limits_only else args.cache_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(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()) diff --git a/shell/plugins/model-usage/scripts/codex_usage_scanner.py b/bin/omarchy-agent-usage-codex old mode 100644 new mode 100755 similarity index 76% rename from shell/plugins/model-usage/scripts/codex_usage_scanner.py rename to bin/omarchy-agent-usage-codex index fba11d07..b8d172e0 --- a/shell/plugins/model-usage/scripts/codex_usage_scanner.py +++ b/bin/omarchy-agent-usage-codex @@ -1,13 +1,28 @@ +#!/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 sys 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: @@ -65,11 +80,9 @@ def find_command(name): 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_set = set(recent_dates) recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} today_tokens_by_model = {} model_usage = {} -sessions_by_day = {day: set() for day in recent_dates} today_sessions = set() active_days = set() @@ -78,8 +91,6 @@ today_total_tokens = 0 total_prompts = 0 total_sessions = set() seen_pi_messages = set() -usage_status = "" -usage_help = "" def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write): @@ -102,7 +113,6 @@ def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, if day in recent: recent[day]["messageCount"] += total - sessions_by_day[day].add(session_key) if day == today: today_prompts += 1 @@ -252,16 +262,31 @@ def rpc_request(proc, request_id, method, params=None, timeout=8): raise TimeoutError(method) -def fetch_codex_rpc(): - result = { - "rateLimitPercent": -1, - "rateLimitLabel": "", - "rateLimitResetAt": "", - "secondaryRateLimitPercent": -1, - "secondaryRateLimitLabel": "", - "secondaryRateLimitResetAt": "", - "tierLabel": "", +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" @@ -283,7 +308,7 @@ def fetch_codex_rpc(): return result try: - rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-model-usage", "version": "1"}}, timeout=8) + 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) @@ -294,26 +319,10 @@ def fetch_codex_rpc(): plan = limits.get("planType") or account.get("planType") or account.get("type") or "" result["tierLabel"] = str(plan) if plan else "" - def fill(prefix, window): - if not isinstance(window, dict): - return - used = window.get("usedPercent") - if used is not None: - result[prefix + "Percent"] = float(used) / 100.0 - mins = number(window.get("windowDurationMins")) - if mins: - if mins == 10080: - result[prefix + "Label"] = "Weekly (7-day)" - elif mins % 60 == 0: - result[prefix + "Label"] = f"{mins // 60}h window" - else: - result[prefix + "Label"] = f"{mins}m window" - reset = window.get("resetsAt") - if reset: - result[prefix + "ResetAt"] = datetime.fromtimestamp(number(reset), timezone.utc).isoformat() - - fill("rateLimit", limits.get("primary")) - fill("secondaryRateLimit", limits.get("secondary")) + 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) @@ -329,28 +338,43 @@ def fetch_codex_rpc(): return result -scan_pi_sessions() -scan_native_codex_sessions() -rpc = fetch_codex_rpc() +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() -out = { - "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, - "usageStatusText": usage_status, - "authHelpText": usage_help, -} -out.update(rpc) -print(json.dumps(out, separators=(",", ":"))) + 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() diff --git a/bin/omarchy-agent-usage-update b/bin/omarchy-agent-usage-update new file mode 100755 index 00000000..8cbfac2e --- /dev/null +++ b/bin/omarchy-agent-usage-update @@ -0,0 +1,68 @@ +#!/bin/bash + +# omarchy:summary=Regenerate the AI agent usage data files +# omarchy:args=[--force] [--limits-only] [--except ] [agent...] +# omarchy:examples=omarchy agent usage-update | omarchy agent usage-update claude | omarchy agent usage-update --except codex + +# Each omarchy-agent-usage- collector prints one display-ready JSON +# record; this writes them to ~/.local/state/omarchy/agents/usage/ where the +# agents panel watches them. Adding an agent is adding a collector — the +# panel picks up any record that appears here. + +USAGE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage" +mkdir -p "$USAGE_DIR" + +flags=() +only=() +declare -A excluded + +while [[ $# -gt 0 ]]; do + case "$1" in + --force | --limits-only) flags+=("$1") ;; + --except) + excluded[$2]=1 + shift + ;; + *) only+=("$1") ;; + esac + shift +done + +wanted() { + local agent="$1" + [[ -n ${excluded[$agent]} ]] && return 1 + (( ${#only[@]} == 0 )) && return 0 + local candidate + for candidate in "${only[@]}"; do + [[ $candidate == "$agent" ]] && return 0 + done + return 1 +} + +collect() { + local collector="$1" agent="$2" + local record tmp + if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then + echo "omarchy-agent-usage-update: $agent collector failed" >&2 + return 1 + fi + tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX") + printf '%s\n' "$record" >"$tmp" + mv "$tmp" "$USAGE_DIR/$agent.json" +} + +pids=() +for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do + [[ -x $collector ]] || continue + agent="${collector##*/omarchy-agent-usage-}" + [[ $agent == "update" ]] && continue + wanted "$agent" || continue + collect "$collector" "$agent" & + pids+=($!) +done + +status=0 +for pid in "${pids[@]}"; do + wait "$pid" || status=1 +done +exit $status diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index c746eef3..553a6b80 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -39,7 +39,7 @@ "id": "omarchy.tray" }, { - "id": "omarchy.model-usage" + "id": "omarchy.agents" }, { "id": "omarchy.bluetooth" diff --git a/migrations/1785344985.sh b/migrations/1785344985.sh index 8668e799..ea85fecb 100644 --- a/migrations/1785344985.sh +++ b/migrations/1785344985.sh @@ -1,4 +1,4 @@ -echo "Add the model usage widget to the bar" +echo "Add the agents widget to the bar" # The widget is now in the default layout. It draws nothing until a provider # reports usage — Panel.qml is `visible: providers.length > 0`, and Main.qml @@ -8,7 +8,7 @@ echo "Add the model usage widget to the bar" config_file="$HOME/.config/omarchy/shell.json" -if [[ -s $config_file ]] && omarchy-cmd-present jq; then +if [[ -s $config_file ]]; then tmp=$(mktemp) jq ' def entry_id: @@ -38,10 +38,10 @@ if [[ -s $config_file ]] && omarchy-cmd-present jq; then # Respect a bar the user already curated: only place the widget when it is # absent from every section, never a second copy. - if has_widget("omarchy.model-usage") then + if has_widget("omarchy.agents") then . else - .bar.layout.right |= insert_after("omarchy.tray"; { id: "omarchy.model-usage" }) + .bar.layout.right |= insert_after("omarchy.tray"; { id: "omarchy.agents" }) end ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" fi diff --git a/migrations/1786099804.sh b/migrations/1786099804.sh new file mode 100644 index 00000000..2956f84f --- /dev/null +++ b/migrations/1786099804.sh @@ -0,0 +1,37 @@ +echo "Rename the model usage widget to agents and prime its data files" + +# The widget formerly known as omarchy.model-usage is now omarchy.agents, and +# it no longer scans providers itself: it displays the records that +# omarchy-agent-usage-update writes under ~/.local/state/omarchy/agents/usage/. +# Rename the widget wherever a user's config mentions it — bar layout entries +# keep their settings, a disabled widget stays disabled — then generate the +# records once so the bar doesn't sit empty until the widget's first refresh +# timer, and drop the old scanner's cache directory, which nothing reads +# anymore. + +config_file="$HOME/.config/omarchy/shell.json" + +if [[ -s $config_file ]]; then + tmp=$(mktemp) + jq ' + def rename: + if . == "omarchy.model-usage" then + "omarchy.agents" + elif type == "object" and .id == "omarchy.model-usage" then + .id = "omarchy.agents" + else + . + end; + + (if (.bar.layout? | type) == "object" then + .bar.layout |= map_values(if type == "array" then map(rename) else . end) + else . end) + | (if (.disabledPlugins? | type) == "array" then + .disabledPlugins |= map(rename) + else . end) + ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" +fi + +rm -rf "$HOME/.cache/omarchy/model-usage" + +omarchy-agent-usage-update || true diff --git a/shell/README.md b/shell/README.md index caaaadba..e72d02ce 100644 --- a/shell/README.md +++ b/shell/README.md @@ -33,7 +33,7 @@ shell/ network/ power/ weather/ - model-usage/ + agents/ services/ battery/ idle/ diff --git a/shell/plugins/README.md b/shell/plugins/README.md index b6194e1d..d4252515 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -27,7 +27,7 @@ User-installed plugins live alongside these conceptually but on disk under | Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` | | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | -| Model usage | `omarchy.model-usage` | `bar-widget` | `model-usage/Panel.qml` | +| Agents | `omarchy.agents` | `bar-widget` | `agents/Panel.qml` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/shell/plugins/agents/Agent.qml b/shell/plugins/agents/Agent.qml new file mode 100644 index 00000000..c9ba4c5b --- /dev/null +++ b/shell/plugins/agents/Agent.qml @@ -0,0 +1,34 @@ +import QtQuick +import Quickshell.Io + +// One agent's usage record, read straight off the data file that +// omarchy-agent-usage-update maintains. The panel never learns how the +// numbers were made — a record that appears in the usage directory is an +// agent, whoever wrote it. +Item { + id: root + visible: false + + property string agentId: "" + property string path: "" + property var record: null + + FileView { + path: root.path + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parse(text()) + onLoadFailed: root.record = null + } + + function parse(content) { + try { + var parsed = JSON.parse(String(content || "")) + root.record = parsed && typeof parsed === "object" ? parsed : null + } catch (e) { + console.warn("agents", "Ignoring bad usage record", root.path, e) + root.record = null + } + } +} diff --git a/shell/plugins/model-usage/Main.qml b/shell/plugins/agents/Main.qml similarity index 62% rename from shell/plugins/model-usage/Main.qml rename to shell/plugins/agents/Main.qml index 223c6fe4..ce75613d 100644 --- a/shell/plugins/model-usage/Main.qml +++ b/shell/plugins/agents/Main.qml @@ -1,102 +1,289 @@ import QtQuick import Quickshell import Quickshell.Io -import "providers" +// The display side of agent usage. All extraction lives behind +// omarchy-agent-usage-update, which writes one JSON record per agent into +// the usage directory; this file only discovers those records, watches them +// for changes, and optionally merges snapshots synced from other machines. Item { id: root visible: false property var settings: ({}) - Claude { - id: claudeProvider - enabled: root.providerEnabled("claude") - providerSettings: root.settings && root.settings.providers && root.settings.providers.claude ? root.settings.providers.claude : ({}) - onLastRefreshedAtMsChanged: root.scheduleSync() - onReadyChanged: root.scheduleSync() - } - - Codex { - id: codexProvider - enabled: root.providerEnabled("codex") - providerSettings: root.settings && root.settings.providers && root.settings.providers.codex ? root.settings.providers.codex : ({}) - onLastRefreshedAtMsChanged: root.scheduleSync() - onReadyChanged: root.scheduleSync() - } - - property var providers: [claudeProvider, codexProvider] - - // A subscription earns a place in the bar and the panel by being switched on - // in settings and having actually produced numbers — locally or on a synced - // device. Presence on disk is not enough: a box that installed Codex and - // never ran it would get a tab full of zeroes. With nothing to show, the - // whole module collapses out of the bar rather than sitting there dimmed. - property var enabledProviders: { - var rev = syncRevision - var running = syncRunning - var result = [] - if (claudeProvider.enabled) { - var claude = displayProvider(claudeProvider) - if (providerHasData(claude)) result.push(claude) - } - if (codexProvider.enabled) { - var codex = displayProvider(codexProvider) - if (providerHasData(codex)) result.push(codex) - } - return result - } - - // All-time, not today: a quiet day is not the same as an absent provider. - function providerHasData(p) { - return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0 - || numberValue(p.activeDays) > 0 || Number(p.rateLimitPercent) >= 0 - || Number(p.secondaryRateLimitPercent) >= 0 - } - - property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning - property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 - property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0) - property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900))) - - property var syncModeSetting: setting("syncMode", setting("syncEnabled", false)) - property bool syncEnabled: parseSyncEnabled(syncModeSetting) - property string syncDir: String(setting("syncDir", "")) - property string syncFileName: String(setting("syncFileName", "")) - property string syncDeviceId: String(setting("syncDeviceId", "")) readonly property string home: Quickshell.env("HOME") || "" - property string detectedHostname: "" - readonly property string syncEffectiveDir: expandPath(syncDir) - readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId) - readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, "")) - readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/model-usage-disabled.json" - property var aggregateData: ({}) - property int syncRevision: 0 - property bool syncRunning: false - property bool syncRequestedWhileRunning: false - property string syncStatusText: "" - property int syncDeviceCount: syncConfigured() && aggregateData && aggregateData.deviceCount ? Number(aggregateData.deviceCount) : 0 + readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/usage" - onSyncEnabledChanged: syncSettingsChanged() - onSyncDirChanged: syncSettingsChanged() - onSyncFileNameChanged: if (syncConfigured()) scheduleSync() - onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync() + // ------------------------------------------------------------- discovery - Component.onCompleted: if (syncConfigured()) scheduleSync() + property var agentIds: [] + property var agents: [] + property int dataRevision: 0 - function setting(name, fallback) { - var value = settings ? settings[name] : undefined - return value === undefined || value === null ? fallback : value + Process { + id: listProcess + running: false + command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"] + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyAgentListing(text) + } } + function rescanAgents() { + if (!listProcess.running) listProcess.running = true + } + + function applyAgentListing(output) { + var ids = [] + var lines = String(output || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var name = lines[i].trim() + if (name.slice(-5) === ".json") ids.push(name.slice(0, -5)) + } + ids.sort() + // Same list, same objects: reassigning the model would tear down every + // FileView just to build identical ones. + if (JSON.stringify(ids) !== JSON.stringify(agentIds)) agentIds = ids + } + + Instantiator { + id: agentInstantiator + model: root.agentIds + + delegate: Agent { + required property var modelData + agentId: modelData + path: root.usageDir + "/" + modelData + ".json" + onRecordChanged: root.recordsChanged() + } + + onObjectAdded: (index, object) => root.rebuildAgents() + onObjectRemoved: (index, object) => root.rebuildAgents() + } + + function rebuildAgents() { + var result = [] + for (var i = 0; i < agentInstantiator.count; i++) { + var agent = agentInstantiator.objectAt(i) + if (agent) result.push(agent) + } + agents = result + recordsChanged() + } + + function recordsChanged() { + dataRevision++ + scheduleLimitsRetry() + scheduleSync() + } + + // A collector that could not reach its limits endpoint at all — typically + // the seconds after login before the network is up — writes retryAdvised + // into its record. Honor it with one sooner try instead of waiting out the + // full refresh interval; a run that reaches the endpoint clears the flag. + // Only the advising agents rerun, so an outage at one provider does not + // put every other collector on a 30-second treadmill. + property var retryAgentIds: [] + + Timer { + id: limitsRetry + interval: 30000 + repeat: false + onTriggered: root.runUpdate("limits", root.retryAgentIds) + } + + function scheduleLimitsRetry() { + var advising = [] + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (record && record.retryAdvised === true && providerEnabled(String(record.id || ""))) + advising.push(String(record.id)) + } + retryAgentIds = advising + if (advising.length > 0) limitsRetry.restart() + else limitsRetry.stop() + } + + Component.onCompleted: { + rescanAgents() + if (syncConfigured()) scheduleSync() + } + + // -------------------------------------------------------------- refresh + + property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900))) + property string pendingUpdateKind: "" + Timer { interval: root.refreshIntervalSec * 1000 running: true repeat: true triggeredOnStart: true - onTriggered: root.refreshAll() + onTriggered: root.runUpdate("normal") } + Process { + id: updateProcess + running: false + onExited: { + root.rescanAgents() + if (root.pendingUpdateKind !== "") { + var kind = root.pendingUpdateKind + root.pendingUpdateKind = "" + root.runUpdate(kind) + } + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim()) + } + } + + function updateCommand(kind, agentIds) { + var command = ["omarchy-agent-usage-update"] + if (kind === "force") command.push("--force") + if (kind === "limits") command.push("--limits-only") + var providers = settings && settings.providers ? settings.providers : {} + for (var id in providers) { + if (providers[id] && providers[id].enabled === false) command.push("--except", id) + } + if (agentIds) { + for (var i = 0; i < agentIds.length; i++) command.push(agentIds[i]) + } + return command + } + + function runUpdate(kind, agentIds) { + if (updateProcess.running) { + // Collapse queued requests to one full rerun; a forced refresh outranks + // the cheaper kinds it might have been queued behind. + if (kind === "force" || root.pendingUpdateKind === "") root.pendingUpdateKind = kind + return + } + updateProcess.command = updateCommand(kind, agentIds) + updateProcess.running = true + } + + function refresh() { refreshAll(true) } + function refreshAll(force) { runUpdate(force === true ? "force" : "normal") } + + // Opening the panel wants the numbers that go stale on the wire, not + // another walk over every transcript on disk — the collectors reuse their + // recent scans in this mode. + function refreshLimits() { runUpdate("limits") } + + // ------------------------------------------------------------- providers + + // An agent earns a place in the bar and the panel by being switched on in + // settings and having actually produced numbers — locally or on a synced + // device. With nothing to show, the whole module collapses out of the bar + // rather than sitting there dimmed. + property var enabledProviders: { + var rev = dataRevision + var syncRev = syncRevision + var result = [] + var localIds = {} + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (!record || !record.id) continue + var id = String(record.id) + localIds[id] = true + if (!providerEnabled(id)) continue + var display = displayProvider(record) + if (providerHasData(display)) result.push(display) + } + // An agent that only ever ran on another machine has no local record, but + // its synced numbers still deserve a tab. Rate limits stay blank — they + // are per-account and never travel. + var syncedProviders = syncConfigured() && aggregateData && aggregateData.providers ? aggregateData.providers : {} + for (var syncedId in syncedProviders) { + if (localIds[syncedId] || !providerEnabled(syncedId)) continue + var stats = syncedProviders[syncedId] || {} + var syncedDisplay = displayProvider({ id: syncedId, name: stats.providerName || syncedId }) + if (providerHasData(syncedDisplay)) result.push(syncedDisplay) + } + return result + } + + function providerEnabled(id) { + if (!settings || !settings.providers || !settings.providers[id]) return true + return settings.providers[id].enabled !== false + } + + // All-time keeps a quiet day from hiding an agent; today's counts admit a + // machine whose only source is history.jsonl, which knows nothing older. + function providerHasData(p) { + return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0 + || numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0 + || numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0) + } + + function displayProvider(record) { + var stats = syncedStatsFor(String(record.id)) + var synced = !!stats + var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0 + + return { + providerId: String(record.id), + providerName: String(record.name || record.id), + ready: record.ready === true || synced, + usageStatusText: String(record.usageStatusText || ""), + authHelpText: String(record.authHelpText || ""), + + // Rate limits stay per-account and are never merged across devices. + limits: Array.isArray(record.limits) ? record.limits : [], + tierLabel: String(record.tierLabel || ""), + + todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts), + todaySessions: synced ? numberValue(stats.todaySessions) : numberValue(record.todaySessions), + todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : numberValue(record.todayTotalTokens), + todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : (record.todayTokensByModel || ({})), + recentDays: synced ? (stats.recentDays || []) : (record.recentDays || []), + totalPrompts: synced ? numberValue(stats.totalPrompts) : numberValue(record.totalPrompts), + totalSessions: synced ? numberValue(stats.totalSessions) : numberValue(record.totalSessions), + activeDays: synced ? numberValue(stats.activeDays) : numberValue(record.activeDays), + modelUsage: synced ? (stats.modelUsage || ({})) : (record.modelUsage || ({})), + hasLocalStats: synced ? (stats.hasLocalStats !== false) : (record.hasLocalStats !== false), + + syncEnabled: synced, + syncDeviceCount: deviceCount, + syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "" + } + } + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + // ------------------------------------------------------------------ sync + + property var syncModeSetting: setting("syncMode", setting("syncEnabled", false)) + property bool syncEnabled: parseSyncEnabled(syncModeSetting) + property string syncDir: String(setting("syncDir", "")) + property string syncFileName: String(setting("syncFileName", "")) + property string syncDeviceId: String(setting("syncDeviceId", "")) + property string detectedHostname: "" + readonly property string syncEffectiveDir: expandPath(syncDir) + readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId) + readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, "")) + readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/agents-disabled.json" + property var aggregateData: ({}) + property int syncRevision: 0 + property bool syncRunning: false + property bool syncRequestedWhileRunning: false + property string syncStatusText: "" + property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 + + onSyncEnabledChanged: syncSettingsChanged() + onSyncDirChanged: syncSettingsChanged() + onSyncFileNameChanged: if (syncConfigured()) scheduleSync() + onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync() + Timer { id: syncDebounce interval: 1000 @@ -134,7 +321,7 @@ Item { stderr: StdioCollector { waitForEnd: true - onStreamFinished: if (text.trim() !== "") console.warn("model-usage/sync", text.trim()) + onStreamFinished: if (text.trim() !== "") console.warn("agents/sync", text.trim()) } } @@ -154,11 +341,6 @@ Item { onLoaded: root.detectedHostname = String(text() || "").trim() } - function providerEnabled(id) { - if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex" - return settings.providers[id].enabled !== false - } - function parseSyncEnabled(value) { if (value === true) return true var text = String(value || "").trim().toLowerCase() @@ -269,7 +451,7 @@ Item { var parsed = JSON.parse(raw) if (parsed && parsed.providers) snapshots.push(parsed) } catch (e) { - console.warn("model-usage/sync", "Ignoring bad snapshot", currentPath, e) + console.warn("agents/sync", "Ignoring bad snapshot", currentPath, e) } currentPath = "" currentJson = [] @@ -446,30 +628,34 @@ Item { } } - function providerSnapshot(provider) { + // Snapshots keep the field names older Omarchy versions wrote, so a fleet + // of machines on mixed versions still merges cleanly in both directions. + function providerSnapshot(record) { return { - providerId: provider.providerId, - providerName: provider.providerName, - ready: provider.ready === true, - hasLocalStats: provider.hasLocalStats !== false, - todayPrompts: numberValue(provider.todayPrompts), - todaySessions: numberValue(provider.todaySessions), - todayTotalTokens: numberValue(provider.todayTotalTokens), - todayTokensByModel: cloneValue(provider.todayTokensByModel, ({})), - recentDays: cloneValue(provider.recentDays, []), - totalPrompts: numberValue(provider.totalPrompts), - totalSessions: numberValue(provider.totalSessions), - activeDays: numberValue(provider.activeDays), - activeDates: cloneValue(provider.activeDates, []), - modelUsage: cloneValue(provider.modelUsage, ({})) + providerId: String(record.id), + providerName: String(record.name || record.id), + ready: record.ready === true, + hasLocalStats: record.hasLocalStats !== false, + todayPrompts: numberValue(record.todayPrompts), + todaySessions: numberValue(record.todaySessions), + todayTotalTokens: numberValue(record.todayTotalTokens), + todayTokensByModel: cloneValue(record.todayTokensByModel, ({})), + recentDays: cloneValue(record.recentDays, []), + totalPrompts: numberValue(record.totalPrompts), + totalSessions: numberValue(record.totalSessions), + activeDays: numberValue(record.activeDays), + activeDates: cloneValue(record.activeDates, []), + modelUsage: cloneValue(record.modelUsage, ({})) } } function localSnapshot() { var providerMap = {} - for (var i = 0; i < providers.length; i++) { - var provider = providers[i] - if (provider.enabled) providerMap[provider.providerId] = providerSnapshot(provider) + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (!record || !record.id) continue + if (!providerEnabled(String(record.id))) continue + providerMap[String(record.id)] = providerSnapshot(record) } return { schemaVersion: 1, @@ -485,68 +671,7 @@ Item { return aggregateData.providers[providerId] || null } - function displayProvider(provider) { - var stats = syncedStatsFor(provider.providerId) - var synced = !!stats - var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0 - - return { - providerId: provider.providerId, - providerName: provider.providerName, - providerIcon: provider.providerIcon, - enabled: provider.enabled, - ready: provider.ready || synced, - refreshing: provider.refreshing || root.syncRunning, - lastRefreshedAtMs: Math.max(provider.lastRefreshedAtMs || 0, root.aggregateUpdatedAtMs || 0), - usageStatusText: provider.usageStatusText, - authHelpText: provider.authHelpText, - - rateLimitPercent: provider.rateLimitPercent, - rateLimitLabel: provider.rateLimitLabel, - rateLimitResetAt: provider.rateLimitResetAt, - secondaryRateLimitPercent: provider.secondaryRateLimitPercent, - secondaryRateLimitLabel: provider.secondaryRateLimitLabel, - secondaryRateLimitResetAt: provider.secondaryRateLimitResetAt, - tierLabel: provider.tierLabel, - - todayPrompts: synced ? numberValue(stats.todayPrompts) : provider.todayPrompts, - todaySessions: synced ? numberValue(stats.todaySessions) : provider.todaySessions, - todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : provider.todayTotalTokens, - todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : provider.todayTokensByModel, - recentDays: synced ? (stats.recentDays || []) : provider.recentDays, - totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts, - totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions, - activeDays: synced ? numberValue(stats.activeDays) : provider.activeDays, - modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage, - hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats, - - syncEnabled: synced, - syncDeviceCount: deviceCount, - syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "", - - formatResetTime: function(isoTimestamp) { return provider.formatResetTime(isoTimestamp) } - } - } - - function refresh() { refreshAll(true) } - - function refreshAll(force) { - for (var i = 0; i < providers.length; i++) { - var p = providers[i] - if (p.enabled && typeof p.refresh === "function") p.refresh(force === true) - } - scheduleSync() - } - - // Opening the panel wants the numbers that go stale on the wire, not another - // walk over every transcript on disk. Forcing a whole refresh would do both, - // and re-opening the panel would then rescan the lot each time. - function refreshLimits() { - for (var i = 0; i < providers.length; i++) { - var p = providers[i] - if (p.enabled && typeof p.refreshLimits === "function") p.refreshLimits() - } - } + // ---------------------------------------------------------------- format function formatTokenCount(n) { if (n === undefined || n === null) return "0" diff --git a/shell/plugins/model-usage/Panel.qml b/shell/plugins/agents/Panel.qml similarity index 91% rename from shell/plugins/model-usage/Panel.qml rename to shell/plugins/agents/Panel.qml index e61f3c9f..e1489829 100644 --- a/shell/plugins/model-usage/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -7,8 +7,8 @@ import qs.Ui Panel { id: root - moduleName: "omarchy.model-usage" - ipcTarget: "omarchy.model-usage" + moduleName: "omarchy.agents" + ipcTarget: "omarchy.agents" manageIpc: false readonly property color foreground: bar ? bar.foreground : Color.foreground @@ -98,8 +98,12 @@ Panel { function limitWindows(p) { if (!p) return [] var out = [] - if (p.rateLimitPercent >= 0) out.push(limitWindow(p.rateLimitLabel, p.rateLimitPercent, p.rateLimitResetAt)) - if (p.secondaryRateLimitPercent >= 0) out.push(limitWindow(p.secondaryRateLimitLabel, p.secondaryRateLimitPercent, p.secondaryRateLimitResetAt)) + var list = p.limits || [] + for (var i = 0; i < list.length; i++) { + var entry = list[i] || {} + var percent = Number(entry.percent) + if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt)) + } return out } @@ -222,8 +226,9 @@ Panel { return "" } - // Codex ships as a white mark; swap to the dark one when the surface behind - // it is light. The Claude mark is brand-orange and works on both. + // Agents that ship a white mark carry an `assets/-light.svg` twin for + // light surfaces; marks that work on both (Claude's brand-orange) ship one + // file. The luminance check decides which candidate to try first. function colorChannelLuminance(value) { var channel = Number(value) if (!isFinite(channel)) return 0 @@ -236,14 +241,16 @@ Panel { + 0.0722 * colorChannelLuminance(color.b) } - function iconSourceForProvider(p, surfaceColor) { - if (!p) return "" - if (p.providerId === "claude") return Qt.resolvedUrl("assets/claude.svg") - if (p.providerId === "codex") - return colorLuminance(surfaceColor || Color.background) >= 0.5 - ? Qt.resolvedUrl("assets/codex-light.svg") - : Qt.resolvedUrl("assets/codex.svg") - return "" + // Marks resolve by convention, so a new agent's data file needs nothing + // from this panel: assets/.svg if it ships one, the module's bar glyph + // if it doesn't. + function iconCandidatesForProvider(p, surfaceColor) { + if (!p) return [] + var candidates = [] + if (colorLuminance(surfaceColor || Color.background) >= 0.5) + candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg")) + candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg")) + return candidates } // Nothing to report, nothing in the bar: Bar.qml collapses a slot whose item @@ -258,7 +265,6 @@ Panel { cursorActive = false nowMs = Date.now() if (panelFlick) panelFlick.contentY = 0 - usage.refreshAll() usage.refreshLimits() Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -358,13 +364,33 @@ Panel { fontFamily: root.fontFamily iconComponent: Component { - Image { - source: root.iconSourceForProvider(root.provider, root.surface) + Item { + id: heroMark + property var candidates: root.iconCandidatesForProvider(root.provider, root.surface) + property int candidateIndex: 0 + onCandidatesChanged: candidateIndex = 0 + width: Style.font.display height: Style.font.display - sourceSize.width: Style.font.display * 2 - sourceSize.height: Style.font.display * 2 - fillMode: Image.PreserveAspectFit + + Image { + id: heroMarkImage + anchors.fill: parent + source: heroMark.candidateIndex < heroMark.candidates.length ? heroMark.candidates[heroMark.candidateIndex] : "" + sourceSize.width: Style.font.display * 2 + sourceSize.height: Style.font.display * 2 + fillMode: Image.PreserveAspectFit + onStatusChanged: if (status === Image.Error && heroMark.candidateIndex < heroMark.candidates.length) heroMark.candidateIndex++ + } + + Text { + anchors.centerIn: parent + visible: heroMarkImage.status !== Image.Ready + text: button.text + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + } } } } @@ -373,7 +399,7 @@ Panel { visible: root.providers.length === 0 width: parent.width topPadding: Style.space(24) - text: "No AI coding subscriptions found.\nClaude Code and Codex show up here once you've used them." + text: "No AI coding subscriptions found.\nAgents show up here once you've used them." color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body diff --git a/shell/plugins/model-usage/README.md b/shell/plugins/agents/README.md similarity index 52% rename from shell/plugins/model-usage/README.md rename to shell/plugins/agents/README.md index a95c7b87..38120b11 100644 --- a/shell/plugins/model-usage/README.md +++ b/shell/plugins/agents/README.md @@ -1,16 +1,18 @@ -# Model usage +# Agents One bar icon and one panel for every AI coding subscription on the machine. -`Panel.qml` owns the bar button and the popup; `Main.qml` owns provider -fan-out and the optional cross-device aggregation; `providers/` holds one -adapter per subscription. +The panel is strictly a display: it watches the usage records that +`omarchy-agent-usage-update` writes to `~/.local/state/omarchy/agents/usage/` +and draws whatever appears there. `Panel.qml` owns the bar button and the +popup; `Main.qml` discovers and watches the records (and handles the optional +cross-device aggregation); `Agent.qml` is the per-record file watcher. ## Panel - **Hero** — the mark, the tool, and the plan it runs on ("Max 20x", "Pro"). Auth and endpoint problems replace the plan line and repeat in a card. -- **Subscription switch** — one chip per enabled provider (`h`/`l` or click). - It appears only when more than one provider is enabled. +- **Subscription switch** — one chip per enabled agent (`h`/`l` or click). + It appears only when more than one agent is enabled. - **Limits** — the percentage of each allowance used, a matching meter, and the time until the session or weekly window resets. - **Tokens by day** — one row per day for the last week: day, bar, tokens, with today @@ -21,42 +23,55 @@ adapter per subscription. input / output / cache split. A subscription appears only when it is enabled in settings and has actually -recorded usage — on this machine or on a synced one. With one such provider +recorded usage — on this machine or on a synced one. With one such agent there is no switch row at all; with none, the module leaves the bar entirely rather than sitting there with nothing to say. A CLI installed mid-session shows up at the next refresh, so nothing polls the disk waiting for it. That self-hiding is why the widget ships in the default bar layout: a machine -that has never run Claude Code or Codex draws nothing, and the icon arrives on +that has never run an AI coding agent draws nothing, and the icon arrives on its own the first time a scan finds usage. Drop it with -`omarchy plugin disable omarchy.model-usage`. +`omarchy plugin disable omarchy.agents`. -## Providers +## Data -| Provider | Limits | Local stats | +Each agent is one JSON record in `~/.local/state/omarchy/agents/usage/`, +written by `omarchy-agent-usage-update`. That command runs one +`omarchy-agent-usage-` collector per agent; the widget invokes it +on its refresh timer and whenever you ask for a refresh, and picks up any +record that lands in the directory regardless of who wrote it. + +Adding an agent therefore never touches this plugin: ship a collector that +prints the record contract (see the `claude` and `codex` collectors in +`bin/`), and the panel gains a tab. An `assets/.svg` mark is optional — +with an `assets/-light.svg` twin if the mark needs a dark variant for +light surfaces — and the bar glyph stands in when there is none. + +| Collector | Limits | Local stats | |---|---|---| -| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` scanned by `scripts/claude_usage_scanner.py`, plus `stats-cache.json` and `history.jsonl` | -| `codex` | `scripts/codex_usage_scanner.py` reading the Codex CLI state | the same scanner | +| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, plus `stats-cache.json` and `history.jsonl` as fallback | +| `codex` | The Codex app-server RPC | native Codex CLI session files (and pi sessions) | Claude limits need a signed-in CLI; without credentials the panel says so and -falls back to local stats only. +falls back to local stats only. A non-default Claude directory is honored via +`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`. ## Interactions - Bar icon: left = panel, right = refresh, middle = next subscription. - Panel: `h`/`l` switch subscription, `j`/`k` scroll, `r` or Enter refresh, Tab moves to the neighboring bar panel, Esc closes. -- IPC: `omarchy-shell omarchy.model-usage `. +- IPC: `omarchy-shell omarchy.agents `. ## Settings Settings live in the widget's entry in `~/.config/omarchy/shell.json`. The top-level keys can be set with -`omarchy bar set omarchy.model-usage `: +`omarchy bar set omarchy.agents `: | Key | Default | What it does | |---|---|---| -| `refreshIntervalSec` | `900` | How often local scans and snapshots refresh | +| `refreshIntervalSec` | `900` | How often the usage records regenerate | | `syncMode` | `"Off"` | `"On"` writes this machine's snapshot and merges the others | | `syncDir` | `""` | A folder synced by Syncthing, Dropbox, rsync, … | | `syncFileName` | `.json` | This machine's snapshot file | @@ -65,34 +80,30 @@ top-level keys can be set with Numbers need `--json`, or they land in `shell.json` as strings: ```bash -omarchy bar set omarchy.model-usage refreshIntervalSec 300 --json -omarchy bar set omarchy.model-usage syncDir '~/Sync/model-usage' +omarchy bar set omarchy.agents refreshIntervalSec 300 --json +omarchy bar set omarchy.agents syncDir '~/Sync/agent-usage' ``` -Per-provider settings are nested, and `set` writes its key literally rather +Per-agent enablement is nested, and `set` writes its key literally rather than walking a dotted path — so pass the whole `providers` object as JSON (or edit `shell.json` directly): ```bash -omarchy bar set omarchy.model-usage providers '{ - "claude": { - "enabled": true, - "statsPath": "~/.claude/stats-cache.json", - "credentialsPath": "~/.claude/.credentials.json", - "projectsPath": "~/.claude/projects" - }, +omarchy bar set omarchy.agents providers '{ + "claude": { "enabled": true }, "codex": { "enabled": false } }' --json ``` -`enabled` defaults to `true` for both; set it to `false` to hide a -subscription that is installed. The paths above are the defaults. +`enabled` defaults to `true` for every discovered agent; set it to `false` to +hide a subscription that is installed. Disabled agents are also skipped when +the records regenerate. With `syncMode` on, every `*.json` snapshot in `syncDir` is merged, so today, the last 7 days, and the all-time totals cover every machine you code on — active days are unioned by date rather than summed. Rate limits stay per-account and are never merged. -One caveat on "all-time": the Codex scanner only reads native session files +One caveat on "all-time": the Codex collector only reads native session files touched in the last 30 days, so Codex totals and its day count cover that window. Claude's cover every transcript still on disk. diff --git a/shell/plugins/model-usage/assets/claude.svg b/shell/plugins/agents/assets/claude.svg similarity index 100% rename from shell/plugins/model-usage/assets/claude.svg rename to shell/plugins/agents/assets/claude.svg diff --git a/shell/plugins/model-usage/assets/codex-light.svg b/shell/plugins/agents/assets/codex-light.svg similarity index 100% rename from shell/plugins/model-usage/assets/codex-light.svg rename to shell/plugins/agents/assets/codex-light.svg diff --git a/shell/plugins/model-usage/assets/codex.svg b/shell/plugins/agents/assets/codex.svg similarity index 100% rename from shell/plugins/model-usage/assets/codex.svg rename to shell/plugins/agents/assets/codex.svg diff --git a/shell/plugins/model-usage/manifest.json b/shell/plugins/agents/manifest.json similarity index 81% rename from shell/plugins/model-usage/manifest.json rename to shell/plugins/agents/manifest.json index 2c294393..86e04239 100644 --- a/shell/plugins/model-usage/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "id": "omarchy.model-usage", - "name": "Model Usage", + "id": "omarchy.agents", + "name": "Agents", "version": "1.0.0", "author": "Omarchy", "license": "MIT", @@ -12,22 +12,15 @@ "barWidget": "Panel.qml" }, "barWidget": { - "displayName": "Model Usage", + "displayName": "Agents", "description": "One bar icon and one panel: rate-limit meters with pace, today, the last week, and the all-time model breakdown for every subscription.", "category": "AI", - "aliases": ["model-usage"], + "aliases": ["agents", "model-usage"], "allowMultiple": false, "defaults": { "providers": { - "claude": { - "enabled": true, - "statsPath": "~/.claude/stats-cache.json", - "credentialsPath": "~/.claude/.credentials.json", - "projectsPath": "~/.claude/projects" - }, - "codex": { - "enabled": true - } + "claude": { "enabled": true }, + "codex": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 2cf61e1f..2f832ed9 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -8,7 +8,7 @@ the shell for its whole session. - `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`) and points at `Bar.qml` as the entry point. - `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly. - `widgets/` holds simple first-party bar widgets with sibling manifests. -- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../model-usage/` provide richer popup bar plugins. +- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../agents/` provide richer popup bar plugins. - The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `config/omarchy/shell.json` when the user has no file). - `omarchy bar position` updates only the user shell.json file. @@ -67,7 +67,7 @@ Example `shell.json` (bar subtree only shown): | `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume | | `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui | | `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh | -| `omarchy.model-usage` | Claude Code and Codex limits with pace, today, last week, and all-time model breakdown | left = panel · right = refresh · middle = next subscription | +| `omarchy.agents` | AI coding agent limits with pace, today, last week, and all-time model breakdown | left = panel · right = refresh · middle = next subscription | | `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup · right = toggle percentage | | `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI | | `omarchy.monitor` | Brightness and laptop display controls | left = popup | @@ -168,7 +168,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o First-party bar widgets are manifest-backed just like third-party widgets. Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`; richer popup plugins live in feature directories such as `../panels/audio/`, -`../panels/network/`, and `../model-usage/`; and feature plugins such as +`../panels/network/`, and `../agents/`; and feature plugins such as `omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own `manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`, `omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as diff --git a/shell/plugins/model-usage/providers/Claude.qml b/shell/plugins/model-usage/providers/Claude.qml deleted file mode 100644 index 07eb1f22..00000000 --- a/shell/plugins/model-usage/providers/Claude.qml +++ /dev/null @@ -1,569 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io - -Item { - id: root - visible: false - - property string providerId: "claude" - property string providerName: "Claude Code" - property string providerIcon: "ai" - property bool enabled: false - property bool ready: false - property bool refreshing: false - property double lastRefreshedAtMs: 0 - property string usageStatusText: "" - - property real rateLimitPercent: -1 - property string rateLimitLabel: "Session (5-hour)" - property string rateLimitResetAt: "" - property real secondaryRateLimitPercent: -1 - property string secondaryRateLimitLabel: "Weekly (7-day)" - property string secondaryRateLimitResetAt: "" - - property int todayPrompts: 0 - property int todaySessions: 0 - property int todayTotalTokens: 0 - property var todayTokensByModel: ({}) - - property var recentDays: [] - property int totalPrompts: 0 - property int totalSessions: 0 - property int activeDays: 0 - property var activeDates: [] - property var modelUsage: ({}) - property var dailyActivity: [] - - property string tierLabel: "" - property string authHelpText: "Run `claude auth login` to restore authoritative usage." - property bool hasLocalStats: true - property bool hasProjectStats: false - - property string oauthAccessToken: "" - property double oauthExpiresAtMs: 0 - property string authMode: "none" - property string subscriptionType: "" - property string rateLimitTier: "" - property bool hasAuthoritativeRateLimit: false - - property bool probeInFlight: false - property double lastProbeAtMs: 0 - property int probeMinIntervalMs: 15 * 60 * 1000 - // Opening the panel asks for fresh limits, so a forced probe skips the - // background interval — but not so freely that flicking the panel open and - // shut turns into a request per flick. - property int probeForcedMinIntervalMs: 15 * 1000 - property int probeRetryMs: 30 * 1000 - property bool projectScanRerunForce: false - - property var providerSettings: ({}) - - function resolvePath(p) { - if (p && p.startsWith("~")) - return (Quickshell.env("HOME") ?? "/home") + p.substring(1); - return p; - } - - function pathFromUrl(url) { - const value = String(url || ""); - if (value.indexOf("file://") === 0) - return decodeURIComponent(value.substring(7)); - return value; - } - - readonly property string projectScannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/claude_usage_scanner.py")) - - FileView { - id: statsFile - path: root.resolvePath(root.providerSettings?.statsPath ?? "~/.claude/stats-cache.json") - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.parseStats(text()) - } - - FileView { - id: historyFile - path: root.resolvePath("~/.claude/history.jsonl") - watchChanges: true - onFileChanged: reload() - onLoaded: root.parseHistory(text()) - onLoadFailed: error => { - if (error === FileViewError.FileNotFound) - console.error("model-usage/claude", "history.jsonl not found"); - } - } - - FileView { - id: credentialsFile - path: root.resolvePath(root.providerSettings?.credentialsPath ?? "~/.claude/.credentials.json") - watchChanges: true - onFileChanged: reload() - onLoaded: root.parseCredentials(text()) - onLoadFailed: error => { - if (error === FileViewError.FileNotFound) - console.error("model-usage/claude", "credentials.json not found at", credentialsFile.path); - } - } - - Process { - id: projectScanner - running: false - command: [] - - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.applyProjectUsageSummary(text) - } - - stderr: StdioCollector { - waitForEnd: true - onStreamFinished: if (text.trim() !== "") console.warn("model-usage/claude", text.trim()) - } - - onExited: { - root.finishRefresh(); - if (root.projectScanRerunForce) { - root.projectScanRerunForce = false; - root.startProjectScanner(true); - } - } - } - - Timer { - interval: root.probeMinIntervalMs - running: root.enabled && root.oauthAccessToken !== "" - repeat: true - onTriggered: root.probeRateLimits(false) - } - - // The first probe fires seconds after login, often before DHCP has handed - // out a route, and comes back as a transport failure rather than an answer - // from Anthropic. Try again shortly instead of showing "limits unavailable" - // until the next background poll a quarter of an hour later. - Timer { - id: probeRetry - interval: root.probeRetryMs - repeat: false - // Straight to the probe: a retry that answered to the same throttle - // that spaces out ordinary polls would never get off the ground. - onTriggered: if (root.enabled && root.oauthAccessToken && !root.oauthTokenExpired()) root.probeOAuthUsage() - } - - // Credentials load whether or not the panel wants this provider, so a - // switched-off Claude must not keep knocking on a downed network forever. - onEnabledChanged: if (!enabled) probeRetry.stop() - - function localDateString() { - const now = new Date(); - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - return y + "-" + m + "-" + d; - } - - function parseStats(content) { - try { - const data = JSON.parse(content); - const today = localDateString(); - - const dailyModelTokens = data.dailyModelTokens ?? []; - const todayTokenEntry = dailyModelTokens.find(d => d.date === today); - root.todayTokensByModel = todayTokenEntry?.tokensByModel ?? {}; - - let tokenSum = 0; - const toks = root.todayTokensByModel; - for (const k in toks) - tokenSum += toks[k]; - root.todayTotalTokens = tokenSum; - - root.dailyActivity = data.dailyActivity ?? []; - root.recentDays = root.dailyActivity.slice(-7); - if (!root.hasProjectStats) - root.applyFallbackActiveDays(root.dailyActivity); - root.modelUsage = data.modelUsage ?? {}; - root.totalPrompts = data.totalMessages ?? 0; - root.totalSessions = data.totalSessions ?? 0; - root.ready = true; - } catch (e) { - console.error("model-usage/claude", "Failed to parse stats-cache.json:", e); - } - } - - // stats-cache.json has no day count of its own, so recover one from the - // daily activity it does carry. The project scan overrides this when it - // finds transcripts. - function applyFallbackActiveDays(dailyActivity) { - const days = Array.isArray(dailyActivity) ? dailyActivity : []; - const dates = []; - for (var i = 0; i < days.length; i++) { - const day = days[i] || {}; - if (Number(day.messageCount || 0) > 0 && day.date) - dates.push(String(day.date)); - } - root.activeDates = dates; - root.activeDays = dates.length; - } - - function applyProjectUsageSummary(content) { - try { - const data = JSON.parse(String(content || "{}")); - const prompts = Math.max(0, Number(data.totalPrompts || 0)); - if (prompts <= 0) - return; - - root.hasProjectStats = true; - root.todayPrompts = Math.max(0, Number(data.todayPrompts || 0)); - root.todaySessions = Math.max(0, Number(data.todaySessions || 0)); - root.todayTotalTokens = Math.max(0, Number(data.todayTotalTokens || 0)); - root.todayTokensByModel = data.todayTokensByModel || ({}); - root.recentDays = data.recentDays || []; - root.modelUsage = data.modelUsage || ({}); - root.totalPrompts = prompts; - root.totalSessions = Math.max(0, Number(data.totalSessions || 0)); - root.activeDays = Math.max(0, Number(data.activeDays || 0)); - root.activeDates = data.activeDates || []; - root.dailyActivity = data.dailyActivity || root.recentDays; - root.ready = true; - } catch (e) { - console.error("model-usage/claude", "Failed to parse project usage summary:", e); - } - } - - function parseHistory(content) { - if (root.hasProjectStats) - return; - try { - const now = new Date(); - const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const lines = content.split("\n"); - let prompts = 0; - const sessions = {}; - - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (!line) - continue; - try { - const entry = JSON.parse(line); - if ((entry.timestamp ?? 0) < startOfDay) - break; - prompts++; - if (entry.sessionId) - sessions[entry.sessionId] = true; - } catch (e) { - continue; - } - } - - root.todayPrompts = prompts; - root.todaySessions = Object.keys(sessions).length; - } catch (e) { - console.error("model-usage/claude", "Failed to parse history.jsonl:", e); - } - } - - function parseCredentials(content) { - try { - const data = JSON.parse(content); - const oauth = data.claudeAiOauth ?? {}; - const fileAccessToken = oauth.accessToken ?? ""; - const fileExpiresAtMs = root.normalizeExpiresAtMs(oauth.expiresAt); - const fileHasOAuth = fileAccessToken !== ""; - - const tokenChanged = (root.oauthAccessToken !== fileAccessToken || root.oauthExpiresAtMs !== fileExpiresAtMs); - root.oauthAccessToken = fileAccessToken; - root.oauthExpiresAtMs = fileExpiresAtMs; - if (tokenChanged) - root.clearAuthoritativeRateLimits(); - - root.authMode = fileHasOAuth ? "oauth" : "none"; - - root.subscriptionType = oauth.subscriptionType ?? ""; - root.rateLimitTier = oauth.rateLimitTier ?? ""; - root.tierLabel = formatTier(); - - if (root.oauthAccessToken && !root.oauthTokenExpired()) { - if (root.usageStatusText === "Waiting for auth") - root.clearUsageStatus(); - // A fresh token just wiped the limits it replaced, so don't let - // the old token's throttle keep them blank for the next quarter - // of an hour. - root.probeRateLimits(tokenChanged); - } else if (!root.oauthAccessToken) { - root.usageStatusText = "Waiting for auth"; - root.clearAuthoritativeRateLimits(); - } else { - root.clearUsageStatus(); - } - } catch (e) { - console.error("model-usage/claude", "Failed to parse credentials.json:", e); - root.usageStatusText = "Waiting for auth"; - root.clearAuthoritativeRateLimits(); - } - } - - function formatTier() { - if (!root.rateLimitTier) - return root.subscriptionType || ""; - const match = root.rateLimitTier.match(/max_(\d+x)/i); - if (match) - return "Max " + match[1]; - if (root.subscriptionType) - return root.subscriptionType.charAt(0).toUpperCase() + root.subscriptionType.slice(1); - return ""; - } - - function normalizeExpiresAtMs(value) { - const n = Number(value ?? 0); - return (isFinite(n) && n > 0) ? n : 0; - } - - function oauthTokenExpired() { - if (!root.oauthAccessToken) - return true; - if (!root.oauthExpiresAtMs || !(root.oauthExpiresAtMs > 0)) - return false; - return root.oauthExpiresAtMs <= Date.now(); - } - - function clearAuthoritativeRateLimits() { - root.hasAuthoritativeRateLimit = false; - root.rateLimitPercent = -1; - root.rateLimitLabel = "Session (5-hour)"; - root.rateLimitResetAt = ""; - root.secondaryRateLimitPercent = -1; - root.secondaryRateLimitLabel = "Weekly (7-day)"; - root.secondaryRateLimitResetAt = ""; - } - - function clearUsageStatus() { - root.usageStatusText = ""; - } - - function parseNumber(value) { - if (value === null || value === undefined) - return NaN; - return parseFloat(String(value).trim().replace("%", "")); - } - - function utilizationPayloadUsesPercentScale(values) { - for (let i = 0; i < values.length; i++) { - const n = parseNumber(values[i]); - if (n >= 1) - return true; - } - return false; - } - - function normalizeUtilization(value, percentScale) { - const n = parseNumber(value); - if (!(n >= 0)) - return -1; - - // Anthropic's OAuth usage endpoint currently reports percentages - // (for example 37.0 or 1.0). Older clients/examples sometimes used - // fractions (0.37). Treat a payload containing any value >= 1 as - // percent-scaled so 1.0 renders as 1%, not 100%. - if (percentScale === true || n > 1) - return Math.min(1, n / 100); - return Math.min(1, n); - } - - function normalizeResetAt(value) { - if (value === null || value === undefined) - return ""; - const raw = String(value).trim(); - if (raw === "") - return ""; - if (/^\d+$/.test(raw)) { - let ts = parseInt(raw, 10); - if (ts < 1e12) - ts = ts * 1000; - const d = new Date(ts); - if (!isNaN(d.getTime())) - return d.toISOString(); - } - const parsed = new Date(raw); - if (!isNaN(parsed.getTime())) - return parsed.toISOString(); - return raw; - } - - function oauthUsageBucket(payload, key) { - const bucket = payload?.[key]; - if (bucket && typeof bucket === "object") - return bucket; - return null; - } - - function applyAuthoritativeRateLimits(weekly, weeklyReset, session, sessionReset, sourceLabel) { - const percentScale = root.utilizationPayloadUsesPercentScale([weekly, session]); - const weeklyNorm = root.normalizeUtilization(weekly, percentScale); - const sessionNorm = root.normalizeUtilization(session, percentScale); - if (weeklyNorm < 0 && sessionNorm < 0) - return false; - - root.hasAuthoritativeRateLimit = true; - root.rateLimitPercent = -1; - root.rateLimitLabel = "Session (5-hour)"; - root.rateLimitResetAt = ""; - root.secondaryRateLimitPercent = -1; - root.secondaryRateLimitLabel = "Weekly (7-day)"; - root.secondaryRateLimitResetAt = ""; - - if (sessionNorm >= 0) - root.rateLimitPercent = sessionNorm; - if (weeklyNorm >= 0) - root.secondaryRateLimitPercent = weeklyNorm; - if (sessionReset !== null && sessionReset !== undefined) - root.rateLimitResetAt = root.normalizeResetAt(sessionReset); - if (weeklyReset !== null && weeklyReset !== undefined) - root.secondaryRateLimitResetAt = root.normalizeResetAt(weeklyReset); - - if (sourceLabel) { - if (root.secondaryRateLimitPercent >= 0) - root.secondaryRateLimitLabel = root.secondaryRateLimitLabel + " (" + sourceLabel + ")"; - else if (root.rateLimitPercent >= 0) - root.rateLimitLabel = root.rateLimitLabel + " (" + sourceLabel + ")"; - } - return true; - } - - function finishRefresh() { - root.refreshing = false; - root.lastRefreshedAtMs = Date.now(); - } - - function probeOAuthUsage() { - // One probe at a time. The retry timer and a forced panel-open refresh - // can otherwise both be in flight, and the slower answer wins. - if (root.probeInFlight) - return; - root.probeInFlight = true; - root.refreshing = true; - root.lastProbeAtMs = Date.now(); - const xhr = new XMLHttpRequest(); - xhr.open("GET", "https://api.anthropic.com/api/oauth/usage"); - xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken); - xhr.setRequestHeader("anthropic-beta", "oauth-2025-04-20"); - xhr.setRequestHeader("Accept", "application/json"); - xhr.onreadystatechange = function () { - if (xhr.readyState !== XMLHttpRequest.DONE) - return; - root.probeInFlight = false; - - if (xhr.status >= 200 && xhr.status < 300) { - try { - const payload = JSON.parse(xhr.responseText ?? "{}"); - const weeklyBucket = root.oauthUsageBucket(payload, "seven_day_oauth_apps") || root.oauthUsageBucket(payload, "seven_day"); - const sessionBucket = root.oauthUsageBucket(payload, "five_hour"); - - if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) { - probeRetry.stop(); - root.clearUsageStatus(); - root.finishRefresh(); - return; - } - } catch (e) { - console.error("model-usage/claude", "Failed to parse oauth usage response:", e); - } - } - - const body = xhr.responseText ? String(xhr.responseText).slice(0, 220) : ""; - const retryAfter = xhr.getResponseHeader("retry-after") || ""; - console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : "")); - if (!root.hasAuthoritativeRateLimit) { - root.usageStatusText = "Claude limits unavailable"; - root.authHelpText = xhr.status === 0 - ? "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown." - : xhr.status === 429 - ? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown." - : "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown."; - } - // Status 0 is a transport failure — no route, no DNS, no server - // reached. Nothing to be a good citizen about, so keep knocking. - // Any real answer, including 429, disarms the retry: a server that - // replied is a server we should stop pestering. - if (xhr.status === 0) - probeRetry.restart(); - else - probeRetry.stop(); - root.finishRefresh(); - }; - xhr.send(); - } - - function startProjectScanner(force) { - if (projectScanner.running) { - if (force === true) - root.projectScanRerunForce = true; - return false; - } - - const command = ["python3", root.projectScannerScriptPath, root.resolvePath(root.providerSettings?.projectsPath ?? "~/.claude/projects")]; - if (force === true) - command.push("--force"); - projectScanner.command = command; - projectScanner.running = true; - return true; - } - - function refresh(force) { - root.refreshing = true; - statsFile.reload(); - historyFile.reload(); - credentialsFile.reload(); - root.startProjectScanner(force === true); - - if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired()) - root.probeRateLimits(force === true); - } - - // The cheap half of a refresh: Anthropic's numbers without the disk walk. - function refreshLimits() { - if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired()) - root.probeRateLimits(true); - } - - function formatResetTime(isoTimestamp) { - if (!isoTimestamp) - return ""; - const reset = new Date(isoTimestamp); - const now = new Date(); - const diffMs = reset.getTime() - now.getTime(); - if (diffMs <= 0) - return "now"; - const hours = Math.floor(diffMs / 3600000); - const mins = Math.floor((diffMs % 3600000) / 60000); - if (hours > 24) - return Math.floor(hours / 24) + "d " + (hours % 24) + "h"; - if (hours > 0) - return hours + "h " + mins + "m"; - return mins + "m"; - } - - function probeRateLimits(force) { - if (!root.oauthAccessToken || root.authMode !== "oauth") { - root.usageStatusText = "Waiting for auth"; - root.clearAuthoritativeRateLimits(); - root.finishRefresh(); - return; - } - - if (root.oauthTokenExpired()) { - root.clearUsageStatus(); - root.finishRefresh(); - return; - } - - const minIntervalMs = force === true ? root.probeForcedMinIntervalMs : root.probeMinIntervalMs; - if (root.lastProbeAtMs > 0 && (Date.now() - root.lastProbeAtMs) < minIntervalMs) { - root.finishRefresh(); - return; - } - - root.probeOAuthUsage(); - } -} diff --git a/shell/plugins/model-usage/providers/Codex.qml b/shell/plugins/model-usage/providers/Codex.qml deleted file mode 100644 index 1656ce90..00000000 --- a/shell/plugins/model-usage/providers/Codex.qml +++ /dev/null @@ -1,143 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io - -Item { - id: root - visible: false - - property string providerId: "codex" - property string providerName: "Codex" - property string providerIcon: "ai" - property bool enabled: false - property bool ready: false - property bool refreshing: false - property double lastRefreshedAtMs: 0 - - property real rateLimitPercent: -1 - property string rateLimitLabel: "" - property string rateLimitResetAt: "" - property real secondaryRateLimitPercent: -1 - property string secondaryRateLimitLabel: "" - property string secondaryRateLimitResetAt: "" - - property int todayPrompts: 0 - property int todaySessions: 0 - property real todayTotalTokens: 0 - property var todayTokensByModel: ({}) - - property var recentDays: [] - property int totalPrompts: 0 - property int totalSessions: 0 - property int activeDays: 0 - property var activeDates: [] - property var modelUsage: ({}) - - property string tierLabel: "" - property string usageStatusText: "" - property string authHelpText: "Run `codex login` to authenticate." - property bool hasLocalStats: true - - property string configModel: "" - property var providerSettings: ({}) - - readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "") - - Process { - id: usageScanner - command: ["python3", root.scannerPath] - running: false - - stdout: StdioCollector { - onStreamFinished: root.parseScannerOutput(text) - } - - onExited: root.finishRefresh() - - stderr: StdioCollector { - onStreamFinished: if (text.trim() !== "") console.warn("model-usage/codex", text.trim()) - } - } - - Timer { - interval: 5 * 60 * 1000 - running: root.enabled - repeat: true - triggeredOnStart: true - onTriggered: root.refresh() - } - - onEnabledChanged: if (enabled) refresh() - - function finishRefresh() { - root.refreshing = false - root.lastRefreshedAtMs = Date.now() - } - - function refresh(force) { - if (usageScanner.running) - return - root.refreshing = true - usageScanner.running = true - } - - // Codex reports limits and local stats from the same scanner run, and that - // run has no expensive mode to skip, so there is nothing cheaper to do. - function refreshLimits() { refresh() } - - function parseScannerOutput(output) { - const raw = String(output || "").trim() - if (raw === "") - return - - try { - const data = JSON.parse(raw.split("\n").pop()) - root.ready = !!data.ready - root.hasLocalStats = data.hasLocalStats !== false - - root.todayPrompts = data.todayPrompts || 0 - root.todaySessions = data.todaySessions || 0 - root.todayTotalTokens = data.todayTotalTokens || 0 - root.todayTokensByModel = data.todayTokensByModel || ({}) - root.recentDays = data.recentDays || [] - root.totalPrompts = data.totalPrompts || 0 - root.totalSessions = data.totalSessions || 0 - root.activeDays = data.activeDays || 0 - root.activeDates = data.activeDates || [] - root.modelUsage = data.modelUsage || ({}) - - root.rateLimitPercent = data.rateLimitPercent ?? -1 - root.rateLimitLabel = data.rateLimitLabel || "" - root.rateLimitResetAt = data.rateLimitResetAt || "" - root.secondaryRateLimitPercent = data.secondaryRateLimitPercent ?? -1 - root.secondaryRateLimitLabel = data.secondaryRateLimitLabel || "" - root.secondaryRateLimitResetAt = data.secondaryRateLimitResetAt || "" - - root.tierLabel = data.tierLabel || "" - root.usageStatusText = data.usageStatusText || "" - root.authHelpText = data.authHelpText || "Run `codex login` to authenticate." - } catch (e) { - console.error("model-usage/codex", "Failed to parse scanner output:", e, raw) - root.usageStatusText = "Codex scan failed" - root.authHelpText = String(e) - root.ready = true - } - } - - function formatResetTime(isoTimestamp) { - if (!isoTimestamp) - return "" - const reset = new Date(isoTimestamp) - const now = new Date() - const diffMs = reset.getTime() - now.getTime() - if (diffMs <= 0) - return "now" - const hours = Math.floor(diffMs / 3600000) - const mins = Math.floor((diffMs % 3600000) / 60000) - if (hours > 24) - return Math.floor(hours / 24) + "d " + (hours % 24) + "h" - if (hours > 0) - return hours + "h " + mins + "m" - return mins + "m" - } -} diff --git a/shell/plugins/model-usage/scripts/claude_usage_scanner.py b/shell/plugins/model-usage/scripts/claude_usage_scanner.py deleted file mode 100755 index bfc19f0a..00000000 --- a/shell/plugins/model-usage/scripts/claude_usage_scanner.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python3 -"""Stream Claude Code project JSONL files and emit compact usage stats. - -This replaces the QML-side `rg --json ... | StdioCollector` path, which can -materialize 100MB+ of ripgrep JSON in the Quickshell process. The helper keeps -that work in a short-lived Python process, parses line-by-line, and returns a -single compact JSON object that matches the fields Claude.qml expects. -""" - -from __future__ import annotations - -import argparse -import datetime as dt -import fcntl -import hashlib -import json -import os -import sys -import time -from pathlib import Path -from typing import Any - - -def expand_path(value: str) -> Path: - return Path(os.path.expandvars(os.path.expanduser(value))).resolve() - - -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 we normalize it 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 empty_bucket() -> dict[str, int]: - return { - "inputTokens": 0, - "outputTokens": 0, - "cacheReadInputTokens": 0, - "cacheCreationInputTokens": 0, - } - - -def iter_jsonl_files(projects_path: Path): - if not projects_path.is_dir(): - return - yield from projects_path.rglob("*.jsonl") - - -def scan(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 - malformed_lines = 0 - scanned_files = 0 - - for path in iter_jsonl_files(projects_path) or []: - scanned_files += 1 - 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. Matches the old rg - # search and keeps files with unrelated lines inexpensive. - if '"usage":' not in line: - continue - - try: - entry = json.loads(line) - except Exception: - malformed_lines += 1 - 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 - - 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: - # Preserve existing QML behavior: recentDays.messageCount - # is actually a token total, despite the legacy name. - 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"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr) - - recent_days = [recent[day] for day in recent_dates] - return { - "schemaVersion": 1, - "todayPrompts": today_prompt_count, - "todaySessions": len(today_sessions), - "todayTotalTokens": today_token_total, - "todayTokensByModel": today_tokens, - "recentDays": recent_days, - "modelUsage": usage_by_model, - "totalPrompts": prompts, - "totalSessions": len(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), - "dailyActivity": recent_days, - "scannedFiles": scanned_files, - "malformedLines": malformed_lines, - } - - -def cache_paths(projects_path: Path) -> tuple[Path, Path]: - cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "model-usage" - cache_root.mkdir(parents=True, exist_ok=True) - digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16] - return cache_root / f"claude-projects-{digest}.json", cache_root / f"claude-projects-{digest}.lock" - - -def read_fresh_cache(path: Path, max_age_seconds: int) -> str | None: - if max_age_seconds <= 0 or not path.exists(): - return None - try: - if time.time() - path.stat().st_mtime <= max_age_seconds: - return path.read_text(encoding="utf-8") - except Exception: - return None - return None - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("projects_path", nargs="?", default="~/.claude/projects") - parser.add_argument("--cache-seconds", type=int, default=20) - parser.add_argument("--force", action="store_true") - args = parser.parse_args() - - projects_path = expand_path(args.projects_path) - cache_file, lock_file = cache_paths(projects_path) - - if not args.force: - cached = read_fresh_cache(cache_file, args.cache_seconds) - if cached is not None: - print(cached, end="" if cached.endswith("\n") else "\n") - return 0 - - with lock_file.open("w") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - if not args.force: - cached = read_fresh_cache(cache_file, args.cache_seconds) - if cached is not None: - print(cached, end="" if cached.endswith("\n") else "\n") - return 0 - - summary = scan(projects_path) - output = json.dumps(summary, separators=(",", ":"), sort_keys=True) + "\n" - tmp = cache_file.with_suffix(".json.tmp") - tmp.write_text(output, encoding="utf-8") - tmp.replace(cache_file) - print(output, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/test/shell.d/agent-usage-claude-scanner-test.sh b/test/shell.d/agent-usage-claude-scanner-test.sh new file mode 100644 index 00000000..37ee4418 --- /dev/null +++ b/test/shell.d/agent-usage-claude-scanner-test.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +projects="$TEST_HOME/.claude/projects/example" +mkdir -p "$projects" + +timestamp="$(date +%Y-%m-%d)T12:00:00Z" +cat >"$projects/session.jsonl" <"$HISTORY_HOME/.claude/history.jsonl" <"$session" <"$FAKE_OMARCHY/bin/omarchy-agent-usage-good" <<'EOF' +#!/bin/bash +echo '{"schemaVersion":1,"id":"good","name":"Good Agent","totalPrompts":3}' +EOF + +cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-noisy" <<'EOF' +#!/bin/bash +echo "this is not json" +EOF + +cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-skipped" <<'EOF' +#!/bin/bash +echo '{"id":"skipped"}' +EOF + +# The updater itself lives in the same namespace as the collectors it globs. +cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-update" <<'EOF' +#!/bin/bash +echo '{"id":"update"}' +EOF + +chmod +x "$FAKE_OMARCHY/bin/"omarchy-agent-usage-* + +usage_dir="$TEST_HOME/.local/state/omarchy/agents/usage" + +HOME="$TEST_HOME" OMARCHY_PATH="$FAKE_OMARCHY" XDG_STATE_HOME="" \ + "$ROOT/bin/omarchy-agent-usage-update" --except skipped 2>/dev/null && fail "update reports a failing collector" +pass "update reports a failing collector" + +[[ $(jq -r '.name' "$usage_dir/good.json") == "Good Agent" ]] || + fail "update writes each collector's record to the usage directory" +pass "update writes each collector's record to the usage directory" + +[[ ! -e $usage_dir/noisy.json ]] || + fail "update refuses records that are not valid JSON" +pass "update refuses records that are not valid JSON" + +[[ ! -e $usage_dir/skipped.json ]] || + fail "update skips agents excluded with --except" +pass "update skips agents excluded with --except" + +[[ ! -e $usage_dir/update.json ]] || + fail "update does not treat itself as a collector" +pass "update does not treat itself as a collector" + +HOME="$TEST_HOME" OMARCHY_PATH="$FAKE_OMARCHY" XDG_STATE_HOME="" \ + "$ROOT/bin/omarchy-agent-usage-update" skipped 2>/dev/null || + fail "update succeeds when the requested collectors all pass" +pass "update succeeds when the requested collectors all pass" + +[[ -e $usage_dir/skipped.json && ! -e $usage_dir/noisy.json ]] || + fail "update with agent arguments only runs the named collectors" +pass "update with agent arguments only runs the named collectors" diff --git a/test/shell.d/model-usage-default-migration-test.sh b/test/shell.d/agents-default-migration-test.sh similarity index 70% rename from test/shell.d/model-usage-default-migration-test.sh rename to test/shell.d/agents-default-migration-test.sh index 8250f3fc..319893a6 100755 --- a/test/shell.d/model-usage-default-migration-test.sh +++ b/test/shell.d/agents-default-migration-test.sh @@ -12,12 +12,6 @@ trap 'rm -rf "$test_dir"' EXIT mkdir -p "$test_dir/bin" -cat >"$test_dir/bin/omarchy-cmd-present" <<'STUB' -#!/bin/bash - -command -v "$1" >/dev/null -STUB - cat >"$test_dir/bin/omarchy-restart-shell" <<'STUB' #!/bin/bash @@ -44,7 +38,7 @@ write_config() { jq "${1:-.}" "$ROOT/config/omarchy/shell.json" >"$config" } -without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.model-usage"))' +without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.agents"))' ids() { jq -c --arg section "$1" '[.bar.layout[$section][]? | if type == "object" then .id else . end]' "$config" @@ -52,21 +46,21 @@ ids() { # ------------------------------------------------------------------ shipped default -jq -e '[.bar.layout.right[].id] | index("omarchy.model-usage")' "$ROOT/config/omarchy/shell.json" >/dev/null || - fail "shipped config puts model usage in the bar" -pass "shipped config puts model usage in the bar" +jq -e '[.bar.layout.right[].id] | index("omarchy.agents")' "$ROOT/config/omarchy/shell.json" >/dev/null || + fail "shipped config puts the agents widget in the bar" +pass "shipped config puts the agents widget in the bar" # ------------------------------------------------------------------ placement write_config "$without_widget" run_migration -[[ $(ids right) == '["omarchy.tray","omarchy.model-usage","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] || - fail "migration inserts model usage after the tray" "$(ids right)" -pass "migration inserts model usage after the tray" +[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] || + fail "migration inserts the agents widget after the tray" "$(ids right)" +pass "migration inserts the agents widget after the tray" -(($(wc -l <"$SHELL_RESTARTS") == 1)) || fail "migration restarts the shell" -pass "migration restarts the shell" +(($(wc -l <"$SHELL_RESTARTS") == 0)) || fail "migration leaves the shell restart to omarchy update" +pass "migration leaves the shell restart to omarchy update" before=$(sha256sum "$config") run_migration @@ -77,18 +71,18 @@ pass "migration is idempotent" # A user who already placed the widget keeps it exactly where they put it, in # whichever section, and never gets a second copy. -write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.model-usage\" }]" +write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.agents\" }]" run_migration -[[ $(ids center) == *'"omarchy.model-usage"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)" -[[ $(ids right) != *'"omarchy.model-usage"'* ]] || fail "migration does not add a second copy" "$(ids right)" +[[ $(ids center) == *'"omarchy.agents"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)" +[[ $(ids right) != *'"omarchy.agents"'* ]] || fail "migration does not add a second copy" "$(ids right)" pass "migration respects a widget the user already placed" # Layouts written before entries grew options are bare id strings. -write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.model-usage\", \"omarchy.power\"]" +write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.agents\", \"omarchy.power\"]" run_migration -[[ $(ids right) == '["omarchy.tray","omarchy.model-usage","omarchy.power"]' ]] || +[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] || fail "migration reads string-form entries" "$(ids right)" pass "migration reads string-form entries" @@ -96,7 +90,7 @@ pass "migration reads string-form entries" write_config "$without_widget | del(.bar.layout.right[] | select(.id == \"omarchy.tray\"))" run_migration -[[ $(ids right) == '["omarchy.model-usage",'* ]] || fail "migration places the widget without a tray" "$(ids right)" +[[ $(ids right) == '["omarchy.agents",'* ]] || fail "migration places the widget without a tray" "$(ids right)" pass "migration places the widget without a tray" # ------------------------------------------------------------------ everything else diff --git a/test/shell.d/agents-rename-migration-test.sh b/test/shell.d/agents-rename-migration-test.sh new file mode 100755 index 00000000..c60b11ad --- /dev/null +++ b/test/shell.d/agents-rename-migration-test.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command jq + +migration="$ROOT/migrations/1786099804.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" + +cat >"$test_dir/bin/omarchy-agent-usage-update" <<'STUB' +#!/bin/bash + +echo run >>"$USAGE_UPDATES" +STUB + +chmod +x "$test_dir/bin/"* + +export USAGE_UPDATES="$test_dir/usage-updates" + +home="$test_dir/home" +config="$home/.config/omarchy/shell.json" + +run_migration() { + : >"$USAGE_UPDATES" + HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +} + +mkdir -p "$home/.config/omarchy" "$home/.cache/omarchy/model-usage" +cat >"$config" <<'JSON' +{ + "bar": { + "layout": { + "center": ["omarchy.model-usage"], + "right": [ + { "id": "omarchy.tray" }, + { "id": "omarchy.model-usage", "syncMode": "On", "syncDir": "~/Sync/agent-usage" } + ] + } + }, + "disabledPlugins": ["omarchy.model-usage", "omarchy.weather"] +} +JSON + +run_migration + +[[ $(jq -c '.bar.layout.right[1]' "$config") == '{"id":"omarchy.agents","syncMode":"On","syncDir":"~/Sync/agent-usage"}' ]] || + fail "migration renames the widget and keeps its settings" "$(cat "$config")" +pass "migration renames the widget and keeps its settings" + +[[ $(jq -c '.bar.layout.center' "$config") == '["omarchy.agents"]' ]] || + fail "migration renames string-form entries" "$(cat "$config")" +pass "migration renames string-form entries" + +[[ $(jq -c '.disabledPlugins' "$config") == '["omarchy.agents","omarchy.weather"]' ]] || + fail "migration keeps a disabled widget disabled" "$(cat "$config")" +pass "migration keeps a disabled widget disabled" + +[[ ! -e $home/.cache/omarchy/model-usage ]] || + fail "migration drops the old scanner cache" +pass "migration drops the old scanner cache" + +(($(wc -l <"$USAGE_UPDATES") == 1)) || fail "migration primes the usage data files" +pass "migration primes the usage data files" + +before=$(sha256sum "$config") +run_migration +[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(cat "$config")" +pass "migration is idempotent" + +# A config the migration cannot parse is left alone rather than truncated. +printf '{ not json' >"$config" +run_migration + +[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")" +pass "migration leaves an unparsable config untouched" diff --git a/test/shell.d/fixtures/bar-widget-contract/shell.qml b/test/shell.d/fixtures/bar-widget-contract/shell.qml index 34c2885d..2c43b75c 100644 --- a/test/shell.d/fixtures/bar-widget-contract/shell.qml +++ b/test/shell.d/fixtures/bar-widget-contract/shell.qml @@ -94,11 +94,12 @@ ShellRoot { if (typeof item.setting === "function") { root.assertEqual(item.setting("missing", "fallback"), "fallback", entry.id + " exposes setting fallback") } - if (entry.id === "omarchy.model-usage" && typeof item.iconSourceForProvider === "function") { - var darkIcon = String(item.iconSourceForProvider({ providerId: "codex" }, Qt.color("#1a1b26"))) - var lightIcon = String(item.iconSourceForProvider({ providerId: "codex" }, Qt.color("#ffffff"))) - root.assertTrue(darkIcon.indexOf("codex.svg") >= 0 && darkIcon.indexOf("codex-light.svg") < 0, entry.id + " uses the dark-theme Codex icon on dark surfaces") - root.assertTrue(lightIcon.indexOf("codex-light.svg") >= 0, entry.id + " uses the light-theme Codex icon on light surfaces") + if (entry.id === "omarchy.agents") { + root.assertTrue(typeof item.iconCandidatesForProvider === "function", entry.id + " resolves provider marks by convention") + var darkIcons = item.iconCandidatesForProvider({ providerId: "codex" }, Qt.color("#1a1b26")).join(" ") + var lightIcons = item.iconCandidatesForProvider({ providerId: "codex" }, Qt.color("#ffffff")).join(" ") + root.assertTrue(darkIcons.indexOf("codex.svg") >= 0 && darkIcons.indexOf("codex-light.svg") < 0, entry.id + " uses the dark-theme Codex icon on dark surfaces") + root.assertTrue(lightIcons.indexOf("codex-light.svg") >= 0, entry.id + " prefers the light-theme Codex icon on light surfaces") } safeCall(item, "refresh", entry) diff --git a/test/shell.d/model-usage-claude-scanner-test.sh b/test/shell.d/model-usage-claude-scanner-test.sh deleted file mode 100644 index c41333cb..00000000 --- a/test/shell.d/model-usage-claude-scanner-test.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -source "$(dirname "$0")/base-test.sh" - -require_command jq -require_command python3 - -TEST_HOME=$(mktemp -d) -trap 'rm -rf "$TEST_HOME"' EXIT - -projects="$TEST_HOME/.claude/projects/example" -mkdir -p "$projects" - -timestamp="$(date +%Y-%m-%d)T12:00:00Z" -cat >"$projects/session.jsonl" <