diff --git a/bin/omarchy-agent-usage-agy b/bin/omarchy-agent-usage-agy new file mode 100755 index 00000000000..d5426f49129 --- /dev/null +++ b/bin/omarchy-agent-usage-agy @@ -0,0 +1,369 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Antigravity usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Antigravity usage into one display-ready JSON record. + +Parses local session databases from Antigravity IDE and Antigravity CLI, +calculating token consumption by day and model, rolling session and weekly limits, +and active sessions with high-performance caching. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import glob +import json +import os +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Any + +AGENT_NAME = "Antigravity" +TIER_LABEL = "Pro" + + +def get_agent_id() -> str: + prog = Path(sys.argv[0]).name + if "antigravity" in prog: + return "antigravity" + return "agy" + + +def cache_dir() -> Path: + return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage" + + +def get_search_dirs() -> list[Path]: + dirs: list[Path] = [] + custom = os.environ.get("ANTIGRAVITY_HOME") or os.environ.get("AGY_HOME") + if custom: + dirs.append(Path(custom)) + dirs.extend([ + Path.home() / ".gemini" / "antigravity", + Path.home() / ".gemini" / "antigravity-cli", + ]) + return dirs + + +def normalize_model(m: str) -> str: + raw = m.lower().strip() + if raw in ("gemini-default", "model_placeholder_m318", "default"): + return "gemini-3.8-flash" + if raw in ("gemini-pro-default", "gemini-pro"): + return "gemini-3.1-pro" + if raw in ("gemini-3.7-flash-exp-a", "gemini-3.7-flash-exp-b"): + return "gemini-3.7-flash" + if raw in ("gemini-3.6-flash-exp-a", "gemini-3-flash-a"): + return "gemini-3.6-flash" + if raw == "gemini-3-flash-medium-a": + return "gemini-3.6-flash-medium" + return raw + + +def decode_varint(buf: bytes, pos: int) -> tuple[int, int]: + res = 0 + shift = 0 + while pos < len(buf): + b = buf[pos] + pos += 1 + res |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + return res, pos + + +def parse_proto(buf: bytes) -> list[tuple[int, int, Any]]: + pos = 0 + fields = [] + while pos < len(buf): + try: + tag, pos = decode_varint(buf, pos) + except Exception: + break + wire_type = tag & 7 + field_num = tag >> 3 + if wire_type == 0: + val, pos = decode_varint(buf, pos) + fields.append((field_num, wire_type, val)) + elif wire_type == 1: + val = buf[pos : pos + 8] + pos += 8 + fields.append((field_num, wire_type, val)) + elif wire_type == 2: + length, pos = decode_varint(buf, pos) + val = buf[pos : pos + length] + pos += length + fields.append((field_num, wire_type, val)) + elif wire_type == 5: + val = buf[pos : pos + 4] + pos += 4 + fields.append((field_num, wire_type, val)) + else: + break + return fields + + +def extract_events_from_db(db_path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + c = conn.cursor() + c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='gen_metadata'") + if not c.fetchone(): + conn.close() + return [] + + c.execute("SELECT g.idx, g.data, s.metadata FROM gen_metadata g JOIN steps s ON g.idx = s.idx") + rows = c.fetchall() + conn.close() + + for _, g_data, s_meta in rows: + if not s_meta or not g_data: + continue + + ts = None + s_fields = parse_proto(s_meta) + for fn, wt, val in s_fields: + if fn == 1 and wt == 2: + sub = parse_proto(val) + for sfn, swt, sval in sub: + if sfn == 1 and swt == 0: + ts = sval + break + if ts: + break + if not ts: + continue + + g_fields = parse_proto(g_data) + model = "gemini-3.8-flash" + in_tok = 0 + out_tok = 0 + for fn, wt, val in g_fields: + if fn == 1 and wt == 2: + sub1 = parse_proto(val) + for s1_fn, s1_wt, s1_val in sub1: + if s1_fn == 19 and s1_wt == 2: + model = s1_val.decode("utf-8", "ignore") + elif s1_fn == 4 and s1_wt == 2: + sub4 = parse_proto(s1_val) + for s4_fn, s4_wt, s4_val in sub4: + if s4_fn == 2 and s4_wt == 0: + in_tok = s4_val + elif s4_fn == 3 and s4_wt == 0: + out_tok = s4_val + + if in_tok > 0 or out_tok > 0: + events.append({ + "ts": ts, + "model": normalize_model(model), + "in": in_tok, + "out": out_tok, + }) + except Exception: + pass + return events + + +def load_cache(cache_file: Path) -> dict[str, Any]: + if cache_file.exists(): + try: + with open(cache_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + return {} + + +def save_cache(cache_file: Path, data: dict[str, Any]) -> None: + try: + cache_file.parent.mkdir(parents=True, exist_ok=True) + tmp_file = cache_file.parent / f".cache-{os.getpid()}-{int(time.time())}.tmp" + with open(tmp_file, "w", encoding="utf-8") as f: + json.dump(data, f) + tmp_file.replace(cache_file) + except Exception: + pass + + +def collect_usage(force: bool = False, limits_only: bool = False) -> dict[str, Any]: + now = dt.datetime.now() + now_utc = dt.datetime.now(dt.timezone.utc) + today_str = now.strftime("%Y-%m-%d") + recent_dates = [(now - dt.timedelta(days=i)).strftime("%Y-%m-%d") for i in range(6, -1, -1)] + + cdir = cache_dir() + agent_id = get_agent_id() + cache_file = cdir / f"{agent_id}-cache.json" + + cache = {} if force else load_cache(cache_file) + db_cache = cache.get("conversations", {}) + updated_cache: dict[str, Any] = {} + + all_events: list[dict[str, Any]] = [] + all_sessions: set[str] = set() + today_sessions: set[str] = set() + + for base in get_search_dirs(): + convo_dir = base / "conversations" + if not convo_dir.is_dir(): + continue + for db_path in convo_dir.glob("*.db"): + db_path_str = str(db_path) + try: + st = db_path.stat() + mtime = st.st_mtime + size = st.st_size + cid = db_path.stem + + cached = db_cache.get(db_path_str) + if ( + not force + and cached + and cached.get("mtime") == mtime + and cached.get("size") == size + ): + updated_cache[db_path_str] = cached + evs = cached.get("events", []) + all_events.extend(evs) + all_sessions.add(cid) + for ev in evs: + if dt.datetime.fromtimestamp(ev["ts"]).strftime("%Y-%m-%d") == today_str: + today_sessions.add(cid) + continue + + evs = extract_events_from_db(db_path) + entry = {"mtime": mtime, "size": size, "events": evs} + updated_cache[db_path_str] = entry + all_events.extend(evs) + all_sessions.add(cid) + for ev in evs: + if dt.datetime.fromtimestamp(ev["ts"]).strftime("%Y-%m-%d") == today_str: + today_sessions.add(cid) + except Exception: + pass + + if not limits_only or force: + save_cache(cache_file, {"version": 1, "conversations": updated_cache}) + + recent_days_map = {d: {"date": d, "messageCount": 0} for d in recent_dates} + model_usage: dict[str, dict[str, int]] = {} + today_tokens_by_model: dict[str, int] = {} + active_dates: set[str] = set() + today_prompts = 0 + today_total_tokens = 0 + total_prompts = len(all_events) + + five_hours_ago = (now - dt.timedelta(hours=5)).timestamp() + seven_days_ago = (now - dt.timedelta(days=7)).timestamp() + + session_tokens = 0 + weekly_tokens = 0 + session_event_ts: list[float] = [] + + for ev in all_events: + ts = ev["ts"] + ev_dt = dt.datetime.fromtimestamp(ts) + d_str = ev_dt.strftime("%Y-%m-%d") + model = ev["model"] + in_tok = ev["in"] + out_tok = ev["out"] + tot = in_tok + out_tok + + active_dates.add(d_str) + + if d_str in recent_days_map: + recent_days_map[d_str]["messageCount"] += tot + + if d_str == today_str: + today_prompts += 1 + today_total_tokens += tot + today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + tot + + bucket = model_usage.setdefault( + model, + { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }, + ) + bucket["inputTokens"] += in_tok + bucket["outputTokens"] += out_tok + + if ts >= five_hours_ago: + session_tokens += tot + session_event_ts.append(ts) + if ts >= seven_days_ago: + weekly_tokens += tot + + session_percent = min(1.0, round(session_tokens / 5_000_000.0, 2)) if session_tokens > 0 else 0.0 + if session_event_ts: + session_reset_dt = dt.datetime.fromtimestamp(min(session_event_ts) + 5 * 3600, dt.timezone.utc) + else: + session_reset_dt = now_utc + dt.timedelta(hours=5) + + weekly_percent = min(1.0, round(weekly_tokens / 50_000_000.0, 2)) if weekly_tokens > 0 else 0.0 + days_until_sunday = (6 - now_utc.weekday()) % 7 + if days_until_sunday == 0: + days_until_sunday = 7 + weekly_reset_dt = (now_utc + dt.timedelta(days=days_until_sunday)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + limits = [ + { + "label": "Session (5-hour)", + "percent": session_percent, + "resetsAt": session_reset_dt.isoformat(), + }, + { + "label": "Weekly (7-day)", + "percent": weekly_percent, + "resetsAt": weekly_reset_dt.isoformat(), + }, + ] + + return { + "schemaVersion": 1, + "id": agent_id, + "name": AGENT_NAME, + "updatedAt": now_utc.isoformat(), + "ready": True, + "hasLocalStats": True, + "hasPromptStats": True, + "todayPrompts": today_prompts, + "todaySessions": max(len(today_sessions), 1 if today_prompts > 0 else 0), + "todayTotalTokens": today_total_tokens, + "todayTokensByModel": today_tokens_by_model, + "recentDays": [recent_days_map[d] for d in recent_dates], + "totalPrompts": total_prompts, + "totalSessions": max(len(all_sessions), 1 if total_prompts > 0 else 0), + "activeDays": len(active_dates), + "activeDates": sorted(list(active_dates)), + "modelUsage": model_usage, + "limits": limits, + "tierLabel": TIER_LABEL, + "usageStatusText": "", + "authHelpText": "", + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Print Antigravity usage record as JSON") + parser.add_argument("--force", action="store_true", help="Force cache invalidation and rescan") + parser.add_argument("--limits-only", action="store_true", help="Quick limits update") + args = parser.parse_args() + + record = collect_usage(force=args.force, limits_only=args.limits_only) + print(json.dumps(record, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index f4ecdd9a09b..66363084bc5 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -341,6 +341,39 @@ Panel { bar: root.bar text: "󱚣" active: root.alarming + iconComponent: Component { + Item { + id: barMark + property var candidates: root.iconCandidatesForProvider(root.provider, root.bar ? root.bar.background : Color.background) + property string candidatesKey: candidates.join("\n") + property int candidateIndex: 0 + onCandidatesKeyChanged: candidateIndex = 0 + + anchors.fill: parent + + Image { + id: barMarkImage + anchors.fill: parent + anchors.margins: Style.space(2) + source: barMark.candidateIndex < barMark.candidates.length ? barMark.candidates[barMark.candidateIndex] : "" + sourceSize.width: button.opticalSize * 2 + sourceSize.height: button.opticalSize * 2 + fillMode: Image.PreserveAspectFit + onStatusChanged: if (status === Image.Error && barMark.candidateIndex < barMark.candidates.length) + Qt.callLater(function() { barMark.candidateIndex++ }) + } + + Text { + textFormat: Text.PlainText + anchors.centerIn: parent + visible: barMarkImage.status !== Image.Ready + text: button.text + color: button.active && button.useActiveColor ? button.activeColor : button.foreground + font.family: root.fontFamily + font.pixelSize: button.fontSize + } + } + } onPressed: function(buttonCode) { if (buttonCode === Qt.RightButton) root.launchAgent() else if (buttonCode === Qt.MiddleButton) root.selectProvider(root.providerIndex + 1) diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 51ad4e43c2d..73644537191 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 | +| `agy` | Session (5-hour) and Weekly (7-day) token limits | Local session databases from `~/.gemini/antigravity` and `~/.gemini/antigravity-cli` | 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/assets/agy-light.svg b/shell/plugins/agents/assets/agy-light.svg new file mode 100644 index 00000000000..1920970cf2c --- /dev/null +++ b/shell/plugins/agents/assets/agy-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/plugins/agents/assets/agy.svg b/shell/plugins/agents/assets/agy.svg new file mode 100644 index 00000000000..ddb07a01246 --- /dev/null +++ b/shell/plugins/agents/assets/agy.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/plugins/agents/assets/antigravity-light.svg b/shell/plugins/agents/assets/antigravity-light.svg new file mode 120000 index 00000000000..6940ee4177c --- /dev/null +++ b/shell/plugins/agents/assets/antigravity-light.svg @@ -0,0 +1 @@ +agy-light.svg \ No newline at end of file diff --git a/shell/plugins/agents/assets/antigravity.svg b/shell/plugins/agents/assets/antigravity.svg new file mode 120000 index 00000000000..f06dc81087a --- /dev/null +++ b/shell/plugins/agents/assets/antigravity.svg @@ -0,0 +1 @@ +agy.svg \ No newline at end of file diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..e251f05d1c9 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 Antigravity 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 }, + "agy": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", diff --git a/test/shell.d/agent-usage-agy-scanner-test.sh b/test/shell.d/agent-usage-agy-scanner-test.sh new file mode 100755 index 00000000000..e5069147448 --- /dev/null +++ b/test/shell.d/agent-usage-agy-scanner-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +mkdir -p "$TEST_HOME/.gemini/antigravity/conversations" + +# Create a test sqlite database structure +python3 -c " +import sqlite3 +conn = sqlite3.connect('$TEST_HOME/.gemini/antigravity/conversations/test.db') +conn.execute('CREATE TABLE steps (idx integer primary key, metadata blob)') +conn.execute('CREATE TABLE gen_metadata (idx integer primary key, data blob)') +conn.close() +" + +result=$(HOME="$TEST_HOME" "$ROOT/bin/omarchy-agent-usage-agy") + +[[ $(jq -r '.id' <<<"$result") == "agy" ]] || + fail "Antigravity collector identifies itself as agy" "$result" +pass "Antigravity collector identifies itself as agy" + +[[ $(jq -r '.name' <<<"$result") == "Antigravity" ]] || + fail "Antigravity collector names itself Antigravity" "$result" +pass "Antigravity collector names itself Antigravity" + +[[ $(jq -r '.ready' <<<"$result") == "true" ]] || + fail "Antigravity collector reports ready" "$result" +pass "Antigravity collector reports ready" + +[[ $(jq -r '.tierLabel' <<<"$result") == "Pro" ]] || + fail "Antigravity collector reports Pro tier" "$result" +pass "Antigravity collector reports Pro tier" + +[[ $(jq -r '.limits | length' <<<"$result") == "2" ]] || + fail "Antigravity collector reports session and weekly limits" "$result" +pass "Antigravity collector reports session and weekly limits" + +[[ $(jq -r '.limits[0].label' <<<"$result") == "Session (5-hour)" ]] || + fail "Antigravity collector reports Session (5-hour) limit" "$result" +pass "Antigravity collector reports Session (5-hour) limit" + +[[ $(jq -r '.limits[1].label' <<<"$result") == "Weekly (7-day)" ]] || + fail "Antigravity collector reports Weekly (7-day) limit" "$result" +pass "Antigravity collector reports Weekly (7-day) limit"