From 3460454181ca08f83d70b5a4a2b632df58068e50 Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Fri, 21 Aug 2026 07:54:35 -0400 Subject: [PATCH 1/6] Add OpenCode Zen usage collector --- bin/omarchy-agent-usage-opencode-zen | 287 ++++++++++++++++++ manual/17-ai.md | 2 +- shell/plugins/agents/README.md | 4 +- .../agents/assets/opencode-zen-light.svg | 1 + shell/plugins/agents/assets/opencode-zen.svg | 1 + shell/plugins/agents/manifest.json | 5 +- .../agent-usage-opencode-zen-scanner-test.sh | 221 ++++++++++++++ 7 files changed, 517 insertions(+), 4 deletions(-) create mode 100755 bin/omarchy-agent-usage-opencode-zen create mode 100644 shell/plugins/agents/assets/opencode-zen-light.svg create mode 100644 shell/plugins/agents/assets/opencode-zen.svg create mode 100755 test/shell.d/agent-usage-opencode-zen-scanner-test.sh diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen new file mode 100755 index 00000000000..bb22975c57e --- /dev/null +++ b/bin/omarchy-agent-usage-opencode-zen @@ -0,0 +1,287 @@ +#!/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 sqlite3 +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +AGENT_ID = "opencode-zen" +AGENT_NAME = "OpenCode" +PROVIDER_ID = "opencode" + + +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: + parsed = float(value or 0) + return 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 + + 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") + for session_id, raw in conn.execute( + "SELECT 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'" + ): + try: + entry = json.loads(raw) + if not isinstance(entry, dict) or entry.get("role") != "assistant": + continue + if str(entry.get("providerID") or "") != 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 "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 + + +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") != 1 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": 1, "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) + stats = cached_scan(db, scan_age) if db.is_file() else empty_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", + "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..0cc172f4adf 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 local OpenCode Zen usage 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..be19478d521 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 | 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..f50fcd1e3b3 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 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..b9e22e07808 --- /dev/null +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -0,0 +1,221 @@ +#!/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" ]] || + 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)") +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"'), +]) +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"] + +# 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), + }, +}, separators=(",", ":"))) +PY +) + +[[ $(jq -c '.record' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-zen","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Zen","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") == "7386" ]] || + 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") == "3" ]] || + 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") == "3" ]] || + fail "OpenCode Zen collector deduplicates sessions" "$result" +pass "OpenCode Zen collector deduplicates sessions" + +[[ $(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") == "7386" ]] || + 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 '.cache | {schemaVersion,mode,cachedPrompts,forcedPrompts}' <<<"$result") == \ + '{"schemaVersion":1,"mode":"644","cachedPrompts":3,"forcedPrompts":4}' ]] || + 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" + +# The update runner must discover the collector by filename and write its id. +UPDATE_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$UPDATE_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" From 9ae4342bfacbcb592257c20adbb9fe8264bdc7de Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Fri, 21 Aug 2026 08:26:08 -0400 Subject: [PATCH 2/6] Clamp negative token values to 0 in the Zen collector --- bin/omarchy-agent-usage-opencode-zen | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen index bb22975c57e..758d5eda266 100755 --- a/bin/omarchy-agent-usage-opencode-zen +++ b/bin/omarchy-agent-usage-opencode-zen @@ -56,7 +56,7 @@ def local_date_string() -> str: def number(value: Any) -> int: try: parsed = float(value or 0) - return round(parsed) if parsed == parsed else 0 + return max(0, round(parsed)) if parsed == parsed else 0 except Exception: return 0 From a67bda1fd9f99aac6bb42f6c063afee7b7e03778 Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Fri, 21 Aug 2026 10:42:31 -0400 Subject: [PATCH 3/6] Add Pi and OMP Zen session scanning and rename display to OpenCode Zen --- bin/omarchy-agent-usage-opencode-zen | 191 +++++++++++++++++- manual/17-ai.md | 2 +- shell/plugins/agents/README.md | 2 +- shell/plugins/agents/manifest.json | 2 +- .../agent-usage-opencode-zen-scanner-test.sh | 4 +- 5 files changed, 194 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen index 758d5eda266..f863cefc7c8 100755 --- a/bin/omarchy-agent-usage-opencode-zen +++ b/bin/omarchy-agent-usage-opencode-zen @@ -18,7 +18,9 @@ import fcntl import hashlib import json import os +import shutil import sqlite3 +import subprocess import sys import tempfile import time @@ -26,7 +28,7 @@ from pathlib import Path from typing import Any AGENT_ID = "opencode-zen" -AGENT_NAME = "OpenCode" +AGENT_NAME = "OpenCode Zen" PROVIDER_ID = "opencode" @@ -177,6 +179,189 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: }, 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 != "opencode" and not api.startswith("opencode"): + 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() @@ -252,7 +437,9 @@ def _cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: 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) - stats = cached_scan(db, scan_age) if db.is_file() else empty_stats() + 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, diff --git a/manual/17-ai.md b/manual/17-ai.md index 0cc172f4adf..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, Fireworks, and local OpenCode Zen usage 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 be19478d521..933297152cb 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-zen` | None; Zen has no supported API-key-authenticated balance endpoint | OpenCode's local database, restricted to the `opencode` provider | +| `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 diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index f50fcd1e3b3..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, Fireworks, and OpenCode Zen 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": { diff --git a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh index b9e22e07808..c98a181de5e 100755 --- a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -14,7 +14,7 @@ empty=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME "$ROOT/bin/omarchy-agent-usage-opencode-zen") [[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$empty") == \ - "opencode-zen:false:OpenCode:Zen" ]] || + "opencode-zen:false:OpenCode Zen:Zen" ]] || fail "OpenCode Zen collector prints a valid record without a database" "$empty" pass "OpenCode Zen collector prints a valid record without a database" @@ -161,7 +161,7 @@ PY ) [[ $(jq -c '.record' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode-zen","name":"OpenCode","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Zen","usageStatusText":"","authHelpText":"","limits":[]}' ]] || + '{"schemaVersion":1,"id":"opencode-zen","name":"OpenCode Zen","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Zen","usageStatusText":"","authHelpText":"","limits":[]}' ]] || fail "OpenCode Zen collector follows the display record contract" "$result" pass "OpenCode Zen collector follows the display record contract" From 9c823a02cc014b1f0167ff9c71a345faec0c0ede Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Fri, 21 Aug 2026 10:44:46 -0400 Subject: [PATCH 4/6] Add Pi fixture tests for Zen collector --- .../agent-usage-opencode-zen-scanner-test.sh | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh index c98a181de5e..babf8fe8fbc 100755 --- a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -208,9 +208,45 @@ pass "OpenCode Zen collector dates its cache" fail "OpenCode Zen collector never caches an interrupted scan" "$result" pass "OpenCode Zen collector never caches an interrupted scan" +# 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" +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-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 + +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") == "2" ]] || + fail "Zen collector counts only Pi assistant messages on the opencode provider" "$pi_only" +pass "Zen collector counts only Pi 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["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"' EXIT +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 From 5b38d625f0708439c490e79daea5c58201aa3535 Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Fri, 21 Aug 2026 12:17:58 -0400 Subject: [PATCH 5/6] Refine OpenCode Zen usage display --- bin/omarchy-agent-usage-opencode-zen | 4 ++-- .../agent-usage-opencode-zen-scanner-test.sh | 24 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen index f863cefc7c8..c91abd18e49 100755 --- a/bin/omarchy-agent-usage-opencode-zen +++ b/bin/omarchy-agent-usage-opencode-zen @@ -28,7 +28,7 @@ from pathlib import Path from typing import Any AGENT_ID = "opencode-zen" -AGENT_NAME = "OpenCode Zen" +AGENT_NAME = "OpenCode" PROVIDER_ID = "opencode" @@ -449,7 +449,7 @@ def scan(db: Path, force: bool = False, limits_only: bool = False, cache_seconds "hasLocalStats": True, "scope": "device", "hasPromptStats": True, - "tierLabel": "Zen", + "tierLabel": "Zen · Pay as you go", "usageStatusText": "", "authHelpText": "", "limits": [], diff --git a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh index babf8fe8fbc..f4884c1a9b6 100755 --- a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -14,7 +14,7 @@ empty=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" XDG_CACHE_HOME "$ROOT/bin/omarchy-agent-usage-opencode-zen") [[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .name + ":" + .tierLabel' <<<"$empty") == \ - "opencode-zen:false:OpenCode Zen:Zen" ]] || + "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" @@ -161,7 +161,7 @@ PY ) [[ $(jq -c '.record' <<<"$result") == \ - '{"schemaVersion":1,"id":"opencode-zen","name":"OpenCode Zen","ready":true,"hasLocalStats":true,"scope":"device","hasPromptStats":true,"tierLabel":"Zen","usageStatusText":"","authHelpText":"","limits":[]}' ]] || + '{"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" @@ -212,19 +212,23 @@ pass "OpenCode Zen collector never caches an interrupted scan" # ~/.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" +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-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") == "2" ]] || - fail "Zen collector counts only Pi assistant messages on the opencode provider" "$pi_only" -pass "Zen collector counts only Pi assistant messages on the opencode provider" +[[ $(jq -r '.totalPrompts' <<<"$pi_only") == "3" ]] || + 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" @@ -240,6 +244,14 @@ pass "Zen collector preserves Pi token splits" 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-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" From b6955a68d7e3995e7298f86cf0e2ebdbbe2861d6 Mon Sep 17 00:00:00 2001 From: "Shauwn D. Rush" Date: Sat, 22 Aug 2026 07:25:34 -0400 Subject: [PATCH 6/6] Support current OpenCode Zen sessions --- bin/omarchy-agent-usage-opencode-zen | 134 +++++++++++------- .../agent-usage-opencode-zen-scanner-test.sh | 92 ++++++++++-- 2 files changed, 168 insertions(+), 58 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode-zen b/bin/omarchy-agent-usage-opencode-zen index c91abd18e49..ab89ed48e9d 100755 --- a/bin/omarchy-agent-usage-opencode-zen +++ b/bin/omarchy-agent-usage-opencode-zen @@ -30,6 +30,7 @@ from typing import Any AGENT_ID = "opencode-zen" AGENT_NAME = "OpenCode" PROVIDER_ID = "opencode" +CACHE_SCHEMA_VERSION = 2 def data_home() -> Path: @@ -37,7 +38,7 @@ def data_home() -> Path: def cache_root() -> Path: - root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage" + root = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage" root.mkdir(parents=True, exist_ok=True) return root @@ -104,6 +105,7 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: 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) @@ -112,54 +114,83 @@ def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: try: conn.execute("PRAGMA query_only = ON") - for session_id, raw in conn.execute( - "SELECT 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'" - ): - try: - entry = json.loads(raw) - if not isinstance(entry, dict) or entry.get("role") != "assistant": - continue - if str(entry.get("providerID") or "") != PROVIDER_ID: + 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 - 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: + 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 - 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 = 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 + 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: @@ -211,7 +242,7 @@ def scan_pi_zen_sessions() -> dict[str, Any] | None: 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)], + [rg, "--json", "-e", r'"provider"\s*:\s*"opencode"', "-e", r'"api"\s*:\s*"opencode"', str(root)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, @@ -243,7 +274,10 @@ def scan_pi_zen_sessions() -> dict[str, Any] | None: continue provider = str(message.get("provider") or "") api = str(message.get("api") or "") - if provider != "opencode" and not api.startswith("opencode"): + if provider: + if provider != PROVIDER_ID: + continue + elif api != PROVIDER_ID: continue usage = message.get("usage") or {} @@ -396,7 +430,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 cached or cached.get("schemaVersion") != 1 or cached.get("scanDate") != local_date_string(): + 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): @@ -406,7 +440,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": CACHE_SCHEMA_VERSION, "scanDate": local_date_string(), "stats": stats}) def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: diff --git a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh index f4884c1a9b6..7b59102c97c 100755 --- a/test/shell.d/agent-usage-opencode-zen-scanner-test.sh +++ b/test/shell.d/agent-usage-opencode-zen-scanner-test.sh @@ -45,6 +45,7 @@ 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): @@ -92,6 +93,44 @@ 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. @@ -125,6 +164,23 @@ 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) @@ -156,6 +212,10 @@ print(json.dumps({ "complete": broken_complete, "cacheWritten": bool(broken_cache_files), }, + "v2Only": { + "totalPrompts": v2_only_stats["totalPrompts"], + "complete": v2_only_complete, + }, }, separators=(",", ":"))) PY ) @@ -169,23 +229,23 @@ pass "OpenCode Zen collector follows the display record contract" 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") == "7386" ]] || +[[ $(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") == "3" ]] || +[[ $(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") == "3" ]] || - fail "OpenCode Zen collector deduplicates sessions" "$result" -pass "OpenCode Zen collector deduplicates sessions" +[[ $(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") == "7386" ]] || +[[ $(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" @@ -194,8 +254,13 @@ pass "OpenCode Zen collector builds the seven-day token series" 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":1,"mode":"644","cachedPrompts":3,"forcedPrompts":4}' ]] || + '{"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" @@ -208,6 +273,10 @@ pass "OpenCode Zen collector dates its cache" 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. @@ -217,6 +286,9 @@ 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 @@ -226,7 +298,7 @@ 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") == "3" ]] || +[[ $(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" @@ -252,6 +324,10 @@ pass "Zen collector counts OMP models" 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"