From 41785bc74bd8795e84fe042a2e45befec0aded3b Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:13:42 -0500 Subject: [PATCH 01/20] feat(agents): add OpenCode Go usage collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collects local token stats from opencode's own database (assistant messages on the opencode-go provider) and the authoritative plan limits from GET /zen/go/v1/usage — the same rolling 5-hour, weekly, and monthly percentages the opencode.ai dashboard shows. Reads the key from OPENCODE_GO_API_KEY or the opencode-go entry of ~/.local/share/opencode/ auth.json, and accepts both the deployed response shape and the merged PR shape defensively. Probes are cached for 15s and kept as the answer of record on transient failure, with retryAdvised for the panel. --- bin/omarchy-agent-usage-opencode-go | 462 ++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100755 bin/omarchy-agent-usage-opencode-go diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go new file mode 100755 index 00000000000..57436e69633 --- /dev/null +++ b/bin/omarchy-agent-usage-opencode-go @@ -0,0 +1,462 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the OpenCode Go usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect OpenCode Go usage into one display-ready JSON record. + +Local stats come from opencode's own database, counting assistant messages +that ran on the opencode-go provider; the authoritative plan limits come from +OpenCode's official usage endpoint (GET /zen/go/v1/usage), which reports the +rolling 5-hour, weekly, and monthly windows as percentages with reset times — +the same numbers the opencode.ai dashboard shows. The agents panel only ever +reads the JSON this prints; it never talks to the database or the endpoint. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import sqlite3 +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +AGENT_ID = "opencode-go" +AGENT_NAME = "OpenCode Go" +AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_GO_API_KEY." +API_BASE_URL = "https://opencode.ai" +USAGE_PATH = "/zen/go/v1/usage" +PROBE_MIN_INTERVAL_SECONDS = 15 + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(value))).resolve() + + +def data_home() -> Path: + return Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) + + +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 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 +# +# opencode records one row per message in its database; every assistant +# message that ran on the opencode-go provider carries the model, token +# counts (cache and reasoning split out), and a millisecond timestamp. This +# is the same database and the same shape the claude and codex collectors +# read, just filtered to their own provider ids. + + +def opencode_db_path() -> Path: + return data_home() / "opencode" / "opencode.db" + + +def scan_opencode_db(db: Path) -> dict[str, Any]: + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + 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 + + try: + # Read-only: opencode may be writing right now. + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return empty_stats() + try: + conn.execute("PRAGMA query_only = ON") + for session_id, raw in conn.execute("SELECT session_id, data FROM message"): + # One malformed row must not abort the scan, so every shape assumption + # lives inside the try. + try: + entry = json.loads(raw) + # Exact match: opencode provider ids are free-form, and a custom + # "opencode-proxy" gateway is not this subscription. + if not isinstance(entry, dict) or entry.get("role") != "assistant": + continue + if str(entry.get("providerID") or "") != "opencode-go": + continue + tokens = entry.get("tokens") or {} + cache = tokens.get("cache") or {} + input_tokens = number(tokens.get("input")) + # opencode keeps thinking tokens out of output; both are generated. + output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) + cache_read = number(cache.get("read")) + cache_write = number(cache.get("write")) + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + continue + + created = number((entry.get("time") or {}).get("created")) + day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today + model = str(entry.get("modelID") or "opencode").rstrip("/").split("/")[-1] + except Exception: + continue + session_key = "opencode:" + str(session_id) + 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 sqlite3.Error: + return empty_stats() + finally: + conn.close() + + return { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + "activeDates": sorted(active_days), + "modelUsage": usage_by_model, + } + + +def empty_stats() -> dict[str, Any]: + return { + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [{"date": day, "messageCount": 0} for day in recent_date_strings()], + "totalPrompts": 0, + "totalSessions": 0, + "activeDays": 0, + "activeDates": [], + "modelUsage": {}, + } + + +# The same freshness contract as the other collectors: --limits-only promises +# to reuse recent local stats, and a big opencode history walked on every +# panel open would break that promise. + + +def scan_cache_paths(db: Path) -> tuple[Path, Path]: + digest = hashlib.sha1(str(db).encode("utf-8")).hexdigest()[:16] + root = cache_root() + return root / f"opencode-scan-{digest}.json", root / f"opencode-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: + handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") + tmp.chmod(0o644) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: + cache_file, lock_file = scan_cache_paths(db) + + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + + import fcntl + + 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_opencode_db(db) + write_json(cache_file, summary) + return summary + + +# ------------------------------------------------------------------ limits +# +# The usage endpoint is opencode's own account dashboard data: rolling 5-hour, +# weekly, and monthly windows as a used percentage and a reset time. Limits +# are per-account and authoritative, so the panel's meters show the same +# numbers the website does — no local estimate that would undercount sessions +# run on other machines or in other harnesses. + + +def read_auth_json(path: Path) -> str: + try: + parsed = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return "" + entry = parsed.get("opencode-go") if isinstance(parsed, dict) else None + if not isinstance(entry, dict): + return "" + return str(entry.get("key") or "").strip() + + +def credentials(auth_path: Path) -> str: + # An explicit key beats whatever opencode happens to be signed in with. + return str(os.environ.get("OPENCODE_GO_API_KEY", "")).strip() or read_auth_json(auth_path) + + +class GoUsageError(Exception): + pass + + +class GoUsageClient: + def __init__(self, api_key: str, base_url: str = API_BASE_URL): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + + def probe(self) -> dict[str, Any]: + # Cloudflare in front of opencode.ai blocks the default Python-urllib + # signature (Error 1010); any recognizable agent passes, so identify the + # probe rather than borrow a browser's. + request = urllib.request.Request( + self.base_url + USAGE_PATH, + headers={ + "Authorization": "Bearer " + self.api_key, + "Accept": "application/json", + "User-Agent": "omarchy-agent-usage/1", + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + return payload if isinstance(payload, dict) else {} + except urllib.error.HTTPError as error: + if error.code in (401, 403): + raise GoUsageError("OpenCode Go rejected the API key") + raise GoUsageError(f"OpenCode's usage endpoint returned status {error.code}") + except urllib.error.URLError as error: + raise GoUsageError("Could not reach OpenCode's usage endpoint") from error + except (json.JSONDecodeError, TimeoutError) as error: + raise GoUsageError("OpenCode returned an invalid usage response") from error + + +# The live endpoint nests the windows under "usage" with "percent" and +# "resetsAt"; the merged PR shape spelled them "Usage" with +# "usagePercent" and "resetInSec". Accept both so the collector does not +# depend on which deployment shape is in front of it. +WINDOW_LABELS = { + "rolling": "Session (5-hour)", + "weekly": "Weekly (7-day)", + "monthly": "Monthly (30-day)", +} + + +def normalize_percent(value: Any) -> float | None: + try: + n = float(str(value).strip()) + except (TypeError, ValueError): + return None + if not (n >= 0): + return None + # The endpoint reports percentages (2 means 2%). A payload speaking in + # fractions (0.02) is handled by the same >= 1 heuristic the Claude + # collector uses, so 1.0 renders as 1%, not 100%. + if n > 1: + n = n / 100.0 + return min(1.0, n) + + +def reset_from_seconds(value: Any) -> str: + seconds = number(value) + if seconds <= 0: + return "" + return (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=seconds)).isoformat() + + +def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: + usage = payload.get("usage") + usage = usage if isinstance(usage, dict) else payload + out: list[dict[str, Any]] = [] + for key, label in WINDOW_LABELS.items(): + entry = usage.get(key) if isinstance(usage, dict) else None + if not isinstance(entry, dict): + entry = payload.get(key + "Usage") + if not isinstance(entry, dict): + continue + raw = entry.get("percent", entry.get("usagePercent")) + percent = normalize_percent(raw) + if percent is None: + continue + # A rate-limited window is at 100% and its reset is what matters. + if str(entry.get("status") or "") == "rate-limited": + percent = 1.0 + resets_at = str(entry.get("resetsAt") or "").strip() or reset_from_seconds(entry.get("resetInSec")) + out.append({"label": label, "percent": percent, "resetsAt": resets_at}) + return out + + +def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: + result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} + + if api_key == "": + result["usageStatusText"] = "Waiting for auth" + 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. A forced refresh + # bypasses the window: that is what the user asked for. + probe_cache = cache_root() / "opencode-go-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 min_interval > 0 and time.time() - fetched_at < min_interval: + result["limits"] = cached["limits"] + return result + + try: + limits = parse_usage_payload(GoUsageClient(api_key, base_url).probe()) + except GoUsageError as error: + # 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. + if "Could not reach" in str(error): + result["retryAdvised"] = True + if cached.get("limits"): + result["limits"] = cached["limits"] + else: + result["usageStatusText"] = "OpenCode Go limits unavailable" + result["authHelpText"] = str(error) + return result + + if not limits: + result["usageStatusText"] = "OpenCode Go limits unavailable" + result["authHelpText"] = "OpenCode's usage endpoint returned no windows." + return result + result["limits"] = limits + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) + return result + + +# -------------------------------------------------------------------- record + + +def scan(auth_path: Path, db: Path, base_url: str = API_BASE_URL, force: bool = False, limits_only: bool = False, cache_seconds: float = 20) -> dict[str, Any]: + scan_age = 0 if force else (900 if limits_only else cache_seconds) + stats = cached_scan(db, scan_age) if db.is_file() else empty_stats() + + api_key = credentials(auth_path) + limits = collect_limits(api_key, base_url, 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, + # The database is machine-local, so cross-device sync sums the numbers + # the way it does for the claude and codex collectors. + "scope": "device", + "hasPromptStats": True, + "tierLabel": "Go", + "usageStatusText": limits["usageStatusText"], + "authHelpText": limits["authHelpText"], + "limits": limits["limits"], + } + if limits.get("retryAdvised"): + record["retryAdvised"] = True + record.update(stats) + return record + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print the OpenCode Go usage record as JSON") + parser.add_argument("--force", action="store_true", help="rescan the database and re-probe limits, ignoring caches") + parser.add_argument("--limits-only", action="store_true", help="reuse any recent database scan; only the limits probe must be fresh") + parser.add_argument("--cache-seconds", type=float, default=20) + parser.add_argument("--api-base-url", default=os.environ.get("OPENCODE_GO_API_BASE_URL", API_BASE_URL)) + args = parser.parse_args() + + record = scan( + data_home() / "opencode" / "auth.json", + opencode_db_path(), + base_url=args.api_base_url, + force=args.force, + limits_only=args.limits_only, + cache_seconds=args.cache_seconds, + ) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From afba23dcf79985384f6f00fd3da31159473f8bde Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:13:42 -0500 Subject: [PATCH 02/20] feat(agents): add OpenCode Go marks to the agents panel The official opencode-go glyph as a white mark for dark surfaces and a dark twin for light ones, following the codex.svg convention. --- shell/plugins/agents/assets/opencode-go-light.svg | 1 + shell/plugins/agents/assets/opencode-go.svg | 1 + 2 files changed, 2 insertions(+) create mode 100644 shell/plugins/agents/assets/opencode-go-light.svg create mode 100644 shell/plugins/agents/assets/opencode-go.svg diff --git a/shell/plugins/agents/assets/opencode-go-light.svg b/shell/plugins/agents/assets/opencode-go-light.svg new file mode 100644 index 00000000000..cc5f8f45ebc --- /dev/null +++ b/shell/plugins/agents/assets/opencode-go-light.svg @@ -0,0 +1 @@ + diff --git a/shell/plugins/agents/assets/opencode-go.svg b/shell/plugins/agents/assets/opencode-go.svg new file mode 100644 index 00000000000..a5b62e4e61c --- /dev/null +++ b/shell/plugins/agents/assets/opencode-go.svg @@ -0,0 +1 @@ + From 8f34f7e4ea710f194e646673ea1a9a0660bbf855 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:13:42 -0500 Subject: [PATCH 03/20] test(agents): cover the OpenCode Go usage collector Fixture opencode database rows (today, older, wrong-provider, zero-token), both endpoint response shapes, percent scaling, rate-limited windows, key precedence, and failure paths keep local stats. --- .../agent-usage-opencode-scanner-test.sh | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100755 test/shell.d/agent-usage-opencode-scanner-test.sh diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh new file mode 100755 index 00000000000..4f7dfab7a19 --- /dev/null +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -0,0 +1,254 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +# Without credentials the collector must still print a full, hidden-by-default +# record: the update runner writes whatever valid JSON appears on stdout. +no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ + OPENCODE_GO_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode-go") + +[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .tierLabel' <<<"$no_key") == "opencode-go:false:Go" ]] || + fail "OpenCode collector prints a valid record without credentials" "$no_key" +pass "OpenCode collector prints a valid record without credentials" + +result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-go" "$TEST_HOME" <<'PY' +import importlib.machinery +import importlib.util +import json +import os +import sqlite3 +import sys +import time +from datetime import date, datetime, timedelta +from pathlib import Path + +collector_path = str(Path(sys.argv[1])) +test_home = Path(sys.argv[2]) + +os.environ["TZ"] = "UTC" +time.tzset() +os.environ.pop("OPENCODE_GO_API_KEY", None) + +loader = importlib.machinery.SourceFileLoader("opencode_collector", collector_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +scanner = importlib.util.module_from_spec(spec) +loader.exec_module(scanner) + +data_home = test_home / "data" +auth_path = data_home / "opencode" / "auth.json" +auth_path.parent.mkdir(parents=True, exist_ok=True) +auth_path.write_text(json.dumps({"opencode-go": {"type": "api", "key": "sk_fixture"}})) + +# A fixture opencode database: assistant messages on the opencode-go provider +# carry the model, token split, and a millisecond timestamp; everything else +# (user rows, other providers, zero-token rows) must be skipped. +db = data_home / "opencode" / "opencode.db" +conn = sqlite3.connect(db) +conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") +now_ms = int(time.time() * 1000) +def day_offset(days): + return now_ms - days * 86400 * 1000 +rows = [ + ("m1", "s1", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 1000, "output": 500, "reasoning": 100, "cache": {"read": 200, "write": 50}}, + "time": {"created": day_offset(0)}}), + ("m2", "s2", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 100, "output": 20, "cache": {"read": 0, "write": 0}}, + "time": {"created": day_offset(2)}}), + ("m3", "s3", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 50, "output": 10}, + "time": {"created": day_offset(8)}}), + ("m4", "s4", {"role": "user", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 9999, "output": 9999}, + "time": {"created": day_offset(0)}}), + ("m5", "s5", {"role": "assistant", "providerID": "anthropic", "modelID": "claude-opus-4-8", + "tokens": {"input": 9999, "output": 9999}, + "time": {"created": day_offset(0)}}), + ("m6", "s6", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 0, "output": 0}, + "time": {"created": day_offset(0)}}), +] +conn.executemany("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + [(mid, sid, json.dumps(data)) for mid, sid, data in rows]) +conn.commit() +conn.close() + +summary = {} +summary["dbStats"] = scanner.scan_opencode_db(db) + +# The official endpoint nests the windows under "usage"; the merged PR shape +# spelled them "Usage" with resetInSec. Both must parse to the same +# panel contract, and a rate-limited window reports 100%. +live_payload = { + "usage": { + "rolling": {"status": "ok", "percent": 2, "resetsAt": "2026-08-13T00:15:17.598Z"}, + "weekly": {"status": "ok", "percent": 3, "resetsAt": "2026-08-17T00:00:00.598Z"}, + "monthly": {"status": "ok", "percent": 38, "resetsAt": "2026-08-27T01:24:06.598Z"}, + } +} +pr_payload = { + "rollingUsage": {"status": "ok", "usagePercent": 19, "resetInSec": 7200}, + "weeklyUsage": {"status": "ok", "usagePercent": 5, "resetInSec": 3600}, +} +summary["liveLimits"] = scanner.parse_usage_payload(live_payload) +summary["prLimits"] = scanner.parse_usage_payload(pr_payload) +summary["rateLimited"] = scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "rate-limited", "percent": 88, "resetsAt": "2026-08-13T00:15:17.598Z"}}} +) +summary["percentScaling"] = [ + scanner.normalize_percent(2), scanner.normalize_percent(0.5), + scanner.normalize_percent(100), scanner.normalize_percent(-1), scanner.normalize_percent("x"), +] + +# The env var is an explicit override; the opencode auth.json entry is the +# native source. Empty store means no key at all. +os.environ["OPENCODE_GO_API_KEY"] = "sk_env" +summary["envKeyWins"] = scanner.credentials(auth_path) == "sk_env" +os.environ.pop("OPENCODE_GO_API_KEY", None) +summary["authJsonKey"] = scanner.credentials(auth_path) +empty_auth = test_home / "empty-auth.json" +empty_auth.write_text("{}") +summary["missingKey"] = scanner.credentials(empty_auth) +summary["noKeyStatus"] = scanner.collect_limits("", "https://example.invalid", False)["usageStatusText"] + +def fresh_cache(name): + return test_home / "cache" / name + +class LiveClient: + def __init__(self, api_key, base_url): + pass + def probe(self): + return live_payload + +class RejectingClient: + def __init__(self, api_key, base_url): + pass + def probe(self): + raise scanner.GoUsageError("OpenCode Go rejected the API key") + +class OfflineClient: + def __init__(self, api_key, base_url): + pass + def probe(self): + raise scanner.GoUsageError("Could not reach OpenCode's usage endpoint") + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("live")) +scanner.GoUsageClient = LiveClient +live_record = scanner.scan(auth_path, db, "https://example.invalid") +summary["record"] = { + "schemaVersion": live_record["schemaVersion"], + "id": live_record["id"], + "ready": live_record["ready"], + "hasLocalStats": live_record["hasLocalStats"], + "scope": live_record["scope"], + "hasPromptStats": live_record["hasPromptStats"], + "tierLabel": live_record["tierLabel"], + "limits": live_record["limits"], +} + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("reject")) +scanner.GoUsageClient = RejectingClient +rejected = scanner.scan(auth_path, db, "https://example.invalid") +summary["rejected"] = { + "ready": rejected["ready"], + "limits": rejected["limits"], + "usageStatusText": rejected["usageStatusText"], + "authHelpText": rejected["authHelpText"], + "totalPrompts": rejected["totalPrompts"], +} + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("offline")) +scanner.GoUsageClient = OfflineClient +offline = scanner.scan(auth_path, db, "https://example.invalid") +summary["offline"] = { + "retryAdvised": offline.get("retryAdvised") is True, + "totalPrompts": offline["totalPrompts"], +} +print(json.dumps(summary, separators=(",", ":"))) +PY +) + +[[ $(jq -r '.record | {schemaVersion, id, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-go","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || + fail "OpenCode collector prints the display-ready record contract" "$result" +pass "OpenCode collector prints the display-ready record contract" + +[[ $(jq -r '.dbStats.todayTotalTokens' <<<"$result") == "1850" ]] || + fail "OpenCode collector totals today's input, output, reasoning, and cache tokens once" "$result" +pass "OpenCode collector totals today's input, output, reasoning, and cache tokens once" + +[[ $(jq -r '.dbStats.todayPrompts' <<<"$result") == "1" ]] || + fail "OpenCode collector counts one prompt for today" "$result" +pass "OpenCode collector counts one prompt for today" + +[[ $(jq -r '.dbStats.totalPrompts' <<<"$result") == "3" ]] || + fail "OpenCode collector keeps user rows, other providers, and zero-token rows out" "$result" +pass "OpenCode collector keeps user rows, other providers, and zero-token rows out" + +[[ $(jq -r '.dbStats.activeDays' <<<"$result") == "3" ]] || + fail "OpenCode collector counts every distinct day with usage" "$result" +pass "OpenCode collector counts every distinct day with usage" + +[[ $(jq -r '.dbStats.recentDays[-1].messageCount' <<<"$result") == "1850" ]] || + fail "OpenCode collector builds the seven-day token series" "$result" +pass "OpenCode collector builds the seven-day token series" + +[[ $(jq -c '.dbStats.modelUsage["deepseek-v4-flash"]' <<<"$result") == \ + '{"inputTokens":1150,"outputTokens":630,"cacheReadInputTokens":200,"cacheCreationInputTokens":50}' ]] || + fail "OpenCode collector keeps cache and reasoning split in model totals" "$result" +pass "OpenCode collector keeps cache and reasoning split in model totals" + +[[ $(jq -c '[.liveLimits[].percent]' <<<"$result") == '[0.02,0.03,0.38]' ]] || + fail "OpenCode collector scales endpoint percentages into 0..1" "$result" +pass "OpenCode collector scales endpoint percentages into 0..1" + +[[ $(jq -r '[.liveLimits[].label] | join(",")' <<<"$result") == "Session (5-hour),Weekly (7-day),Monthly (30-day)" ]] || + fail "OpenCode collector labels the rolling, weekly, and monthly windows" "$result" +pass "OpenCode collector labels the rolling, weekly, and monthly windows" + +[[ $(jq -r '.liveLimits[0].resetsAt' <<<"$result") == "2026-08-13T00:15:17.598Z" ]] || + fail "OpenCode collector passes reset timestamps through" "$result" +pass "OpenCode collector passes reset timestamps through" + +[[ $(jq -r '[.prLimits[].percent] | join(",")' <<<"$result") == "0.19,0.05" ]] || + fail "OpenCode collector parses the merged PR shape" "$result" +pass "OpenCode collector parses the merged PR shape" + +[[ $(jq -r '.prLimits[0].resetsAt | length > 10' <<<"$result") == "true" ]] || + fail "OpenCode collector turns resetInSec into a timestamp" "$result" +pass "OpenCode collector turns resetInSec into a timestamp" + +[[ $(jq -r '.rateLimited[0].percent' <<<"$result") == "1.0" ]] || + fail "OpenCode collector reports a rate-limited window at 100%" "$result" +pass "OpenCode collector reports a rate-limited window at 100%" + +[[ $(jq -c '.percentScaling' <<<"$result") == '[0.02,0.5,1.0,null,null]' ]] || + fail "OpenCode collector distinguishes percent and fraction scales" "$result" +pass "OpenCode collector distinguishes percent and fraction scales" + +[[ $(jq -r '(.envKeyWins | tostring) + ":" + .authJsonKey + ":" + .missingKey' <<<"$result") == "true:sk_fixture:" ]] || + fail "OpenCode collector reads the key from the environment or the opencode auth store" "$result" +pass "OpenCode collector reads the key from the environment or the opencode auth store" + +[[ $(jq -r '.noKeyStatus' <<<"$result") == "Waiting for auth" ]] || + fail "OpenCode collector says so when no key exists" "$result" +pass "OpenCode collector says so when no key exists" + +[[ $(jq -c '.rejected | {ready, limits, usageStatusText, totalPrompts}' <<<"$result") == \ + '{"ready":true,"limits":[],"usageStatusText":"OpenCode Go limits unavailable","totalPrompts":3}' ]] || + fail "OpenCode collector keeps local stats when the endpoint rejects the key" "$result" +pass "OpenCode collector keeps local stats when the endpoint rejects the key" + +[[ $(jq -r '.rejected.authHelpText' <<<"$result") == *"rejected"* ]] || + fail "OpenCode collector explains a rejected key" "$result" +pass "OpenCode collector explains a rejected key" + +[[ $(jq -r '(.offline.retryAdvised | tostring) + ":" + (.offline.totalPrompts | tostring)' <<<"$result") == "true:3" ]] || + fail "OpenCode collector asks for a sooner retry after a transport failure" "$result" +pass "OpenCode collector asks for a sooner retry after a transport failure" From 76bd4fbf4e0808b5fb1e6abd2ccae0fa3608aaff Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:13:42 -0500 Subject: [PATCH 04/20] docs(agents): document the OpenCode Go collector --- shell/plugins/agents/README.md | 15 ++++++++++++++- shell/plugins/agents/assets/opencode-go-light.svg | 2 +- shell/plugins/agents/assets/opencode-go.svg | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 51ad4e43c2d..4831c1ea503 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -55,6 +55,7 @@ light surfaces — and the bar glyph stands in when there is none. | `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | | `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | +| `opencode-go` | OpenCode's official usage endpoint (rolling 5-hour, weekly, monthly) | opencode's own database, messages on the `opencode-go` provider | Claude limits need a signed-in CLI; without credentials the panel says so and falls back to local stats only. A non-default Claude directory is honored via @@ -62,7 +63,19 @@ falls back to local stats only. A non-default Claude directory is honored via `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is -signed in there. +signed in there. OpenCode Go reads `OPENCODE_GO_API_KEY` first, then the +`opencode-go` entry of that same `auth.json`. + +### OpenCode Go limits + +The collector asks `GET https://opencode.ai/zen/go/v1/usage` with the Go API +key and shows the same rolling / weekly / monthly percentages the +opencode.ai dashboard does, so the meters are authoritative account-wide — +even for usage burned on other machines or in other harnesses. Local token +stats are only what opencode recorded on this machine, like the claude and +codex collectors. The endpoint is rate-limited on the server, so probes are +reused for 15 seconds; a probe that cannot reach the server keeps the last +successful numbers and asks the panel to retry sooner. ### Fireworks balance diff --git a/shell/plugins/agents/assets/opencode-go-light.svg b/shell/plugins/agents/assets/opencode-go-light.svg index cc5f8f45ebc..84295dcd5c3 100644 --- a/shell/plugins/agents/assets/opencode-go-light.svg +++ b/shell/plugins/agents/assets/opencode-go-light.svg @@ -1 +1 @@ - +OpenCode Go diff --git a/shell/plugins/agents/assets/opencode-go.svg b/shell/plugins/agents/assets/opencode-go.svg index a5b62e4e61c..6c209faddfe 100644 --- a/shell/plugins/agents/assets/opencode-go.svg +++ b/shell/plugins/agents/assets/opencode-go.svg @@ -1 +1 @@ - +OpenCode Go From 2bbc7505262beef14184db1a1f6afa28ceefe620 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:32:47 -0500 Subject: [PATCH 05/20] fix(agents): show the Go plan below the OpenCode title The hero meta line is the plan, never the product: claude shows its subscription tier and codex its planType there. "OpenCode Go" duplicated the Go plan, so the display name is now just OpenCode and tierLabel keeps the Go plan. Tests pin both fields. --- bin/omarchy-agent-usage-opencode-go | 2 +- test/shell.d/agent-usage-opencode-scanner-test.sh | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index 57436e69633..a57b2b816c8 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -29,7 +29,7 @@ from pathlib import Path from typing import Any AGENT_ID = "opencode-go" -AGENT_NAME = "OpenCode Go" +AGENT_NAME = "OpenCode" AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_GO_API_KEY." API_BASE_URL = "https://opencode.ai" USAGE_PATH = "/zen/go/v1/usage" diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index 4f7dfab7a19..efb7fc1e9d5 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -13,7 +13,7 @@ trap 'rm -rf "$TEST_HOME"' EXIT no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ OPENCODE_GO_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode-go") -[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .tierLabel' <<<"$no_key") == "opencode-go:false:Go" ]] || +[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$no_key") == "opencode-go:false:OpenCode:Go" ]] || fail "OpenCode collector prints a valid record without credentials" "$no_key" pass "OpenCode collector prints a valid record without credentials" @@ -144,6 +144,7 @@ live_record = scanner.scan(auth_path, db, "https://example.invalid") summary["record"] = { "schemaVersion": live_record["schemaVersion"], "id": live_record["id"], + "name": live_record["name"], "ready": live_record["ready"], "hasLocalStats": live_record["hasLocalStats"], "scope": live_record["scope"], @@ -174,8 +175,8 @@ print(json.dumps(summary, separators=(",", ":"))) PY ) -[[ $(jq -r '.record | {schemaVersion, id, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode-go","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || +[[ $(jq -r '.record | {schemaVersion, id, name, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || fail "OpenCode collector prints the display-ready record contract" "$result" pass "OpenCode collector prints the display-ready record contract" From b08f7040b3bdeb9c91fb241286eb91b288e211fc Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 18:50:01 -0500 Subject: [PATCH 06/20] test(agents): pin the OpenCode probe's request and error mapping The client swaps never exercise the real transport, so GoUsageClient.probe is now driven against a stubbed urllib.request.urlopen, the way the claude limits test stubs it: the request path and its bearer, accept, and user-agent headers are asserted, the deployed response shape is decoded, and a rejected key, a server status, and a transport failure each map to their distinct GoUsageError. --- .../agent-usage-opencode-scanner-test.sh | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index efb7fc1e9d5..3ee32215e31 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -39,6 +39,7 @@ loader = importlib.machinery.SourceFileLoader("opencode_collector", collector_pa spec = importlib.util.spec_from_loader(loader.name, loader) scanner = importlib.util.module_from_spec(spec) loader.exec_module(scanner) +RealGoUsageClient = scanner.GoUsageClient data_home = test_home / "data" auth_path = data_home / "opencode" / "auth.json" @@ -171,6 +172,65 @@ summary["offline"] = { "retryAdvised": offline.get("retryAdvised") is True, "totalPrompts": offline["totalPrompts"], } + +# The probe itself is exercised against a stubbed transport so the request +# (path, bearer, user agent) and the failure mapping are pinned too — the +# client swaps above never see them. The collector's urlopen is swapped, not +# its GoUsageClient, the way the claude limits test swaps it. +scanner.GoUsageClient = RealGoUsageClient +captured = {} +class FakeResponse: + def __init__(self, body): + self._body = body + def read(self): + return self._body + def __enter__(self): + return self + def __exit__(self, *args): + return False + +def stub_urlopen(request, timeout=None): + captured["url"] = request.full_url + captured["headers"] = {name.lower(): value for name, value in request.header_items()} + if stub_urlopen.error is not None: + raise stub_urlopen.error + return FakeResponse(stub_urlopen.body) +stub_urlopen.error = None +stub_urlopen.body = b"{}" +scanner.urllib.request.urlopen = stub_urlopen + +probe_client = scanner.GoUsageClient("sk_probe", "https://example.invalid") +stub_urlopen.body = json.dumps(live_payload).encode() +payload = probe_client.probe() +summary["probe"] = { + "payloadIsLive": isinstance(payload, dict) and payload.get("usage") is not None, + "url": captured["url"], + "auth": captured["headers"].get("authorization", ""), + "ua": captured["headers"].get("user-agent", ""), + "accept": captured["headers"].get("accept", ""), +} + +def error_text(error): + return type(error).__name__ + ":" + str(error) + +stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 401, "Unauthorized", {}, None) +try: + probe_client.probe() + summary["probe401"] = "no-error" +except scanner.GoUsageError as error: + summary["probe401"] = error_text(error) +stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 500, "Server Error", {}, None) +try: + probe_client.probe() + summary["probe500"] = "no-error" +except scanner.GoUsageError as error: + summary["probe500"] = error_text(error) +stub_urlopen.error = scanner.urllib.error.URLError(OSError("no route to host")) +try: + probe_client.probe() + summary["probeOffline"] = "no-error" +except scanner.GoUsageError as error: + summary["probeOffline"] = error_text(error) print(json.dumps(summary, separators=(",", ":"))) PY ) @@ -253,3 +313,19 @@ pass "OpenCode collector explains a rejected key" [[ $(jq -r '(.offline.retryAdvised | tostring) + ":" + (.offline.totalPrompts | tostring)' <<<"$result") == "true:3" ]] || fail "OpenCode collector asks for a sooner retry after a transport failure" "$result" pass "OpenCode collector asks for a sooner retry after a transport failure" + +[[ $(jq -r '.probe.url' <<<"$result") == "https://example.invalid/zen/go/v1/usage" ]] || + fail "OpenCode probe hits the official usage path" "$result" +pass "OpenCode probe hits the official usage path" + +[[ $(jq -r '.probe.auth + ":" + .probe.accept + ":" + .probe.ua' <<<"$result") == "Bearer sk_probe:application/json:omarchy-agent-usage/1" ]] || + fail "OpenCode probe sends the bearer, accept, and user-agent headers" "$result" +pass "OpenCode probe sends the bearer, accept, and user-agent headers" + +[[ $(jq -r '.probe.payloadIsLive' <<<"$result") == "true" ]] || + fail "OpenCode probe decodes the deployed response shape" "$result" +pass "OpenCode probe decodes the deployed response shape" + +{ [[ $(jq -r '.probe401' <<<"$result") == *"rejected"* ]] && [[ $(jq -r '.probe500' <<<"$result") == *"status 500"* ]] && [[ $(jq -r '.probeOffline' <<<"$result") == *"Could not reach"* ]]; } || + fail "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" "$result" +pass "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" From 9d0f305171f1dc47c45859b0cc2953d01ed7631e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 03:12:46 -0700 Subject: [PATCH 07/20] Settle the OpenCode Go percent scale payload-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-value heuristic rendered 1% as a full meter and 0.5% as half of one: any value above 1 was divided by 100 while its neighbors below 1 were not, so one payload could mix scales. Detect the scale across the whole payload the way the claude collector does — any value reaching 1 means percentages — and normalize every window with it. Co-Authored-By: Claude Fable 5 --- bin/omarchy-agent-usage-opencode-go | 32 ++++++++++++------- .../agent-usage-opencode-scanner-test.sh | 27 +++++++++++++--- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index a57b2b816c8..ed7047964fb 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -319,18 +319,23 @@ WINDOW_LABELS = { } -def normalize_percent(value: Any) -> float | None: +def parse_percent(value: Any) -> float: try: - n = float(str(value).strip()) - except (TypeError, ValueError): - return None + return float(str(value).strip()) + except Exception: + return float("nan") + + +def normalize_percent(value: Any, percent_scale: bool) -> float | None: + n = parse_percent(value) if not (n >= 0): return None # The endpoint reports percentages (2 means 2%). A payload speaking in - # fractions (0.02) is handled by the same >= 1 heuristic the Claude - # collector uses, so 1.0 renders as 1%, not 100%. - if n > 1: - n = n / 100.0 + # fractions (0.02) is only assumed when nothing in it reaches 1 — the + # payload-wide scale detection the Claude collector uses — so a lone 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) @@ -344,15 +349,20 @@ def reset_from_seconds(value: Any) -> str: def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: usage = payload.get("usage") usage = usage if isinstance(usage, dict) else payload - out: list[dict[str, Any]] = [] + windows: list[tuple[str, dict[str, Any]]] = [] for key, label in WINDOW_LABELS.items(): entry = usage.get(key) if isinstance(usage, dict) else None if not isinstance(entry, dict): entry = payload.get(key + "Usage") if not isinstance(entry, dict): continue - raw = entry.get("percent", entry.get("usagePercent")) - percent = normalize_percent(raw) + windows.append((label, entry)) + # One payload speaks one convention, so every window settles the scale + # together rather than each assuming its own. + percent_scale = any(parse_percent(entry.get("percent", entry.get("usagePercent"))) >= 1 for _, entry in windows) + out: list[dict[str, Any]] = [] + for label, entry in windows: + percent = normalize_percent(entry.get("percent", entry.get("usagePercent")), percent_scale) if percent is None: continue # A rate-limited window is at 100% and its reset is what matters. diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index 3ee32215e31..f31ea9e9488 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -103,10 +103,22 @@ summary["rateLimited"] = scanner.parse_usage_payload( {"usage": {"rolling": {"status": "rate-limited", "percent": 88, "resetsAt": "2026-08-13T00:15:17.598Z"}}} ) summary["percentScaling"] = [ - scanner.normalize_percent(2), scanner.normalize_percent(0.5), - scanner.normalize_percent(100), scanner.normalize_percent(-1), scanner.normalize_percent("x"), + scanner.normalize_percent(2, True), scanner.normalize_percent(0.5, True), + scanner.normalize_percent(100, True), scanner.normalize_percent(-1, True), scanner.normalize_percent("x", True), ] +# The scale is settled payload-wide, the way the claude collector does it: any +# value reaching 1 means the payload speaks percentages, so a lone 1 is 1% and +# a 0.5 beside a 2 is 0.5%, while an all-fraction payload keeps its values. +summary["onePercent"] = [w["percent"] for w in scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "ok", "percent": 1, "resetsAt": ""}}})] +summary["mixedScale"] = [w["percent"] for w in scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "ok", "percent": 0.5}, "weekly": {"status": "ok", "percent": 2}}})] +summary["mixedScaleLegacy"] = [w["percent"] for w in scanner.parse_usage_payload( + {"rollingUsage": {"status": "ok", "usagePercent": 0.5}, "weeklyUsage": {"status": "ok", "usagePercent": 2}})] +summary["fractionScale"] = [w["percent"] for w in scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "ok", "percent": 0.25}, "weekly": {"status": "ok", "percent": 0.8}}})] + # The env var is an explicit override; the opencode auth.json entry is the # native source. Empty store means no key at all. os.environ["OPENCODE_GO_API_KEY"] = "sk_env" @@ -289,9 +301,14 @@ pass "OpenCode collector turns resetInSec into a timestamp" fail "OpenCode collector reports a rate-limited window at 100%" "$result" pass "OpenCode collector reports a rate-limited window at 100%" -[[ $(jq -c '.percentScaling' <<<"$result") == '[0.02,0.5,1.0,null,null]' ]] || - fail "OpenCode collector distinguishes percent and fraction scales" "$result" -pass "OpenCode collector distinguishes percent and fraction scales" +[[ $(jq -c '.percentScaling' <<<"$result") == '[0.02,0.005,1.0,null,null]' ]] || + fail "OpenCode collector scales every value once the payload speaks percentages" "$result" +pass "OpenCode collector scales every value once the payload speaks percentages" + +{ [[ $(jq -c '.onePercent' <<<"$result") == '[0.01]' ]] && [[ $(jq -c '.mixedScale' <<<"$result") == '[0.005,0.02]' ]] && + [[ $(jq -c '.mixedScaleLegacy' <<<"$result") == '[0.005,0.02]' ]] && [[ $(jq -c '.fractionScale' <<<"$result") == '[0.25,0.8]' ]]; } || + fail "OpenCode collector settles the percent scale payload-wide" "$result" +pass "OpenCode collector settles the percent scale payload-wide" [[ $(jq -r '(.envKeyWins | tostring) + ":" + .authJsonKey + ":" + .missingKey' <<<"$result") == "true:sk_fixture:" ]] || fail "OpenCode collector reads the key from the environment or the opencode auth store" "$result" From 85a4d025f36107d9bc2bfeccd042e02b6faf3e2e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 03:13:02 -0700 Subject: [PATCH 08/20] Expire cached OpenCode Go limits and surface a rejected key A probe failure fell back to the cached limits forever, so a window whose reset time had long passed kept a dead meter on the panel, and a rejected API key hid behind that silent fallback with no status at all. Filter the cache through the same window-expiry check the claude collector uses, flag auth and transport failures on the error itself, and answer a rejected key with a "Sign-in rejected" status and the remedy. A missing key now keeps still-open windows under "Waiting for auth" too, matching claude's fallback handling. Co-Authored-By: Claude Fable 5 --- bin/omarchy-agent-usage-opencode-go | 81 ++++++++-- shell/plugins/agents/README.md | 5 +- .../agent-usage-opencode-scanner-test.sh | 145 +++++++++++++++++- 3 files changed, 207 insertions(+), 24 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index ed7047964fb..5569e972b5c 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -274,7 +274,10 @@ def credentials(auth_path: Path) -> str: class GoUsageError(Exception): - pass + def __init__(self, message: str, auth: bool = False, transport: bool = False): + super().__init__(message) + self.auth = auth + self.transport = transport class GoUsageClient: @@ -300,10 +303,10 @@ class GoUsageClient: return payload if isinstance(payload, dict) else {} except urllib.error.HTTPError as error: if error.code in (401, 403): - raise GoUsageError("OpenCode Go rejected the API key") + raise GoUsageError("OpenCode Go rejected the API key", auth=True) raise GoUsageError(f"OpenCode's usage endpoint returned status {error.code}") except urllib.error.URLError as error: - raise GoUsageError("Could not reach OpenCode's usage endpoint") from error + raise GoUsageError("Could not reach OpenCode's usage endpoint", transport=True) from error except (json.JSONDecodeError, TimeoutError) as error: raise GoUsageError("OpenCode returned an invalid usage response") from error @@ -373,23 +376,55 @@ def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: return out +# A cached percentage outlives the probe that measured it, but only until its +# window rolls over: once a window has reset, the figure describes a period +# that is over, and a stale 78% would misreport an allowance that is now +# untouched. A window with no reset time, or one that will not parse, is kept +# — an unreadable timestamp is no reason to throw away a real number. +def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: + raw = str(entry.get("resetsAt") or "") + if raw == "": + return True + try: + resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return True + if resets_at.tzinfo is None: + resets_at = resets_at.replace(tzinfo=dt.timezone.utc) + return resets_at > now + + +def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + entries = cached.get("limits") + if not isinstance(entries, list): + return [] + now = dt.datetime.now(dt.timezone.utc) + return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] + + def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} - if api_key == "": - result["usageStatusText"] = "Waiting for auth" - 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. A forced refresh - # bypasses the window: that is what the user asked for. + # kept as the answer of record when a later probe fails, for as long as + # their windows have not since reset. A forced refresh bypasses the window: + # that is what the user asked for. probe_cache = cache_root() / "opencode-go-limits.json" cached = read_fresh_json(probe_cache, float("inf")) or {} + fallback = usable_cached_limits(cached) + + if api_key == "": + # Still-open windows outlive the key that fetched them, so show them + # under the auth status rather than an empty section. + result["limits"] = fallback + result["usageStatusText"] = "Waiting for auth" + return result + fetched_at = number(cached.get("fetchedAtMs")) / 1000 min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS - if cached.get("limits") and min_interval > 0 and time.time() - fetched_at < min_interval: - result["limits"] = cached["limits"] + if fallback and min_interval > 0 and time.time() - fetched_at < min_interval: + result["limits"] = fallback return result try: @@ -398,18 +433,32 @@ def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: # 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. - if "Could not reach" in str(error): + if error.transport: result["retryAdvised"] = True - if cached.get("limits"): - result["limits"] = cached["limits"] + if error.auth: + # A rejected key is an account problem, not a transient outage: keep + # any still-open numbers, but say the sign-in is bad. + result["limits"] = fallback + result["usageStatusText"] = "Sign-in rejected" + result["authHelpText"] = ( + "OpenCode Go rejected the API key" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) + return result + if fallback: + result["limits"] = fallback else: result["usageStatusText"] = "OpenCode Go limits unavailable" result["authHelpText"] = str(error) return result if not limits: - result["usageStatusText"] = "OpenCode Go limits unavailable" - result["authHelpText"] = "OpenCode's usage endpoint returned no windows." + if fallback: + result["limits"] = fallback + else: + result["usageStatusText"] = "OpenCode Go limits unavailable" + result["authHelpText"] = "OpenCode's usage endpoint returned no windows." return result result["limits"] = limits write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 4831c1ea503..662bda66c81 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -74,8 +74,9 @@ opencode.ai dashboard does, so the meters are authoritative account-wide — even for usage burned on other machines or in other harnesses. Local token stats are only what opencode recorded on this machine, like the claude and codex collectors. The endpoint is rate-limited on the server, so probes are -reused for 15 seconds; a probe that cannot reach the server keeps the last -successful numbers and asks the panel to retry sooner. +reused for 15 seconds; a failed probe keeps the last successful numbers while +their windows are still open — a rejected key says so in the panel — and one +that cannot reach the server at all asks the panel to retry sooner. ### Fireworks balance diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index f31ea9e9488..da137685c5b 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -25,7 +25,7 @@ import os import sqlite3 import sys import time -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path collector_path = str(Path(sys.argv[1])) @@ -34,6 +34,7 @@ test_home = Path(sys.argv[2]) os.environ["TZ"] = "UTC" time.tzset() os.environ.pop("OPENCODE_GO_API_KEY", None) +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "default") loader = importlib.machinery.SourceFileLoader("opencode_collector", collector_path) spec = importlib.util.spec_from_loader(loader.name, loader) @@ -143,13 +144,13 @@ class RejectingClient: def __init__(self, api_key, base_url): pass def probe(self): - raise scanner.GoUsageError("OpenCode Go rejected the API key") + raise scanner.GoUsageError("OpenCode Go rejected the API key", auth=True) class OfflineClient: def __init__(self, api_key, base_url): pass def probe(self): - raise scanner.GoUsageError("Could not reach OpenCode's usage endpoint") + raise scanner.GoUsageError("Could not reach OpenCode's usage endpoint", transport=True) os.environ["XDG_CACHE_HOME"] = str(fresh_cache("live")) scanner.GoUsageClient = LiveClient @@ -185,6 +186,96 @@ summary["offline"] = { "totalPrompts": offline["totalPrompts"], } +# A cached window only stands in for a failed probe while it is still open: a +# window whose reset time has passed describes a period that is over and must +# drop out instead of sitting on the panel as a dead meter. +def seed_probe_cache(limits, age_seconds=3600): + cache_dir = Path(os.environ["XDG_CACHE_HOME"]) / "omarchy" / "agent-usage" + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "opencode-go-limits.json").write_text(json.dumps( + {"fetchedAtMs": int((time.time() - age_seconds) * 1000), "limits": limits})) + +open_window = {"label": "Weekly (7-day)", "percent": 0.4, + "resetsAt": (datetime.now(timezone.utc) + timedelta(hours=6)).isoformat()} +closed_window = {"label": "Session (5-hour)", "percent": 0.9, + "resetsAt": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()} +# A missing or unreadable timestamp is no reason to throw away a real number. +unstamped_window = {"label": "Monthly (30-day)", "percent": 0.2, "resetsAt": ""} +odd_window = {"label": "Session (5-hour)", "percent": 0.1, "resetsAt": "not-a-date"} + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("stale")) +seed_probe_cache([closed_window, open_window, unstamped_window, odd_window]) +scanner.GoUsageClient = OfflineClient +stale = scanner.collect_limits("sk_fixture", "https://example.invalid", False) +summary["staleCache"] = { + "labels": [w["label"] for w in stale["limits"]], + "usageStatusText": stale["usageStatusText"], + "retryAdvised": stale.get("retryAdvised") is True, +} + +# A fresh-but-expired cache must not satisfy the probe-reuse window either; +# the collector re-probes and shows the live numbers. +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("expired-fresh")) +seed_probe_cache([closed_window], age_seconds=0) +scanner.GoUsageClient = LiveClient +summary["expiredCacheReprobes"] = [w["label"] for w in scanner.collect_limits("sk_fixture", "https://example.invalid", False)["limits"]] + +# A fresh cache of open windows satisfies the reuse window without a request; +# a forced refresh probes anyway — that is what the user asked for. +class CountingClient: + calls = 0 + def __init__(self, api_key, base_url): + pass + def probe(self): + CountingClient.calls += 1 + return live_payload + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("reuse")) +seed_probe_cache([open_window], age_seconds=0) +scanner.GoUsageClient = CountingClient +reused = scanner.collect_limits("sk_fixture", "https://example.invalid", False) +calls_after_reuse = CountingClient.calls +forced = scanner.collect_limits("sk_fixture", "https://example.invalid", True) +summary["probeReuse"] = { + "reusedLabels": [w["label"] for w in reused["limits"]], + "callsAfterReuse": calls_after_reuse, + "forcedLabels": [w["label"] for w in forced["limits"]], + "callsAfterForce": CountingClient.calls, +} + +# A payload with no recognizable windows is a failure too: still-open cached +# numbers beat an empty section. +class EmptyClient: + def __init__(self, api_key, base_url): + pass + def probe(self): + return {} + +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("empty-payload")) +seed_probe_cache([closed_window, open_window]) +scanner.GoUsageClient = EmptyClient +summary["emptyPayloadFallback"] = [w["label"] for w in scanner.collect_limits("sk_fixture", "https://example.invalid", False)["limits"]] + +# A rejected key keeps still-open numbers but says the sign-in is bad. +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("rejected-cached")) +seed_probe_cache([closed_window, open_window]) +scanner.GoUsageClient = RejectingClient +rejected_cached = scanner.collect_limits("sk_fixture", "https://example.invalid", False) +summary["rejectedCached"] = { + "labels": [w["label"] for w in rejected_cached["limits"]], + "usageStatusText": rejected_cached["usageStatusText"], + "authHelpText": rejected_cached["authHelpText"], +} + +# So does a missing key: still-open windows outlive the key that fetched them. +os.environ["XDG_CACHE_HOME"] = str(fresh_cache("no-key-cached")) +seed_probe_cache([closed_window, open_window]) +no_key_cached = scanner.collect_limits("", "https://example.invalid", False) +summary["noKeyCached"] = { + "labels": [w["label"] for w in no_key_cached["limits"]], + "usageStatusText": no_key_cached["usageStatusText"], +} + # The probe itself is exercised against a stubbed transport so the request # (path, bearer, user agent) and the failure mapping are pinned too — the # client swaps above never see them. The collector's urlopen is swapped, not @@ -223,7 +314,8 @@ summary["probe"] = { } def error_text(error): - return type(error).__name__ + ":" + str(error) + flags = (":auth" if error.auth else "") + (":transport" if error.transport else "") + return type(error).__name__ + ":" + str(error) + flags stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 401, "Unauthorized", {}, None) try: @@ -231,6 +323,12 @@ try: summary["probe401"] = "no-error" except scanner.GoUsageError as error: summary["probe401"] = error_text(error) +stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 403, "Forbidden", {}, None) +try: + probe_client.probe() + summary["probe403"] = "no-error" +except scanner.GoUsageError as error: + summary["probe403"] = error_text(error) stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 500, "Server Error", {}, None) try: probe_client.probe() @@ -319,7 +417,7 @@ pass "OpenCode collector reads the key from the environment or the opencode auth pass "OpenCode collector says so when no key exists" [[ $(jq -c '.rejected | {ready, limits, usageStatusText, totalPrompts}' <<<"$result") == \ - '{"ready":true,"limits":[],"usageStatusText":"OpenCode Go limits unavailable","totalPrompts":3}' ]] || + '{"ready":true,"limits":[],"usageStatusText":"Sign-in rejected","totalPrompts":3}' ]] || fail "OpenCode collector keeps local stats when the endpoint rejects the key" "$result" pass "OpenCode collector keeps local stats when the endpoint rejects the key" @@ -331,6 +429,38 @@ pass "OpenCode collector explains a rejected key" fail "OpenCode collector asks for a sooner retry after a transport failure" "$result" pass "OpenCode collector asks for a sooner retry after a transport failure" +[[ $(jq -c '.staleCache' <<<"$result") == \ + '{"labels":["Weekly (7-day)","Monthly (30-day)","Session (5-hour)"],"usageStatusText":"","retryAdvised":true}' ]] || + fail "OpenCode collector drops cached windows that have reset when a probe fails" "$result" +pass "OpenCode collector drops cached windows that have reset when a probe fails" + +[[ $(jq -c '.probeReuse' <<<"$result") == \ + '{"reusedLabels":["Weekly (7-day)"],"callsAfterReuse":0,"forcedLabels":["Session (5-hour)","Weekly (7-day)","Monthly (30-day)"],"callsAfterForce":1}' ]] || + fail "OpenCode collector reuses a fresh probe unless the refresh is forced" "$result" +pass "OpenCode collector reuses a fresh probe unless the refresh is forced" + +[[ $(jq -r '.expiredCacheReprobes | join(",")' <<<"$result") == "Session (5-hour),Weekly (7-day),Monthly (30-day)" ]] || + fail "OpenCode collector re-probes instead of reusing a cache of expired windows" "$result" +pass "OpenCode collector re-probes instead of reusing a cache of expired windows" + +[[ $(jq -c '.emptyPayloadFallback' <<<"$result") == '["Weekly (7-day)"]' ]] || + fail "OpenCode collector keeps open windows when the endpoint returns none" "$result" +pass "OpenCode collector keeps open windows when the endpoint returns none" + +[[ $(jq -c '.rejectedCached | {labels, usageStatusText}' <<<"$result") == \ + '{"labels":["Weekly (7-day)"],"usageStatusText":"Sign-in rejected"}' ]] || + fail "OpenCode collector keeps open windows and flags the sign-in when the key is rejected" "$result" +pass "OpenCode collector keeps open windows and flags the sign-in when the key is rejected" + +{ [[ $(jq -r '.rejectedCached.authHelpText' <<<"$result") == *"last known limits"* ]] && + [[ $(jq -r '.rejectedCached.authHelpText' <<<"$result") == *"opencode auth login"* ]]; } || + fail "OpenCode collector says the shown limits are the last known ones and how to fix the key" "$result" +pass "OpenCode collector says the shown limits are the last known ones and how to fix the key" + +[[ $(jq -c '.noKeyCached' <<<"$result") == '{"labels":["Weekly (7-day)"],"usageStatusText":"Waiting for auth"}' ]] || + fail "OpenCode collector keeps open windows while waiting for auth" "$result" +pass "OpenCode collector keeps open windows while waiting for auth" + [[ $(jq -r '.probe.url' <<<"$result") == "https://example.invalid/zen/go/v1/usage" ]] || fail "OpenCode probe hits the official usage path" "$result" pass "OpenCode probe hits the official usage path" @@ -343,6 +473,9 @@ pass "OpenCode probe sends the bearer, accept, and user-agent headers" fail "OpenCode probe decodes the deployed response shape" "$result" pass "OpenCode probe decodes the deployed response shape" -{ [[ $(jq -r '.probe401' <<<"$result") == *"rejected"* ]] && [[ $(jq -r '.probe500' <<<"$result") == *"status 500"* ]] && [[ $(jq -r '.probeOffline' <<<"$result") == *"Could not reach"* ]]; } || +{ [[ $(jq -r '.probe401' <<<"$result") == "GoUsageError:OpenCode Go rejected the API key:auth" ]] && + [[ $(jq -r '.probe403' <<<"$result") == "GoUsageError:OpenCode Go rejected the API key:auth" ]] && + [[ $(jq -r '.probe500' <<<"$result") == "GoUsageError:OpenCode's usage endpoint returned status 500" ]] && + [[ $(jq -r '.probeOffline' <<<"$result") == "GoUsageError:Could not reach OpenCode's usage endpoint:transport" ]]; } || fail "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" "$result" pass "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" From 91e314fb90e944de28192465a063d7e3cf723fc3 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Thu, 13 Aug 2026 09:33:56 -0500 Subject: [PATCH 09/20] perf(agents): filter the opencode scan in SQL and harden its cache The database holds every historical message, JSON included, so the scan now filters in SQL (LIKE gates plus json_valid/json_extract with providerID opencode-go) instead of parsing tens-of-MB blobs in Python. The stats cache gains a versioned envelope stamped with the scan date, negative-age and cross-day misses, and never persists a scan cut short by a database error; cache-layer failures degrade to a direct scan instead of a crash. --- bin/omarchy-agent-usage-opencode-go | 82 +++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index 5569e972b5c..dabc4a8c171 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -16,6 +16,7 @@ from __future__ import annotations import argparse import datetime as dt +import fcntl import hashlib import json import os @@ -36,10 +37,6 @@ USAGE_PATH = "/zen/go/v1/usage" PROBE_MIN_INTERVAL_SECONDS = 15 -def expand_path(value: str) -> Path: - return Path(os.path.expandvars(os.path.expanduser(value))).resolve() - - def data_home() -> Path: return Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) @@ -93,7 +90,10 @@ def opencode_db_path() -> Path: return data_home() / "opencode" / "opencode.db" -def scan_opencode_db(db: Path) -> dict[str, Any]: +def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: + """Returns (stats, complete): a scan cut short by a database error still + contributes what it read, but its caller must not cache it as the whole + story.""" today = local_date_string() recent_dates = recent_date_strings() recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} @@ -110,10 +110,22 @@ def scan_opencode_db(db: Path) -> dict[str, Any]: # Read-only: opencode may be writing right now. conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) except sqlite3.Error: - return empty_stats() + return empty_stats(), False try: conn.execute("PRAGMA query_only = ON") - for session_id, raw in conn.execute("SELECT session_id, data FROM message"): + # The DB holds every historical message, JSON included, so filtering in + # SQL keeps tens-of-MB blobs out of Python. The json_extract conditions + # are the authority; the LIKE gates in front are pure acceleration (they + # differ only on duplicate keys or ASCII escapes, vanishingly rare), and + # the CASE guards keep json_extract — which raises on malformed JSON — + # from aborting the scan. + for session_id, raw in conn.execute( + "SELECT session_id, data FROM message" + " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" + " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.providerID') END = 'opencode-go'" + ): # One malformed row must not abort the scan, so every shape assumption # lives inside the try. try: @@ -161,7 +173,7 @@ def scan_opencode_db(db: Path) -> dict[str, Any]: today_token_total += total today_tokens[model] = today_tokens.get(model, 0) + total except sqlite3.Error: - return empty_stats() + return empty_stats(), False finally: conn.close() @@ -176,7 +188,7 @@ def scan_opencode_db(db: Path) -> dict[str, Any]: "activeDays": len(active_days), "activeDates": sorted(active_days), "modelUsage": usage_by_model, - } + }, True def empty_stats() -> dict[str, Any]: @@ -209,7 +221,10 @@ 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: + # A negative age means the mtime is in the future: the clock moved + # backwards since the write, so the cache's freshness cannot be trusted. + age = time.time() - path.stat().st_mtime + if 0 <= age <= max_age_seconds: return json.loads(path.read_text(encoding="utf-8")) except Exception: return None @@ -229,23 +244,56 @@ def write_json(path: Path, payload: dict[str, Any]) -> None: raise +def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None: + cached = read_fresh_json(cache_file, max_age_seconds) + if not isinstance(cached, dict) or cached.get("schemaVersion") != 1: + return None + # today* fields only mean "today" on the day they were scanned: a cache + # from another local date (midnight passed, or the clock moved) is a miss, + # not merely old, whatever its mtime says. + if cached.get("scanDate") != local_date_string(): + return None + stats = cached.get("stats") + if not isinstance(stats, dict): + return None + if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")): + return None + return stats + + +def write_cached_stats(cache_file: Path, stats: dict[str, Any]) -> None: + write_json(cache_file, {"schemaVersion": 1, "scanDate": local_date_string(), "stats": stats}) + + def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: + # The cache is a pure optimization: any cache-layer failure (unwritable + # root, lock errors, disk full) degrades to a direct scan, never a crash. + try: + return _cached_scan(db, max_age_seconds) + except Exception as exc: + print(f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", file=sys.stderr) + stats, _ = scan_opencode_db(db) + return stats + + +def _cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: cache_file, lock_file = scan_cache_paths(db) - cached = read_fresh_json(cache_file, max_age_seconds) + cached = read_cached_stats(cache_file, max_age_seconds) if cached is not None: return cached - import fcntl - with lock_file.open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) - cached = read_fresh_json(cache_file, max_age_seconds) + cached = read_cached_stats(cache_file, max_age_seconds) if cached is not None: return cached - summary = scan_opencode_db(db) - write_json(cache_file, summary) - return summary + stats, complete = scan_opencode_db(db) + # An interrupted scan still serves this run, but caching it would + # suppress the missing usage for every reader until the cache expires. + if complete: + write_cached_stats(cache_file, stats) + return stats # ------------------------------------------------------------------ limits From 060fa95250ff54066e13cddf3e9e3ed832c8cc31 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Thu, 13 Aug 2026 09:46:54 -0500 Subject: [PATCH 10/20] test(agents): cover the opencode stats cache envelope and broken scans A versioned, dated envelope on first scan, corrupt-cache recovery, limits-only reuse with a force bypass, dated and future-dated misses, malformed opencode rows that must not abort the scan, an interrupted scan that must never be cached, and an unwritable cache root that falls back to a direct scan. --- .../agent-usage-opencode-scanner-test.sh | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index da137685c5b..5bcf87c456b 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -82,7 +82,7 @@ conn.commit() conn.close() summary = {} -summary["dbStats"] = scanner.scan_opencode_db(db) +summary["dbStats"] = scanner.scan_opencode_db(db)[0] # The official endpoint nests the windows under "usage"; the merged PR shape # spelled them "Usage" with resetInSec. Both must parse to the same @@ -341,6 +341,127 @@ try: summary["probeOffline"] = "no-error" except scanner.GoUsageError as error: summary["probeOffline"] = error_text(error) + +# ------------------------------------------------------------- cache layer +# The stats cache is a versioned envelope stamped with the scan date, +# reused by --limits-only, bypassed by --force, and never allowed to kill +# the collector. Each scenario gets its own cache root. + +def cache_dir(name): + return test_home / "cache-layer" / name + +def cached_stats_result(cache_home, max_age): + os.environ["XDG_CACHE_HOME"] = str(cache_home) + return scanner.cached_scan(db, max_age) + +# First scan writes a versioned envelope stamped with today. +fresh = cache_dir("fresh") +stats1 = cached_stats_result(fresh, 20) +cache_file = sorted((fresh / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] +envelope = json.loads(cache_file.read_text()) +summary["cacheEnvelope"] = { + "scanDateMatches": envelope["scanDate"] == scanner.local_date_string(), + "schemaVersion": envelope["schemaVersion"], + "statsToday": envelope["stats"]["todayTotalTokens"], + "mode": f"{cache_file.stat().st_mode & 0o777:03o}", + "todayTotalTokens": stats1["todayTotalTokens"], +} + +# A corrupt-but-parseable cache (wrong shape) is a miss: rescan + rewrite. +cache_file.write_text("[]") +stats2 = cached_stats_result(fresh, 900) +summary["cacheCorrupt"] = { + "todayTotalTokens": stats2["todayTotalTokens"], + "rewritten": json.loads(cache_file.read_text()).get("schemaVersion") == 1, +} + +# --limits-only reuses the cache; --force rescans past it. A new message +# lands only in the forced result. +conn = sqlite3.connect(db) +now_ms = int(time.time() * 1000) +conn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( + "m_extra", "s_extra", json.dumps({"role": "assistant", "providerID": "opencode-go", + "modelID": "deepseek-v4-flash", "tokens": {"input": 10, "output": 0}, + "time": {"created": now_ms}}))) +conn.commit() +conn.close() +summary["cacheReuse"] = { + "limitsOnly": cached_stats_result(fresh, 900)["totalPrompts"], + "forced": cached_stats_result(fresh, 0)["totalPrompts"], +} + +# A cache stamped with another date is a miss even under a fresh mtime, and +# so is one whose mtime sits in the future (the clock moved backwards). +other_day = cache_dir("other-day") +cached_stats_result(other_day, 20) +other_cache = sorted((other_day / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] +envelope = json.loads(other_cache.read_text()) +envelope["scanDate"] = "1999-01-01" +other_cache.write_text(json.dumps(envelope)) # fresh mtime, wrong day +summary["cacheOtherDay"] = cached_stats_result(other_day, 900)["totalPrompts"] + +future = cache_dir("future") +cached_stats_result(future, 20) +future_cache = sorted((future / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] +future_time = time.time() + 3600 +os.utime(future_cache, (future_time, future_time)) +summary["cacheFuture"] = cached_stats_result(future, 900)["totalPrompts"] + +# Malformed rows must not abort the scan: json_valid() guards the parse. +malformed_db = test_home / "malformed" / "opencode" / "opencode.db" +malformed_db.parent.mkdir(parents=True, exist_ok=True) +mconn = sqlite3.connect(malformed_db) +mconn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") +good = json.dumps({"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", + "tokens": {"input": 5, "output": 0}, "time": {"created": now_ms}}, separators=(",", ":")) +mconn.executemany("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", [ + ("mm_1", "s", good), + ("mm_2", "s", good + " trailing-garbage"), + ("mm_3", "s", "this is not json"), +]) +mconn.commit() +mconn.close() +malformed_stats, malformed_complete = scanner.scan_opencode_db(malformed_db) +summary["malformed"] = {"totalPrompts": malformed_stats["totalPrompts"], "complete": malformed_complete} + +# A scan cut short by a database error must not be cached as the whole +# story, or the missing usage would be suppressed for every reader until +# the cache expires. A database without the message table makes the scan +# fail mid-flight. +interrupted = cache_dir("interrupted") +interrupted_db = test_home / "interrupted" / "opencode" / "opencode.db" +interrupted_db.parent.mkdir(parents=True, exist_ok=True) +iconn = sqlite3.connect(interrupted_db) +iconn.execute("CREATE TABLE unrelated (id TEXT PRIMARY KEY)") +iconn.commit() +iconn.close() +os.environ["XDG_CACHE_HOME"] = str(interrupted) +stats_i, complete_i = scanner.scan_opencode_db(interrupted_db) +scanner.cached_scan(interrupted_db, 20) +summary["interrupted"] = { + "todayTotalTokens": stats_i["todayTotalTokens"], + "complete": complete_i, + "cacheWritten": len(list((interrupted / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))) > 0, +} + +# Once the database is whole again, the next limits-only run rescans it +# instead of reusing a zero snapshot. +iconn = sqlite3.connect(interrupted_db) +iconn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") +now_ms = int(time.time() * 1000) +iconn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( + "i_1", "s", json.dumps({"role": "assistant", "providerID": "opencode-go", + "modelID": "deepseek-v4-flash", "tokens": {"input": 9, "output": 0}, + "time": {"created": now_ms}}))) +iconn.commit() +iconn.close() +summary["interrupted"]["rescanned"] = scanner.cached_scan(interrupted_db, 900)["todayTotalTokens"] + +# An unwritable cache root must not kill the collector: direct scan instead. +unwritable = test_home / "unwritable" +unwritable.write_text("not a directory") +os.environ["XDG_CACHE_HOME"] = str(unwritable) +summary["unwritableCache"] = scanner.cached_scan(db, 20)["totalPrompts"] print(json.dumps(summary, separators=(",", ":"))) PY ) @@ -479,3 +600,33 @@ pass "OpenCode probe decodes the deployed response shape" [[ $(jq -r '.probeOffline' <<<"$result") == "GoUsageError:Could not reach OpenCode's usage endpoint:transport" ]]; } || fail "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" "$result" pass "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" + +[[ $(jq -c '.cacheEnvelope | {scanDateMatches, schemaVersion, statsToday, mode, todayTotalTokens}' <<<"$result") == \ + '{"scanDateMatches":true,"schemaVersion":1,"statsToday":1850,"mode":"644","todayTotalTokens":1850}' ]] || + fail "OpenCode collector writes a versioned, dated stats cache" "$result" +pass "OpenCode collector writes a versioned, dated stats cache" + +[[ $(jq -c '.cacheCorrupt' <<<"$result") == '{"todayTotalTokens":1850,"rewritten":true}' ]] || + fail "OpenCode collector recovers from a corrupt cache" "$result" +pass "OpenCode collector recovers from a corrupt cache" + +[[ $(jq -c '.cacheReuse' <<<"$result") == '{"limitsOnly":3,"forced":4}' ]] || + fail "OpenCode collector reuses the cache for limits-only and bypasses it on force" "$result" +pass "OpenCode collector reuses the cache for limits-only and bypasses it on force" + +[[ $(jq -r '(.cacheOtherDay | tostring) + ":" + (.cacheFuture | tostring)' <<<"$result") == "4:4" ]] || + fail "OpenCode collector treats dated and future caches as misses" "$result" +pass "OpenCode collector treats dated and future caches as misses" + +[[ $(jq -c '.malformed' <<<"$result") == '{"totalPrompts":1,"complete":true}' ]] || + fail "OpenCode collector counts good rows past malformed ones" "$result" +pass "OpenCode collector counts good rows past malformed ones" + +[[ $(jq -c '.interrupted | {todayTotalTokens, complete, cacheWritten, rescanned}' <<<"$result") == \ + '{"todayTotalTokens":0,"complete":false,"cacheWritten":false,"rescanned":9}' ]] || + fail "OpenCode collector never caches an interrupted scan" "$result" +pass "OpenCode collector never caches an interrupted scan" + +[[ $(jq -r '.unwritableCache' <<<"$result") == "4" ]] || + fail "OpenCode collector degrades to a direct scan when the cache is unwritable" "$result" +pass "OpenCode collector degrades to a direct scan when the cache is unwritable" From 3b5316ecd01af2275dbf6de534e20f91baa55962 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sat, 15 Aug 2026 19:46:16 -0500 Subject: [PATCH 11/20] chore(agents): rename from opencode-go to opencode --- ...-opencode-go => omarchy-agent-usage-opencode} | 12 ++++++------ shell/plugins/agents/README.md | 4 ++-- ...{opencode-go-light.svg => opencode-light.svg} | 0 .../assets/{opencode-go.svg => opencode.svg} | 0 .../shell.d/agent-usage-opencode-scanner-test.sh | 16 ++++++++-------- 5 files changed, 16 insertions(+), 16 deletions(-) rename bin/{omarchy-agent-usage-opencode-go => omarchy-agent-usage-opencode} (98%) rename shell/plugins/agents/assets/{opencode-go-light.svg => opencode-light.svg} (100%) rename shell/plugins/agents/assets/{opencode-go.svg => opencode.svg} (100%) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode similarity index 98% rename from bin/omarchy-agent-usage-opencode-go rename to bin/omarchy-agent-usage-opencode index dabc4a8c171..3571257dc80 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode @@ -29,9 +29,9 @@ import urllib.request from pathlib import Path from typing import Any -AGENT_ID = "opencode-go" +AGENT_ID = "opencode" AGENT_NAME = "OpenCode" -AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_GO_API_KEY." +AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_API_KEY." API_BASE_URL = "https://opencode.ai" USAGE_PATH = "/zen/go/v1/usage" PROBE_MIN_INTERVAL_SECONDS = 15 @@ -271,7 +271,7 @@ def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: try: return _cached_scan(db, max_age_seconds) except Exception as exc: - print(f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", file=sys.stderr) + print(f"omarchy-agent-usage-opencode: cache unavailable ({exc}); scanning directly", file=sys.stderr) stats, _ = scan_opencode_db(db) return stats @@ -318,7 +318,7 @@ def read_auth_json(path: Path) -> str: def credentials(auth_path: Path) -> str: # An explicit key beats whatever opencode happens to be signed in with. - return str(os.environ.get("OPENCODE_GO_API_KEY", "")).strip() or read_auth_json(auth_path) + return str(os.environ.get("OPENCODE_API_KEY", "")).strip() or read_auth_json(auth_path) class GoUsageError(Exception): @@ -458,7 +458,7 @@ def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: # kept as the answer of record when a later probe fails, for as long as # their windows have not since reset. A forced refresh bypasses the window: # that is what the user asked for. - probe_cache = cache_root() / "opencode-go-limits.json" + probe_cache = cache_root() / "opencode-limits.json" cached = read_fresh_json(probe_cache, float("inf")) or {} fallback = usable_cached_limits(cached) @@ -550,7 +550,7 @@ def main() -> int: parser.add_argument("--force", action="store_true", help="rescan the database and re-probe limits, ignoring caches") parser.add_argument("--limits-only", action="store_true", help="reuse any recent database scan; only the limits probe must be fresh") parser.add_argument("--cache-seconds", type=float, default=20) - parser.add_argument("--api-base-url", default=os.environ.get("OPENCODE_GO_API_BASE_URL", API_BASE_URL)) + parser.add_argument("--api-base-url", default=os.environ.get("OPENCODE_API_BASE_URL", API_BASE_URL)) args = parser.parse_args() record = scan( diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 662bda66c81..96ed857b031 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -55,7 +55,7 @@ light surfaces — and the bar glyph stands in when there is none. | `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | | `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | -| `opencode-go` | OpenCode's official usage endpoint (rolling 5-hour, weekly, monthly) | opencode's own database, messages on the `opencode-go` provider | +| `opencode` | OpenCode's official usage endpoint (rolling 5-hour, weekly, monthly) | opencode's own database, messages on the `opencode-go` provider | Claude limits need a signed-in CLI; without credentials the panel says so and falls back to local stats only. A non-default Claude directory is honored via @@ -63,7 +63,7 @@ falls back to local stats only. A non-default Claude directory is honored via `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is -signed in there. OpenCode Go reads `OPENCODE_GO_API_KEY` first, then the +signed in there. OpenCode reads `OPENCODE_API_KEY` first, then the `opencode-go` entry of that same `auth.json`. ### OpenCode Go limits diff --git a/shell/plugins/agents/assets/opencode-go-light.svg b/shell/plugins/agents/assets/opencode-light.svg similarity index 100% rename from shell/plugins/agents/assets/opencode-go-light.svg rename to shell/plugins/agents/assets/opencode-light.svg diff --git a/shell/plugins/agents/assets/opencode-go.svg b/shell/plugins/agents/assets/opencode.svg similarity index 100% rename from shell/plugins/agents/assets/opencode-go.svg rename to shell/plugins/agents/assets/opencode.svg diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-scanner-test.sh index 5bcf87c456b..403aa417528 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-scanner-test.sh @@ -11,13 +11,13 @@ trap 'rm -rf "$TEST_HOME"' EXIT # Without credentials the collector must still print a full, hidden-by-default # record: the update runner writes whatever valid JSON appears on stdout. no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ - OPENCODE_GO_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode-go") + OPENCODE_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode") -[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$no_key") == "opencode-go:false:OpenCode:Go" ]] || +[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$no_key") == "opencode:false:OpenCode:Go" ]] || fail "OpenCode collector prints a valid record without credentials" "$no_key" pass "OpenCode collector prints a valid record without credentials" -result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-go" "$TEST_HOME" <<'PY' +result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode" "$TEST_HOME" <<'PY' import importlib.machinery import importlib.util import json @@ -33,7 +33,7 @@ test_home = Path(sys.argv[2]) os.environ["TZ"] = "UTC" time.tzset() -os.environ.pop("OPENCODE_GO_API_KEY", None) +os.environ.pop("OPENCODE_API_KEY", None) os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "default") loader = importlib.machinery.SourceFileLoader("opencode_collector", collector_path) @@ -122,9 +122,9 @@ summary["fractionScale"] = [w["percent"] for w in scanner.parse_usage_payload( # The env var is an explicit override; the opencode auth.json entry is the # native source. Empty store means no key at all. -os.environ["OPENCODE_GO_API_KEY"] = "sk_env" +os.environ["OPENCODE_API_KEY"] = "sk_env" summary["envKeyWins"] = scanner.credentials(auth_path) == "sk_env" -os.environ.pop("OPENCODE_GO_API_KEY", None) +os.environ.pop("OPENCODE_API_KEY", None) summary["authJsonKey"] = scanner.credentials(auth_path) empty_auth = test_home / "empty-auth.json" empty_auth.write_text("{}") @@ -192,7 +192,7 @@ summary["offline"] = { def seed_probe_cache(limits, age_seconds=3600): cache_dir = Path(os.environ["XDG_CACHE_HOME"]) / "omarchy" / "agent-usage" cache_dir.mkdir(parents=True, exist_ok=True) - (cache_dir / "opencode-go-limits.json").write_text(json.dumps( + (cache_dir / "opencode-limits.json").write_text(json.dumps( {"fetchedAtMs": int((time.time() - age_seconds) * 1000), "limits": limits})) open_window = {"label": "Weekly (7-day)", "percent": 0.4, @@ -467,7 +467,7 @@ PY ) [[ $(jq -r '.record | {schemaVersion, id, name, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || + '{"schemaVersion":1,"id":"opencode","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || fail "OpenCode collector prints the display-ready record contract" "$result" pass "OpenCode collector prints the display-ready record contract" From 545638b0dc8cb07d72593319fb286b2f825ff40c Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sun, 20 Sep 2026 12:52:23 -0500 Subject: [PATCH 12/20] fix(agents): keep the OpenCode Go identity --- ...agent-usage-opencode => omarchy-agent-usage-opencode-go} | 2 +- .../assets/{opencode-light.svg => opencode-go-light.svg} | 0 .../plugins/agents/assets/{opencode.svg => opencode-go.svg} | 0 ...nner-test.sh => agent-usage-opencode-go-scanner-test.sh} | 6 +++--- 4 files changed, 4 insertions(+), 4 deletions(-) rename bin/{omarchy-agent-usage-opencode => omarchy-agent-usage-opencode-go} (99%) rename shell/plugins/agents/assets/{opencode-light.svg => opencode-go-light.svg} (100%) rename shell/plugins/agents/assets/{opencode.svg => opencode-go.svg} (100%) rename test/shell.d/{agent-usage-opencode-scanner-test.sh => agent-usage-opencode-go-scanner-test.sh} (99%) diff --git a/bin/omarchy-agent-usage-opencode b/bin/omarchy-agent-usage-opencode-go similarity index 99% rename from bin/omarchy-agent-usage-opencode rename to bin/omarchy-agent-usage-opencode-go index 3571257dc80..448e13e8c39 100755 --- a/bin/omarchy-agent-usage-opencode +++ b/bin/omarchy-agent-usage-opencode-go @@ -29,7 +29,7 @@ import urllib.request from pathlib import Path from typing import Any -AGENT_ID = "opencode" +AGENT_ID = "opencode-go" AGENT_NAME = "OpenCode" AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_API_KEY." API_BASE_URL = "https://opencode.ai" diff --git a/shell/plugins/agents/assets/opencode-light.svg b/shell/plugins/agents/assets/opencode-go-light.svg similarity index 100% rename from shell/plugins/agents/assets/opencode-light.svg rename to shell/plugins/agents/assets/opencode-go-light.svg diff --git a/shell/plugins/agents/assets/opencode.svg b/shell/plugins/agents/assets/opencode-go.svg similarity index 100% rename from shell/plugins/agents/assets/opencode.svg rename to shell/plugins/agents/assets/opencode-go.svg diff --git a/test/shell.d/agent-usage-opencode-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh similarity index 99% rename from test/shell.d/agent-usage-opencode-scanner-test.sh rename to test/shell.d/agent-usage-opencode-go-scanner-test.sh index 403aa417528..7be7b0dd7fc 100755 --- a/test/shell.d/agent-usage-opencode-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -11,13 +11,13 @@ trap 'rm -rf "$TEST_HOME"' EXIT # Without credentials the collector must still print a full, hidden-by-default # record: the update runner writes whatever valid JSON appears on stdout. no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ - OPENCODE_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode") + OPENCODE_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode-go") [[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$no_key") == "opencode:false:OpenCode:Go" ]] || fail "OpenCode collector prints a valid record without credentials" "$no_key" pass "OpenCode collector prints a valid record without credentials" -result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode" "$TEST_HOME" <<'PY' +result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-go" "$TEST_HOME" <<'PY' import importlib.machinery import importlib.util import json @@ -467,7 +467,7 @@ PY ) [[ $(jq -r '.record | {schemaVersion, id, name, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || + '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || fail "OpenCode collector prints the display-ready record contract" "$result" pass "OpenCode collector prints the display-ready record contract" From b2e0a2f3738c4090e07f4fd881841278ccb84004 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sun, 20 Sep 2026 12:52:34 -0500 Subject: [PATCH 13/20] feat(agents): support OpenCode V2 data and credentials --- bin/omarchy-agent-usage-opencode-go | 426 ++++++++++++++++++++-------- 1 file changed, 304 insertions(+), 122 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index 448e13e8c39..ed4e8efcfa8 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -19,6 +19,7 @@ import datetime as dt import fcntl import hashlib import json +import math import os import sqlite3 import sys @@ -79,15 +80,81 @@ def empty_bucket() -> dict[str, int]: # ---------------------------------------------------------------- local scan # -# opencode records one row per message in its database; every assistant -# message that ran on the opencode-go provider carries the model, token -# counts (cache and reasoning split out), and a millisecond timestamp. This -# is the same database and the same shape the claude and codex collectors -# read, just filtered to their own provider ids. +# OpenCode has both the original message table and the newer session_message +# table. Both carry assistant usage, but a migrated message can appear in both, +# so rows are deduplicated by message id after filtering to opencode-go. + + +def opencode_db_path() -> Path | None: + root = data_home() / "opencode" + configured = os.environ.get("OPENCODE_DB", "").strip() + if configured: + configured_path = Path(configured).expanduser() + return (configured_path if configured_path.is_absolute() else root / configured_path).resolve() + v2 = root / "opencode-v2.db" + if v2.is_file(): + return v2 + normal = root / "opencode.db" + return normal if normal.is_file() else None + + +def parse_usage_message( + raw: str, legacy: bool, today: str +) -> dict[str, Any] | None: + try: + entry = json.loads(raw) + except Exception: + return None + if not isinstance(entry, dict): + return None + + if legacy: + if entry.get("role") != "assistant": + return None + provider = entry.get("providerID") + model_id = entry.get("modelID") + else: + model_info = entry.get("model") + if not isinstance(model_info, dict): + return None + provider = model_info.get("providerID") + model_id = model_info.get("id") + if str(provider or "") != "opencode-go": + return None + tokens = entry.get("tokens") + if not isinstance(tokens, dict): + return None + cache = tokens.get("cache") or {} + if not isinstance(cache, dict): + cache = {} + input_tokens = number(tokens.get("input")) + output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) + cache_read = number(cache.get("read")) + cache_write = number(cache.get("write")) + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + return None -def opencode_db_path() -> Path: - return data_home() / "opencode" / "opencode.db" + time_info = entry.get("time") or {} + if not isinstance(time_info, dict): + return None + created = number(time_info.get("created")) + day = ( + dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") + if created > 0 + else today + ) + model = str(model_id or "opencode").rstrip("/").split("/")[-1] + return { + "day": day, + "model": model or "opencode", + "input": input_tokens, + "output": output_tokens, + "cacheRead": cache_read, + "cacheWrite": cache_write, + "total": total, + } def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: @@ -111,69 +178,82 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) except sqlite3.Error: return empty_stats(), False + complete = True try: conn.execute("PRAGMA query_only = ON") - # The DB holds every historical message, JSON included, so filtering in - # SQL keeps tens-of-MB blobs out of Python. The json_extract conditions - # are the authority; the LIKE gates in front are pure acceleration (they - # differ only on duplicate keys or ASCII escapes, vanishingly rare), and - # the CASE guards keep json_extract — which raises on malformed JSON — - # from aborting the scan. - for session_id, raw in conn.execute( - "SELECT session_id, data FROM message" - " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" - " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" - " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'" - " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.providerID') END = 'opencode-go'" - ): - # One malformed row must not abort the scan, so every shape assumption - # lives inside the try. - try: - entry = json.loads(raw) - # Exact match: opencode provider ids are free-form, and a custom - # "opencode-proxy" gateway is not this subscription. - if not isinstance(entry, dict) or entry.get("role") != "assistant": - continue - if str(entry.get("providerID") or "") != "opencode-go": + tables = { + name + for (name,) in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + if not tables.intersection({"message", "session_message"}): + return empty_stats(), False + + seen_message_ids: set[str] = set() + + def scan_table(legacy: bool) -> None: + nonlocal prompts, today_prompt_count, today_token_total + if legacy: + query = ( + "SELECT id, session_id, data FROM message" + " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" + " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'" + " AND CASE WHEN json_valid(data) THEN " + "json_extract(data, '$.providerID') END = 'opencode-go'" + ) + else: + query = ( + "SELECT id, session_id, data FROM session_message" + " WHERE type = 'assistant'" + " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(data) THEN " + "json_extract(data, '$.model.providerID') END = 'opencode-go'" + ) + + for message_id, session_id, raw in conn.execute(query): + try: + usage = parse_usage_message(raw, legacy, today) + except Exception: continue - tokens = entry.get("tokens") or {} - cache = tokens.get("cache") or {} - input_tokens = number(tokens.get("input")) - # opencode keeps thinking tokens out of output; both are generated. - output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) - cache_read = number(cache.get("read")) - cache_write = number(cache.get("write")) - total = input_tokens + output_tokens + cache_read + cache_write - if total <= 0: + if usage is None or str(message_id) in seen_message_ids: continue - - created = number((entry.get("time") or {}).get("created")) - day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today - model = str(entry.get("modelID") or "opencode").rstrip("/").split("/")[-1] - except Exception: - continue - session_key = "opencode:" + str(session_id) - 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 + seen_message_ids.add(str(message_id)) + day = usage["day"] + model = usage["model"] + input_tokens = usage["input"] + output_tokens = usage["output"] + cache_read = usage["cacheRead"] + cache_write = usage["cacheWrite"] + total = usage["total"] + session_key = "opencode:" + str(session_id) + 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 by 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 + + if "message" in tables: + scan_table(True) + if "session_message" in tables: + scan_table(False) except sqlite3.Error: - return empty_stats(), False + complete = False finally: conn.close() @@ -188,7 +268,7 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: "activeDays": len(active_days), "activeDates": sorted(active_days), "modelUsage": usage_by_model, - }, True + }, complete def empty_stats() -> dict[str, Any]: @@ -212,9 +292,9 @@ def empty_stats() -> dict[str, Any]: def scan_cache_paths(db: Path) -> tuple[Path, Path]: - digest = hashlib.sha1(str(db).encode("utf-8")).hexdigest()[:16] + digest = hashlib.sha1(str(db.resolve()).encode("utf-8")).hexdigest()[:16] root = cache_root() - return root / f"opencode-scan-{digest}.json", root / f"opencode-scan-{digest}.lock" + return root / f"opencode-go-scan-{digest}.json", root / f"opencode-go-scan-{digest}.lock" def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: @@ -246,7 +326,7 @@ def write_json(path: Path, payload: dict[str, Any]) -> None: def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None: cached = read_fresh_json(cache_file, max_age_seconds) - if not isinstance(cached, dict) or cached.get("schemaVersion") != 1: + if not isinstance(cached, dict) or cached.get("schemaVersion") != 2: return None # today* fields only mean "today" on the day they were scanned: a cache # from another local date (midnight passed, or the clock moved) is a miss, @@ -262,7 +342,7 @@ def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any def write_cached_stats(cache_file: Path, stats: dict[str, Any]) -> None: - write_json(cache_file, {"schemaVersion": 1, "scanDate": local_date_string(), "stats": stats}) + write_json(cache_file, {"schemaVersion": 2, "scanDate": local_date_string(), "stats": stats}) def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: @@ -271,7 +351,7 @@ def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: try: return _cached_scan(db, max_age_seconds) except Exception as exc: - print(f"omarchy-agent-usage-opencode: cache unavailable ({exc}); scanning directly", file=sys.stderr) + print(f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", file=sys.stderr) stats, _ = scan_opencode_db(db) return stats @@ -316,16 +396,59 @@ def read_auth_json(path: Path) -> str: return str(entry.get("key") or "").strip() -def credentials(auth_path: Path) -> str: +def read_sqlite_credential(db: Path) -> str: + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return "" + try: + rows = conn.execute( + "SELECT value FROM credential WHERE integration_id = ? " + "ORDER BY active DESC, time_updated DESC", + ("opencode-go",), + ) + for (raw,) in rows: + try: + value = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if isinstance(value, dict) and value.get("type") == "key": + key = str(value.get("key") or "").strip() + if key: + return key + except sqlite3.Error: + return "" + finally: + conn.close() + return "" + + +def credentials(auth_path: Path, db: Path | None = None) -> str: # An explicit key beats whatever opencode happens to be signed in with. - return str(os.environ.get("OPENCODE_API_KEY", "")).strip() or read_auth_json(auth_path) + explicit = str(os.environ.get("OPENCODE_API_KEY", "")).strip() + if explicit: + return explicit + if db is not None: + key = read_sqlite_credential(db) + if key: + return key + return read_auth_json(auth_path) class GoUsageError(Exception): - def __init__(self, message: str, auth: bool = False, transport: bool = False): + def __init__( + self, + message: str, + auth: bool = False, + entitlement: bool = False, + transport: bool = False, + retry: bool = False, + ): super().__init__(message) self.auth = auth + self.entitlement = entitlement self.transport = transport + self.retry = retry class GoUsageClient: @@ -348,15 +471,34 @@ class GoUsageClient: try: with urllib.request.urlopen(request, timeout=10) as response: payload = json.loads(response.read().decode("utf-8", errors="replace")) - return payload if isinstance(payload, dict) else {} + if not isinstance(payload, dict) or not payload: + raise GoUsageError( + "OpenCode's usage endpoint returned an empty payload", retry=True + ) + return payload except urllib.error.HTTPError as error: - if error.code in (401, 403): + if error.code == 401: raise GoUsageError("OpenCode Go rejected the API key", auth=True) - raise GoUsageError(f"OpenCode's usage endpoint returned status {error.code}") - except urllib.error.URLError as error: + body = "" + if error.code == 403: + try: + body = error.read().decode("utf-8", errors="replace") + except Exception: + pass + if "EntitlementError" in body: + raise GoUsageError( + "OpenCode Go subscription required", entitlement=True + ) + retry = error.code == 429 or error.code >= 500 + raise GoUsageError( + f"OpenCode's usage endpoint returned status {error.code}", retry=retry + ) + except (urllib.error.URLError, OSError, TimeoutError) as error: raise GoUsageError("Could not reach OpenCode's usage endpoint", transport=True) from error - except (json.JSONDecodeError, TimeoutError) as error: - raise GoUsageError("OpenCode returned an invalid usage response") from error + except json.JSONDecodeError as error: + raise GoUsageError( + "OpenCode returned an invalid usage response", retry=True + ) from error # The live endpoint nests the windows under "usage" with "percent" and @@ -377,17 +519,11 @@ def parse_percent(value: Any) -> float: return float("nan") -def normalize_percent(value: Any, percent_scale: bool) -> float | None: +def normalize_percent(value: Any) -> float | None: n = parse_percent(value) if not (n >= 0): return None - # The endpoint reports percentages (2 means 2%). A payload speaking in - # fractions (0.02) is only assumed when nothing in it reaches 1 — the - # payload-wide scale detection the Claude collector uses — so a lone 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) + return min(1.0, n / 100.0) def reset_from_seconds(value: Any) -> str: @@ -408,12 +544,9 @@ def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: if not isinstance(entry, dict): continue windows.append((label, entry)) - # One payload speaks one convention, so every window settles the scale - # together rather than each assuming its own. - percent_scale = any(parse_percent(entry.get("percent", entry.get("usagePercent"))) >= 1 for _, entry in windows) out: list[dict[str, Any]] = [] for label, entry in windows: - percent = normalize_percent(entry.get("percent", entry.get("usagePercent")), percent_scale) + percent = normalize_percent(entry.get("percent", entry.get("usagePercent"))) if percent is None: continue # A rate-limited window is at 100% and its reset is what matters. @@ -443,6 +576,8 @@ def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + if cached_fetched_at(cached) is None: + return [] entries = cached.get("limits") if not isinstance(entries, list): return [] @@ -450,6 +585,23 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] +def cached_fetched_at(cached: dict[str, Any]) -> float | None: + try: + fetched_at_ms = float(cached.get("fetchedAtMs")) + except (TypeError, ValueError): + return None + if not math.isfinite(fetched_at_ms) or fetched_at_ms <= 0: + return None + if fetched_at_ms > time.time() * 1000: + return None + return fetched_at_ms / 1000 + + +def limits_cache_path(api_key: str) -> Path: + digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16] + return cache_root() / f"opencode-go-limits-{digest}.json" + + def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} @@ -458,30 +610,44 @@ def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: # kept as the answer of record when a later probe fails, for as long as # their windows have not since reset. A forced refresh bypasses the window: # that is what the user asked for. - probe_cache = cache_root() / "opencode-limits.json" - cached = read_fresh_json(probe_cache, float("inf")) or {} - fallback = usable_cached_limits(cached) - if api_key == "": - # Still-open windows outlive the key that fetched them, so show them - # under the auth status rather than an empty section. - result["limits"] = fallback result["usageStatusText"] = "Waiting for auth" return result - fetched_at = number(cached.get("fetchedAtMs")) / 1000 + probe_cache = None + cached: dict[str, Any] = {} + try: + probe_cache = limits_cache_path(api_key) + if not force: + loaded = read_fresh_json(probe_cache, float("inf")) + if isinstance(loaded, dict): + cached = loaded + except Exception: + probe_cache = None + fallback = [] if force else usable_cached_limits(cached) + fetched_at = cached_fetched_at(cached) min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS - if fallback and min_interval > 0 and time.time() - fetched_at < min_interval: + if ( + fallback + and fetched_at is not None + and min_interval > 0 + and time.time() - fetched_at < min_interval + ): result["limits"] = fallback return result try: - limits = parse_usage_payload(GoUsageClient(api_key, base_url).probe()) + payload = GoUsageClient(api_key, base_url).probe() + limits = parse_usage_payload(payload) + if not limits: + raise GoUsageError( + "OpenCode's usage endpoint returned no usage windows", retry=True + ) except GoUsageError as error: # 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. - if error.transport: + if error.transport or error.retry: result["retryAdvised"] = True if error.auth: # A rejected key is an account problem, not a transient outage: keep @@ -494,6 +660,15 @@ def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: + " " + AUTH_HELP ) return result + if error.entitlement: + result["limits"] = fallback + result["usageStatusText"] = "OpenCode Go subscription required" + result["authHelpText"] = ( + "This API key does not include OpenCode Go" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) + return result if fallback: result["limits"] = fallback else: @@ -501,26 +676,30 @@ def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: result["authHelpText"] = str(error) return result - if not limits: - if fallback: - result["limits"] = fallback - else: - result["usageStatusText"] = "OpenCode Go limits unavailable" - result["authHelpText"] = "OpenCode's usage endpoint returned no windows." - return result result["limits"] = limits - write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) + if probe_cache is not None: + try: + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) + except Exception: + pass return result # -------------------------------------------------------------------- record -def scan(auth_path: Path, db: Path, base_url: str = API_BASE_URL, force: bool = False, limits_only: bool = False, cache_seconds: float = 20) -> dict[str, Any]: - scan_age = 0 if force else (900 if limits_only else cache_seconds) - stats = cached_scan(db, scan_age) if db.is_file() else empty_stats() +def scan( + auth_path: Path, + db: Path | None = None, + base_url: str = API_BASE_URL, + force: bool = False, + limits_only: bool = False, +) -> dict[str, Any]: + db = opencode_db_path() if db is None else db + scan_age = 0 if force else (900 if limits_only else 20) + stats = cached_scan(db, scan_age) if db and db.is_file() else empty_stats() - api_key = credentials(auth_path) + api_key = credentials(auth_path, db) limits = collect_limits(api_key, base_url, force) record = { @@ -547,19 +726,22 @@ def scan(auth_path: Path, db: Path, base_url: str = API_BASE_URL, force: bool = def main() -> int: parser = argparse.ArgumentParser(description="Print the OpenCode Go usage record as JSON") - parser.add_argument("--force", action="store_true", help="rescan the database and re-probe limits, ignoring caches") - parser.add_argument("--limits-only", action="store_true", help="reuse any recent database scan; only the limits probe must be fresh") - parser.add_argument("--cache-seconds", type=float, default=20) - parser.add_argument("--api-base-url", default=os.environ.get("OPENCODE_API_BASE_URL", API_BASE_URL)) + parser.add_argument( + "--force", + action="store_true", + help="rescan the database and re-probe limits, ignoring caches", + ) + parser.add_argument( + "--limits-only", + action="store_true", + help="reuse any recent database scan; only the limits probe must be fresh", + ) args = parser.parse_args() record = scan( data_home() / "opencode" / "auth.json", - opencode_db_path(), - base_url=args.api_base_url, force=args.force, limits_only=args.limits_only, - cache_seconds=args.cache_seconds, ) print(json.dumps(record, separators=(",", ":"), sort_keys=True)) return 0 From b72386dd3736aa4bfb0f1a2cf7bda2248ead3680 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sun, 20 Sep 2026 12:52:41 -0500 Subject: [PATCH 14/20] fix(agents): prefer V2 records and active credentials --- bin/omarchy-agent-usage-opencode-go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index ed4e8efcfa8..b4f451162ca 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -64,7 +64,7 @@ def local_date_string() -> str: def number(value: Any) -> int: try: n = float(value or 0) - return round(n) if n == n else 0 + return max(0, round(n)) if n == n else 0 except Exception: return 0 @@ -248,10 +248,10 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: today_token_total += total today_tokens[model] = today_tokens.get(model, 0) + total - if "message" in tables: - scan_table(True) if "session_message" in tables: scan_table(False) + if "message" in tables: + scan_table(True) except sqlite3.Error: complete = False finally: @@ -336,7 +336,8 @@ def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any stats = cached.get("stats") if not isinstance(stats, dict): return None - if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")): + required = ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage") + if not all(key in stats for key in required): return None return stats @@ -351,7 +352,10 @@ def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: try: return _cached_scan(db, max_age_seconds) except Exception as exc: - print(f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", file=sys.stderr) + print( + f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", + file=sys.stderr, + ) stats, _ = scan_opencode_db(db) return stats @@ -403,7 +407,7 @@ def read_sqlite_credential(db: Path) -> str: return "" try: rows = conn.execute( - "SELECT value FROM credential WHERE integration_id = ? " + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 " "ORDER BY active DESC, time_updated DESC", ("opencode-go",), ) @@ -552,7 +556,8 @@ def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: # A rate-limited window is at 100% and its reset is what matters. if str(entry.get("status") or "") == "rate-limited": percent = 1.0 - resets_at = str(entry.get("resetsAt") or "").strip() or reset_from_seconds(entry.get("resetInSec")) + resets_at = str(entry.get("resetsAt") or "").strip() + resets_at = resets_at or reset_from_seconds(entry.get("resetInSec")) out.append({"label": label, "percent": percent, "resetsAt": resets_at}) return out From eeb6f97a8d02810e7a21dd74037560c3309ba886 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sun, 20 Sep 2026 12:52:54 -0500 Subject: [PATCH 15/20] test(agents): focus OpenCode Go compatibility coverage --- .../agent-usage-opencode-go-scanner-test.sh | 1035 ++++++++--------- 1 file changed, 504 insertions(+), 531 deletions(-) diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh index 7be7b0dd7fc..b4f4c533e2b 100755 --- a/test/shell.d/agent-usage-opencode-go-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -8,85 +8,234 @@ require_command python3 TEST_HOME=$(mktemp -d) trap 'rm -rf "$TEST_HOME"' EXIT -# Without credentials the collector must still print a full, hidden-by-default -# record: the update runner writes whatever valid JSON appears on stdout. -no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ - OPENCODE_API_KEY="" "$ROOT/bin/omarchy-agent-usage-opencode-go") +no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" \ + XDG_CACHE_HOME="$TEST_HOME/.cache" OPENCODE_API_KEY="" \ + "$ROOT/bin/omarchy-agent-usage-opencode-go") -[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$no_key") == "opencode:false:OpenCode:Go" ]] || - fail "OpenCode collector prints a valid record without credentials" "$no_key" -pass "OpenCode collector prints a valid record without credentials" +[[ $(jq -r '[.id, .ready, .name, .tierLabel] | join(":")' <<<"$no_key") == \ + "opencode-go:false:OpenCode:Go" ]] || + fail "OpenCode collector prints a valid offline record" "$no_key" +pass "OpenCode collector prints a valid offline record" result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-go" "$TEST_HOME" <<'PY' +import datetime as dt import importlib.machinery import importlib.util +import io import json import os import sqlite3 import sys import time -from datetime import date, datetime, timedelta, timezone from pathlib import Path -collector_path = str(Path(sys.argv[1])) +collector_path = Path(sys.argv[1]) test_home = Path(sys.argv[2]) - os.environ["TZ"] = "UTC" time.tzset() os.environ.pop("OPENCODE_API_KEY", None) -os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "default") +os.environ.pop("OPENCODE_DB", None) -loader = importlib.machinery.SourceFileLoader("opencode_collector", collector_path) +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "default") +loader = importlib.machinery.SourceFileLoader("collector", str(collector_path)) spec = importlib.util.spec_from_loader(loader.name, loader) scanner = importlib.util.module_from_spec(spec) loader.exec_module(scanner) -RealGoUsageClient = scanner.GoUsageClient +real_client = scanner.GoUsageClient data_home = test_home / "data" +os.environ["XDG_DATA_HOME"] = str(data_home) auth_path = data_home / "opencode" / "auth.json" auth_path.parent.mkdir(parents=True, exist_ok=True) -auth_path.write_text(json.dumps({"opencode-go": {"type": "api", "key": "sk_fixture"}})) - -# A fixture opencode database: assistant messages on the opencode-go provider -# carry the model, token split, and a millisecond timestamp; everything else -# (user rows, other providers, zero-token rows) must be skipped. -db = data_home / "opencode" / "opencode.db" -conn = sqlite3.connect(db) -conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") +auth_path.write_text(json.dumps({"opencode-go": {"type": "api", "key": "sk_auth"}})) now_ms = int(time.time() * 1000) + + def day_offset(days): return now_ms - days * 86400 * 1000 -rows = [ - ("m1", "s1", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 1000, "output": 500, "reasoning": 100, "cache": {"read": 200, "write": 50}}, - "time": {"created": day_offset(0)}}), - ("m2", "s2", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 100, "output": 20, "cache": {"read": 0, "write": 0}}, - "time": {"created": day_offset(2)}}), - ("m3", "s3", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 50, "output": 10}, - "time": {"created": day_offset(8)}}), - ("m4", "s4", {"role": "user", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 9999, "output": 9999}, - "time": {"created": day_offset(0)}}), - ("m5", "s5", {"role": "assistant", "providerID": "anthropic", "modelID": "claude-opus-4-8", - "tokens": {"input": 9999, "output": 9999}, - "time": {"created": day_offset(0)}}), - ("m6", "s6", {"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 0, "output": 0}, - "time": {"created": day_offset(0)}}), + + +def v1_message(message_id, session_id, provider, model, role="assistant", **tokens): + data = { + "role": role, + "providerID": provider, + "modelID": model, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": { + "read": tokens.get("read", 0), + "write": tokens.get("write", 0), + }, + }, + "time": {"created": tokens.get("created", now_ms)}, + } + return message_id, session_id, json.dumps(data) + + +def v2_message(message_id, session_id, provider, model, message_type="assistant", **tokens): + data = { + "time": {"created": tokens.get("created", now_ms)}, + "model": {"id": model, "providerID": provider}, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": { + "read": tokens.get("read", 0), + "write": tokens.get("write", 0), + }, + }, + } + return ( + message_id, session_id, message_type, 1, now_ms, now_ms, json.dumps(data) + ) + + +def create_v1_table(conn): + conn.execute( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, " + "data TEXT NOT NULL)" + ) + + +def create_v2_table(conn): + conn.execute( + "CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, " + "type TEXT NOT NULL, seq INTEGER NOT NULL, time_created INTEGER NOT NULL, " + "time_updated INTEGER NOT NULL, data TEXT NOT NULL)" + ) + + +db = data_home / "opencode" / "opencode.db" +conn = sqlite3.connect(db) +create_v1_table(conn) +create_v2_table(conn) +v1_rows = [ + v1_message( + "m1", "s1", "opencode-go", "deepseek-v4-flash", + input=900, output=500, reasoning=100, read=200, write=50, + ), + v1_message( + "m2", "s2", "opencode-go", "deepseek-v4-flash", + input=100, output=20, created=day_offset(2), + ), + v1_message( + "m3", "s3", "opencode-go", "deepseek-v4-flash", + input=50, output=10, created=day_offset(8), + ), + v1_message( + "user", "s4", "opencode-go", "deepseek-v4-flash", + role="user", input=9999, output=9999, + ), + v1_message( + "other", "s5", "anthropic", "claude-opus", input=9999, output=9999, + ), + v1_message("zero", "s6", "opencode-go", "deepseek-v4-flash"), ] -conn.executemany("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", - [(mid, sid, json.dumps(data)) for mid, sid, data in rows]) +conn.executemany( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", v1_rows +) +v2_rows = [ + # This duplicate is deliberately different: V2 must win without double-counting. + v2_message( + "m1", "s1", "opencode-go", "deepseek-v4-flash", + input=1000, output=500, reasoning=100, read=200, write=50, + ), + v2_message( + "v2-1", "s-v2", "opencode-go", "deepseek-v4-pro", + input=7, output=3, reasoning=2, read=1, write=4, + ), + v2_message( + "wrong", "s-wrong", "opencode-proxy", "deepseek-v4-pro", + input=999, output=999, + ), + v2_message( + "v2-user", "s-user", "opencode-go", "deepseek-v4-pro", + message_type="user", input=999, output=999, + ), + v2_message("v2-zero", "s-zero", "opencode-go", "deepseek-v4-pro"), + ("v2-bad", "s-bad", "assistant", 1, now_ms, now_ms, "not json"), +] +conn.executemany( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_rows, +) conn.commit() conn.close() -summary = {} -summary["dbStats"] = scanner.scan_opencode_db(db)[0] +stats, complete = scanner.scan_opencode_db(db) + +v1_db = data_home / "v1-only.db" +conn = sqlite3.connect(v1_db) +create_v1_table(conn) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", v1_rows[0] +) +conn.commit() +conn.close() +v1_stats, v1_complete = scanner.scan_opencode_db(v1_db) + +v2_db = data_home / "v2-only.db" +conn = sqlite3.connect(v2_db) +create_v2_table(conn) +conn.execute( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_message("v2-only", "s-v2-only", "opencode-go", "deepseek-v4-pro", input=11, reasoning=1), +) +conn.commit() +conn.close() +v2_stats, v2_complete = scanner.scan_opencode_db(v2_db) + +selected_db = data_home / "opencode" / "opencode-v2.db" +conn = sqlite3.connect(selected_db) +create_v2_table(conn) +conn.execute( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, " + "label TEXT NOT NULL, value TEXT NOT NULL, active INTEGER, " + "time_updated INTEGER NOT NULL)" +) +conn.execute( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_message( + "selected", "s-selected", "opencode-go", "muse-spark-1.3-contributor", + input=13, output=2, reasoning=1, + ), +) +conn.execute( + "INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)", + ( + "active", "opencode-go", "default", + json.dumps({"type": "key", "key": "sk_v2"}), 1, now_ms, + ), +) +conn.commit() +conn.close() + +tui_db = data_home / "opencode" / "opencode-tui-v2.db" +sqlite3.connect(tui_db).close() +selected = scanner.opencode_db_path() +os.environ["OPENCODE_DB"] = str(v1_db) +override = scanner.opencode_db_path() +os.environ.pop("OPENCODE_DB") +v2_key = scanner.credentials(auth_path, selected_db) +empty_auth = test_home / "empty-auth.json" +empty_auth.write_text("{}") +conn = sqlite3.connect(selected_db) +conn.execute("UPDATE credential SET active = 0") +conn.commit() +conn.close() +inactive_key = scanner.credentials(empty_auth, selected_db) +selected_db.unlink() +fallback = scanner.opencode_db_path() -# The official endpoint nests the windows under "usage"; the merged PR shape -# spelled them "Usage" with resetInSec. Both must parse to the same -# panel contract, and a rate-limited window reports 100%. live_payload = { "usage": { "rolling": {"status": "ok", "percent": 2, "resetsAt": "2026-08-13T00:15:17.598Z"}, @@ -98,535 +247,359 @@ pr_payload = { "rollingUsage": {"status": "ok", "usagePercent": 19, "resetInSec": 7200}, "weeklyUsage": {"status": "ok", "usagePercent": 5, "resetInSec": 3600}, } -summary["liveLimits"] = scanner.parse_usage_payload(live_payload) -summary["prLimits"] = scanner.parse_usage_payload(pr_payload) -summary["rateLimited"] = scanner.parse_usage_payload( - {"usage": {"rolling": {"status": "rate-limited", "percent": 88, "resetsAt": "2026-08-13T00:15:17.598Z"}}} +limits = scanner.parse_usage_payload(live_payload) +old_limits = scanner.parse_usage_payload(pr_payload) +rate_limited = scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "rate-limited", "percent": 88}}} ) -summary["percentScaling"] = [ - scanner.normalize_percent(2, True), scanner.normalize_percent(0.5, True), - scanner.normalize_percent(100, True), scanner.normalize_percent(-1, True), scanner.normalize_percent("x", True), +percentages = [ + scanner.normalize_percent(value) + for value in (0, 0.5, 1, 100, -1, "x") ] +zero_limits = scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "ok", "percent": 0}}} +) -# The scale is settled payload-wide, the way the claude collector does it: any -# value reaching 1 means the payload speaks percentages, so a lone 1 is 1% and -# a 0.5 beside a 2 is 0.5%, while an all-fraction payload keeps its values. -summary["onePercent"] = [w["percent"] for w in scanner.parse_usage_payload( - {"usage": {"rolling": {"status": "ok", "percent": 1, "resetsAt": ""}}})] -summary["mixedScale"] = [w["percent"] for w in scanner.parse_usage_payload( - {"usage": {"rolling": {"status": "ok", "percent": 0.5}, "weekly": {"status": "ok", "percent": 2}}})] -summary["mixedScaleLegacy"] = [w["percent"] for w in scanner.parse_usage_payload( - {"rollingUsage": {"status": "ok", "usagePercent": 0.5}, "weeklyUsage": {"status": "ok", "usagePercent": 2}})] -summary["fractionScale"] = [w["percent"] for w in scanner.parse_usage_payload( - {"usage": {"rolling": {"status": "ok", "percent": 0.25}, "weekly": {"status": "ok", "percent": 0.8}}})] - -# The env var is an explicit override; the opencode auth.json entry is the -# native source. Empty store means no key at all. os.environ["OPENCODE_API_KEY"] = "sk_env" -summary["envKeyWins"] = scanner.credentials(auth_path) == "sk_env" -os.environ.pop("OPENCODE_API_KEY", None) -summary["authJsonKey"] = scanner.credentials(auth_path) -empty_auth = test_home / "empty-auth.json" -empty_auth.write_text("{}") -summary["missingKey"] = scanner.credentials(empty_auth) -summary["noKeyStatus"] = scanner.collect_limits("", "https://example.invalid", False)["usageStatusText"] +env_key = scanner.credentials(auth_path) == "sk_env" +os.environ.pop("OPENCODE_API_KEY") +auth_key = scanner.credentials(auth_path) == "sk_auth" +missing_key = scanner.credentials(empty_auth) == "" -def fresh_cache(name): - return test_home / "cache" / name -class LiveClient: - def __init__(self, api_key, base_url): - pass - def probe(self): - return live_payload +class FakeClient: + mode = "live" + calls = 0 -class RejectingClient: def __init__(self, api_key, base_url): pass - def probe(self): - raise scanner.GoUsageError("OpenCode Go rejected the API key", auth=True) -class OfflineClient: - def __init__(self, api_key, base_url): - pass def probe(self): - raise scanner.GoUsageError("Could not reach OpenCode's usage endpoint", transport=True) - -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("live")) -scanner.GoUsageClient = LiveClient -live_record = scanner.scan(auth_path, db, "https://example.invalid") -summary["record"] = { - "schemaVersion": live_record["schemaVersion"], - "id": live_record["id"], - "name": live_record["name"], - "ready": live_record["ready"], - "hasLocalStats": live_record["hasLocalStats"], - "scope": live_record["scope"], - "hasPromptStats": live_record["hasPromptStats"], - "tierLabel": live_record["tierLabel"], - "limits": live_record["limits"], -} + FakeClient.calls += 1 + if FakeClient.mode == "live": + return live_payload + if FakeClient.mode == "reject": + raise scanner.GoUsageError("rejected", auth=True) + if FakeClient.mode == "entitlement": + raise scanner.GoUsageError("subscription required", entitlement=True) + if FakeClient.mode == "offline": + raise scanner.GoUsageError("offline", transport=True) + return {} -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("reject")) -scanner.GoUsageClient = RejectingClient -rejected = scanner.scan(auth_path, db, "https://example.invalid") -summary["rejected"] = { - "ready": rejected["ready"], - "limits": rejected["limits"], - "usageStatusText": rejected["usageStatusText"], - "authHelpText": rejected["authHelpText"], - "totalPrompts": rejected["totalPrompts"], -} -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("offline")) -scanner.GoUsageClient = OfflineClient +scanner.GoUsageClient = FakeClient +cache_root = test_home / "cache" / "records" +os.environ["XDG_CACHE_HOME"] = str(cache_root) +record = scanner.scan(auth_path, db, "https://example.invalid") +FakeClient.mode = "reject" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "reject") +rejected = scanner.scan(auth_path, db, "https://example.invalid") +FakeClient.mode = "offline" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "offline") offline = scanner.scan(auth_path, db, "https://example.invalid") -summary["offline"] = { - "retryAdvised": offline.get("retryAdvised") is True, - "totalPrompts": offline["totalPrompts"], -} -# A cached window only stands in for a failed probe while it is still open: a -# window whose reset time has passed describes a period that is over and must -# drop out instead of sitting on the panel as a dead meter. -def seed_probe_cache(limits, age_seconds=3600): - cache_dir = Path(os.environ["XDG_CACHE_HOME"]) / "omarchy" / "agent-usage" - cache_dir.mkdir(parents=True, exist_ok=True) - (cache_dir / "opencode-limits.json").write_text(json.dumps( - {"fetchedAtMs": int((time.time() - age_seconds) * 1000), "limits": limits})) - -open_window = {"label": "Weekly (7-day)", "percent": 0.4, - "resetsAt": (datetime.now(timezone.utc) + timedelta(hours=6)).isoformat()} -closed_window = {"label": "Session (5-hour)", "percent": 0.9, - "resetsAt": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()} -# A missing or unreadable timestamp is no reason to throw away a real number. -unstamped_window = {"label": "Monthly (30-day)", "percent": 0.2, "resetsAt": ""} -odd_window = {"label": "Session (5-hour)", "percent": 0.1, "resetsAt": "not-a-date"} - -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("stale")) -seed_probe_cache([closed_window, open_window, unstamped_window, odd_window]) -scanner.GoUsageClient = OfflineClient -stale = scanner.collect_limits("sk_fixture", "https://example.invalid", False) -summary["staleCache"] = { - "labels": [w["label"] for w in stale["limits"]], - "usageStatusText": stale["usageStatusText"], - "retryAdvised": stale.get("retryAdvised") is True, -} -# A fresh-but-expired cache must not satisfy the probe-reuse window either; -# the collector re-probes and shows the live numbers. -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("expired-fresh")) -seed_probe_cache([closed_window], age_seconds=0) -scanner.GoUsageClient = LiveClient -summary["expiredCacheReprobes"] = [w["label"] for w in scanner.collect_limits("sk_fixture", "https://example.invalid", False)["limits"]] +def seed_limits(name, entries, age=3600, fetched_at_ms=None, key="sk_auth"): + os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / name) + cache_file = scanner.limits_cache_path(key) + cache_file.parent.mkdir(parents=True, exist_ok=True) + if fetched_at_ms is None: + fetched_at_ms = int((time.time() - age) * 1000) + cache_file.write_text(json.dumps({"fetchedAtMs": fetched_at_ms, "limits": entries})) -# A fresh cache of open windows satisfies the reuse window without a request; -# a forced refresh probes anyway — that is what the user asked for. -class CountingClient: - calls = 0 - def __init__(self, api_key, base_url): - pass - def probe(self): - CountingClient.calls += 1 - return live_payload - -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("reuse")) -seed_probe_cache([open_window], age_seconds=0) -scanner.GoUsageClient = CountingClient -reused = scanner.collect_limits("sk_fixture", "https://example.invalid", False) -calls_after_reuse = CountingClient.calls -forced = scanner.collect_limits("sk_fixture", "https://example.invalid", True) -summary["probeReuse"] = { - "reusedLabels": [w["label"] for w in reused["limits"]], - "callsAfterReuse": calls_after_reuse, - "forcedLabels": [w["label"] for w in forced["limits"]], - "callsAfterForce": CountingClient.calls, -} -# A payload with no recognizable windows is a failure too: still-open cached -# numbers beat an empty section. -class EmptyClient: - def __init__(self, api_key, base_url): - pass - def probe(self): - return {} - -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("empty-payload")) -seed_probe_cache([closed_window, open_window]) -scanner.GoUsageClient = EmptyClient -summary["emptyPayloadFallback"] = [w["label"] for w in scanner.collect_limits("sk_fixture", "https://example.invalid", False)["limits"]] - -# A rejected key keeps still-open numbers but says the sign-in is bad. -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("rejected-cached")) -seed_probe_cache([closed_window, open_window]) -scanner.GoUsageClient = RejectingClient -rejected_cached = scanner.collect_limits("sk_fixture", "https://example.invalid", False) -summary["rejectedCached"] = { - "labels": [w["label"] for w in rejected_cached["limits"]], - "usageStatusText": rejected_cached["usageStatusText"], - "authHelpText": rejected_cached["authHelpText"], +open_window = { + "label": "Weekly (7-day)", + "percent": 0.4, + "resetsAt": (dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=6)).isoformat(), } - -# So does a missing key: still-open windows outlive the key that fetched them. -os.environ["XDG_CACHE_HOME"] = str(fresh_cache("no-key-cached")) -seed_probe_cache([closed_window, open_window]) -no_key_cached = scanner.collect_limits("", "https://example.invalid", False) -summary["noKeyCached"] = { - "labels": [w["label"] for w in no_key_cached["limits"]], - "usageStatusText": no_key_cached["usageStatusText"], +closed_window = { + "label": "Session (5-hour)", + "percent": 0.9, + "resetsAt": (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1)).isoformat(), } +seed_limits("stale", [closed_window, open_window]) +stale = scanner.collect_limits("sk_auth", "https://example.invalid", False) +seed_limits("reuse", [open_window], age=0) +FakeClient.mode = "offline" +reused = scanner.collect_limits("sk_auth", "https://example.invalid", False) +FakeClient.mode = "live" +before_force = FakeClient.calls +forced = scanner.collect_limits("sk_auth", "https://example.invalid", True) +forced_calls = FakeClient.calls - before_force +seed_limits("expired", [closed_window], age=0) +expired = scanner.collect_limits("sk_auth", "https://example.invalid", False) +seed_limits("rejected-cache", [closed_window, open_window]) +FakeClient.mode = "reject" +rejected_cached = scanner.collect_limits("sk_auth", "https://example.invalid", False) +seed_limits("entitlement", [open_window]) +FakeClient.mode = "entitlement" +entitlement = scanner.collect_limits("sk_auth", "https://example.invalid", False) +seed_limits("other-account", [open_window]) +FakeClient.mode = "offline" +other_account = scanner.collect_limits("sk_other", "https://example.invalid", False) +no_key = scanner.collect_limits("", "https://example.invalid", False) + +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "corrupt-limit") +cache_file = scanner.limits_cache_path("sk_auth") +cache_file.parent.mkdir(parents=True, exist_ok=True) +cache_file.write_text("[]") +FakeClient.mode = "live" +corrupt_limits = scanner.collect_limits("sk_auth", "https://example.invalid", False) + -# The probe itself is exercised against a stubbed transport so the request -# (path, bearer, user agent) and the failure mapping are pinned too — the -# client swaps above never see them. The collector's urlopen is swapped, not -# its GoUsageClient, the way the claude limits test swaps it. -scanner.GoUsageClient = RealGoUsageClient captured = {} + + class FakeResponse: def __init__(self, body): - self._body = body + self.body = body + def read(self): - return self._body + return self.body + def __enter__(self): return self + def __exit__(self, *args): return False -def stub_urlopen(request, timeout=None): + +def fake_urlopen(request, timeout=None): captured["url"] = request.full_url - captured["headers"] = {name.lower(): value for name, value in request.header_items()} - if stub_urlopen.error is not None: - raise stub_urlopen.error - return FakeResponse(stub_urlopen.body) -stub_urlopen.error = None -stub_urlopen.body = b"{}" -scanner.urllib.request.urlopen = stub_urlopen - -probe_client = scanner.GoUsageClient("sk_probe", "https://example.invalid") -stub_urlopen.body = json.dumps(live_payload).encode() -payload = probe_client.probe() -summary["probe"] = { - "payloadIsLive": isinstance(payload, dict) and payload.get("usage") is not None, - "url": captured["url"], - "auth": captured["headers"].get("authorization", ""), - "ua": captured["headers"].get("user-agent", ""), - "accept": captured["headers"].get("accept", ""), + captured["headers"] = { + name.lower(): value for name, value in request.header_items() + } + if fake_urlopen.error: + raise fake_urlopen.error + return FakeResponse(fake_urlopen.body) + + +fake_urlopen.error = None +fake_urlopen.body = json.dumps(live_payload).encode() +scanner.urllib.request.urlopen = fake_urlopen +probe_client = real_client("sk_probe", "https://example.invalid") +probe_client.probe() + + +def probe_error(error): + fake_urlopen.error = error + try: + probe_client.probe() + except scanner.GoUsageError as caught: + return type(caught).__name__ + ":" + str(caught) + return "no-error" + + +errors = { + "401": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 401, "Unauthorized", {}, None + ) + ), + "403": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 403, "Forbidden", {}, + io.BytesIO(b'{"error":{"type":"EntitlementError"}}'), + ) + ), + "500": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 500, "Server Error", {}, None + ) + ), + "offline": probe_error(scanner.urllib.error.URLError(OSError("offline"))), } -def error_text(error): - flags = (":auth" if error.auth else "") + (":transport" if error.transport else "") - return type(error).__name__ + ":" + str(error) + flags - -stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 401, "Unauthorized", {}, None) -try: - probe_client.probe() - summary["probe401"] = "no-error" -except scanner.GoUsageError as error: - summary["probe401"] = error_text(error) -stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 403, "Forbidden", {}, None) -try: - probe_client.probe() - summary["probe403"] = "no-error" -except scanner.GoUsageError as error: - summary["probe403"] = error_text(error) -stub_urlopen.error = scanner.urllib.error.HTTPError("https://example.invalid", 500, "Server Error", {}, None) -try: - probe_client.probe() - summary["probe500"] = "no-error" -except scanner.GoUsageError as error: - summary["probe500"] = error_text(error) -stub_urlopen.error = scanner.urllib.error.URLError(OSError("no route to host")) -try: - probe_client.probe() - summary["probeOffline"] = "no-error" -except scanner.GoUsageError as error: - summary["probeOffline"] = error_text(error) - -# ------------------------------------------------------------- cache layer -# The stats cache is a versioned envelope stamped with the scan date, -# reused by --limits-only, bypassed by --force, and never allowed to kill -# the collector. Each scenario gets its own cache root. def cache_dir(name): - return test_home / "cache-layer" / name + return test_home / "cache" / "stats" / name -def cached_stats_result(cache_home, max_age): - os.environ["XDG_CACHE_HOME"] = str(cache_home) - return scanner.cached_scan(db, max_age) -# First scan writes a versioned envelope stamped with today. -fresh = cache_dir("fresh") -stats1 = cached_stats_result(fresh, 20) -cache_file = sorted((fresh / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] -envelope = json.loads(cache_file.read_text()) -summary["cacheEnvelope"] = { - "scanDateMatches": envelope["scanDate"] == scanner.local_date_string(), - "schemaVersion": envelope["schemaVersion"], - "statsToday": envelope["stats"]["todayTotalTokens"], - "mode": f"{cache_file.stat().st_mode & 0o777:03o}", - "todayTotalTokens": stats1["todayTotalTokens"], -} +def cached(name, age): + os.environ["XDG_CACHE_HOME"] = str(cache_dir(name)) + return scanner.cached_scan(db, age) + -# A corrupt-but-parseable cache (wrong shape) is a miss: rescan + rewrite. +stats_cache = cached("envelope", 0) +cache_file = next((cache_dir("envelope") / "omarchy" / "agent-usage").glob("*.json")) +envelope = json.loads(cache_file.read_text()) cache_file.write_text("[]") -stats2 = cached_stats_result(fresh, 900) -summary["cacheCorrupt"] = { - "todayTotalTokens": stats2["todayTotalTokens"], - "rewritten": json.loads(cache_file.read_text()).get("schemaVersion") == 1, -} +cache_recovered = cached("envelope", 900) -# --limits-only reuses the cache; --force rescans past it. A new message -# lands only in the forced result. +cached("modes", 0) conn = sqlite3.connect(db) -now_ms = int(time.time() * 1000) -conn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( - "m_extra", "s_extra", json.dumps({"role": "assistant", "providerID": "opencode-go", - "modelID": "deepseek-v4-flash", "tokens": {"input": 10, "output": 0}, - "time": {"created": now_ms}}))) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + v1_message("extra", "s-extra", "opencode-go", "deepseek-v4-flash", input=10), +) conn.commit() conn.close() -summary["cacheReuse"] = { - "limitsOnly": cached_stats_result(fresh, 900)["totalPrompts"], - "forced": cached_stats_result(fresh, 0)["totalPrompts"], -} - -# A cache stamped with another date is a miss even under a fresh mtime, and -# so is one whose mtime sits in the future (the clock moved backwards). -other_day = cache_dir("other-day") -cached_stats_result(other_day, 20) -other_cache = sorted((other_day / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] -envelope = json.loads(other_cache.read_text()) -envelope["scanDate"] = "1999-01-01" -other_cache.write_text(json.dumps(envelope)) # fresh mtime, wrong day -summary["cacheOtherDay"] = cached_stats_result(other_day, 900)["totalPrompts"] - -future = cache_dir("future") -cached_stats_result(future, 20) -future_cache = sorted((future / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))[0] -future_time = time.time() + 3600 -os.utime(future_cache, (future_time, future_time)) -summary["cacheFuture"] = cached_stats_result(future, 900)["totalPrompts"] - -# Malformed rows must not abort the scan: json_valid() guards the parse. -malformed_db = test_home / "malformed" / "opencode" / "opencode.db" -malformed_db.parent.mkdir(parents=True, exist_ok=True) -mconn = sqlite3.connect(malformed_db) -mconn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") -good = json.dumps({"role": "assistant", "providerID": "opencode-go", "modelID": "deepseek-v4-flash", - "tokens": {"input": 5, "output": 0}, "time": {"created": now_ms}}, separators=(",", ":")) -mconn.executemany("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", [ - ("mm_1", "s", good), - ("mm_2", "s", good + " trailing-garbage"), - ("mm_3", "s", "this is not json"), -]) -mconn.commit() -mconn.close() -malformed_stats, malformed_complete = scanner.scan_opencode_db(malformed_db) -summary["malformed"] = {"totalPrompts": malformed_stats["totalPrompts"], "complete": malformed_complete} - -# A scan cut short by a database error must not be cached as the whole -# story, or the missing usage would be suppressed for every reader until -# the cache expires. A database without the message table makes the scan -# fail mid-flight. -interrupted = cache_dir("interrupted") -interrupted_db = test_home / "interrupted" / "opencode" / "opencode.db" -interrupted_db.parent.mkdir(parents=True, exist_ok=True) -iconn = sqlite3.connect(interrupted_db) -iconn.execute("CREATE TABLE unrelated (id TEXT PRIMARY KEY)") -iconn.commit() -iconn.close() -os.environ["XDG_CACHE_HOME"] = str(interrupted) -stats_i, complete_i = scanner.scan_opencode_db(interrupted_db) -scanner.cached_scan(interrupted_db, 20) -summary["interrupted"] = { - "todayTotalTokens": stats_i["todayTotalTokens"], - "complete": complete_i, - "cacheWritten": len(list((interrupted / "omarchy" / "agent-usage").glob("opencode-scan-*.json"))) > 0, -} +limits_only = cached("modes", 900) +forced_stats = cached("modes", 0) + +bad_db = test_home / "broken" / "opencode.db" +bad_db.parent.mkdir(parents=True, exist_ok=True) +sqlite3.connect(bad_db).close() +os.environ["XDG_CACHE_HOME"] = str(cache_dir("broken")) +broken_stats, broken_complete = scanner.scan_opencode_db(bad_db) +scanner.cached_scan(bad_db, 20) +broken_cache = list((cache_dir("broken") / "omarchy" / "agent-usage").glob("*.json")) +conn = sqlite3.connect(bad_db) +create_v1_table(conn) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + v1_message("recovered", "s", "opencode-go", "deepseek-v4-flash", input=9), +) +conn.commit() +conn.close() +rescanned = scanner.cached_scan(bad_db, 900) -# Once the database is whole again, the next limits-only run rescans it -# instead of reusing a zero snapshot. -iconn = sqlite3.connect(interrupted_db) -iconn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") -now_ms = int(time.time() * 1000) -iconn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( - "i_1", "s", json.dumps({"role": "assistant", "providerID": "opencode-go", - "modelID": "deepseek-v4-flash", "tokens": {"input": 9, "output": 0}, - "time": {"created": now_ms}}))) -iconn.commit() -iconn.close() -summary["interrupted"]["rescanned"] = scanner.cached_scan(interrupted_db, 900)["todayTotalTokens"] - -# An unwritable cache root must not kill the collector: direct scan instead. -unwritable = test_home / "unwritable" -unwritable.write_text("not a directory") +unwritable = test_home / "not-a-directory" +unwritable.write_text("blocked") os.environ["XDG_CACHE_HOME"] = str(unwritable) -summary["unwritableCache"] = scanner.cached_scan(db, 20)["totalPrompts"] -print(json.dumps(summary, separators=(",", ":"))) +unwritable_stats = scanner.cached_scan(db, 20) + +print(json.dumps({ + "stats": stats, + "complete": complete, + "v1": [v1_stats["totalPrompts"], v1_stats["todayTotalTokens"], v1_complete], + "v2": [v2_stats["totalPrompts"], v2_stats["todayTotalTokens"], v2_complete], + "selection": { + "v2": selected.resolve() == selected_db.resolve(), + "override": override == v1_db.resolve(), + "fallback": fallback.resolve() == db.resolve(), + "ignoresTui": selected != tui_db, + "v2Key": v2_key == "sk_v2", + "inactiveIgnored": inactive_key == "", + }, + "limits": { + "live": limits, + "old": old_limits, + "rate": rate_limited, + "percentages": percentages, + "zero": zero_limits, + }, + "auth": [env_key, auth_key, missing_key], + "record": { + "schemaVersion": record["schemaVersion"], + "id": record["id"], + "name": record["name"], + "ready": record["ready"], + "limits": record["limits"], + }, + "rejected": [rejected["usageStatusText"], rejected["totalPrompts"]], + "offline": [offline.get("retryAdvised"), offline["totalPrompts"]], + "cacheLimits": { + "stale": [w["label"] for w in stale["limits"]], + "reused": [w["label"] for w in reused["limits"]], + "forcedCalls": forced_calls, + "expired": [w["label"] for w in expired["limits"]], + "rejected": [w["label"] for w in rejected_cached["limits"]], + "rejectedStatus": rejected_cached["usageStatusText"], + "entitlement": entitlement["usageStatusText"], + "other": [other_account["limits"], other_account["usageStatusText"]], + "noKey": [no_key["limits"], no_key["usageStatusText"]], + "corrupt": len(corrupt_limits["limits"]), + }, + "probe": { + "url": captured["url"], + "auth": captured["headers"]["authorization"], + "ua": captured["headers"]["user-agent"], + "errors": errors, + }, + "statsCache": { + "date": envelope["scanDate"] == scanner.local_date_string(), + "schema": envelope["schemaVersion"], + "tokens": stats_cache["todayTotalTokens"], + "recovered": cache_recovered["todayTotalTokens"], + "limitsOnly": limits_only["totalPrompts"], + "forced": forced_stats["totalPrompts"], + "broken": [broken_stats["todayTotalTokens"], broken_complete, bool(broken_cache)], + "rescanned": rescanned["todayTotalTokens"], + "unwritable": unwritable_stats["totalPrompts"], + }, +}, separators=(",", ":"))) PY ) -[[ $(jq -r '.record | {schemaVersion, id, name, ready, hasLocalStats, scope, hasPromptStats, tierLabel} | tostring' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Go"}' ]] || - fail "OpenCode collector prints the display-ready record contract" "$result" -pass "OpenCode collector prints the display-ready record contract" - -[[ $(jq -r '.dbStats.todayTotalTokens' <<<"$result") == "1850" ]] || - fail "OpenCode collector totals today's input, output, reasoning, and cache tokens once" "$result" -pass "OpenCode collector totals today's input, output, reasoning, and cache tokens once" - -[[ $(jq -r '.dbStats.todayPrompts' <<<"$result") == "1" ]] || - fail "OpenCode collector counts one prompt for today" "$result" -pass "OpenCode collector counts one prompt for today" - -[[ $(jq -r '.dbStats.totalPrompts' <<<"$result") == "3" ]] || - fail "OpenCode collector keeps user rows, other providers, and zero-token rows out" "$result" -pass "OpenCode collector keeps user rows, other providers, and zero-token rows out" - -[[ $(jq -r '.dbStats.activeDays' <<<"$result") == "3" ]] || - fail "OpenCode collector counts every distinct day with usage" "$result" -pass "OpenCode collector counts every distinct day with usage" - -[[ $(jq -r '.dbStats.recentDays[-1].messageCount' <<<"$result") == "1850" ]] || - fail "OpenCode collector builds the seven-day token series" "$result" -pass "OpenCode collector builds the seven-day token series" - -[[ $(jq -c '.dbStats.modelUsage["deepseek-v4-flash"]' <<<"$result") == \ - '{"inputTokens":1150,"outputTokens":630,"cacheReadInputTokens":200,"cacheCreationInputTokens":50}' ]] || - fail "OpenCode collector keeps cache and reasoning split in model totals" "$result" -pass "OpenCode collector keeps cache and reasoning split in model totals" - -[[ $(jq -c '[.liveLimits[].percent]' <<<"$result") == '[0.02,0.03,0.38]' ]] || - fail "OpenCode collector scales endpoint percentages into 0..1" "$result" -pass "OpenCode collector scales endpoint percentages into 0..1" - -[[ $(jq -r '[.liveLimits[].label] | join(",")' <<<"$result") == "Session (5-hour),Weekly (7-day),Monthly (30-day)" ]] || - fail "OpenCode collector labels the rolling, weekly, and monthly windows" "$result" -pass "OpenCode collector labels the rolling, weekly, and monthly windows" - -[[ $(jq -r '.liveLimits[0].resetsAt' <<<"$result") == "2026-08-13T00:15:17.598Z" ]] || - fail "OpenCode collector passes reset timestamps through" "$result" -pass "OpenCode collector passes reset timestamps through" - -[[ $(jq -r '[.prLimits[].percent] | join(",")' <<<"$result") == "0.19,0.05" ]] || - fail "OpenCode collector parses the merged PR shape" "$result" -pass "OpenCode collector parses the merged PR shape" - -[[ $(jq -r '.prLimits[0].resetsAt | length > 10' <<<"$result") == "true" ]] || - fail "OpenCode collector turns resetInSec into a timestamp" "$result" -pass "OpenCode collector turns resetInSec into a timestamp" - -[[ $(jq -r '.rateLimited[0].percent' <<<"$result") == "1.0" ]] || - fail "OpenCode collector reports a rate-limited window at 100%" "$result" -pass "OpenCode collector reports a rate-limited window at 100%" - -[[ $(jq -c '.percentScaling' <<<"$result") == '[0.02,0.005,1.0,null,null]' ]] || - fail "OpenCode collector scales every value once the payload speaks percentages" "$result" -pass "OpenCode collector scales every value once the payload speaks percentages" - -{ [[ $(jq -c '.onePercent' <<<"$result") == '[0.01]' ]] && [[ $(jq -c '.mixedScale' <<<"$result") == '[0.005,0.02]' ]] && - [[ $(jq -c '.mixedScaleLegacy' <<<"$result") == '[0.005,0.02]' ]] && [[ $(jq -c '.fractionScale' <<<"$result") == '[0.25,0.8]' ]]; } || - fail "OpenCode collector settles the percent scale payload-wide" "$result" -pass "OpenCode collector settles the percent scale payload-wide" - -[[ $(jq -r '(.envKeyWins | tostring) + ":" + .authJsonKey + ":" + .missingKey' <<<"$result") == "true:sk_fixture:" ]] || - fail "OpenCode collector reads the key from the environment or the opencode auth store" "$result" -pass "OpenCode collector reads the key from the environment or the opencode auth store" - -[[ $(jq -r '.noKeyStatus' <<<"$result") == "Waiting for auth" ]] || - fail "OpenCode collector says so when no key exists" "$result" -pass "OpenCode collector says so when no key exists" - -[[ $(jq -c '.rejected | {ready, limits, usageStatusText, totalPrompts}' <<<"$result") == \ - '{"ready":true,"limits":[],"usageStatusText":"Sign-in rejected","totalPrompts":3}' ]] || - fail "OpenCode collector keeps local stats when the endpoint rejects the key" "$result" -pass "OpenCode collector keeps local stats when the endpoint rejects the key" - -[[ $(jq -r '.rejected.authHelpText' <<<"$result") == *"rejected"* ]] || - fail "OpenCode collector explains a rejected key" "$result" -pass "OpenCode collector explains a rejected key" - -[[ $(jq -r '(.offline.retryAdvised | tostring) + ":" + (.offline.totalPrompts | tostring)' <<<"$result") == "true:3" ]] || - fail "OpenCode collector asks for a sooner retry after a transport failure" "$result" -pass "OpenCode collector asks for a sooner retry after a transport failure" - -[[ $(jq -c '.staleCache' <<<"$result") == \ - '{"labels":["Weekly (7-day)","Monthly (30-day)","Session (5-hour)"],"usageStatusText":"","retryAdvised":true}' ]] || - fail "OpenCode collector drops cached windows that have reset when a probe fails" "$result" -pass "OpenCode collector drops cached windows that have reset when a probe fails" - -[[ $(jq -c '.probeReuse' <<<"$result") == \ - '{"reusedLabels":["Weekly (7-day)"],"callsAfterReuse":0,"forcedLabels":["Session (5-hour)","Weekly (7-day)","Monthly (30-day)"],"callsAfterForce":1}' ]] || - fail "OpenCode collector reuses a fresh probe unless the refresh is forced" "$result" -pass "OpenCode collector reuses a fresh probe unless the refresh is forced" - -[[ $(jq -r '.expiredCacheReprobes | join(",")' <<<"$result") == "Session (5-hour),Weekly (7-day),Monthly (30-day)" ]] || - fail "OpenCode collector re-probes instead of reusing a cache of expired windows" "$result" -pass "OpenCode collector re-probes instead of reusing a cache of expired windows" - -[[ $(jq -c '.emptyPayloadFallback' <<<"$result") == '["Weekly (7-day)"]' ]] || - fail "OpenCode collector keeps open windows when the endpoint returns none" "$result" -pass "OpenCode collector keeps open windows when the endpoint returns none" - -[[ $(jq -c '.rejectedCached | {labels, usageStatusText}' <<<"$result") == \ - '{"labels":["Weekly (7-day)"],"usageStatusText":"Sign-in rejected"}' ]] || - fail "OpenCode collector keeps open windows and flags the sign-in when the key is rejected" "$result" -pass "OpenCode collector keeps open windows and flags the sign-in when the key is rejected" - -{ [[ $(jq -r '.rejectedCached.authHelpText' <<<"$result") == *"last known limits"* ]] && - [[ $(jq -r '.rejectedCached.authHelpText' <<<"$result") == *"opencode auth login"* ]]; } || - fail "OpenCode collector says the shown limits are the last known ones and how to fix the key" "$result" -pass "OpenCode collector says the shown limits are the last known ones and how to fix the key" - -[[ $(jq -c '.noKeyCached' <<<"$result") == '{"labels":["Weekly (7-day)"],"usageStatusText":"Waiting for auth"}' ]] || - fail "OpenCode collector keeps open windows while waiting for auth" "$result" -pass "OpenCode collector keeps open windows while waiting for auth" - -[[ $(jq -r '.probe.url' <<<"$result") == "https://example.invalid/zen/go/v1/usage" ]] || - fail "OpenCode probe hits the official usage path" "$result" -pass "OpenCode probe hits the official usage path" - -[[ $(jq -r '.probe.auth + ":" + .probe.accept + ":" + .probe.ua' <<<"$result") == "Bearer sk_probe:application/json:omarchy-agent-usage/1" ]] || - fail "OpenCode probe sends the bearer, accept, and user-agent headers" "$result" -pass "OpenCode probe sends the bearer, accept, and user-agent headers" - -[[ $(jq -r '.probe.payloadIsLive' <<<"$result") == "true" ]] || - fail "OpenCode probe decodes the deployed response shape" "$result" -pass "OpenCode probe decodes the deployed response shape" - -{ [[ $(jq -r '.probe401' <<<"$result") == "GoUsageError:OpenCode Go rejected the API key:auth" ]] && - [[ $(jq -r '.probe403' <<<"$result") == "GoUsageError:OpenCode Go rejected the API key:auth" ]] && - [[ $(jq -r '.probe500' <<<"$result") == "GoUsageError:OpenCode's usage endpoint returned status 500" ]] && - [[ $(jq -r '.probeOffline' <<<"$result") == "GoUsageError:Could not reach OpenCode's usage endpoint:transport" ]]; } || - fail "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" "$result" -pass "OpenCode probe maps rejected keys, status errors, and transport failures distinctly" - -[[ $(jq -c '.cacheEnvelope | {scanDateMatches, schemaVersion, statsToday, mode, todayTotalTokens}' <<<"$result") == \ - '{"scanDateMatches":true,"schemaVersion":1,"statsToday":1850,"mode":"644","todayTotalTokens":1850}' ]] || - fail "OpenCode collector writes a versioned, dated stats cache" "$result" -pass "OpenCode collector writes a versioned, dated stats cache" - -[[ $(jq -c '.cacheCorrupt' <<<"$result") == '{"todayTotalTokens":1850,"rewritten":true}' ]] || - fail "OpenCode collector recovers from a corrupt cache" "$result" -pass "OpenCode collector recovers from a corrupt cache" - -[[ $(jq -c '.cacheReuse' <<<"$result") == '{"limitsOnly":3,"forced":4}' ]] || - fail "OpenCode collector reuses the cache for limits-only and bypasses it on force" "$result" -pass "OpenCode collector reuses the cache for limits-only and bypasses it on force" - -[[ $(jq -r '(.cacheOtherDay | tostring) + ":" + (.cacheFuture | tostring)' <<<"$result") == "4:4" ]] || - fail "OpenCode collector treats dated and future caches as misses" "$result" -pass "OpenCode collector treats dated and future caches as misses" - -[[ $(jq -c '.malformed' <<<"$result") == '{"totalPrompts":1,"complete":true}' ]] || - fail "OpenCode collector counts good rows past malformed ones" "$result" -pass "OpenCode collector counts good rows past malformed ones" - -[[ $(jq -c '.interrupted | {todayTotalTokens, complete, cacheWritten, rescanned}' <<<"$result") == \ - '{"todayTotalTokens":0,"complete":false,"cacheWritten":false,"rescanned":9}' ]] || - fail "OpenCode collector never caches an interrupted scan" "$result" -pass "OpenCode collector never caches an interrupted scan" - -[[ $(jq -r '.unwritableCache' <<<"$result") == "4" ]] || - fail "OpenCode collector degrades to a direct scan when the cache is unwritable" "$result" -pass "OpenCode collector degrades to a direct scan when the cache is unwritable" +[[ $(jq -r '[.stats.todayTotalTokens, .stats.totalPrompts, .complete] | join(":")' \ + <<<"$result") == "1867:4:true" ]] || + fail "OpenCode collector scans V1 and V2 without double-counting" "$result" +pass "OpenCode collector scans V1 and V2 without double-counting" + +[[ $(jq -c '.stats.modelUsage["deepseek-v4-flash"]' <<<"$result") == \ + '{"inputTokens":1150,"outputTokens":630,"cacheReadInputTokens":200,'\ +'"cacheCreationInputTokens":50}' ]] || + fail "OpenCode collector prefers V2 data for duplicate message IDs" "$result" +pass "OpenCode collector prefers V2 data for duplicate message IDs" + +{ [[ $(jq -c '.v1' <<<"$result") == '[1,1750,true]' ]] && + [[ $(jq -c '.v2' <<<"$result") == '[1,12,true]' ]] && + [[ $(jq -r '.stats.activeDays' <<<"$result") == "3" ]]; } || + fail "OpenCode collector supports both SQLite generations and date totals" "$result" +pass "OpenCode collector supports both SQLite generations and date totals" + +[[ $(jq -c '.selection' <<<"$result") == \ + '{"v2":true,"override":true,"fallback":true,"ignoresTui":true,'\ +'"v2Key":true,"inactiveIgnored":true}' ]] || + fail "OpenCode collector selects V2, honors overrides, and ignores inactive credentials" "$result" +pass "OpenCode collector selects V2, honors overrides, and ignores inactive credentials" + +{ jq -e '.limits.live | map(.percent) == [0.02, 0.03, 0.38]' <<<"$result" >/dev/null && + jq -e '.limits.old | map(.percent) == [0.19, 0.05]' <<<"$result" >/dev/null && + jq -e '.limits.percentages == [0, 0.005, 0.01, 1, null, null]' <<<"$result" >/dev/null && + jq -e '[.limits.rate[0].percent, .limits.zero[0].percent] == [1, 0]' \ + <<<"$result" >/dev/null; } || + fail "OpenCode collector parses and normalizes all limit response shapes" "$result" +pass "OpenCode collector parses and normalizes all limit response shapes" + +[[ $(jq -c '.auth' <<<"$result") == '[true,true,true]' ]] || + fail "OpenCode collector applies API key precedence" "$result" +pass "OpenCode collector applies API key precedence" + +{ [[ $(jq -c '.record | {schemaVersion,id,name,ready}' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true}' ]] && + [[ $(jq -c '.rejected' <<<"$result") == '["Sign-in rejected",4]' ]] && + [[ $(jq -c '.offline' <<<"$result") == '[true,4]' ]]; } || + fail "OpenCode collector preserves local stats through auth and transport failures" "$result" +pass "OpenCode collector preserves local stats through auth and transport failures" + +{ [[ $(jq -c '.cacheLimits.stale' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -c '.cacheLimits.reused' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -r '.cacheLimits.forcedCalls' <<<"$result") == "1" ]] && + [[ $(jq -c '.cacheLimits.expired' <<<"$result") == \ + '["Session (5-hour)","Weekly (7-day)","Monthly (30-day)"]' ]] && + [[ $(jq -r '.cacheLimits.rejectedStatus' <<<"$result") == "Sign-in rejected" ]]; } || + fail "OpenCode collector keeps only current, account-specific limit caches" "$result" +pass "OpenCode collector keeps only current, account-specific limit caches" + +{ [[ $(jq -r '.cacheLimits.entitlement' <<<"$result") == \ + "OpenCode Go subscription required" ]] && + [[ $(jq -c '.cacheLimits.other' <<<"$result") == \ + '[[],"OpenCode Go limits unavailable"]' ]] && + [[ $(jq -c '.cacheLimits.noKey' <<<"$result") == '[[],"Waiting for auth"]' ]] && + [[ $(jq -r '.cacheLimits.corrupt' <<<"$result") == "3" ]]; } || + fail "OpenCode collector separates entitlement, account, and corrupt-cache failures" "$result" +pass "OpenCode collector separates entitlement, account, and corrupt-cache failures" + +{ [[ $(jq -r '.probe.url' <<<"$result") == \ + "https://example.invalid/zen/go/v1/usage" ]] && + [[ $(jq -r '.probe.auth + ":" + .probe.ua' <<<"$result") == \ + "Bearer sk_probe:omarchy-agent-usage/1" ]] && + [[ $(jq -r '.probe.errors["401"]' <<<"$result") == *"rejected"* ]] && + [[ $(jq -r '.probe.errors["403"]' <<<"$result") == *"subscription required"* ]] && + [[ $(jq -r '.probe.errors["500"]' <<<"$result") == *"status 500"* ]] && + [[ $(jq -r '.probe.errors.offline' <<<"$result") == *"Could not reach"* ]]; } || + fail "OpenCode probe maps endpoint authentication and transport failures" "$result" +pass "OpenCode probe maps endpoint authentication and transport failures" + +{ [[ $(jq -r '[.statsCache.date, .statsCache.schema, .statsCache.tokens] | join(":")' \ + <<<"$result") == "true:2:1867" ]] && + [[ $(jq -c '.statsCache.recovered' <<<"$result") == "1867" ]] && + [[ $(jq -c '[.statsCache.limitsOnly, .statsCache.forced]' <<<"$result") == "[4,5]" ]] && + [[ $(jq -c '.statsCache.broken' <<<"$result") == '[0,false,false]' ]] && + [[ $(jq -r '.statsCache.rescanned' <<<"$result") == "9" ]] && + [[ $(jq -r '.statsCache.unwritable' <<<"$result") == "5" ]]; } || + fail "OpenCode collector validates and safely bypasses local stats caches" "$result" +pass "OpenCode collector validates and safely bypasses local stats caches" From a31280da4c84a5791c6c33f5f11dd17f80dc38ac Mon Sep 17 00:00:00 2001 From: felixzsh Date: Sun, 20 Sep 2026 12:53:11 -0500 Subject: [PATCH 16/20] docs(agents): document OpenCode Go integration --- manual/17-ai.md | 7 ++++++- shell/plugins/agents/README.md | 30 +++++++++++++++++++++--------- shell/plugins/agents/manifest.json | 3 ++- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index 0d62622d947..ced14eae48a 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -36,7 +36,12 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre ### The agents panel -The top bar grows an agents icon the first time Omarchy finds AI coding usage on the machine (and stays out of the way until then). The panel behind it tracks every subscription in one place: your plan, the percentage used of the 5-hour session and weekly limits (or the remaining prepaid balance), and token usage by day and by model. Claude Code, Codex, and Fireworks are covered out of the box. +The top bar grows an agents icon the first time Omarchy finds AI coding usage on +the machine (and stays out of the way until then). The panel behind it tracks +every subscription in one place: your plan, the percentage used of the 5-hour +session and weekly limits (or the remaining prepaid balance), and token usage by +day and by model. Claude Code, Codex, OpenCode Go, and Fireworks are covered out +of the box. Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings. diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 96ed857b031..17a17e5fdf9 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -55,7 +55,7 @@ light surfaces — and the bar glyph stands in when there is none. | `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | | `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | -| `opencode` | OpenCode's official usage endpoint (rolling 5-hour, weekly, monthly) | opencode's own database, messages on the `opencode-go` provider | +| `opencode-go` | OpenCode Go limits (5-hour, weekly, monthly) | OpenCode SQLite v1/v2 | Claude limits need a signed-in CLI; without credentials the panel says so and falls back to local stats only. A non-default Claude directory is honored via @@ -63,7 +63,8 @@ falls back to local stats only. A non-default Claude directory is honored via `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is -signed in there. OpenCode reads `OPENCODE_API_KEY` first, then the +signed in there. OpenCode Go reads `OPENCODE_API_KEY` first, then the active +`opencode-go` credential in the selected SQLite database, and finally the `opencode-go` entry of that same `auth.json`. ### OpenCode Go limits @@ -71,12 +72,22 @@ signed in there. OpenCode reads `OPENCODE_API_KEY` first, then the The collector asks `GET https://opencode.ai/zen/go/v1/usage` with the Go API key and shows the same rolling / weekly / monthly percentages the opencode.ai dashboard does, so the meters are authoritative account-wide — -even for usage burned on other machines or in other harnesses. Local token -stats are only what opencode recorded on this machine, like the claude and -codex collectors. The endpoint is rate-limited on the server, so probes are -reused for 15 seconds; a failed probe keeps the last successful numbers while -their windows are still open — a rejected key says so in the panel — and one -that cannot reach the server at all asks the panel to retry sooner. +even for usage burned on other machines or in other harnesses. Percentages +from 0 to 100 are normalized to the panel's 0 to 1 scale, including 0%. +The collector accepts both the deployed `usage..percent/resetsAt` +shape and the historical `Usage.usagePercent/resetInSec` shape. +Local token stats select `OPENCODE_DB`, then `opencode-v2.db` when present, and +finally `opencode.db`. Within the selected database they read both SQLite +generations (`message` and `session_message`) and deduplicate migrated rows by +message ID. Future releases that use only `opencode.db` need no migration. + +The limits endpoint is rate-limited, so successful probes are reused for 15 +seconds. The limits cache is keyed by a hash of the selected API key; the local +stats cache follows the selected database path. Neither is +reused without a key or for another account. A failed probe keeps open cached +windows, while transport and transient server failures ask the panel to retry. +Expired, corrupt, and future-dated cache entries are discarded. `--force` +bypasses both caches. ### Fireworks balance @@ -142,7 +153,8 @@ edit `shell.json` directly): omarchy bar set omarchy.agents providers '{ "claude": { "enabled": true }, "codex": { "enabled": false }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-go": { "enabled": true } }' --json ``` diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..2d49abe50b8 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -21,7 +21,8 @@ "providers": { "claude": { "enabled": true }, "codex": { "enabled": true }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-go": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", From 8e82bf3e25864fb57223244e4c1b09c52613263a Mon Sep 17 00:00:00 2001 From: felixzsh Date: Tue, 22 Sep 2026 16:52:17 -0500 Subject: [PATCH 17/20] fix(agents): read OpenCode Go usage through the Console session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode v2 resolves the opencode-go provider through the `opencode` (OpenCode Console) integration and moved usage to /inference/go, so all three stored keys went dead and the panel told a signed-in user to run `opencode auth login`. Try every way in and let the endpoint pick: OPENCODE_API_KEY, the SQLite opencode-go key, the auth.json key, then the Console OAuth session on its own endpoint carrying the workspace header — and only when that id is known, since a wrong one is a hard 403. An expired session now says so instead of claiming the sign-in was rejected. --- bin/omarchy-agent-usage-opencode-go | 247 ++++++++++++------ shell/plugins/agents/README.md | 37 ++- .../agent-usage-opencode-go-scanner-test.sh | 140 ++++++++-- 3 files changed, 314 insertions(+), 110 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index b4f451162ca..ddf821ef372 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -28,13 +28,18 @@ import time import urllib.error import urllib.request from pathlib import Path -from typing import Any +from typing import Any, NamedTuple AGENT_ID = "opencode-go" AGENT_NAME = "OpenCode" AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_API_KEY." API_BASE_URL = "https://opencode.ai" USAGE_PATH = "/zen/go/v1/usage" +CONSOLE_USAGE_PATH = "/inference/go/v1/usage" +CONSOLE_ORG_HEADER = "x-opencode-org-id" +CONSOLE_EXPIRED_HELP = ( + "OpenCode's Console session has expired — run `opencode auth login` to refresh it." +) PROBE_MIN_INTERVAL_SECONDS = 15 @@ -389,6 +394,15 @@ def _cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: # run on other machines or in other harnesses. +class Credential(NamedTuple): + """One way of answering who is asking for OpenCode Go usage.""" + + token: str + path: str + headers: dict[str, str] + expired: bool = False + + def read_auth_json(path: Path) -> str: try: parsed = json.loads(path.read_text()) @@ -427,16 +441,62 @@ def read_sqlite_credential(db: Path) -> str: return "" -def credentials(auth_path: Path, db: Path | None = None) -> str: - # An explicit key beats whatever opencode happens to be signed in with. - explicit = str(os.environ.get("OPENCODE_API_KEY", "")).strip() - if explicit: - return explicit +def read_console_credential(db: Path) -> Credential | None: + """Read the OpenCode Console session opencode v2 stores in SQLite. + + The opencode-go provider resolves its own credentials through the `opencode` + integration, so a Console session answers as an OpenCode Go credential — on + Console's own endpoint, carrying the workspace id Console traffic is scoped + to. A wrong workspace is a hard 403, so the header only goes out when the + id is actually there. + """ + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return None + try: + rows = conn.execute( + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 " + "ORDER BY active DESC, time_updated DESC", + ("opencode",), + ) + for (raw,) in rows: + try: + value = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(value, dict) or value.get("type") != "oauth": + continue + token = str(value.get("access") or "").strip() + if not token: + continue + metadata = value.get("metadata") + org = str(metadata.get("orgID") or "").strip() if isinstance(metadata, dict) else "" + headers = {CONSOLE_ORG_HEADER: org} if org else {} + expired = 0 < number(value.get("expires")) <= time.time() * 1000 + return Credential(token, CONSOLE_USAGE_PATH, headers, expired) + except sqlite3.Error: + return None + finally: + conn.close() + return None + + +def credential_candidates(auth_path: Path, db: Path | None = None) -> list[Credential]: + # An explicit key beats whatever opencode happens to be signed in with, and + # the Console session is the implicit one, so it comes last. Candidates are + # only skipped when they are absent: which one the endpoint accepts is not + # knowable from here, and a stale key in front must not hide a live one. + tokens = [str(os.environ.get("OPENCODE_API_KEY", "")).strip()] if db is not None: - key = read_sqlite_credential(db) - if key: - return key - return read_auth_json(auth_path) + tokens.append(read_sqlite_credential(db)) + tokens.append(read_auth_json(auth_path)) + candidates = [Credential(token, USAGE_PATH, {}) for token in tokens if token] + if db is not None: + console = read_console_credential(db) + if console is not None: + candidates.append(console) + return candidates class GoUsageError(Exception): @@ -456,20 +516,29 @@ class GoUsageError(Exception): class GoUsageClient: - def __init__(self, api_key: str, base_url: str = API_BASE_URL): - self.api_key = api_key + def __init__( + self, + token: str, + base_url: str = API_BASE_URL, + path: str = USAGE_PATH, + headers: dict[str, str] | None = None, + ): + self.token = token self.base_url = base_url.rstrip("/") + self.path = path + self.headers = headers or {} def probe(self) -> dict[str, Any]: # Cloudflare in front of opencode.ai blocks the default Python-urllib # signature (Error 1010); any recognizable agent passes, so identify the # probe rather than borrow a browser's. request = urllib.request.Request( - self.base_url + USAGE_PATH, + self.base_url + self.path, headers={ - "Authorization": "Bearer " + self.api_key, + "Authorization": "Bearer " + self.token, "Accept": "application/json", "User-Agent": "omarchy-agent-usage/1", + **self.headers, }, ) try: @@ -607,86 +676,105 @@ def limits_cache_path(api_key: str) -> Path: return cache_root() / f"opencode-go-limits-{digest}.json" -def collect_limits(api_key: str, base_url: str, force: bool) -> dict[str, Any]: - result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} +def collect_limits( + candidates: list[Credential], base_url: str, force: bool +) -> dict[str, Any]: + result: dict[str, Any] = { + "limits": [], + "usageStatusText": "", + "authHelpText": AUTH_HELP, + } # 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, for as long as # their windows have not since reset. A forced refresh bypasses the window: # that is what the user asked for. - if api_key == "": + if not candidates: result["usageStatusText"] = "Waiting for auth" return result - probe_cache = None - cached: dict[str, Any] = {} - try: - probe_cache = limits_cache_path(api_key) - if not force: - loaded = read_fresh_json(probe_cache, float("inf")) - if isinstance(loaded, dict): - cached = loaded - except Exception: - probe_cache = None - fallback = [] if force else usable_cached_limits(cached) - fetched_at = cached_fetched_at(cached) min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS - if ( - fallback - and fetched_at is not None - and min_interval > 0 - and time.time() - fetched_at < min_interval - ): - result["limits"] = fallback - return result + refused: tuple[GoUsageError, list[dict[str, Any]], bool] | None = None - try: - payload = GoUsageClient(api_key, base_url).probe() - limits = parse_usage_payload(payload) - if not limits: - raise GoUsageError( - "OpenCode's usage endpoint returned no usage windows", retry=True - ) - except GoUsageError as error: - # 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. - if error.transport or error.retry: - result["retryAdvised"] = True - if error.auth: - # A rejected key is an account problem, not a transient outage: keep - # any still-open numbers, but say the sign-in is bad. + for candidate in candidates: + probe_cache = None + cached: dict[str, Any] = {} + try: + probe_cache = limits_cache_path(candidate.token) + if not force: + loaded = read_fresh_json(probe_cache, float("inf")) + if isinstance(loaded, dict): + cached = loaded + except Exception: + probe_cache = None + fallback = [] if force else usable_cached_limits(cached) + fetched_at = cached_fetched_at(cached) + if ( + fallback + and fetched_at is not None + and min_interval > 0 + and time.time() - fetched_at < min_interval + ): result["limits"] = fallback - result["usageStatusText"] = "Sign-in rejected" - result["authHelpText"] = ( - "OpenCode Go rejected the API key" - + (" — showing the last known limits." if fallback else ".") - + " " + AUTH_HELP - ) return result - if error.entitlement: - result["limits"] = fallback - result["usageStatusText"] = "OpenCode Go subscription required" - result["authHelpText"] = ( - "This API key does not include OpenCode Go" - + (" — showing the last known limits." if fallback else ".") - + " " + AUTH_HELP - ) + + try: + payload = GoUsageClient( + candidate.token, base_url, candidate.path, candidate.headers + ).probe() + limits = parse_usage_payload(payload) + if not limits: + raise GoUsageError( + "OpenCode's usage endpoint returned no usage windows", retry=True + ) + except GoUsageError as error: + # A credential the endpoint refuses may just be the wrong one for this + # account, so the next way in still gets its turn. Anything else is not + # about which credential we picked: a transport failure reached no + # server at all, and any real answer — including an error status — is a + # server we should stop pestering. + if error.auth or error.entitlement: + refused = (error, fallback, candidate.expired) + continue + if error.transport or error.retry: + result["retryAdvised"] = True + if fallback: + result["limits"] = fallback + else: + result["usageStatusText"] = "OpenCode Go limits unavailable" + result["authHelpText"] = str(error) return result - if fallback: - result["limits"] = fallback - else: - result["usageStatusText"] = "OpenCode Go limits unavailable" - result["authHelpText"] = str(error) + + result["limits"] = limits + if probe_cache is not None: + try: + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) + except Exception: + pass return result - result["limits"] = limits - if probe_cache is not None: - try: - write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) - except Exception: - pass + # Every way in was refused. A rejected credential is an account problem, not + # a transient outage: keep any still-open numbers, but say what went wrong. + error, fallback, expired = refused + result["limits"] = fallback + if error.entitlement: + result["usageStatusText"] = "OpenCode Go subscription required" + result["authHelpText"] = ( + "This API key does not include OpenCode Go" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) + elif expired: + result["usageStatusText"] = "OpenCode Console session expired" + result["authHelpText"] = CONSOLE_EXPIRED_HELP + else: + result["usageStatusText"] = "Sign-in rejected" + result["authHelpText"] = ( + "OpenCode rejected the stored sign-in" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) return result @@ -704,8 +792,7 @@ def scan( scan_age = 0 if force else (900 if limits_only else 20) stats = cached_scan(db, scan_age) if db and db.is_file() else empty_stats() - api_key = credentials(auth_path, db) - limits = collect_limits(api_key, base_url, force) + limits = collect_limits(credential_candidates(auth_path, db), base_url, force) record = { "schemaVersion": 1, diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 17a17e5fdf9..770c6b6a2f5 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -63,17 +63,22 @@ falls back to local stats only. A non-default Claude directory is honored via `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is -signed in there. OpenCode Go reads `OPENCODE_API_KEY` first, then the active -`opencode-go` credential in the selected SQLite database, and finally the -`opencode-go` entry of that same `auth.json`. +signed in there. OpenCode Go tries each way in: `OPENCODE_API_KEY`, the active +`opencode-go` key in the selected SQLite database, the `opencode-go` entry of +that same `auth.json`, and finally the OpenCode Console session opencode v2 +stores beside them — the credential its own `opencode-go` provider resolves +through. The first one the usage endpoint accepts answers the panel. ### OpenCode Go limits -The collector asks `GET https://opencode.ai/zen/go/v1/usage` with the Go API -key and shows the same rolling / weekly / monthly percentages the -opencode.ai dashboard does, so the meters are authoritative account-wide — -even for usage burned on other machines or in other harnesses. Percentages -from 0 to 100 are normalized to the panel's 0 to 1 scale, including 0%. +An API key asks `GET https://opencode.ai/zen/go/v1/usage`; a Console session +asks `GET https://opencode.ai/inference/go/v1/usage` carrying the workspace's +`x-opencode-org-id` header, and only when that workspace id is known — a wrong +one is refused outright. Either way the answer is the same rolling / weekly / +monthly percentages the opencode.ai dashboard shows, so the meters are +authoritative account-wide — even for usage burned on other machines or in +other harnesses. Percentages from 0 to 100 are normalized to the panel's +0 to 1 scale, including 0%. The collector accepts both the deployed `usage..percent/resetsAt` shape and the historical `Usage.usagePercent/resetInSec` shape. Local token stats select `OPENCODE_DB`, then `opencode-v2.db` when present, and @@ -82,12 +87,16 @@ generations (`message` and `session_message`) and deduplicate migrated rows by message ID. Future releases that use only `opencode.db` need no migration. The limits endpoint is rate-limited, so successful probes are reused for 15 -seconds. The limits cache is keyed by a hash of the selected API key; the local -stats cache follows the selected database path. Neither is -reused without a key or for another account. A failed probe keeps open cached -windows, while transport and transient server failures ask the panel to retry. -Expired, corrupt, and future-dated cache entries are discarded. `--force` -bypasses both caches. +seconds. The limits cache is keyed by a hash of the selected credential; the +local stats cache follows the selected database path. Neither is reused +without a credential or for another account. A credential the endpoint +refuses gets one more way in tried before the panel is told anything, so a +stale key cannot hide a live Console session. A failed probe keeps open +cached windows, while transport and transient server failures ask the panel +to retry; a rejected sign-in, an expired Console session, and a missing Go +subscription each get their own message, because each has a different fix. +Cache entries are dropped when corrupt, future-dated, or past their windows. +`--force` bypasses both caches. ### Fireworks balance diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh index b4f4c533e2b..f37646ea714 100755 --- a/test/shell.d/agent-usage-opencode-go-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -43,6 +43,17 @@ scanner = importlib.util.module_from_spec(spec) loader.exec_module(scanner) real_client = scanner.GoUsageClient + +def first_token(auth_path, db=None): + found = scanner.credential_candidates(auth_path, db) + return found[0].token if found else "" + + +def probe_limits(key, force=False): + candidates = [scanner.Credential(key, scanner.USAGE_PATH, {})] if key else [] + return scanner.collect_limits(candidates, "https://example.invalid", force) + + data_home = test_home / "data" os.environ["XDG_DATA_HOME"] = str(data_home) auth_path = data_home / "opencode" / "auth.json" @@ -225,14 +236,14 @@ selected = scanner.opencode_db_path() os.environ["OPENCODE_DB"] = str(v1_db) override = scanner.opencode_db_path() os.environ.pop("OPENCODE_DB") -v2_key = scanner.credentials(auth_path, selected_db) +v2_key = first_token(auth_path, selected_db) empty_auth = test_home / "empty-auth.json" empty_auth.write_text("{}") conn = sqlite3.connect(selected_db) conn.execute("UPDATE credential SET active = 0") conn.commit() conn.close() -inactive_key = scanner.credentials(empty_auth, selected_db) +inactive_key = first_token(empty_auth, selected_db) selected_db.unlink() fallback = scanner.opencode_db_path() @@ -261,21 +272,28 @@ zero_limits = scanner.parse_usage_payload( ) os.environ["OPENCODE_API_KEY"] = "sk_env" -env_key = scanner.credentials(auth_path) == "sk_env" +env_key = first_token(auth_path) == "sk_env" os.environ.pop("OPENCODE_API_KEY") -auth_key = scanner.credentials(auth_path) == "sk_auth" -missing_key = scanner.credentials(empty_auth) == "" +auth_key = first_token(auth_path) == "sk_auth" +missing_key = first_token(empty_auth) == "" class FakeClient: mode = "live" calls = 0 + reject = set() + last_path = "" + last_headers = {} - def __init__(self, api_key, base_url): - pass + def __init__(self, token, base_url, path, headers=None): + self.token = token + FakeClient.last_path = path + FakeClient.last_headers = headers or {} def probe(self): FakeClient.calls += 1 + if self.token in FakeClient.reject: + raise scanner.GoUsageError("rejected", auth=True) if FakeClient.mode == "live": return live_payload if FakeClient.mode == "reject": @@ -319,33 +337,101 @@ closed_window = { "resetsAt": (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1)).isoformat(), } seed_limits("stale", [closed_window, open_window]) -stale = scanner.collect_limits("sk_auth", "https://example.invalid", False) +stale = probe_limits("sk_auth") seed_limits("reuse", [open_window], age=0) FakeClient.mode = "offline" -reused = scanner.collect_limits("sk_auth", "https://example.invalid", False) +reused = probe_limits("sk_auth") FakeClient.mode = "live" before_force = FakeClient.calls -forced = scanner.collect_limits("sk_auth", "https://example.invalid", True) +forced = probe_limits("sk_auth", True) forced_calls = FakeClient.calls - before_force seed_limits("expired", [closed_window], age=0) -expired = scanner.collect_limits("sk_auth", "https://example.invalid", False) +expired = probe_limits("sk_auth") seed_limits("rejected-cache", [closed_window, open_window]) FakeClient.mode = "reject" -rejected_cached = scanner.collect_limits("sk_auth", "https://example.invalid", False) +rejected_cached = probe_limits("sk_auth") seed_limits("entitlement", [open_window]) FakeClient.mode = "entitlement" -entitlement = scanner.collect_limits("sk_auth", "https://example.invalid", False) +entitlement = probe_limits("sk_auth") seed_limits("other-account", [open_window]) FakeClient.mode = "offline" -other_account = scanner.collect_limits("sk_other", "https://example.invalid", False) -no_key = scanner.collect_limits("", "https://example.invalid", False) +other_account = probe_limits("sk_other") +no_key = probe_limits("") os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "corrupt-limit") cache_file = scanner.limits_cache_path("sk_auth") cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text("[]") FakeClient.mode = "live" -corrupt_limits = scanner.collect_limits("sk_auth", "https://example.invalid", False) +corrupt_limits = probe_limits("sk_auth") + + +# opencode v2 signs in to OpenCode Console, and the opencode-go provider +# resolves its credentials through that same integration: a different endpoint, +# workspace scoped, and last in line behind every explicit key. +def console_db(name, expires_delta, org="wrk_1"): + path = data_home / "opencode" / name + conn = sqlite3.connect(path) + create_v2_table(conn) + conn.execute( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, " + "label TEXT NOT NULL, value TEXT NOT NULL, active INTEGER, " + "time_updated INTEGER NOT NULL)" + ) + metadata = {"email": "a@example.com"} + if org: + metadata["orgID"] = org + conn.execute( + "INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)", + ( + "console", "opencode", "Default", + json.dumps({ + "type": "oauth", "methodID": "device", "access": "sess_console", + "refresh": "r", "expires": now_ms + expires_delta, "metadata": metadata, + }), + 1, now_ms, + ), + ) + conn.commit() + conn.close() + return path + + +live_console_db = console_db("console-live.db", 86400000) +stale_console_db = console_db("console-stale.db", -1000) +no_org_db = console_db("console-no-org.db", 86400000, org="") +console_candidates = scanner.credential_candidates(auth_path, live_console_db) +console = console_candidates[-1] +console_parse = { + "count": len(console_candidates), + "token": console.token, + "path": console.path, + "org": console.headers.get(scanner.CONSOLE_ORG_HEADER), + "expired": console.expired, +} + +# A dead key in front must not hide the live Console session behind it. +FakeClient.mode = "live" +FakeClient.reject = {"sk_auth"} +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "console") +fallthrough = scanner.collect_limits( + console_candidates, "https://example.invalid", False +) +console_path = FakeClient.last_path +console_headers = dict(FakeClient.last_headers) +FakeClient.reject = set() + +# An expired session is a refresh problem, not a rejected sign-in. +FakeClient.mode = "reject" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "console-expired") +expired_session = scanner.collect_limits( + scanner.credential_candidates(empty_auth, stale_console_db), + "https://example.invalid", False, +) +FakeClient.mode = "live" +stale_console = scanner.credential_candidates(empty_auth, stale_console_db)[0] +# A wrong workspace is a hard 403, so the header is dropped rather than guessed. +no_org = scanner.credential_candidates(empty_auth, no_org_db)[0] captured = {} @@ -508,6 +594,15 @@ print(json.dumps({ "ua": captured["headers"]["user-agent"], "errors": errors, }, + "console": { + "parse": console_parse, + "path": console_path, + "orgHeader": console_headers.get(scanner.CONSOLE_ORG_HEADER), + "fallsThrough": [len(fallthrough["limits"]), fallthrough["usageStatusText"]], + "stale": stale_console.expired, + "expiredStatus": expired_session["usageStatusText"], + "droppedOrg": no_org.headers, + }, "statsCache": { "date": envelope["scanDate"] == scanner.local_date_string(), "schema": envelope["schemaVersion"], @@ -594,6 +689,19 @@ pass "OpenCode collector separates entitlement, account, and corrupt-cache failu fail "OpenCode probe maps endpoint authentication and transport failures" "$result" pass "OpenCode probe maps endpoint authentication and transport failures" +{ [[ $(jq -c '.console.parse' <<<"$result") == \ + '{"count":2,"token":"sess_console","path":"/inference/go/v1/usage",'\ +'"org":"wrk_1","expired":false}' ]] && + [[ $(jq -r '.console.path' <<<"$result") == "/inference/go/v1/usage" ]] && + [[ $(jq -r '.console.orgHeader' <<<"$result") == "wrk_1" ]] && + [[ $(jq -c '.console.fallsThrough' <<<"$result") == '[3,""]' ]] && + [[ $(jq -r '.console.stale' <<<"$result") == "true" ]] && + [[ $(jq -r '.console.expiredStatus' <<<"$result") == \ + "OpenCode Console session expired" ]] && + [[ $(jq -c '.console.droppedOrg' <<<"$result") == '{}' ]]; } || + fail "OpenCode collector falls through to the Console session on its own endpoint" "$result" +pass "OpenCode collector falls through to the Console session on its own endpoint" + { [[ $(jq -r '[.statsCache.date, .statsCache.schema, .statsCache.tokens] | join(":")' \ <<<"$result") == "true:2:1867" ]] && [[ $(jq -c '.statsCache.recovered' <<<"$result") == "1867" ]] && From 9b7b2748c7a3c974203b249943d833ce7171e989 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Mon, 28 Sep 2026 19:41:09 -0500 Subject: [PATCH 18/20] fix(agents): read the live OpenCode database and stop retrying answered servers opencode-v2.db only comes from a build whose channel was literally "v2"; current releases write opencode.db, so a stale v2 file shadowed the live database. Prefer OPENCODE_DB, then opencode.db, and keep the v2 name only as a fallback. Also stop marking 429/5xx responses as retryable, which put the panel on a 30-second re-probe loop for a server that had already answered. Document both in the agents README and reflow the manual paragraph to a full line. --- bin/omarchy-agent-usage-opencode-go | 12 ++++--- manual/17-ai.md | 7 +---- shell/plugins/agents/README.md | 12 +++---- .../agent-usage-opencode-go-scanner-test.sh | 31 +++++++++++++++---- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index ddf821ef372..3c169c23f35 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -96,11 +96,13 @@ def opencode_db_path() -> Path | None: if configured: configured_path = Path(configured).expanduser() return (configured_path if configured_path.is_absolute() else root / configured_path).resolve() - v2 = root / "opencode-v2.db" - if v2.is_file(): - return v2 normal = root / "opencode.db" - return normal if normal.is_file() else None + if normal.is_file(): + return normal + # Only a build whose channel was literally "v2" wrote this name; current + # releases use opencode.db, so it remains a fallback for older installs. + legacy = root / "opencode-v2.db" + return legacy if legacy.is_file() else None def parse_usage_message( @@ -737,7 +739,7 @@ def collect_limits( if error.auth or error.entitlement: refused = (error, fallback, candidate.expired) continue - if error.transport or error.retry: + if error.transport: result["retryAdvised"] = True if fallback: result["limits"] = fallback diff --git a/manual/17-ai.md b/manual/17-ai.md index ced14eae48a..5c6436e67ae 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -36,12 +36,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre ### The agents panel -The top bar grows an agents icon the first time Omarchy finds AI coding usage on -the machine (and stays out of the way until then). The panel behind it tracks -every subscription in one place: your plan, the percentage used of the 5-hour -session and weekly limits (or the remaining prepaid balance), and token usage by -day and by model. Claude Code, Codex, OpenCode Go, and Fireworks are covered out -of the box. +The top bar grows an agents icon the first time Omarchy finds AI coding usage on the machine (and stays out of the way until then). The panel behind it tracks every subscription in one place: your plan, the percentage used of the 5-hour session and weekly limits (or the remaining prepaid balance), and token usage by day and by model. Claude Code, Codex, OpenCode Go, and Fireworks are covered out of the box. Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings. diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 770c6b6a2f5..5abf6b4a8d9 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -81,10 +81,10 @@ other harnesses. Percentages from 0 to 100 are normalized to the panel's 0 to 1 scale, including 0%. The collector accepts both the deployed `usage..percent/resetsAt` shape and the historical `Usage.usagePercent/resetInSec` shape. -Local token stats select `OPENCODE_DB`, then `opencode-v2.db` when present, and -finally `opencode.db`. Within the selected database they read both SQLite -generations (`message` and `session_message`) and deduplicate migrated rows by -message ID. Future releases that use only `opencode.db` need no migration. +Local token stats select `OPENCODE_DB`, then `opencode.db`, and +`opencode-v2.db` only when `opencode.db` is absent. Within the selected +database they read both SQLite generations (`message` and `session_message`) +and deduplicate migrated rows by message ID. The limits endpoint is rate-limited, so successful probes are reused for 15 seconds. The limits cache is keyed by a hash of the selected credential; the @@ -92,8 +92,8 @@ local stats cache follows the selected database path. Neither is reused without a credential or for another account. A credential the endpoint refuses gets one more way in tried before the panel is told anything, so a stale key cannot hide a live Console session. A failed probe keeps open -cached windows, while transport and transient server failures ask the panel -to retry; a rejected sign-in, an expired Console session, and a missing Go +cached windows, while transport failures ask the panel to retry; a rejected +sign-in, an expired Console session, and a missing Go subscription each get their own message, because each has a different fix. Cache entries are dropped when corrupt, future-dated, or past their windows. `--force` bypasses both caches. diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh index f37646ea714..0345e3789a0 100755 --- a/test/shell.d/agent-usage-opencode-go-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -9,7 +9,7 @@ TEST_HOME=$(mktemp -d) trap 'rm -rf "$TEST_HOME"' EXIT no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" \ - XDG_CACHE_HOME="$TEST_HOME/.cache" OPENCODE_API_KEY="" \ + XDG_CACHE_HOME="$TEST_HOME/.cache" OPENCODE_API_KEY="" OPENCODE_DB="" \ "$ROOT/bin/omarchy-agent-usage-opencode-go") [[ $(jq -r '[.id, .ready, .name, .tierLabel] | join(":")' <<<"$no_key") == \ @@ -233,6 +233,10 @@ conn.close() tui_db = data_home / "opencode" / "opencode-tui-v2.db" sqlite3.connect(tui_db).close() selected = scanner.opencode_db_path() +held_db = db.with_name("opencode.db.held") +db.rename(held_db) +legacy = scanner.opencode_db_path() +held_db.rename(db) os.environ["OPENCODE_DB"] = str(v1_db) override = scanner.opencode_db_path() os.environ.pop("OPENCODE_DB") @@ -302,6 +306,10 @@ class FakeClient: raise scanner.GoUsageError("subscription required", entitlement=True) if FakeClient.mode == "offline": raise scanner.GoUsageError("offline", transport=True) + if FakeClient.mode == "server-error": + raise scanner.GoUsageError( + "OpenCode's usage endpoint returned status 500", retry=True + ) return {} @@ -356,6 +364,9 @@ entitlement = probe_limits("sk_auth") seed_limits("other-account", [open_window]) FakeClient.mode = "offline" other_account = probe_limits("sk_other") +seed_limits("server-error", [open_window]) +FakeClient.mode = "server-error" +server_error = probe_limits("sk_auth") no_key = probe_limits("") os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "corrupt-limit") @@ -552,7 +563,8 @@ print(json.dumps({ "v1": [v1_stats["totalPrompts"], v1_stats["todayTotalTokens"], v1_complete], "v2": [v2_stats["totalPrompts"], v2_stats["todayTotalTokens"], v2_complete], "selection": { - "v2": selected.resolve() == selected_db.resolve(), + "normal": selected.resolve() == db.resolve(), + "legacy": legacy.resolve() == selected_db.resolve(), "override": override == v1_db.resolve(), "fallback": fallback.resolve() == db.resolve(), "ignoresTui": selected != tui_db, @@ -585,6 +597,8 @@ print(json.dumps({ "rejectedStatus": rejected_cached["usageStatusText"], "entitlement": entitlement["usageStatusText"], "other": [other_account["limits"], other_account["usageStatusText"]], + "serverError": [w["label"] for w in server_error["limits"]], + "serverErrorRetry": server_error.get("retryAdvised"), "noKey": [no_key["limits"], no_key["usageStatusText"]], "corrupt": len(corrupt_limits["limits"]), }, @@ -636,10 +650,10 @@ pass "OpenCode collector prefers V2 data for duplicate message IDs" pass "OpenCode collector supports both SQLite generations and date totals" [[ $(jq -c '.selection' <<<"$result") == \ - '{"v2":true,"override":true,"fallback":true,"ignoresTui":true,'\ -'"v2Key":true,"inactiveIgnored":true}' ]] || - fail "OpenCode collector selects V2, honors overrides, and ignores inactive credentials" "$result" -pass "OpenCode collector selects V2, honors overrides, and ignores inactive credentials" + '{"normal":true,"legacy":true,"override":true,"fallback":true,'\ +'"ignoresTui":true,"v2Key":true,"inactiveIgnored":true}' ]] || + fail "OpenCode collector prefers opencode.db, honors overrides, and ignores inactive credentials" "$result" +pass "OpenCode collector prefers opencode.db, honors overrides, and ignores inactive credentials" { jq -e '.limits.live | map(.percent) == [0.02, 0.03, 0.38]' <<<"$result" >/dev/null && jq -e '.limits.old | map(.percent) == [0.19, 0.05]' <<<"$result" >/dev/null && @@ -678,6 +692,11 @@ pass "OpenCode collector keeps only current, account-specific limit caches" fail "OpenCode collector separates entitlement, account, and corrupt-cache failures" "$result" pass "OpenCode collector separates entitlement, account, and corrupt-cache failures" +{ [[ $(jq -c '.cacheLimits.serverError' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -r '.cacheLimits.serverErrorRetry' <<<"$result") == "null" ]]; } || + fail "OpenCode collector keeps cached windows and withholds retry on a 500" "$result" +pass "OpenCode collector keeps cached windows and withholds retry on a 500" + { [[ $(jq -r '.probe.url' <<<"$result") == \ "https://example.invalid/zen/go/v1/usage" ]] && [[ $(jq -r '.probe.auth + ":" + .probe.ua' <<<"$result") == \ From b811bcb722e1eb0d1a2af112ba22fa29b54a31ea Mon Sep 17 00:00:00 2001 From: felixzsh Date: Mon, 28 Sep 2026 19:42:18 -0500 Subject: [PATCH 19/20] fix(agents): keep OpenCode limits through forced and refused refreshes A forced refresh discarded the cached windows before probing, so an offline or 5xx probe blanked the meters even though the cache still held open windows. Load the cache in every case and keep it as the answer of record; --force still bypasses the 15-second reuse window. When several credentials are refused, the last one overwrote the saved fallback, so an earlier credential with open windows could be hidden. Keep the first refusal that still has windows. --- bin/omarchy-agent-usage-opencode-go | 18 ++++++++++-------- shell/plugins/agents/README.md | 3 ++- .../agent-usage-opencode-go-scanner-test.sh | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index 3c169c23f35..453b043b76a 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -690,8 +690,8 @@ def collect_limits( # 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, for as long as - # their windows have not since reset. A forced refresh bypasses the window: - # that is what the user asked for. + # their windows have not since reset. A forced refresh bypasses the reuse + # window, but a failed probe still falls back to the open cached windows. if not candidates: result["usageStatusText"] = "Waiting for auth" return result @@ -704,13 +704,12 @@ def collect_limits( cached: dict[str, Any] = {} try: probe_cache = limits_cache_path(candidate.token) - if not force: - loaded = read_fresh_json(probe_cache, float("inf")) - if isinstance(loaded, dict): - cached = loaded + loaded = read_fresh_json(probe_cache, float("inf")) + if isinstance(loaded, dict): + cached = loaded except Exception: probe_cache = None - fallback = [] if force else usable_cached_limits(cached) + fallback = usable_cached_limits(cached) fetched_at = cached_fetched_at(cached) if ( fallback @@ -737,7 +736,10 @@ def collect_limits( # server at all, and any real answer — including an error status — is a # server we should stop pestering. if error.auth or error.entitlement: - refused = (error, fallback, candidate.expired) + # Keep the first refusal that still has open windows, so a later + # credential without a cache cannot blank them. + if refused is None or (not refused[1] and fallback): + refused = (error, fallback, candidate.expired) continue if error.transport: result["retryAdvised"] = True diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 5abf6b4a8d9..445340a230d 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -96,7 +96,8 @@ cached windows, while transport failures ask the panel to retry; a rejected sign-in, an expired Console session, and a missing Go subscription each get their own message, because each has a different fix. Cache entries are dropped when corrupt, future-dated, or past their windows. -`--force` bypasses both caches. +`--force` bypasses the stats cache and the 15-second reuse window, but a failed +probe still shows open cached windows. ### Fireworks balance diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh index 0345e3789a0..2f85265238a 100755 --- a/test/shell.d/agent-usage-opencode-go-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -54,6 +54,11 @@ def probe_limits(key, force=False): return scanner.collect_limits(candidates, "https://example.invalid", force) +def probe_candidates(keys, force=False): + candidates = [scanner.Credential(key, scanner.USAGE_PATH, {}) for key in keys] + return scanner.collect_limits(candidates, "https://example.invalid", force) + + data_home = test_home / "data" os.environ["XDG_DATA_HOME"] = str(data_home) auth_path = data_home / "opencode" / "auth.json" @@ -348,16 +353,24 @@ seed_limits("stale", [closed_window, open_window]) stale = probe_limits("sk_auth") seed_limits("reuse", [open_window], age=0) FakeClient.mode = "offline" +before_reuse = FakeClient.calls reused = probe_limits("sk_auth") +reuse_calls = FakeClient.calls - before_reuse FakeClient.mode = "live" before_force = FakeClient.calls forced = probe_limits("sk_auth", True) forced_calls = FakeClient.calls - before_force +seed_limits("forced-fallback", [open_window], age=0) +FakeClient.mode = "offline" +forced_fallback = probe_limits("sk_auth", True) +FakeClient.mode = "live" seed_limits("expired", [closed_window], age=0) expired = probe_limits("sk_auth") seed_limits("rejected-cache", [closed_window, open_window]) FakeClient.mode = "reject" rejected_cached = probe_limits("sk_auth") +seed_limits("refused-first", [open_window], key="sk_first") +refused_first = probe_candidates(["sk_first", "sk_second"]) seed_limits("entitlement", [open_window]) FakeClient.mode = "entitlement" entitlement = probe_limits("sk_auth") @@ -591,10 +604,13 @@ print(json.dumps({ "cacheLimits": { "stale": [w["label"] for w in stale["limits"]], "reused": [w["label"] for w in reused["limits"]], + "reuseCalls": reuse_calls, "forcedCalls": forced_calls, + "forcedFallback": [w["label"] for w in forced_fallback["limits"]], "expired": [w["label"] for w in expired["limits"]], "rejected": [w["label"] for w in rejected_cached["limits"]], "rejectedStatus": rejected_cached["usageStatusText"], + "refusedFirst": [w["label"] for w in refused_first["limits"]], "entitlement": entitlement["usageStatusText"], "other": [other_account["limits"], other_account["usageStatusText"]], "serverError": [w["label"] for w in server_error["limits"]], @@ -676,7 +692,10 @@ pass "OpenCode collector preserves local stats through auth and transport failur { [[ $(jq -c '.cacheLimits.stale' <<<"$result") == '["Weekly (7-day)"]' ]] && [[ $(jq -c '.cacheLimits.reused' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -r '.cacheLimits.reuseCalls' <<<"$result") == "0" ]] && [[ $(jq -r '.cacheLimits.forcedCalls' <<<"$result") == "1" ]] && + [[ $(jq -c '.cacheLimits.forcedFallback' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -c '.cacheLimits.refusedFirst' <<<"$result") == '["Weekly (7-day)"]' ]] && [[ $(jq -c '.cacheLimits.expired' <<<"$result") == \ '["Session (5-hour)","Weekly (7-day)","Monthly (30-day)"]' ]] && [[ $(jq -r '.cacheLimits.rejectedStatus' <<<"$result") == "Sign-in rejected" ]]; } || From 820e62e8535e38350905b67dac3e631430fabe34 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Mon, 28 Sep 2026 19:45:09 -0500 Subject: [PATCH 20/20] fix(agents): count OpenCode V2 forks and compaction correctly V2 stores compaction usage in its own rows and copies a source session's messages into a fork under new IDs, so the collector counted copied history again and dropped compaction tokens. Read compaction rows too and count a fork only from its creation time on, matching OpenCode's own statistics. --- bin/omarchy-agent-usage-opencode-go | 22 ++++++-- shell/plugins/agents/README.md | 5 +- .../agent-usage-opencode-go-scanner-test.sh | 54 ++++++++++++++++--- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go index 453b043b76a..e3c84fba207 100755 --- a/bin/omarchy-agent-usage-opencode-go +++ b/bin/omarchy-agent-usage-opencode-go @@ -211,12 +211,24 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: "json_extract(data, '$.providerID') END = 'opencode-go'" ) else: + # V2 records usage in assistant and compaction rows. Always alias the + # table so the optional fork join can qualify its columns. + source = "session_message m" + fork_filter = "" + if "session_v2" in tables: + # A fork copies the source session's messages under new IDs; OpenCode + # counts only messages created at or after the fork session. + source += " LEFT JOIN session_v2 s ON s.id = m.session_id" + fork_filter = ( + " AND (s.fork_session_id IS NULL OR m.time_created >= s.time_created)" + ) query = ( - "SELECT id, session_id, data FROM session_message" - " WHERE type = 'assistant'" - " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" - " AND CASE WHEN json_valid(data) THEN " - "json_extract(data, '$.model.providerID') END = 'opencode-go'" + "SELECT m.id, m.session_id, m.data FROM " + source + + " WHERE m.type IN ('assistant', 'compaction')" + " AND m.data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(m.data) THEN " + "json_extract(m.data, '$.model.providerID') END = 'opencode-go'" + + fork_filter ) for message_id, session_id, raw in conn.execute(query): diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 445340a230d..381b81668d7 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -83,8 +83,9 @@ The collector accepts both the deployed `usage..percent/resetsAt` shape and the historical `Usage.usagePercent/resetInSec` shape. Local token stats select `OPENCODE_DB`, then `opencode.db`, and `opencode-v2.db` only when `opencode.db` is absent. Within the selected -database they read both SQLite generations (`message` and `session_message`) -and deduplicate migrated rows by message ID. +database they read both SQLite generations (`message` and `session_message`), +count V2 compaction usage, drop the history a fork copied from its source +session, and deduplicate migrated rows by message ID. The limits endpoint is rate-limited, so successful probes are reused for 15 seconds. The limits cache is keyed by a hash of the selected credential; the diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh index 2f85265238a..db9b2641505 100755 --- a/test/shell.d/agent-usage-opencode-go-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -122,6 +122,10 @@ def create_v2_table(conn): "type TEXT NOT NULL, seq INTEGER NOT NULL, time_created INTEGER NOT NULL, " "time_updated INTEGER NOT NULL, data TEXT NOT NULL)" ) + conn.execute( + "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, fork_session_id TEXT, " + "time_created INTEGER NOT NULL)" + ) db = data_home / "opencode" / "opencode.db" @@ -180,6 +184,42 @@ conn.executemany( "VALUES (?, ?, ?, ?, ?, ?, ?)", v2_rows, ) + + +# A fork copies its source session's messages under new IDs; OpenCode counts +# only messages created at or after the fork. A compaction row carries usage of +# its own. Both would otherwise be missed or double-counted. +def v2_row_at(message_id, session_id, message_type, created, **tokens): + data = { + "time": {"created": created}, + "model": {"id": "deepseek-v4-pro", "providerID": "opencode-go"}, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": {"read": 0, "write": 0}, + }, + } + return ( + message_id, session_id, message_type, 1, created, created, json.dumps(data) + ) + + +fork_cutoff = now_ms - 1000 +conn.execute( + "INSERT INTO session_v2 (id, fork_session_id, time_created) VALUES (?, ?, ?)", + ("s-fork", "s-src", fork_cutoff), +) +conn.executemany( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + v2_row_at("fork-copied", "s-fork", "assistant", now_ms - 5000, input=5000), + v2_row_at("fork-fresh", "s-fork", "assistant", now_ms, input=30, output=10), + v2_row_at("comp-1", "s-comp", "compaction", now_ms, input=100, output=20), + ], +) conn.commit() conn.close() @@ -649,7 +689,7 @@ PY ) [[ $(jq -r '[.stats.todayTotalTokens, .stats.totalPrompts, .complete] | join(":")' \ - <<<"$result") == "1867:4:true" ]] || + <<<"$result") == "2027:6:true" ]] || fail "OpenCode collector scans V1 and V2 without double-counting" "$result" pass "OpenCode collector scans V1 and V2 without double-counting" @@ -685,8 +725,8 @@ pass "OpenCode collector applies API key precedence" { [[ $(jq -c '.record | {schemaVersion,id,name,ready}' <<<"$result") == \ '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true}' ]] && - [[ $(jq -c '.rejected' <<<"$result") == '["Sign-in rejected",4]' ]] && - [[ $(jq -c '.offline' <<<"$result") == '[true,4]' ]]; } || + [[ $(jq -c '.rejected' <<<"$result") == '["Sign-in rejected",6]' ]] && + [[ $(jq -c '.offline' <<<"$result") == '[true,6]' ]]; } || fail "OpenCode collector preserves local stats through auth and transport failures" "$result" pass "OpenCode collector preserves local stats through auth and transport failures" @@ -741,11 +781,11 @@ pass "OpenCode probe maps endpoint authentication and transport failures" pass "OpenCode collector falls through to the Console session on its own endpoint" { [[ $(jq -r '[.statsCache.date, .statsCache.schema, .statsCache.tokens] | join(":")' \ - <<<"$result") == "true:2:1867" ]] && - [[ $(jq -c '.statsCache.recovered' <<<"$result") == "1867" ]] && - [[ $(jq -c '[.statsCache.limitsOnly, .statsCache.forced]' <<<"$result") == "[4,5]" ]] && + <<<"$result") == "true:2:2027" ]] && + [[ $(jq -c '.statsCache.recovered' <<<"$result") == "2027" ]] && + [[ $(jq -c '[.statsCache.limitsOnly, .statsCache.forced]' <<<"$result") == "[6,7]" ]] && [[ $(jq -c '.statsCache.broken' <<<"$result") == '[0,false,false]' ]] && [[ $(jq -r '.statsCache.rescanned' <<<"$result") == "9" ]] && - [[ $(jq -r '.statsCache.unwritable' <<<"$result") == "5" ]]; } || + [[ $(jq -r '.statsCache.unwritable' <<<"$result") == "7" ]]; } || fail "OpenCode collector validates and safely bypasses local stats caches" "$result" pass "OpenCode collector validates and safely bypasses local stats caches"