diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen new file mode 100755 index 00000000000..ab89ed48e9d --- /dev/null +++ b/bin/omarchy-agent-usage-opencode-zen @@ -0,0 +1,508 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the OpenCode Zen usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect local OpenCode Zen usage into one display-ready JSON record. + +OpenCode records assistant-message token usage in its local SQLite database. +Zen messages use the exact provider id ``opencode``; OpenCode Go uses +``opencode-go``. No supported API-key-authenticated Zen balance endpoint is +known, so this collector deliberately reports local token statistics only. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +AGENT_ID = "opencode-zen" +AGENT_NAME = "OpenCode" +PROVIDER_ID = "opencode" +CACHE_SCHEMA_VERSION = 2 + + +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") or (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: + parsed = float(value or 0) + return max(0, round(parsed)) if parsed == parsed else 0 + except Exception: + return 0 + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +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": {}, + } + + +def opencode_db_path() -> Path: + return data_home() / "opencode" / "opencode.db" + + +def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: + """Return local Zen stats and whether the database scan completed.""" + 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 + seen_messages: set[str] = set() + + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return empty_stats(), False + + try: + conn.execute("PRAGMA query_only = ON") + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'session_message')" + ) + } + if not tables: + return empty_stats(), False + + queries = [] + if "message" in tables: + queries.append( + "SELECT id, session_id, data FROM message" + " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" + " AND data LIKE '%\"providerID\"%:%\"opencode\"%'" + " 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'" + ) + if "session_message" in tables: + queries.append( + "SELECT id, session_id, data FROM session_message" + " WHERE type = 'assistant'" + " AND data LIKE '%\"providerID\"%:%\"opencode\"%'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.model.providerID') END = 'opencode'" + ) + + for query in queries: + for message_id, session_id, raw in conn.execute(query): + message_key = str(message_id) + if message_key in seen_messages: + continue + seen_messages.add(message_key) + try: + entry = json.loads(raw) + if not isinstance(entry, dict): + continue + if entry.get("role") is not None and entry.get("role") != "assistant": + continue + model_info = entry.get("model") or {} + provider = str(entry.get("providerID") or model_info.get("providerID") or "") + if provider != PROVIDER_ID: + continue + tokens = entry.get("tokens") or {} + cache = tokens.get("cache") or {} + input_tokens = number(tokens.get("input")) + # OpenCode stores generated reasoning separately from generated output. + 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 model_info.get("id") or "opencode").rstrip("/").split("/")[-1] + except Exception: + continue + + session_key = AGENT_ID + ":" + 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: + 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(), False + 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, + }, True + + +# --------------------------------------------------------------- pi and omp +# +# Pi and OMP can consume a Zen subscription. Their compatible JSONL session +# formats carry the provider, model, and token usage on every assistant +# message. + + +def scan_pi_zen_sessions() -> dict[str, Any] | None: + roots = [ + Path.home() / ".pi" / "agent" / "sessions", + Path.home() / ".omp" / "agent" / "sessions", + ] + rg = shutil.which("rg") or "rg" + 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]] = {} + seen: set[str] = set() + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + for root in roots: + if not root.exists(): + continue + harness = "Pi" if "/.pi/" in str(root) else "Omp" if "/.omp/" in str(root) else "" + try: + proc = subprocess.Popen( + [rg, "--json", "-e", r'"provider"\s*:\s*"opencode"', "-e", r'"api"\s*:\s*"opencode"', str(root)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + errors="replace", + ) + except FileNotFoundError: + continue + + assert proc.stdout is not None + for raw in proc.stdout: + try: + event = json.loads(raw) + if event.get("type") != "match": + continue + line = event.get("data", {}).get("lines", {}).get("text", "") + path = event.get("data", {}).get("path", {}).get("text", "pi-session") + entry = json.loads(line) + except Exception: + continue + + if entry.get("type") != "message": + continue + message_key = path + ":" + str(entry.get("id") or "") + if message_key in seen: + continue + seen.add(message_key) + message = entry.get("message") or {} + if message.get("role") != "assistant": + continue + provider = str(message.get("provider") or "") + api = str(message.get("api") or "") + if provider: + if provider != PROVIDER_ID: + continue + elif api != PROVIDER_ID: + continue + + usage = message.get("usage") or {} + if not usage: + continue + total = number(usage.get("totalTokens")) + input_tokens = number(usage.get("input")) + output_tokens = number(usage.get("output")) + cache_read = number(usage.get("cacheRead")) + cache_write = number(usage.get("cacheWrite")) + if total and not (input_tokens or output_tokens or cache_read or cache_write): + input_tokens = total + if not (input_tokens or output_tokens or cache_read or cache_write): + continue + + day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp")) + session_key = path + model = model_name(str(message.get("model"))) + if harness: + model = f"{harness}-{model}" + 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: + 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 + + try: + proc.wait(timeout=1) + except Exception: + proc.kill() + + if prompts == 0: + return None + return { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + "activeDates": sorted(active_days), + } + + +def merge_stats(base: dict[str, Any], extra: dict[str, Any] | None) -> dict[str, Any]: + if extra is None: + return base + merged = dict(base) + for key in ("todayPrompts", "todaySessions", "todayTotalTokens", "totalPrompts", "totalSessions"): + merged[key] = number(base.get(key)) + number(extra.get(key)) + + combined = dict(base.get("todayTokensByModel") or {}) + for model, count in (extra.get("todayTokensByModel") or {}).items(): + combined[model] = number(combined.get(model)) + number(count) + merged["todayTokensByModel"] = combined + + usage = {model: dict(bucket) for model, bucket in (base.get("modelUsage") or {}).items()} + for model, bucket in (extra.get("modelUsage") or {}).items(): + target = usage.setdefault(model, empty_bucket()) + for field, count in (bucket or {}).items(): + target[field] = number(target.get(field)) + number(count) + merged["modelUsage"] = usage + + by_date: dict[str, int] = {} + for source in (base.get("recentDays") or [], extra.get("recentDays") or []): + for day in source: + date = str((day or {}).get("date") or "") + if date: + by_date[date] = by_date.get(date, 0) + number((day or {}).get("messageCount")) + merged["recentDays"] = [{"date": date, "messageCount": by_date[date]} for date in sorted(by_date)] + + dates = set(base.get("activeDates") or []) | set(extra.get("activeDates") or []) + merged["activeDates"] = sorted(dates) + merged["activeDays"] = max(len(dates), number(base.get("activeDays")), number(extra.get("activeDays"))) + return merged + + +def model_name(raw: Any) -> str: + value = str(raw or "opencode") + return value if value else "opencode" + + +def local_date_from_timestamp(value: Any) -> str: + if value is None: + return local_date_string() + if isinstance(value, (int, float)): + try: + seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) + return date_string(dt.datetime.fromtimestamp(seconds).date()) + except Exception: + return local_date_string() + raw = str(value).strip() + if not raw: + return local_date_string() + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return date_string(parsed.date()) + except Exception: + return local_date_string() + + +def 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-zen-scan-{digest}.json", root / f"opencode-zen-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: + age = time.time() - path.stat().st_mtime + if 0 <= age <= max_age_seconds: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + 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 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 cached or cached.get("schemaVersion") != CACHE_SCHEMA_VERSION or cached.get("scanDate") != local_date_string(): + return None + stats = cached.get("stats") + if not isinstance(stats, dict): + return None + required = ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage") + return stats if all(key in stats for key in required) else None + + +def write_cached_stats(cache_file: Path, stats: dict[str, Any]) -> None: + write_json(cache_file, {"schemaVersion": CACHE_SCHEMA_VERSION, "scanDate": local_date_string(), "stats": stats}) + + +def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: + try: + return _cached_scan(db, max_age_seconds) + except Exception as error: + print(f"omarchy-agent-usage-opencode-zen: cache unavailable ({error}); 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_cached_stats(cache_file, max_age_seconds) + if cached is not None: + return cached + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_cached_stats(cache_file, max_age_seconds) + if cached is not None: + return cached + stats, complete = scan_opencode_db(db) + if complete: + write_cached_stats(cache_file, stats) + return stats + + +def scan(db: Path, 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) + db_stats = cached_scan(db, scan_age) if db.is_file() else empty_stats() + pi_stats = scan_pi_zen_sessions() + stats = merge_stats(db_stats, pi_stats) + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), + "ready": number(stats.get("totalPrompts")) > 0, + "hasLocalStats": True, + "scope": "device", + "hasPromptStats": True, + "tierLabel": "Zen · Pay as you go", + "usageStatusText": "", + "authHelpText": "", + "limits": [], + } + record.update(stats) + return record + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print the OpenCode Zen usage record as JSON") + parser.add_argument("--force", action="store_true", help="rescan the database, ignoring cached local stats") + parser.add_argument("--limits-only", action="store_true", help="reuse any recent local database scan") + parser.add_argument("--cache-seconds", type=float, default=20) + args = parser.parse_args() + + record = scan(opencode_db_path(), 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()) diff --git a/manual/17-ai.md b/manual/17-ai.md index a0fc17f4485..44f8aef8b17 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,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, 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, Fireworks, and OpenCode Zen usage (including Pi and OMP sessions on Zen) 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 51ad4e43c2d..933297152cb 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-zen` | None; Zen has no supported API-key-authenticated balance endpoint | OpenCode's local database (restricted to the `opencode` provider) plus Pi and OMP sessions on a Zen 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 @@ -128,7 +129,8 @@ edit `shell.json` directly): omarchy bar set omarchy.agents providers '{ "claude": { "enabled": true }, "codex": { "enabled": false }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-zen": { "enabled": true } }' --json ``` diff --git a/shell/plugins/agents/assets/opencode-zen-light.svg b/shell/plugins/agents/assets/opencode-zen-light.svg new file mode 100644 index 00000000000..107c1e053a1 --- /dev/null +++ b/shell/plugins/agents/assets/opencode-zen-light.svg @@ -0,0 +1 @@ +OpenCode Zen diff --git a/shell/plugins/agents/assets/opencode-zen.svg b/shell/plugins/agents/assets/opencode-zen.svg new file mode 100644 index 00000000000..083f0c0c5ca --- /dev/null +++ b/shell/plugins/agents/assets/opencode-zen.svg @@ -0,0 +1 @@ +OpenCode Zen diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..88b42514dc1 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -5,7 +5,7 @@ "version": "1.0.0", "author": "Omarchy", "license": "MIT", - "description": "Claude Code, Codex, and Fireworks usage, limits, and pace in a native Omarchy bar panel.", + "description": "Claude Code, Codex, Fireworks, and OpenCode Zen (including Pi/OMP) usage, limits, and pace in a native Omarchy bar panel.", "kinds": ["bar-widget"], "activation": "on-demand", "entryPoints": { @@ -21,7 +21,8 @@ "providers": { "claude": { "enabled": true }, "codex": { "enabled": true }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-zen": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", diff --git a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh new file mode 100755 index 00000000000..7b59102c97c --- /dev/null +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -0,0 +1,345 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +empty=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME="$TEST_HOME/.cache" \ + "$ROOT/bin/omarchy-agent-usage-opencode-zen") + +[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$empty") == \ + "opencode-zen:false:OpenCode:Zen · Pay as you go" ]] || + fail "OpenCode Zen collector prints a valid record without a database" "$empty" +pass "OpenCode Zen collector prints a valid record without a database" + +result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-zen" "$TEST_HOME" <<'PY' +import importlib.machinery +import importlib.util +import json +import os +import sqlite3 +import sys +import time +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["XDG_CACHE_HOME"] = str(test_home / "cache") + +loader = importlib.machinery.SourceFileLoader("opencode_zen_collector", collector_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +scanner = importlib.util.module_from_spec(spec) +loader.exec_module(scanner) + +db = test_home / "data" / "opencode" / "opencode.db" +db.parent.mkdir(parents=True, exist_ok=True) +conn = sqlite3.connect(db) +conn.execute("PRAGMA journal_mode=WAL") +conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)") +conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL)") +now_ms = int(time.time() * 1000) + +def day_offset(days): + return now_ms - days * 86400 * 1000 + +rows = [ + ("zen-today", "session-one", { + "role": "assistant", "providerID": "opencode", "modelID": "kimi-k2.6", + "tokens": {"input": 607, "output": 36, "reasoning": 55, "total": 7386, + "cache": {"read": 6688, "write": 0}}, + "time": {"created": day_offset(0)}, + }), + ("zen-prior", "session-two", { + "role": "assistant", "providerID": "opencode", "modelID": "glm-4.5", + "tokens": {"input": 100, "output": 20, "reasoning": 5, + "cache": {"read": 10, "write": 2}}, + "time": {"created": day_offset(2)}, + }), + ("zen-old", "session-three", { + "role": "assistant", "providerID": "opencode", "modelID": "kimi-k2.6", + "tokens": {"input": 50, "output": 10}, "time": {"created": day_offset(8)}, + }), + ("go", "session-go", { + "role": "assistant", "providerID": "opencode-go", "modelID": "kimi-k2.6", + "tokens": {"input": 9000, "output": 9000}, "time": {"created": day_offset(0)}, + }), + ("openai", "session-openai", { + "role": "assistant", "providerID": "openai", "modelID": "gpt-5", + "tokens": {"input": 9000, "output": 9000}, "time": {"created": day_offset(0)}, + }), + ("user", "session-one", { + "role": "user", "providerID": "opencode", "modelID": "kimi-k2.6", + "tokens": {"input": 9000, "output": 9000}, "time": {"created": day_offset(0)}, + }), + ("zero", "session-zero", { + "role": "assistant", "providerID": "opencode", "modelID": "kimi-k2.6", + "tokens": {"input": 0, "output": 0}, "time": {"created": day_offset(0)}, + }), +] +conn.executemany( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + [(message_id, session_id, json.dumps(data)) for message_id, session_id, data in rows], +) +conn.executemany("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", [ + ("malformed-one", "session-bad", "not json"), + ("malformed-two", "session-bad", '{"role":"assistant","providerID":"opencode"'), +]) +v2_rows = [ + ("zen-today", "session-one", "assistant", { + "model": {"providerID": "opencode", "id": "kimi-k2.6"}, + "tokens": {"input": 607, "output": 36, "reasoning": 55, + "cache": {"read": 6688, "write": 0}}, + "time": {"created": day_offset(0)}, + }), + ("v2-zen", "session-four", "assistant", { + "model": {"providerID": "opencode", "id": "gpt-5.6-terra"}, + "tokens": {"input": 300, "output": 40, "reasoning": 10, + "cache": {"read": 20, "write": 5}}, + "time": {"created": day_offset(0)}, + }), + ("v2-go", "session-v2-go", "assistant", { + "model": {"providerID": "opencode-go", "id": "gpt-5.6-luna"}, + "tokens": {"input": 9000, "output": 9000}, + "time": {"created": day_offset(0)}, + }), + ("v2-openai", "session-v2-openai", "assistant", { + "model": {"providerID": "openai", "id": "gpt-5.6-sol"}, + "tokens": {"input": 9000, "output": 9000}, + "time": {"created": day_offset(0)}, + }), + ("v2-user", "session-v2-user", "user", { + "model": {"providerID": "opencode", "id": "gpt-5.6-terra"}, + "tokens": {"input": 9000, "output": 9000}, + "time": {"created": day_offset(0)}, + }), +] +conn.executemany( + "INSERT INTO session_message (id, session_id, type, time_created, data) VALUES (?, ?, ?, ?, ?)", + [(message_id, session_id, message_type, data["time"]["created"], json.dumps(data)) + for message_id, session_id, message_type, data in v2_rows], +) +conn.execute( + "INSERT INTO session_message (id, session_id, type, time_created, data) VALUES (?, ?, ?, ?, ?)", + ("v2-malformed", "session-v2-bad", "assistant", now_ms, "not json"), +) +conn.commit() + +# Keep a writer transaction open while the collector reads the committed WAL. +conn.execute("BEGIN IMMEDIATE") +conn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( + "uncommitted", "session-pending", json.dumps({ + "role": "assistant", "providerID": "opencode", "modelID": "pending", + "tokens": {"input": 999, "output": 999}, "time": {"created": now_ms}, + }), +)) +stats, complete = scanner.scan_opencode_db(db) +conn.rollback() +conn.close() + +record = scanner.scan(db, force=True) + +# First scan writes a dated cache. A later row is hidden by limits-only and +# revealed by force, matching the shared collector refresh contract. +scanner.cached_scan(db, 0) +cache_file = sorted((test_home / "cache" / "omarchy" / "agent-usage").glob("opencode-zen-scan-*.json"))[0] +cache_payload = json.loads(cache_file.read_text()) +conn = sqlite3.connect(db) +conn.execute("INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", ( + "later", "session-later", json.dumps({ + "role": "assistant", "providerID": "opencode", "modelID": "kimi-k2.6", + "tokens": {"input": 10, "output": 0}, "time": {"created": now_ms}, + }), +)) +conn.commit() +conn.close() +cached_prompts = scanner.cached_scan(db, 900)["totalPrompts"] +forced_prompts = scanner.cached_scan(db, 0)["totalPrompts"] + +# New OpenCode databases can expose session_message without the legacy message +# table. That remains a complete scan rather than an interrupted one. +v2_only_db = test_home / "v2-only" / "opencode.db" +v2_only_db.parent.mkdir(parents=True, exist_ok=True) +v2_only = sqlite3.connect(v2_only_db) +v2_only.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL)") +v2_only.execute( + "INSERT INTO session_message (id, session_id, type, time_created, data) VALUES (?, ?, ?, ?, ?)", + ("v2-only", "session-v2-only", "assistant", now_ms, json.dumps({ + "model": {"providerID": "opencode", "id": "kimi-k2.6"}, + "tokens": {"input": 12, "output": 3}, "time": {"created": now_ms}, + })), +) +v2_only.commit() +v2_only.close() +v2_only_stats, v2_only_complete = scanner.scan_opencode_db(v2_only_db) + +# A database with the wrong schema is an incomplete scan and must not be cached. +broken_db = test_home / "broken" / "opencode.db" +broken_db.parent.mkdir(parents=True, exist_ok=True) +broken = sqlite3.connect(broken_db) +broken.execute("CREATE TABLE unrelated (id TEXT PRIMARY KEY)") +broken.commit() +broken.close() +os.environ["XDG_CACHE_HOME"] = str(test_home / "broken-cache") +broken_stats, broken_complete = scanner.scan_opencode_db(broken_db) +scanner.cached_scan(broken_db, 20) +broken_cache_files = list((test_home / "broken-cache" / "omarchy" / "agent-usage").glob("opencode-zen-scan-*.json")) + +print(json.dumps({ + "stats": stats, + "complete": complete, + "record": {key: record[key] for key in ( + "schemaVersion", "id", "name", "ready", "hasLocalStats", "scope", + "hasPromptStats", "tierLabel", "usageStatusText", "authHelpText", "limits" + )}, + "cache": { + "schemaVersion": cache_payload["schemaVersion"], + "scanDate": cache_payload["scanDate"], + "mode": f"{cache_file.stat().st_mode & 0o777:03o}", + "cachedPrompts": cached_prompts, + "forcedPrompts": forced_prompts, + }, + "broken": { + "totalPrompts": broken_stats["totalPrompts"], + "complete": broken_complete, + "cacheWritten": bool(broken_cache_files), + }, + "v2Only": { + "totalPrompts": v2_only_stats["totalPrompts"], + "complete": v2_only_complete, + }, +}, separators=(",", ":"))) +PY +) + +[[ $(jq -c '.record' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-zen","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Zen · Pay as you go","usageStatusText":"","authHelpText":"","limits":[]}' ]] || + fail "OpenCode Zen collector follows the display record contract" "$result" +pass "OpenCode Zen collector follows the display record contract" + +[[ $(jq -r '.complete' <<<"$result") == "true" ]] || + fail "OpenCode Zen collector reads a WAL database while a writer is active" "$result" +pass "OpenCode Zen collector reads a WAL database while a writer is active" + +[[ $(jq -r '.stats.todayTotalTokens' <<<"$result") == "7761" ]] || + fail "OpenCode Zen collector totals verified input, output, reasoning, and cache tokens" "$result" +pass "OpenCode Zen collector totals verified input, output, reasoning, and cache tokens" + +[[ $(jq -r '.stats.totalPrompts' <<<"$result") == "4" ]] || + fail "OpenCode Zen collector excludes Go, other providers, user rows, zero rows, and malformed JSON" "$result" +pass "OpenCode Zen collector excludes Go, other providers, user rows, zero rows, and malformed JSON" + +[[ $(jq -r '.stats.totalSessions' <<<"$result") == "4" ]] || + fail "OpenCode Zen collector deduplicates message ids across database generations" "$result" +pass "OpenCode Zen collector deduplicates message ids across database generations" + +[[ $(jq -r '.stats.activeDays' <<<"$result") == "3" ]] || + fail "OpenCode Zen collector counts all active dates" "$result" +pass "OpenCode Zen collector counts all active dates" + +[[ $(jq -r '.stats.recentDays[-1].messageCount' <<<"$result") == "7761" ]] || + fail "OpenCode Zen collector builds the seven-day token series" "$result" +pass "OpenCode Zen collector builds the seven-day token series" + +[[ $(jq -c '.stats.modelUsage["kimi-k2.6"]' <<<"$result") == \ + '{"inputTokens":657,"outputTokens":101,"cacheReadInputTokens":6688,"cacheCreationInputTokens":0}' ]] || + fail "OpenCode Zen collector preserves token splits and folds reasoning into output" "$result" +pass "OpenCode Zen collector preserves token splits and folds reasoning into output" + +[[ $(jq -c '.stats.modelUsage["gpt-5.6-terra"]' <<<"$result") == \ + '{"inputTokens":300,"outputTokens":50,"cacheReadInputTokens":20,"cacheCreationInputTokens":5}' ]] || + fail "OpenCode Zen collector reads current session_message records" "$result" +pass "OpenCode Zen collector reads current session_message records" + +[[ $(jq -c '.cache | {schemaVersion,mode,cachedPrompts,forcedPrompts}' <<<"$result") == \ + '{"schemaVersion":2,"mode":"644","cachedPrompts":4,"forcedPrompts":5}' ]] || + fail "OpenCode Zen collector caches local stats and honors forced refresh" "$result" +pass "OpenCode Zen collector caches local stats and honors forced refresh" + +[[ $(jq -r '.cache.scanDate | length == 10' <<<"$result") == "true" ]] || + fail "OpenCode Zen collector dates its cache" "$result" +pass "OpenCode Zen collector dates its cache" + +[[ $(jq -c '.broken' <<<"$result") == \ + '{"totalPrompts":0,"complete":false,"cacheWritten":false}' ]] || + fail "OpenCode Zen collector never caches an interrupted scan" "$result" +pass "OpenCode Zen collector never caches an interrupted scan" + +[[ $(jq -c '.v2Only' <<<"$result") == '{"totalPrompts":1,"complete":true}' ]] || + fail "OpenCode Zen collector supports databases without the legacy message table" "$result" +pass "OpenCode Zen collector supports databases without the legacy message table" + +# Pi sessions: the collector discovers Zen-originated assistant messages in +# ~/.pi/agent/sessions/*.jsonl and prefixes the model name so the panel shows +# the harness source. +PI_HOME=$(mktemp -d) +mkdir -p "$PI_HOME/.pi/agent/sessions" "$PI_HOME/.omp/agent/sessions" +cat > "$PI_HOME/.pi/agent/sessions/test.jsonl" <<'JSONL' +{"type":"message","id":"pi-zen-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"opencode","model":"kimi-k2.6","usage":{"input":100,"output":50,"cacheRead":10,"cacheWrite":5,"totalTokens":165}}} +{"type":"message","id":"pi-zen-2","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"opencode","model":"big-pickle","usage":{"input":200,"output":100,"totalTokens":300}}} +{"type":"message","id":"pi-go-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"opencode-go","model":"kimi-k2.6","usage":{"input":999,"output":999,"totalTokens":1998}}} +{"type":"message","id":"pi-go-api-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"opencode-go","api":"opencode","model":"kimi-k2.6","usage":{"input":999,"output":999,"totalTokens":1998}}} +{"type":"message","id":"pi-legacy-zen-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","api":"opencode","model":"legacy-zen","usage":{"input":40,"output":10,"totalTokens":50}}} +{"type":"message","id":"pi-legacy-go-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","api":"opencode-go","model":"legacy-go","usage":{"input":999,"output":999,"totalTokens":1998}}} +{"type":"message","id":"pi-claude-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"anthropic","model":"claude-haiku","usage":{"input":999,"output":999,"totalTokens":1998}}} +{"type":"message","id":"pi-user-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"user","provider":"opencode","model":"kimi-k2.6","usage":{"input":999,"output":999,"totalTokens":1998}}} +JSONL +cat > "$PI_HOME/.omp/agent/sessions/test.jsonl" <<'JSONL' +{"type":"message","id":"omp-zen-1","timestamp":"2026-08-21T12:00:00.000Z","message":{"role":"assistant","provider":"opencode","model":"qwen3.6-plus","usage":{"input":80,"output":20,"cacheRead":5,"cacheWrite":0,"totalTokens":105}}} +JSONL + +pi_only=$(HOME="$PI_HOME" XDG_DATA_HOME="$PI_HOME/.local/share" XDG_CACHE_HOME="$PI_HOME/.cache" "$ROOT/bin/omarchy-agent-usage-opencode-zen") + +[[ $(jq -r '.totalPrompts' <<<"$pi_only") == "4" ]] || + fail "Zen collector counts only Pi and OMP assistant messages on the opencode provider" "$pi_only" +pass "Zen collector counts only Pi and OMP assistant messages on the opencode provider" + +[[ $(jq -r '.modelUsage["Pi-kimi-k2.6"].inputTokens' <<<"$pi_only") == "100" ]] || + fail "Zen collector preserves Pi token splits" "$pi_only" +[[ $(jq -r '.modelUsage["Pi-kimi-k2.6"].outputTokens' <<<"$pi_only") == "50" ]] || + fail "Zen collector preserves Pi token splits" "$pi_only" +[[ $(jq -r '.modelUsage["Pi-kimi-k2.6"].cacheReadInputTokens' <<<"$pi_only") == "10" ]] || + fail "Zen collector preserves Pi token splits" "$pi_only" +[[ $(jq -r '.modelUsage["Pi-kimi-k2.6"].cacheCreationInputTokens' <<<"$pi_only") == "5" ]] || + fail "Zen collector preserves Pi token splits" "$pi_only" +pass "Zen collector preserves Pi token splits" + +[[ $(jq -r '.modelUsage["Pi-big-pickle"].inputTokens' <<<"$pi_only") == "200" ]] || + fail "Zen collector counts multiple Pi models" "$pi_only" +pass "Zen collector counts multiple Pi models" + +[[ $(jq -r '.modelUsage["Omp-qwen3.6-plus"].inputTokens' <<<"$pi_only") == "80" ]] || + fail "Zen collector counts OMP models" "$pi_only" +pass "Zen collector counts OMP models" + +[[ $(jq -r '.modelUsage["Pi-kimi-k2.6"].inputTokens' <<<"$pi_only") == "100" ]] || + fail "Zen collector excludes Pi messages from OpenCode Go" "$pi_only" +pass "Zen collector excludes Pi messages from OpenCode Go" + +[[ $(jq -r '.modelUsage["Pi-legacy-zen"].inputTokens' <<<"$pi_only") == "40" ]] || + fail "Zen collector accepts the exact legacy Pi API id when provider is absent" "$pi_only" +pass "Zen collector accepts the exact legacy Pi API id when provider is absent" + +[[ $(jq -r '.modelUsage["Pi-claude-haiku"] // "missing"' <<<"$pi_only") == "missing" ]] || + fail "Zen collector excludes Pi messages from other providers" "$pi_only" +pass "Zen collector excludes Pi messages from other providers" + +# The update runner must discover the collector by filename and write its id. +UPDATE_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$UPDATE_HOME" "$PI_HOME"' EXIT +HOME="$UPDATE_HOME" XDG_DATA_HOME="$TEST_HOME/data" XDG_CACHE_HOME="$UPDATE_HOME/.cache" \ + XDG_STATE_HOME="$UPDATE_HOME/.state" OMARCHY_PATH="$ROOT" \ + "$ROOT/bin/omarchy-agent-usage-update" opencode-zen + +written="$UPDATE_HOME/.state/omarchy/agents/usage/opencode-zen.json" +[[ -f $written && $(jq -r '.id + ":" + (.ready | tostring)' "$written") == "opencode-zen:true" ]] || + fail "Agent usage update discovers and writes the OpenCode Zen collector" +pass "Agent usage update discovers and writes the OpenCode Zen collector"