{"repo": "bozdemir/claude-usage-widget", "files": {"claude_usage/collector.py": {"5a76f0481593387a8aff64ec8bf1c91805c16873": {"t": 1785674430.2703614, "n": 58083, "h": "86ca3fe778a15809", "c": "\"\"\"Data collection from ~/.claude/ sources and Anthropic API.\"\"\"\n\nfrom __future__ import annotations\n\nimport functools\nimport glob\nimport json\nimport math\nimport os\nimport random\nimport ssl\nimport sys\nimport time\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\nfrom urllib.error import HTTPError, URLError\nfrom urllib.request import Request, urlopen\n\nfrom claude_usage import forecast, pricing\nfrom claude_usage import budget as _budget\nfrom claude_usage import codex as _codex\nfrom claude_usage import peak as _peak\nfrom claude_usage.analytics import AnomalyReport, detect_anomaly, generate_tips\nfrom claude_usage.burn import (\n BurnAlert,\n detect_fast_burn,\n detect_retry_storm,\n detect_token_spike,\n merge_alerts,\n)\nfrom claude_usage.cache_analyzer import CacheOpportunity, analyze_cache_opportunities\nfrom claude_usage.history import aggregate, append_sample, load_samples, prune\nfrom claude_usage.live_stream import LiveActivity, detect_live_activity\nfrom claude_usage.subagents import count_active_subagents\nfrom claude_usage.ticker import TickerItem, scan_ticker_items\nfrom claude_usage.trends import daily_heatmap, hourly_histogram, monthly_summary\nfrom claude_usage.news_fetcher import NewsItem, get_news_items\n\nHISTORY_FILENAME = \"usage-history.jsonl\"\nHISTORY_KEEP_DAYS = 90 # keep 90 days for trend/anomaly analysis\nSESSION_WINDOW_SECONDS = 5 * 3600\nSESSION_BUCKETS = 30\nWEEKLY_WINDOW_SECONDS = 7 * 86400\nANALYTICS_WINDOW_SECONDS = 90 * 86400\nWEEKLY_BUCKETS = 28\n\n# Transient-fault retry policy for the usage endpoint. Bounds are\n# deliberately TIGHT: both the GUI (every 30s) and CLI status-bar scripts\n# (polled by tmux/waybar every few seconds via --field) call this, so the\n# worst-case added latency must stay well under a second. We retry only on\n# transient network errors (connection reset, DNS blip, timeout) — never on\n# HTTP 4xx, which won't get better by retrying. Exponential 0.2s→0.4s with\n# jitter spreads retries so a flock of widgets doesn't thundering-herd.\n_USAGE_MAX_RETRIES = 2 # 1 initial attempt + 2 retries = 3 tries max\n_USAGE_BASE_DELAY = 0.2 # seconds; doubled each retry, plus jitter\n\n\n@dataclass\nclass UsageStats:\n \"\"\"Aggregated usage statistics from local data and API rate limits.\"\"\"\n\n today_messages: int = 0\n today_sessions: int = 0\n week_messages: int = 0\n week_sessions: int = 0\n today_tokens: int = 0\n week_tokens: int = 0\n active_sessions: list[dict[str, Any]] = field(default_factory=list)\n today_model_tokens: dict[str, int] = field(default_factory=dict)\n today_hourly: dict[int, int] = field(default_factory=dict)\n # Real rate limit data from API\n session_utilization: float = 0.0 # 0.0 - 1.0\n session_reset: int = 0 # unix timestamp (seconds)\n weekly_utilization: float = 0.0\n weekly_reset: int = 0\n # Optional model-scoped weekly cap (e.g. \"Fable\" weekly limit). Empty\n # scoped_label means the API reported no scoped limit → no third bar.\n scoped_utilization: float = 0.0\n scoped_reset: int = 0\n scoped_label: str = \"\"\n # Optional second provider: OpenAI Codex rate limits, populated only when\n # \"codex\" is listed in the `providers` config key. codex_available=False\n # means the overlay draws no Codex rows at all.\n codex_available: bool = False\n codex_session_utilization: float = 0.0\n codex_session_reset: int = 0\n codex_weekly_utilization: float = 0.0\n codex_weekly_reset: int = 0\n overage_status: str = \"\" # \"rejected\" or \"allowed\"\n fallback_status: str = \"\" # \"available\" or \"\"\n rate_limit_error: str = \"\" # error message if API call fails\n session_history: list = field(default_factory=list) # bucketed sparkline (oldest first)\n weekly_history: list = field(default_factory=list)\n # Subscription type from OAuth credentials (\"max\", \"pro\", \"free\", or \"\" if unknown).\n # Used to relabel cost fields: subscribers pay a flat fee, so the \"cost\" is really\n # the pay-as-you-go API-equivalent value of their usage, not what they're billed.\n subscription_type: str = \"\"\n # Cost estimates (USD) — for subscribers these represent pay-as-you-go equivalent\n today_cost: float = 0.0\n week_cost: float = 0.0\n cache_savings: float = 0.0 # $ saved this week via prompt caching\n # {model: {\"input\": N, \"output\": N, \"cache_read\": N, \"cache_creation\": N}}\n today_by_model_detailed: dict = field(default_factory=dict)\n # {project_name: output_tokens} -- trimmed to the top N projects by tokens\n today_by_project: dict = field(default_factory=dict)\n # Forecast dicts produced by forecast.forecast_time_to_limit\n session_forecast: dict = field(default_factory=dict)\n weekly_forecast: dict = field(default_factory=dict)\n # Anomaly detection over the 90-day baseline\n anomaly: AnomalyReport = field(default_factory=AnomalyReport)\n # Cost optimisation tips (0-3 short strings)\n tips: list[str] = field(default_factory=list)\n # Long-range trends\n daily_heatmap: list = field(default_factory=list) # 90-day peaks (newest last)\n yearly_heatmap: list = field(default_factory=list) # 364-day peaks (52 wk × 7 d)\n monthly_summary: list = field(default_factory=list) # last 6 months\n hourly_histogram: list = field(default_factory=list) # 24 buckets\n # Prompt-cache savings opportunities (top N repeated prefixes)\n cache_opportunities: list[CacheOpportunity] = field(default_factory=list)\n # Live-activity snapshot for the OSD indicator\n live_activity: LiveActivity = field(default_factory=LiveActivity)\n # Claude-authored weekly summary text (empty when unavailable / not yet cached)\n weekly_report_text: str = \"\"\n # Rolling per-turn cost feed for the OSD's scrolling ticker tape\n ticker_items: list[TickerItem] = field(default_factory=list)\n # Recent Anthropic/Claude news from RSS, shown in the ticker tape\n news_items: list[NewsItem] = field(default_factory=list)\n # Count of subagent JSONLs touched in the last minute — surfaced as the\n # \"⚙ N\" rozet next to the CLAUDE title when > 0.\n active_subagent_count: int = 0\n # Peak-window awareness: True + a short hint during Anthropic's weekday\n # reduced-limit window (see peak.py). Empty hint means not-in-peak/disabled.\n in_peak_window: bool = False\n peak_hint: str = \"\"\n # Monthly budget: month-to-date USD spend + a BudgetStatus (projection vs\n # the monthly_budget_usd cap). Only populated when a cap is configured;\n # budget is None / month_budget_usd is 0 when the feature is off.\n month_cost: float = 0.0\n month_budget_usd: float = 0.0\n budget: \"_budget.BudgetStatus | None\" = None\n # Real-time burn/spike/retry-storm snapshot for the OSD badge (stateless;\n # the debounced notification lives in the widget's BurnMonitor).\n burn_alert: BurnAlert = field(default_factory=BurnAlert)\n # A tiny, burn-window-bounded slice of utilisation samples handed to the\n # widget's stateful BurnMonitor for the fast-burn notification debounce.\n burn_samples: list = field(default_factory=list)\n\n\ndef parse_history(path: str) -> UsageStats:\n \"\"\"Parse ~/.claude/history.jsonl for message counts and session tracking.\n\n Counts messages and unique sessions for today and the rolling 7-day window.\n Also builds an hourly message histogram for today.\n \"\"\"\n stats = UsageStats()\n if not os.path.isfile(path):\n return stats\n\n now = datetime.now()\n today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)\n # Rolling 7-day window: include today plus the 6 previous calendar days\n week_start = today_start - timedelta(days=6)\n\n today_session_ids: set[str] = set()\n week_session_ids: set[str] = set()\n\n with open(path, encoding=\"utf-8\", errors=\"replace\") as f:\n for line in f:\n line = line.strip()\n if not line:\n continue\n try:\n entry = json.loads(line)\n except json.JSONDecodeError:\n continue\n\n ts_ms = entry.get(\"timestamp\", 0)\n if ts_ms <= 0:\n continue\n # history.jsonl stores timestamps in milliseconds\n dt = datetime.fromtimestamp(ts_ms / 1000)\n sid = entry.get(\"sessionId\", \"\")\n\n if dt >= today_start:\n stats.today_messages += 1\n today_session_ids.add(sid)\n stats.today_hourly[dt.hour] = stats.today_hourly.get(dt.hour, 0) + 1\n\n if dt >= week_start:\n stats.week_messages += 1\n week_session_ids.add(sid)\n\n stats.today_sessions = len(today_session_ids)\n stats.week_sessions = len(week_session_ids)\n return stats\n\n\ndef _collect_tokens_single_pass(\n claude_dir: str,\n today_prefix: str,\n week_prefixes: list[str],\n) -> dict[str, Any]:\n \"\"\"Scan conversation JSONL files once, collecting tokens for both today and week.\n\n A single filesystem pass avoids reading every file twice when the caller needs\n both today and week totals. ``today_prefix`` is a YYYY-MM-DD string;\n ``week_prefixes`` is the full list of 7 such strings (including today).\n \"\"\"\n result: dict[str, Any] = {\n \"today_output\": 0,\n \"week_output\": 0,\n \"today_by_model\": {},\n # Full per-model breakdowns (input/output/cache_read/cache_creation)\n \"today_by_model_detailed\": {},\n \"week_by_model_detailed\": {},\n # Today's output tokens grouped by the immediate parent directory name\n \"today_by_project\": {},\n }\n projects_dir = os.path.join(claude_dir, \"projects\")\n if not os.path.isdir(projects_dir):\n return result\n\n # Resolve symlinks so glob patterns match the real on-disk layout\n projects_dir = os.path.realpath(projects_dir)\n\n # mtime cutoff: we only care about files touched within the week window\n # we're aggregating, plus a one-day slack for clock skew / slow flushes.\n mtime_cutoff = datetime.now().timestamp() - 8 * 86400\n\n for jsonl_path in glob.glob(os.path.join(projects_dir, \"*\", \"*.jsonl\")):\n parts = jsonl_path.split(os.sep)\n # Subagent conversations share tokens with their parent; skip to avoid double-counting\n if \"subagents\" in parts:\n continue\n try:\n if os.path.getmtime(jsonl_path) < mtime_cutoff:\n continue\n except OSError:\n continue\n _parse_tokens_file(jsonl_path, today_prefix, week_prefixes, result)\n\n return result\n\n\ndef _parse_tokens_file(\n path: str,\n today_prefix: str,\n week_prefixes: list[str],\n result: dict[str, Any],\n) -> None:\n \"\"\"Extract token usage from a single conversation JSONL file.\n\n Mutates *result* in-place. Only processes ``assistant`` entries because\n those are the ones that carry the ``usage`` block with ``output_tokens``.\n \"\"\"\n try:\n f = open(path, encoding=\"utf-8\", errors=\"replace\")\n except OSError:\n return\n\n # Project name = name of the immediate parent directory under projects/\n # (e.g. \"-home-user-my-project\"). Used for per-project token breakdowns.\n project_name = os.path.basename(os.path.dirname(path))\n\n with f:\n for line in f:\n line = line.strip()\n if not line:\n continue\n try:\n entry = json.loads(line)\n except json.JSONDecodeError:\n continue\n\n if entry.get(\"type\") != \"assistant\":\n continue\n\n timestamp = entry.get(\"timestamp\", \"\")\n # Check today first; if true, week is automatically true -- avoids iterating week_prefixes\n is_today = timestamp.startswith(today_prefix)\n is_week = is_today or any(timestamp.startswith(p) for p in week_prefixes)\n if not is_week:\n continue\n\n msg = entry.get(\"message\", {})\n if not isinstance(msg, dict):\n continue\n\n usage = msg.get(\"usage\", {})\n output_tokens = usage.get(\"output_tokens\", 0) or 0\n input_tokens = usage.get(\"input_tokens\", 0) or 0\n cache_read = usage.get(\"cache_read_input_tokens\", 0) or 0\n cache_creation = usage.get(\"cache_creation_input_tokens\", 0) or 0\n model = msg.get(\"model\", \"unknown\")\n\n result[\"week_output\"] += output_tokens\n\n week_bucket = result[\"week_by_model_detailed\"].setdefault(\n model, {\"input\": 0, \"output\": 0, \"cache_read\": 0, \"cache_creation\": 0},\n )\n week_bucket[\"input\"] += input_tokens\n week_bucket[\"output\"] += output_tokens\n week_bucket[\"cache_read\"] += cache_read\n week_bucket[\"cache_creation\"] += cache_creation\n\n if is_today:\n result[\"today_output\"] += output_tokens\n result[\"today_by_model\"][model] = result[\"today_by_model\"].get(model, 0) + output_tokens\n\n today_bucket = result[\"today_by_model_detailed\"].setdefault(\n model, {\"input\": 0, \"output\": 0, \"cache_read\": 0, \"cache_creation\": 0},\n )\n today_bucket[\"input\"] += input_tokens\n today_bucket[\"output\"] += output_tokens\n today_bucket[\"cache_read\"] += cache_read\n today_bucket[\"cache_creation\"] += cache_creation\n\n result[\"today_by_project\"][project_name] = (\n result[\"today_by_project\"].get(project_name, 0) + output_tokens\n )\n\n\ndef _collect_month_tokens(\n claude_dir: str,\n month_prefix: str,\n now_ts: float | None = None,\n) -> dict[str, dict[str, int]]:\n \"\"\"Aggregate per-model token buckets for one calendar month.\n\n ``month_prefix`` is a ``YYYY-MM`` string; a JSONL entry counts when its\n ISO-8601 (UTC) ``timestamp`` starts with it. This is a SEPARATE pass from\n :func:`_collect_tokens_single_pass` — that one hard-caps at an 8-day mtime\n cutoff for the today/week hot path, which cannot see a whole month. Here the\n cutoff is ~32 days. Returns ``{model: {input, output, cache_read,\n cache_creation}}``, ready for :func:`pricing.calculate_stats_cost`.\n \"\"\"\n by_model: dict[str, dict[str, int]] = {}\n projects_dir = os.path.join(claude_dir, \"projects\")\n if not os.path.isdir(projects_dir):\n return by_model\n projects_dir = os.path.realpath(projects_dir)\n\n now_ts = now_ts if now_ts is not None else datetime.now().timestamp()\n mtime_cutoff = now_ts - 32 * 86400\n\n for jsonl_path in glob.glob(os.path.join(projects_dir, \"*\", \"*.jsonl\")):\n if \"subagents\" in jsonl_path.split(os.sep):\n continue\n try:\n if os.path.getmtime(jsonl_path) < mtime_cutoff:\n continue\n except OSError:\n continue\n _parse_month_tokens_file(jsonl_path, month_prefix, by_model)\n\n return by_model\n\n\ndef _parse_month_tokens_file(\n path: str,\n month_prefix: str,\n by_model: dict[str, dict[str, int]],\n) -> None:\n \"\"\"Accumulate one file's assistant-turn tokens into *by_model* in-place.\"\"\"\n try:\n f = open(path, encoding=\"utf-8\", errors=\"replace\")\n except OSError:\n return\n\n with f:\n for line in f:\n line = line.strip()\n if not line:\n continue\n try:\n entry = json.loads(line)\n except json.JSONDecodeError:\n continue\n\n if entry.get(\"type\") != \"assistant\":\n continue\n if not str(entry.get(\"timestamp\", \"\")).startswith(month_prefix):\n continue\n\n msg = entry.get(\"message\", {})\n if not isinstance(msg, dict):\n continue\n usage = msg.get(\"usage\", {})\n model = msg.get(\"model\", \"unknown\")\n bucket = by_model.setdefault(\n model, {\"input\": 0, \"output\": 0, \"cache_read\": 0, \"cache_creation\": 0},\n )\n bucket[\"input\"] += usage.get(\"input_tokens\", 0) or 0\n bucket[\"output\"] += usage.get(\"output_tokens\", 0) or 0\n bucket[\"cache_read\"] += usage.get(\"cache_read_input_tokens\", 0) or 0\n bucket[\"cache_creation\"] += usage.get(\"cache_creation_input_tokens\", 0) or 0\n\n\n# Preserved for test compatibility -- superseded by _collect_tokens_single_pass\ndef collect_tokens_from_conversations(\n claude_dir: str,\n date_prefixes: list[str],\n) -> dict[str, Any]:\n \"\"\"Scan conversation JSONL files for token usage on the given date prefixes.\n\n Legacy entry point kept so existing tests don't break. New callers should\n use ``_collect_tokens_single_pass`` which covers today and week in one pass.\n Returns totals split by input/output and broken down per model.\n \"\"\"\n result: dict[str, Any] = {\"total_output\": 0, \"total_input\": 0, \"by_model\": {}}\n projects_dir = os.path.join(claude_dir, \"projects\")\n if not os.path.isdir(projects_dir):\n return result\n\n projects_dir = os.path.realpath(projects_dir)\n\n for jsonl_path in glob.glob(os.path.join(projects_dir, \"*\", \"*.jsonl\")):\n parts = jsonl_path.split(os.sep)\n if \"subagents\" in parts:\n continue\n _parse_conversation_tokens(jsonl_path, date_prefixes, result)\n\n return result\n\n\ndef _parse_conversation_tokens(\n path: str,\n date_prefixes: list[str],\n result: dict[str, Any],\n) -> None:\n \"\"\"Extract token usage from a single conversation JSONL file.\n\n Mutates *result* in-place, accumulating input/output totals and per-model\n breakdowns.\n \"\"\"\n try:\n f = open(path, encoding=\"utf-8\", errors=\"replace\")\n except OSError:\n return\n\n with f:\n for line in f:\n line = line.strip()\n if not line:\n continue\n try:\n entry = json.loads(line)\n except json.JSONDecodeError:\n continue\n\n if entry.get(\"type\") != \"assistant\":\n continue\n\n timestamp = entry.get(\"timestamp\", \"\")\n if not any(timestamp.startswith(prefix) for prefix in date_prefixes):\n continue\n\n msg = entry.get(\"message\", {})\n if not isinstance(msg, dict):\n continue\n\n usage = msg.get(\"usage\", {})\n output_tokens = usage.get(\"output_tokens\", 0)\n input_tokens = usage.get(\"input_tokens\", 0)\n model = msg.get(\"model\", \"unknown\")\n\n result[\"total_output\"] += output_tokens\n result[\"total_input\"] += input_tokens\n\n if model not in result[\"by_model\"]:\n result[\"by_model\"][model] = {\"input\": 0, \"output\": 0}\n result[\"by_model\"][model][\"input\"] += input_tokens\n result[\"by_model\"][model][\"output\"] += output_tokens\n\n\ndef _process_alive(pid: int) -> bool:\n \"\"\"Return True iff a process with *pid* is currently running.\n\n On POSIX we use ``os.kill(pid, 0)`` — sends no signal, just asks the\n kernel to validate the pid. On Windows ``os.kill`` unconditionally\n calls ``TerminateProcess`` (yes, even for signal 0 — it would KILL\n the process instead of probing it), so we fall back to the\n OpenProcess/GetExitCodeProcess idiom via ``ctypes``.\n \"\"\"\n if pid <= 0:\n return False\n if sys.platform == \"win32\":\n try:\n import ctypes\n PROCESS_QUERY_LIMITED_INFORMATION = 0x1000\n STILL_ACTIVE = 259\n k32 = ctypes.windll.kernel32\n handle = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)\n if not handle:\n return False\n try:\n exit_code = ctypes.c_ulong()\n if not k32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):\n return False\n return exit_code.value == STILL_ACTIVE\n finally:\n k32.CloseHandle(handle)\n except Exception:\n return False\n try:\n os.kill(pid, 0)\n except ProcessLookupError:\n return False\n except PermissionError:\n # Process exists but is owned by a different UID — still alive.\n return True\n except OSError:\n return False\n return True\n\n\ndef get_active_sessions(claude_dir: str) -> list[dict[str, Any]]:\n \"\"\"Return list of active Claude sessions whose recorded PID is still alive.\n\n Uses :func:`_process_alive` which dispatches to platform-safe probes —\n ``os.kill(pid, 0)`` is a destructive ``TerminateProcess`` call on\n Windows, so we never call it there.\n \"\"\"\n sessions_dir = os.path.join(claude_dir, \"sessions\")\n if not os.path.isdir(sessions_dir):\n return []\n\n active: list[dict[str, Any]] = []\n for fname in os.listdir(sessions_dir):\n if not fname.endswith(\".json\"):\n continue\n path = os.path.join(sessions_dir, fname)\n try:\n with open(path, encoding=\"utf-8\") as f:\n sess = json.load(f)\n except (json.JSONDecodeError, OSError):\n continue\n pid = sess.get(\"pid\", 0)\n if pid <= 0:\n continue\n if _process_alive(pid):\n active.append(sess)\n return active\n\n\ndef _load_subscription_type(claude_dir: str) -> str:\n \"\"\"Return subscription type (\"max\", \"pro\", \"free\", ...) from credentials, or \"\".\"\"\"\n creds_path = os.path.join(claude_dir, \".credentials.json\")\n if not os.path.isfile(creds_path):\n return \"\"\n try:\n with open(creds_path, encoding=\"utf-8\", errors=\"replace\") as f:\n creds = json.load(f)\n return str(creds.get(\"claudeAiOauth\", {}).get(\"subscriptionType\", \"\"))\n except (json.JSONDecodeError, OSError):\n return \"\"\n\n\n# Keychain service names Claude Code has used for its credentials item on\n# macOS. Tried in order so a future rename can't silently blank the widget.\n_MACOS_KEYCHAIN_SERVICES = (\"Claude Code-credentials\", \"Claude Code\")\n\n\ndef _extract_token(blob: str) -> str | None:\n \"\"\"Pull the OAuth access token out of a credentials JSON blob.\n\n Returns a non-empty, stripped token, or ``None`` if the blob doesn't parse\n or the token is absent/empty (an empty token is as useless as no token —\n the caller treats both as \"not logged in\").\"\"\"\n try:\n creds = json.loads(blob)\n token = creds[\"claudeAiOauth\"][\"accessToken\"]\n except (json.JSONDecodeError, KeyError, TypeError):\n return None\n token = (token or \"\").strip()\n return token or None\n\n\ndef _load_credentials(claude_dir: str) -> str | None:\n \"\"\"Load the OAuth access token, mirroring Claude Code's own lookup order.\n\n Order: ``CLAUDE_CODE_OAUTH_TOKEN`` env var → ``~/.claude/.credentials.json``\n flat file (Linux + macOS) → macOS Keychain. The Keychain path is the only\n source on macOS installs where Claude Code stores credentials there and\n never writes the flat file, which is exactly when the widget would\n otherwise show blank session/weekly numbers with no explanation.\n\n Returns the raw access-token string, or ``None`` if none is found.\n \"\"\"\n # 0. Environment override — the highest-priority source Claude Code honours.\n env_tok = (os.environ.get(\"CLAUDE_CODE_OAUTH_TOKEN\") or \"\").strip()\n if env_tok:\n return env_tok\n\n # 1. Flat credentials file (Linux + macOS).\n creds_path = os.path.join(claude_dir, \".credentials.json\")\n if os.path.isfile(creds_path):\n try:\n with open(creds_path, encoding=\"utf-8\", errors=\"replace\") as f:\n blob = f.read()\n token = _extract_token(blob)\n if token:\n return token\n except OSError:\n pass # Unreadable; fall through to the Keychain on macOS.\n\n # 2. macOS Keychain fallback. /usr/bin/security is the canonical CLI; try\n # each known service name. Errors are recorded (not silently swallowed)\n # so the caller can surface an actionable message instead of a blank UI.\n if sys.platform == \"darwin\":\n import subprocess\n for service in _MACOS_KEYCHAIN_SERVICES:\n try:\n result = subprocess.run(\n [\"/usr/bin/security\", \"find-generic-password\",\n \"-s\", service, \"-w\"],\n capture_output=True, text=True, timeout=5,\n )\n except (OSError, subprocess.SubprocessError):\n continue\n if result.returncode == 0 and result.stdout.strip():\n token = _extract_token(result.stdout.strip())\n if token:\n return token\n\n return None\n\n\n@functools.lru_cache(maxsize=1)\ndef _ssl_context() -> ssl.SSLContext:\n \"\"\"HTTPS verification context with a CA bundle that works everywhere.\n\n macOS Python from python.org (and other framework builds) does not verify\n against the system keychain, so HTTPS to api.anthropic.com fails with\n ``CERTIFICATE_VERIFY_FAILED`` and the widget shows blank session/weekly\n numbers — exactly the \"works on Linux, blank on macOS\" symptom. certifi\n ships a real CA bundle on every platform; fall back to the stdlib default\n (already fine on Linux/Homebrew) if certifi is not installed.\n \"\"\"\n try:\n import certifi\n return ssl.create_default_context(cafile=certifi.where())\n except Exception:\n return ssl.create_default_context()\n\n\ndef fetch_rate_limits(claude_dir: str) -> dict[str, Any]:\n \"\"\"Fetch the user's plan utilization from Anthropic.\n\n Calls Claude Code's own ``/api/oauth/usage`` endpoint, which returns the\n five-hour and seven-day plan-level utilization the Claude UI shows. The\n legacy path (issuing a tiny ``/v1/messages`` call to read response\n headers) was per-API-key rate limits — a different thing — so it\n chronically under-reported real usage. We keep that path as a fallback\n for any future schema break in the OAuth endpoint.\n \"\"\"\n token = _load_credentials(claude_dir)\n if not token:\n if sys.platform == \"darwin\":\n # The token lives in the macOS Keychain; a GUI launch (Finder /\n # Homebrew / login item) may lack access to the item the Claude\n # Code CLI created, so it reads as \"missing\". Tell the user how\n # to grant it rather than showing silently-blank bars.\n return {\"error\": \"No credentials -- run 'claude-usage' once from a \"\n \"Terminal and click 'Always Allow' on the Keychain \"\n \"prompt (or set CLAUDE_CODE_OAUTH_TOKEN)\"}\n return {\"error\": \"No credentials found -- run 'claude' to log in\"}\n\n # Primary path — the OAuth usage endpoint Claude Code itself uses.\n primary = _fetch_oauth_usage(token)\n if \"error\" not in primary:\n return primary\n # If we were merely rate-limited, return that calm state directly. The\n # /v1/messages fallback below sends the OAuth token as an x-api-key,\n # which always 401s for OAuth users and would mislabel a throttle as\n # \"credentials expired\" — exactly the bug we're avoiding here.\n if primary.get(\"rate_limited\"):\n return primary\n # Same reasoning for EVERY other primary failure when the token is an\n # OAuth token (sk-ant-oat…): it can never authenticate as an x-api-key,\n # so the fallback would turn any transient 5xx/timeout into a false\n # \"Credentials expired\". Only attempt the fallback for real API keys.\n if token.startswith(\"sk-ant-oat\"):\n return primary\n\n # Fallback: tiny /v1/messages call to harvest rate-limit headers. These\n # cover API-key-level limits (not plan limits) but are better than\n # nothing if the OAuth endpoint is unreachable or 4xxs.\n body = json.dumps({\n \"model\": \"claude-haiku-4-5-20251001\",\n \"max_tokens\": 1,\n \"messages\": [{\"role\": \"user\", \"content\": \"h\"}],\n }).encode()\n req = Request(\n \"https://api.anthropic.com/v1/messages\",\n data=body,\n headers={\n \"x-api-key\": token,\n \"anthropic-version\": \"2023-06-01\",\n \"anthropic-beta\": \"oauth-2025-04-20\",\n \"content-type\": \"application/json\",\n },\n )\n try:\n with urlopen(req, timeout=15, context=_ssl_context()) as resp:\n headers = {k.lower(): v for k, v in resp.getheaders()}\n except HTTPError as e:\n if e.code == 401:\n return {\"error\": \"Credentials expired -- re-authenticate with 'claude'\"}\n if e.code == 429:\n headers = {k.lower(): v for k, v in e.headers.items()}\n prefix = \"anthropic-ratelimit-unified-\"\n if any(k.startswith(prefix) for k in headers):\n return _parse_rate_limit_headers(headers)\n return {\"error\": \"Rate limited -- try again later\"}\n return {\"error\": f\"API error {e.code}\"}\n except (URLError, OSError, TimeoutError):\n return {\"error\": \"API request failed -- check network\"}\n return _parse_rate_limit_headers(headers)\n\n\ndef _parse_retry_after(headers) -> float | None:\n \"\"\"Return the Retry-After delay in seconds, or None if absent/invalid.\n\n The HTTP spec allows either an integer seconds value or an HTTP-date;\n Anthropic sends seconds, so we only handle that form (an HTTP-date would\n parse to None and fall back to the exponential schedule).\"\"\"\n if headers is None:\n return None\n raw = headers.get(\"Retry-After\") or headers.get(\"retry-after\")\n if not raw:\n return None\n try:\n secs = float(raw)\n return secs if secs >= 0 else None\n except (TypeError, ValueError):\n return None\n\n\ndef _fetch_oauth_usage(token: str) -> dict[str, Any]:\n \"\"\"Hit ``/api/oauth/usage`` and translate the response into the same\n shape ``_parse_rate_limit_headers`` produces, so callers don't care\n which path won.\n\n The response uses 0-100 percentages and ISO-8601 ``resets_at`` strings;\n we normalise both to the internal 0-1 fraction + unix-seconds epoch.\n \"\"\"\n req = Request(\n \"https://api.anthropic.com/api/oauth/usage\",\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"anthropic-beta\": \"oauth-2025-04-20\",\n \"User-Agent\": \"claude-usage-widget\",\n },\n )\n # Exponential backoff with jitter on transient faults. HTTPError is a\n # subclass of URLError, so we catch it FIRST. A 401 (bad token) won't fix\n # itself — return immediately. A 429 (rate limited) IS the one 4xx that's\n # transient, so we retry it (honouring Retry-After when present) and, if\n # it persists, surface a calm \"rate limited\" state — NOT the misleading\n # \"credentials expired\", and crucially without falling through to the\n # x-api-key path which can't authenticate an OAuth token anyway.\n payload = None\n for attempt in range(_USAGE_MAX_RETRIES + 1):\n try:\n with urlopen(req, timeout=10, context=_ssl_context()) as resp:\n payload = json.loads(resp.read(65536).decode(\"utf-8\", errors=\"replace\"))\n break\n except HTTPError as e:\n if e.code == 401:\n return {\"error\": \"Credentials expired -- re-authenticate with 'claude'\"}\n if e.code == 429:\n # A 429 here is budget-based, not a momentary blip: this\n # endpoint replies \"Retry-After: 0\" (or sends no Retry-After\n # at all) once the window's quota is spent. Retrying such a\n # 429 immediately just fires a multi-request burst per poll\n # that burns the budget faster and keeps us throttled. So we\n # only wait out an *explicit positive* Retry-After; anything\n # else bails straight to the calm last-known state. The caller\n # keeps the last-known values instead of blanking the UI.\n retry_after = _parse_retry_after(e.headers)\n if not retry_after or retry_after <= 0 or attempt >= _USAGE_MAX_RETRIES:\n return {\"error\": \"Rate limited -- using last known values\",\n \"rate_limited\": True}\n time.sleep(min(retry_after, 5.0)) # cap so a huge Retry-After can't stall the poll\n continue\n if e.code >= 500 and attempt < _USAGE_MAX_RETRIES:\n # 5xx is exactly the transient server fault the backoff was\n # built for — fall through to the exponential sleep below\n # instead of bailing on the first blip.\n pass\n else:\n return {\"error\": f\"OAuth usage error {e.code}\"}\n except json.JSONDecodeError:\n # Malformed body — a retry might catch a truncated response, but\n # don't loop forever; one more attempt is plenty.\n if attempt >= _USAGE_MAX_RETRIES:\n return {\"error\": \"OAuth usage request failed\"}\n except (URLError, OSError, TimeoutError):\n if attempt >= _USAGE_MAX_RETRIES:\n return {\"error\": \"OAuth usage request failed\"}\n # Transient failure with retries left — back off, then try again.\n delay = _USAGE_BASE_DELAY * (2 ** attempt) + random.uniform(0.0, 0.1)\n time.sleep(delay)\n if payload is None:\n return {\"error\": \"OAuth usage request failed\"}\n\n if not isinstance(payload, dict):\n return {\"error\": \"Unexpected /api/oauth/usage payload\"}\n\n def _pct_to_frac(v: Any) -> float:\n try:\n f = float(v) / 100.0\n except (TypeError, ValueError):\n return 0.0\n if math.isnan(f) or math.isinf(f):\n return 0.0\n return max(0.0, min(f, 1.0))\n\n def _iso_to_epoch(v: Any) -> int:\n if not isinstance(v, str) or not v:\n return 0\n try:\n # Python's fromisoformat handles \"+00:00\" suffixes natively.\n from datetime import datetime\n return int(datetime.fromisoformat(v).timestamp())\n except (ValueError, TypeError):\n return 0\n\n five = payload.get(\"five_hour\") or {}\n seven = payload.get(\"seven_day\") or {}\n extra = payload.get(\"extra_usage\") or {}\n\n # Model-scoped weekly cap (e.g. the separate \"Fable\" weekly limit). It is\n # NOT in the top-level seven_day_* keys (those stay null) — it arrives in\n # the structured `limits` array as a `weekly_scoped` entry carrying a\n # scope.model.display_name. We surface whichever scoped weekly the API\n # returns, labelled by that name, so a third bar appears automatically for\n # Fable today and any future scoped model without a code change. When\n # several scoped weeklies exist we take the highest-utilised one — that's\n # the cap the user is closest to hitting.\n scoped_util, scoped_reset, scoped_label = 0.0, 0, \"\"\n limits = payload.get(\"limits\")\n if isinstance(limits, list):\n best = -1.0\n for lim in limits:\n if not isinstance(lim, dict) or lim.get(\"kind\") != \"weekly_scoped\":\n continue\n scope = lim.get(\"scope\") or {}\n model = (scope.get(\"model\") or {}) if isinstance(scope, dict) else {}\n label = str(model.get(\"display_name\") or \"\").strip()\n if not label:\n continue\n frac = _pct_to_frac(lim.get(\"percent\", 0))\n if frac > best:\n best = frac\n scoped_util = frac\n scoped_reset = _iso_to_epoch(lim.get(\"resets_at\"))\n scoped_label = label\n\n return {\n \"session_utilization\": _pct_to_frac(five.get(\"utilization\", 0)),\n \"session_reset\": _iso_to_epoch(five.get(\"resets_at\")),\n \"weekly_utilization\": _pct_to_frac(seven.get(\"utilization\", 0)),\n \"weekly_reset\": _iso_to_epoch(seven.get(\"resets_at\")),\n \"scoped_utilization\": scoped_util,\n \"scoped_reset\": scoped_reset,\n \"scoped_label\": scoped_label,\n \"overage_status\": (\n \"allowed\" if extra.get(\"is_enabled\") else \"rejected\"\n ),\n \"fallback_status\": \"\",\n }\n\n\ndef _parse_rate_limit_headers(headers: dict[str, str]) -> dict[str, Any]:\n \"\"\"Parse Anthropic unified rate-limit headers into typed values.\n\n All header values arrive as strings and may be missing or malformed, so\n every field goes through a safe converter with a sensible default.\n \"\"\"\n prefix = \"anthropic-ratelimit-unified-\"\n if not any(k.startswith(prefix) for k in headers):\n return {\"error\": \"No rate limit headers in response\"}\n\n def _safe_float(suffix: str, default: float = 0.0) -> float:\n \"\"\"Return a clamped [0.0, 1.0] float, falling back to *default* on bad input.\"\"\"\n try:\n val = float(headers.get(prefix + suffix, default))\n # NaN/Inf cannot be displayed or compared meaningfully\n if math.isnan(val) or math.isinf(val):\n return default\n return max(0.0, min(val, 1.0))\n except (ValueError, TypeError):\n return default\n\n def _safe_int(suffix: str, default: int = 0) -> int:\n \"\"\"Return a non-negative int, normalising millisecond timestamps to seconds.\"\"\"\n try:\n # float() first because the API may send \"1234567890.0\"\n val = int(float(headers.get(prefix + suffix, default)))\n # Guard against the API accidentally sending ms instead of s:\n # 4_102_444_800 is 2100-01-01 00:00:00 UTC -- no valid reset\n # timestamp should exceed that in seconds.\n if suffix.endswith(\"-reset\") and val > 4_102_444_800:\n val = val // 1000\n return max(0, val)\n except (ValueError, TypeError):\n return default\n\n return {\n \"session_utilization\": _safe_float(\"5h-utilization\"),\n \"session_reset\": _safe_int(\"5h-reset\"),\n \"weekly_utilization\": _safe_float(\"7d-utilization\"),\n \"weekly_reset\": _safe_int(\"7d-reset\"),\n \"overage_status\": headers.get(prefix + \"overage-status\", \"\"),\n \"fallback_status\": headers.get(prefix + \"fallback\", \"\"),\n }\n\n\nSTATUSLINE_CACHE_MAX_AGE_SECONDS = 20 * 60\n\n\ndef _load_statusline_rate_limits(\n config: dict[str, Any],\n now_ts: float,\n max_age_seconds: float = STATUSLINE_CACHE_MAX_AGE_SECONDS,\n) -> dict[str, tuple[float, int]] | None:\n \"\"\"Read a statusLine-dumped rate-limit file (see `statusline_cache_path`).\n\n Returns ``{\"session\": (pct, reset_ts), \"weekly\": (pct, reset_ts)}`` with\n expired windows clamped to zero, or None when the feature is disabled,\n the file is missing/older than ``max_age_seconds``/from the future, or\n either window is absent.\n \"\"\"\n path = str(config.get(\"statusline_cache_path\", \"\") or \"\")\n if not path:\n return None\n try:\n with open(path, \"r\", encoding=\"utf-8\") as fh:\n data = json.load(fh)\n # Python 3.10's fromisoformat rejects a trailing 'Z'; normalize it to\n # +00:00 so statusline scripts that emit Zulu timestamps work on 3.10.\n captured_str = str(data[\"captured_at\"]).strip()\n if captured_str.endswith(\"Z\"):\n captured_str = captured_str[:-1] + \"+00:00\"\n captured = datetime.fromisoformat(captured_str).timestamp()\n age = now_ts - captured\n if age > max_age_seconds or age < -300:\n return None\n limits = data[\"rate_limits\"]\n\n def window(block: Any) -> tuple[float, int] | None:\n if not isinstance(block, dict) or block.get(\"used_percentage\") is None:\n return None\n pct = max(0.0, min(1.0, float(block[\"used_percentage\"]) / 100.0))\n reset = int(block.get(\"resets_at\") or 0)\n if reset and now_ts >= reset: # window rolled over since capture\n return 0.0, 0\n return pct, reset\n\n session = window(limits.get(\"five_hour\"))\n weekly = window(limits.get(\"seven_day\"))\n if session is None or weekly is None:\n return None\n return {\"session\": session, \"weekly\": weekly}\n except (OSError, ValueError, KeyError, TypeError):\n return None\n\n\ndef collect_all(config: dict[str, Any]) -> UsageStats:\n \"\"\"Collect all usage stats from local ``~/.claude/`` files and the Anthropic API.\n\n Combines history-based message/session counts, token totals from conversation\n files, live session detection, and API-sourced rate-limit data into a single\n ``UsageStats`` snapshot. A rate-limit API failure is non-fatal; the error is\n recorded in ``stats.rate_limit_error`` and all other fields remain valid.\n \"\"\"\n claude_dir = config[\"claude_dir\"]\n # Two distinct files with similar names — keep them separate to avoid the\n # \"history_path\" shadowing bomb where a later reassignment silently swaps\n # which file the rest of collect_all reads/writes.\n claude_history_path = os.path.join(claude_dir, \"history.jsonl\")\n samples_path = os.path.join(claude_dir, HISTORY_FILENAME)\n\n stats = parse_history(claude_history_path)\n stats.subscription_type = _load_subscription_type(claude_dir)\n\n # Build date prefix strings used to filter conversation entries by timestamp.\n # JSONL timestamps are ISO-8601 with a `Z` suffix (UTC), so we MUST build\n # `today_str` / `week_dates` in UTC — otherwise users west of UTC double-\n # count their evenings and miss part of their morning.\n now = datetime.now(timezone.utc)\n today_str = now.strftime(\"%Y-%m-%d\")\n week_start = now - timedelta(days=6)\n week_dates = [(week_start + timedelta(days=i)).strftime(\"%Y-%m-%d\") for i in range(7)]\n\n tokens = _collect_tokens_single_pass(claude_dir, today_str, week_dates)\n stats.today_tokens = tokens[\"today_output\"]\n stats.week_tokens = tokens[\"week_output\"]\n stats.today_model_tokens = tokens[\"today_by_model\"]\n stats.today_by_model_detailed = tokens.get(\"today_by_model_detailed\", {})\n\n # Per-project breakdown: keep only the top 10 projects by output tokens,\n # preserving descending order so callers can iterate directly.\n project_totals = tokens.get(\"today_by_project\", {})\n top_projects = sorted(project_totals.items(), key=lambda kv: kv[1], reverse=True)[:10]\n stats.today_by_project = dict(top_projects)\n\n # Cost estimates via pricing module. A single call covers today + week so\n # the pricing table is walked twice rather than per-model per-request.\n today_cost_summary = pricing.calculate_stats_cost(stats.today_by_model_detailed)\n week_cost_summary = pricing.calculate_stats_cost(tokens.get(\"week_by_model_detailed\", {}))\n stats.today_cost = float(today_cost_summary.get(\"total\", 0.0))\n stats.week_cost = float(week_cost_summary.get(\"total\", 0.0))\n stats.cache_savings = float(week_cost_summary.get(\"cache_savings\", 0.0))\n\n stats.active_sessions = get_active_sessions(claude_dir)\n\n now_ts = datetime.now().timestamp()\n\n # Endpoint relief: when a statusLine dump is seconds-fresh, the endpoint\n # has nothing newer to say about the session/weekly pair — skip the call\n # and spend its budget at most once per `usage_endpoint_min_seconds`\n # (scoped/overage data, and consumption from headless `claude -p` runs\n # that never render a statusline, still need the real endpoint).\n # `samples_path`'s mtime records the last *successful* endpoint fetch:\n # append_sample/prune only run in the success branch below, and the\n # statusline/skip paths deliberately never touch the file.\n sl_live = _load_statusline_rate_limits(\n config, now_ts,\n max_age_seconds=2 * int(config.get(\"refresh_seconds\", 60) or 60))\n endpoint_min = int(config.get(\"usage_endpoint_min_seconds\", 300) or 300)\n try:\n last_fetch_ts = os.path.getmtime(samples_path)\n except OSError:\n last_fetch_ts = 0.0\n if sl_live is not None and (now_ts - last_fetch_ts) < endpoint_min:\n stats.session_utilization, stats.session_reset = sl_live[\"session\"]\n stats.weekly_utilization, stats.weekly_reset = sl_live[\"weekly\"]\n # Scoped cap isn't in the statusline payload — carry the last\n # sampled triple forward, clamping an expired window to zero.\n try:\n recent = load_samples(samples_path)\n except OSError:\n recent = []\n for prev in reversed(recent):\n if prev.get(\"scoped_label\"):\n scoped_reset = int(prev.get(\"scoped_reset\", 0) or 0)\n if not scoped_reset or now_ts < scoped_reset:\n stats.scoped_label = str(prev[\"scoped_label\"])\n stats.scoped_utilization = float(prev.get(\"scoped\", 0.0) or 0.0)\n stats.scoped_reset = scoped_reset\n break\n rate_limits = None\n else:\n rate_limits = fetch_rate_limits(claude_dir)\n\n if rate_limits is None:\n pass\n elif \"error\" in rate_limits:\n stats.rate_limit_error = rate_limits[\"error\"]\n # API call failed (transient network glitch, OAuth hiccup, etc.).\n # Without this, both utilization fields stay at the dataclass\n # default 0.0 and the widget paints \"0% / 0%\" until the next\n # successful refresh — a long-standing visible-flicker bug. Fall\n # back to the most recent on-disk sample so the OSD shows the\n # *last known* numbers instead of a misleading zero.\n try:\n recent = load_samples(samples_path)\n except OSError:\n recent = []\n if recent:\n last = recent[-1]\n sess_util = float(last.get(\"session\", 0.0) or 0.0)\n week_util = float(last.get(\"weekly\", 0.0) or 0.0)\n # Restore the reset countdowns too — otherwise they stay 0 and\n # every formatter blanks the reset label on a single throttled\n # poll (issue #11). Search each key INDEPENDENTLY: append_sample\n # only writes a reset key when it was truthy, so a sample can\n # carry one key without the other — stopping at the first sample\n # with either key would silently zero the other one and let an\n # expired window slip past the clamp below.\n sess_reset = week_reset = scoped_reset = 0\n scoped_util = 0.0\n scoped_label = \"\"\n for prev in reversed(recent):\n if not sess_reset and prev.get(\"session_reset\"):\n sess_reset = int(prev[\"session_reset\"])\n if not week_reset and prev.get(\"weekly_reset\"):\n week_reset = int(prev[\"weekly_reset\"])\n # Scoped limit (e.g. Fable weekly) — restore the whole triple\n # from the most recent sample that carried it so the third bar\n # doesn't flicker away on a single throttled poll.\n if not scoped_label and prev.get(\"scoped_label\"):\n scoped_label = str(prev[\"scoped_label\"])\n scoped_util = float(prev.get(\"scoped\", 0.0) or 0.0)\n scoped_reset = int(prev.get(\"scoped_reset\", 0) or 0)\n if sess_reset and week_reset and scoped_label:\n break\n # A window whose reset time has already passed has rolled over:\n # its true utilization is back to 0, not the stale last sample.\n # Showing the old percentage here is exactly the \"display lags a\n # whole cycle behind after a reset\" bug — clamp expired windows to\n # zero so a throttled poll never resurrects a finished window.\n if sess_reset and now_ts >= sess_reset:\n sess_util, sess_reset = 0.0, 0\n if week_reset and now_ts >= week_reset:\n week_util, week_reset = 0.0, 0\n if scoped_reset and now_ts >= scoped_reset:\n scoped_util, scoped_reset, scoped_label = 0.0, 0, \"\"\n stats.session_utilization = sess_util\n stats.weekly_utilization = week_util\n stats.session_reset = sess_reset\n stats.weekly_reset = week_reset\n stats.scoped_utilization = scoped_util\n stats.scoped_reset = scoped_reset\n stats.scoped_label = scoped_label\n # Fresher zero-cost source: a statusLine-dumped rate-limit file (see\n # `statusline_cache_path` in config). Claude Code pushes it on every\n # statusline render, so while the user is in a session it's seconds\n # old — beats the last sample, which can lag a whole rate-limit\n # window behind. Overrides session/weekly only; scoped stays on the\n # sample fallback (the statusline payload doesn't carry it).\n sl = _load_statusline_rate_limits(config, now_ts)\n if sl is not None:\n stats.session_utilization, stats.session_reset = sl[\"session\"]\n stats.weekly_utilization, stats.weekly_reset = sl[\"weekly\"]\n else:\n stats.session_utilization = rate_limits[\"session_utilization\"]\n stats.session_reset = rate_limits[\"session_reset\"]\n stats.weekly_utilization = rate_limits[\"weekly_utilization\"]\n stats.weekly_reset = rate_limits[\"weekly_reset\"]\n stats.scoped_utilization = rate_limits.get(\"scoped_utilization\", 0.0)\n stats.scoped_reset = rate_limits.get(\"scoped_reset\", 0)\n stats.scoped_label = rate_limits.get(\"scoped_label\", \"\")\n stats.overage_status = rate_limits[\"overage_status\"]\n stats.fallback_status = rate_limits[\"fallback_status\"]\n try:\n append_sample(\n samples_path, now_ts,\n stats.session_utilization, stats.weekly_utilization,\n session_reset=stats.session_reset,\n weekly_reset=stats.weekly_reset,\n scoped=stats.scoped_utilization,\n scoped_reset=stats.scoped_reset,\n scoped_label=stats.scoped_label,\n )\n prune(samples_path, keep_seconds=HISTORY_KEEP_DAYS * 86400, now=now_ts)\n except OSError:\n pass\n\n # Load 90 days of history for analytics/trends; the aggregators below\n # filter it down to their respective windows.\n samples = load_samples(samples_path, since_ts=now_ts - ANALYTICS_WINDOW_SECONDS)\n stats.session_history = aggregate(\n samples, \"session\", now=now_ts,\n window_seconds=SESSION_WINDOW_SECONDS, n_buckets=SESSION_BUCKETS,\n )\n stats.weekly_history = aggregate(\n samples, \"weekly\", now=now_ts,\n window_seconds=WEEKLY_WINDOW_SECONDS, n_buckets=WEEKLY_BUCKETS,\n )\n\n # Anomaly detection — compares today's session utilization against the\n # per-day peaks over prior days (requires >= 7 days of history).\n stats.anomaly = detect_anomaly(samples, today_usage=stats.session_utilization)\n\n # Cost optimisation tips (up to 3 short actionable suggestions).\n stats.tips = generate_tips(\n by_model=stats.today_by_model_detailed,\n week_cost=stats.week_cost,\n cache_savings=stats.cache_savings,\n )\n\n # Long-range trend aggregations for the popup UI.\n stats.daily_heatmap = daily_heatmap(samples, now=now_ts, n_days=90)\n # 52 weeks × 7 days = 364 cells — GitHub-style yearly calendar grid.\n stats.yearly_heatmap = daily_heatmap(samples, now=now_ts, n_days=364)\n stats.monthly_summary = monthly_summary(samples, now=now_ts, n_months=6)\n stats.hourly_histogram = hourly_histogram(samples, now=now_ts)\n\n # Prompt-cache savings opportunities — scans ~/.claude/projects/ for\n # repeated prompt prefixes; bounded cost by the mtime cutoff in the\n # analyser, so this stays cheap on every refresh.\n try:\n stats.cache_opportunities = analyze_cache_opportunities(claude_dir, days=7, now=now_ts)\n except OSError:\n stats.cache_opportunities = []\n\n # Live-activity rate: scans the same tree but only touches recently-\n # modified files, so it's O(active-sessions) per refresh.\n try:\n stats.live_activity = detect_live_activity(claude_dir, now=now_ts)\n except OSError:\n stats.live_activity = LiveActivity()\n\n # Ticker tape: latest ~40 assistant turns across active sessions, each\n # with its USD cost and primary tool. Drives the scrolling strip on the\n # OSD. Same cheap mtime-filtered scan as the other recent-activity modules.\n try:\n stats.ticker_items = scan_ticker_items(claude_dir, now=now_ts)\n except OSError:\n stats.ticker_items = []\n\n # Active subagent count — stat-only glob (no file contents opened).\n try:\n stats.active_subagent_count = count_active_subagents(claude_dir, now=now_ts)\n except OSError:\n stats.active_subagent_count = 0\n\n # Recent Anthropic news — only fetched when the user has explicitly\n # opted in (config.show_news == true). Without this guard a fresh\n # install would hit hnrss.org / reddit.com on every refresh tick even\n # though the strip is hidden by default.\n if config.get(\"show_news\"):\n try:\n stats.news_items = get_news_items()\n except Exception:\n stats.news_items = []\n else:\n stats.news_items = []\n\n # Claude-authored weekly report — we only *read* the on-disk cache here;\n # regeneration happens in a background thread from the widget so the\n # refresh path stays synchronous and never blocks on a network call.\n from claude_usage.ai_report import load_cached_report\n cached_report = load_cached_report(claude_dir, now=now_ts)\n if cached_report is not None:\n stats.weekly_report_text = cached_report.text\n\n # Burn-rate forecasts: project when utilization will hit 100% at the current rate.\n # Requires at least 2 samples in the window; falls back to an empty dict otherwise.\n session_rate = forecast.calculate_burn_rate(samples, \"session\")\n weekly_rate = forecast.calculate_burn_rate(samples, \"weekly\")\n stats.session_forecast = forecast.forecast_time_to_limit(\n stats.session_utilization, session_rate, stats.session_reset,\n )\n stats.weekly_forecast = forecast.forecast_time_to_limit(\n stats.weekly_utilization, weekly_rate, stats.weekly_reset,\n )\n\n # Real-time burn/spike/retry-storm — stateless snapshot merged to the\n # highest-severity alert for the OSD badge. Fast-burn reads the session\n # utilisation series (`samples`); spike/storm read the per-turn `ticker_items`.\n # The stateful once-per-episode notification lives in the widget (BurnMonitor).\n if config.get(\"burn_alerts_enabled\", True):\n try:\n spike_min = int(config.get(\"spike_min_tokens\", 20_000))\n fb = detect_fast_burn(\n samples, now_ts,\n float(config.get(\"burn_warn_pct_per_min\", 2.0)),\n float(config.get(\"burn_crit_pct_per_min\", 5.0)),\n float(config.get(\"burn_window_seconds\", 600)),\n )\n spike = detect_token_spike(\n stats.ticker_items,\n float(config.get(\"spike_token_multiplier\", 4.0)),\n spike_min,\n int(config.get(\"spike_baseline_min_turns\", 5)),\n )\n storm = detect_retry_storm(\n stats.ticker_items, now_ts,\n int(config.get(\"retry_storm_turns\", 3)),\n float(config.get(\"retry_storm_window_seconds\", 120)),\n spike_min,\n )\n stats.burn_alert = merge_alerts(fb, spike, storm)\n # Bounded slice for the widget's stateful debounce (fast-burn).\n win = float(config.get(\"burn_window_seconds\", 600)) + 120\n stats.burn_samples = [\n s for s in samples if float(s.get(\"ts\", 0.0)) >= now_ts - win\n ]\n except Exception:\n stats.burn_alert = BurnAlert()\n stats.burn_samples = []\n\n # Monthly budget — month-to-date spend + linear end-of-month projection.\n # Only scanned when a cap is set (monthly_budget_usd > 0): it's a separate\n # ~32-day JSONL pass on top of the 8-day today/week scan. Keyed off the UTC\n # month to match today/week (which bucket by UTC timestamps).\n monthly_budget = float(config.get(\"monthly_budget_usd\", 0.0) or 0.0)\n if monthly_budget > 0:\n try:\n month_by_model = _collect_month_tokens(\n claude_dir, now.strftime(\"%Y-%m\"), now_ts=now_ts,\n )\n stats.month_cost = float(\n pricing.calculate_stats_cost(month_by_model).get(\"total\", 0.0)\n )\n except OSError:\n stats.month_cost = 0.0\n stats.month_budget_usd = monthly_budget\n stats.budget = _budget.evaluate_budget(\n stats.month_cost, monthly_budget, now,\n notify_ratio=float(config.get(\"budget_notify_ratio\", 1.0) or 1.0),\n )\n\n # Peak-window awareness — is `now` inside Anthropic's weekday reduced-limit\n # window? Pure + cheap; guarded so a bad peak_timezone override can never\n # break a refresh. `now` is aware-UTC, which peak_status normalizes.\n try:\n ps = _peak.peak_status(now, config)\n stats.in_peak_window = ps.in_peak\n stats.peak_hint = ps.hint\n except Exception:\n stats.in_peak_window, stats.peak_hint = False, \"\"\n\n # Optional second provider: OpenAI Codex (opt-in via `providers` config).\n # collect_codex serves an on-disk cache between polls, so calling it on\n # every refresh cycle is cheap; a failure just hides the Codex rows.\n if \"codex\" in (config.get(\"providers\") or []):\n try:\n cx = _codex.collect_codex(\n poll_seconds=int(config.get(\"codex_poll_seconds\", 300) or 300))\n stats.codex_available = bool(cx[\"available\"])\n stats.codex_session_utilization = float(cx[\"session_pct\"])\n stats.codex_session_reset = int(cx[\"session_reset\"])\n stats.codex_weekly_utilization = float(cx[\"weekly_pct\"])\n stats.codex_weekly_reset = int(cx[\"weekly_reset\"])\n except Exception:\n stats.codex_available = False\n\n return stats\n"}}}}