diff --git a/bin/omarchy-agent-usage-x b/bin/omarchy-agent-usage-x
new file mode 100755
index 00000000000..b09636ee058
--- /dev/null
+++ b/bin/omarchy-agent-usage-x
@@ -0,0 +1,658 @@
+#!/usr/bin/python3
+# omarchy:summary=Print The X Subscription usage record as JSON
+# omarchy:args=[--force] [--limits-only]
+# omarchy:hidden=true
+"""Collect The X Subscription usage into one display-ready JSON record.
+
+The shared first-party pool (Cursor + Grok) and plan come from Cursor's
+dashboard API using the sign-in in ~/.config/cursor/auth.json. Tokens by day
+and by model come from the same dashboard's GetAggregatedUsageEvents. Local
+prompt and session counts come from Cursor agent transcripts under
+~/.cursor/projects. The agents panel only ever reads the JSON this prints.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from collections import defaultdict
+from datetime import date, datetime, time as dt_time, timedelta, timezone
+from pathlib import Path
+from typing import Any
+
+AGENT_ID = "x"
+AGENT_NAME = "X Subscription"
+AUTH_HELP = "Run `cursor-agent login`, or sign in to Cursor, to refresh X Subscription usage."
+API_BASE = "https://api2.cursor.sh"
+PROBE_REUSE_SECONDS = 60
+LIMITS_ONLY_SCAN_REUSE_SECONDS = 900
+# Cursor agent transcripts stamp user turns as
+# Monday, Oct 12, 2026, 6:27 PM (UTC-5).
+TIMESTAMP_RE = re.compile(
+ r"\s*([A-Za-z]+,\s+[A-Za-z]+\s+\d{1,2},\s+\d{4},\s+\d{1,2}:\d{2}\s*[AP]M)\s*(?:\(([^)]+)\))?\s*",
+ re.IGNORECASE,
+)
+TZ_OFFSET_RE = re.compile(r"^(?:UTC|GMT)([+-])(\d{1,2})(?::?(\d{2}))?$", re.IGNORECASE)
+
+
+def read_json(path: Path) -> Any:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+
+
+def write_json(path: Path, payload: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle)
+ os.replace(tmp, path)
+ except Exception:
+ Path(tmp).unlink(missing_ok=True)
+ raise
+
+
+def cache_root() -> Path:
+ return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
+
+
+def auth_path() -> Path:
+ config = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
+ return config / "cursor" / "auth.json"
+
+
+def load_token() -> str | None:
+ data = read_json(auth_path())
+ if not isinstance(data, dict):
+ return None
+ token = data.get("accessToken")
+ return token if isinstance(token, str) and token else None
+
+
+def token_fingerprint(token: str) -> str:
+ return hashlib.sha1(token.encode("utf-8")).hexdigest()[:16]
+
+
+def limits_cache_path(token: str | None) -> Path:
+ if token:
+ return cache_root() / f"x-limits-{token_fingerprint(token)}.json"
+ return cache_root() / "x-limits.json"
+
+
+def rpc(token: str, method: str, body: dict | None = None) -> dict[str, Any]:
+ request = urllib.request.Request(
+ f"{API_BASE}/{method}",
+ data=json.dumps(body or {}).encode(),
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ "Connect-Protocol-Version": "1",
+ "Accept": "application/json",
+ "User-Agent": "omarchy-agent-usage-x/1.0",
+ "x-cursor-client-type": "cli",
+ },
+ method="POST",
+ )
+ with urllib.request.urlopen(request, timeout=20) as response:
+ payload = json.loads(response.read().decode())
+ return payload if isinstance(payload, dict) else {}
+
+
+def ms_to_iso(value: Any) -> str:
+ try:
+ ms = int(str(value))
+ except (TypeError, ValueError):
+ return ""
+ if ms <= 0:
+ return ""
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def pct_to_unit(value: Any) -> float:
+ """Convert Cursor's 0–100 `*PercentUsed` fields to a 0–1 panel unit."""
+ try:
+ n = float(value)
+ except (TypeError, ValueError):
+ return -1.0
+ if n < 0:
+ return -1.0
+ return min(n / 100.0, 1.0)
+
+
+def empty_stats() -> dict[str, Any]:
+ today = date.today()
+ # recentDays.messageCount is token totals across agents; keep prompts in
+ # todayPrompts/totalPrompts so the hero does not call them tokens.
+ recent = [{"date": (today - timedelta(days=i)).isoformat(), "messageCount": 0} for i in range(6, -1, -1)]
+ return {
+ "hasLocalStats": False,
+ "hasPromptStats": True,
+ "usageUnit": "prompts",
+ "todayPrompts": 0,
+ "todaySessions": 0,
+ "todayTotalTokens": 0,
+ "todayTokensByModel": {},
+ "recentDays": recent,
+ "totalPrompts": 0,
+ "totalSessions": 0,
+ "activeDays": 0,
+ "activeDates": [],
+ "modelUsage": {},
+ }
+
+
+def local_tzinfo() -> timezone:
+ return datetime.now().astimezone().tzinfo or timezone.utc
+
+
+def parse_tz_label(label: str) -> timezone | None:
+ match = TZ_OFFSET_RE.fullmatch(label.strip())
+ if not match:
+ return None
+ sign = 1 if match.group(1) == "+" else -1
+ hours = int(match.group(2))
+ minutes = int(match.group(3) or 0)
+ return timezone(timedelta(hours=sign * hours, minutes=sign * minutes))
+
+
+def to_local_date(value: datetime) -> date:
+ """Calendar day in the machine's local zone (same basis as date.today())."""
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=local_tzinfo())
+ return value.astimezone(local_tzinfo()).date()
+
+
+def empty_token_bucket() -> dict[str, int]:
+ return {
+ "inputTokens": 0,
+ "outputTokens": 0,
+ "cacheReadInputTokens": 0,
+ "cacheCreationInputTokens": 0,
+ }
+
+
+def day_windows(today: date, count: int = 7) -> list[tuple[str, int, int]]:
+ """Local calendar days as (date, startMs, endMs), oldest first."""
+ windows = []
+ for offset in range(count - 1, -1, -1):
+ day = today - timedelta(days=offset)
+ start = datetime.combine(day, dt_time.min).astimezone()
+ end = datetime.combine(day + timedelta(days=1), dt_time.min).astimezone()
+ windows.append((day.isoformat(), int(start.timestamp() * 1000), int(end.timestamp() * 1000)))
+ return windows
+
+
+def summarize_day_rows(rows: list[Any]) -> dict[str, dict[str, int]]:
+ usage: dict[str, dict[str, int]] = {}
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ model = str(row.get("modelIntent") or "cursor")
+ bucket = usage.setdefault(model, empty_token_bucket())
+ bucket["inputTokens"] += int(float(row.get("inputTokens") or 0))
+ bucket["outputTokens"] += int(float(row.get("outputTokens") or 0))
+ bucket["cacheReadInputTokens"] += int(float(row.get("cacheReadTokens") or 0))
+ bucket["cacheCreationInputTokens"] += int(float(row.get("cacheWriteTokens") or 0))
+ return usage
+
+
+def remote_token_stats(token: str, previous: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Fill recentDays.messageCount with real token totals from Cursor's dashboard.
+
+ A failed day keeps the last known count for that date instead of writing zero,
+ so a timeout cannot wipe the panel's week of tokens.
+ """
+ previous = previous if isinstance(previous, dict) else {}
+ prev_by_day = {
+ str(row.get("date")): row
+ for row in (previous.get("recentDays") or [])
+ if isinstance(row, dict) and row.get("date")
+ }
+ today = date.today()
+ recent: list[dict[str, Any]] = []
+ model_usage: dict[str, dict[str, int]] = {}
+ today_by_model: dict[str, int] = {}
+ active_dates: list[str] = []
+ any_ok = False
+ any_fail = False
+
+ for index, (day, start_ms, end_ms) in enumerate(day_windows(today, 7)):
+ usage: dict[str, dict[str, int]] | None = None
+ try:
+ payload = rpc(
+ token,
+ "aiserver.v1.DashboardService/GetAggregatedUsageEvents",
+ {"startDate": str(start_ms), "endDate": str(end_ms)},
+ )
+ rows = payload.get("aggregations") if isinstance(payload, dict) else None
+ usage = summarize_day_rows(rows if isinstance(rows, list) else [])
+ any_ok = True
+ except Exception:
+ any_fail = True
+ usage = None
+
+ if usage is None:
+ kept = prev_by_day.get(day) or {"date": day, "messageCount": 0}
+ tokens = int(kept.get("messageCount") or 0)
+ recent.append({"date": day, "messageCount": tokens})
+ else:
+ tokens = 0
+ for model, bucket in usage.items():
+ carried = model_usage.setdefault(model, empty_token_bucket())
+ for key, value in bucket.items():
+ carried[key] += value
+ tokens += sum(bucket.values())
+ recent.append({"date": day, "messageCount": tokens})
+ if index == 6:
+ today_by_model = {
+ model: sum(bucket.values()) for model, bucket in usage.items() if sum(bucket.values()) > 0
+ }
+
+ if tokens > 0:
+ active_dates.append(day)
+
+ if not any_ok and previous.get("recentDays"):
+ carried = dict(previous)
+ carried["tokensStale"] = True
+ return carried
+
+ # A partial week isn't a trustworthy model ledger; keep the last full one.
+ if any_fail and isinstance(previous.get("modelUsage"), dict):
+ model_usage = previous.get("modelUsage") or model_usage
+ if not today_by_model:
+ today_by_model = previous.get("todayTokensByModel") or {}
+
+ today_total = sum(today_by_model.values())
+ if not today_total and recent:
+ today_total = int(recent[-1].get("messageCount") or 0)
+
+ return {
+ "recentDays": recent,
+ "todayTotalTokens": int(today_total),
+ "todayTokensByModel": today_by_model,
+ "modelUsage": model_usage,
+ "tokenActiveDates": active_dates,
+ "tokensStale": any_fail,
+ }
+
+
+def cached_remote_token_stats(token: str, reuse_seconds: int) -> dict[str, Any]:
+ cache = cache_root() / f"x-tokens-{token_fingerprint(token)}.json"
+ today = date.today().isoformat()
+ cached = read_json(cache) if cache.exists() else None
+ cached = cached if isinstance(cached, dict) else {}
+ if reuse_seconds > 0 and cached.get("scanDate") == today and "recentDays" in cached:
+ age = time.time() - cache.stat().st_mtime
+ if age <= reuse_seconds:
+ return cached
+ stats = remote_token_stats(token, cached)
+ # Never replace a good cache with a total failure.
+ if stats.get("recentDays") or not cached.get("recentDays"):
+ stats["scanDate"] = today
+ write_json(cache, stats)
+ return stats
+ cached = dict(cached)
+ cached["tokensStale"] = True
+ return cached
+
+
+def prompt_day(obj: dict[str, Any]) -> date | None:
+ """Return the local calendar day of a transcript turn when the turn names it."""
+ chunks: list[str] = []
+ message = obj.get("message")
+ if isinstance(message, dict):
+ content = message.get("content")
+ if isinstance(content, str):
+ chunks.append(content)
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
+ chunks.append(part["text"])
+ elif isinstance(part, str):
+ chunks.append(part)
+ for key in ("content", "text"):
+ value = obj.get(key)
+ if isinstance(value, str):
+ chunks.append(value)
+
+ for chunk in chunks:
+ match = TIMESTAMP_RE.search(chunk)
+ if not match:
+ continue
+ stamp = match.group(1)
+ zone = parse_tz_label(match.group(2) or "") or local_tzinfo()
+ for fmt in ("%A, %b %d, %Y, %I:%M %p", "%A, %B %d, %Y, %I:%M %p"):
+ try:
+ return to_local_date(datetime.strptime(stamp, fmt).replace(tzinfo=zone))
+ except ValueError:
+ continue
+
+ for key in ("createdAt", "timestamp", "time"):
+ raw = obj.get(key)
+ if raw is None:
+ continue
+ if isinstance(raw, (int, float)):
+ ms = float(raw)
+ if ms > 10_000_000_000:
+ ms /= 1000.0
+ try:
+ return to_local_date(datetime.fromtimestamp(ms, tz=timezone.utc))
+ except (OverflowError, OSError, ValueError):
+ continue
+ if isinstance(raw, str) and raw:
+ try:
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ except ValueError:
+ continue
+ return to_local_date(parsed)
+ return None
+
+
+def local_stats() -> dict[str, Any]:
+ today = date.today()
+ by_day: dict[str, int] = defaultdict(int)
+ sessions: set[str] = set()
+ today_sessions: set[str] = set()
+ today_prompts = 0
+ total_prompts = 0
+ roots = [Path.home() / ".cursor" / "projects"]
+ files: list[Path] = []
+ for root in roots:
+ if root.exists():
+ files.extend(root.glob("**/agent-transcripts/**/*.jsonl"))
+
+ for path in files:
+ try:
+ session = path.stem
+ session_prompts = 0
+ with path.open(encoding="utf-8", errors="replace") as handle:
+ for line in handle:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ obj = json.loads(line)
+ except Exception:
+ continue
+ if not isinstance(obj, dict):
+ continue
+ role = obj.get("role") or obj.get("type")
+ if role not in ("user", "human"):
+ continue
+ session_prompts += 1
+ total_prompts += 1
+ day = prompt_day(obj)
+ if day is None:
+ # Undated turns still count toward totals, but must not land on
+ # today just because the file was touched later.
+ continue
+ by_day[day.isoformat()] += 1
+ if day == today:
+ today_prompts += 1
+ today_sessions.add(session)
+ if session_prompts:
+ sessions.add(session)
+ except Exception:
+ continue
+
+ recent = []
+ for i in range(6, -1, -1):
+ day = (today - timedelta(days=i)).isoformat()
+ # Token field; Cursor transcripts do not expose token ledgers here.
+ recent.append({"date": day, "messageCount": 0})
+
+ active_dates = sorted(by_day.keys())
+ stats = empty_stats()
+ stats.update(
+ {
+ "hasLocalStats": total_prompts > 0 or bool(sessions),
+ "todayPrompts": today_prompts,
+ "todaySessions": len(today_sessions),
+ "recentDays": recent,
+ "totalPrompts": total_prompts,
+ "totalSessions": len(sessions),
+ "activeDays": len(active_dates),
+ "activeDates": active_dates[-30:],
+ }
+ )
+ return stats
+
+
+def cached_local_stats(reuse_seconds: int) -> dict[str, Any]:
+ cache = cache_root() / "x-scan.json"
+ if reuse_seconds > 0 and cache.exists():
+ age = time.time() - cache.stat().st_mtime
+ if age <= reuse_seconds:
+ cached = read_json(cache)
+ if isinstance(cached, dict) and "totalPrompts" in cached:
+ return cached
+ stats = local_stats()
+ write_json(cache, stats)
+ return stats
+
+
+def plan_label(token: str) -> str:
+ try:
+ plan = rpc(token, "aiserver.v1.DashboardService/GetPlanInfo")
+ except Exception:
+ return ""
+ info = plan.get("planInfo") or {}
+ if not isinstance(info, dict):
+ return ""
+ name = str(info.get("planName") or "").strip()
+ price = str(info.get("price") or "").strip()
+ period = str(info.get("includedUsagePeriod") or "")
+ period_label = "monthly" if "MONTHLY" in period else ("weekly" if "WEEKLY" in period else "")
+ label = " · ".join(part for part in (name, price) if part)
+ if label and period_label:
+ label = f"{label} · shared {period_label} pool"
+ return label
+
+
+def usable_cached_limits(cached: dict[str, Any], token: str | None) -> list[dict[str, Any]]:
+ if token:
+ cached_fp = str(cached.get("tokenFingerprint") or "")
+ if cached_fp and cached_fp != token_fingerprint(token):
+ return []
+ elif cached.get("tokenFingerprint"):
+ # No sign-in: never surface another account's numbers as current.
+ return []
+
+ entries = cached.get("limits")
+ if not isinstance(entries, list):
+ return []
+ now = datetime.now(timezone.utc)
+ out = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ raw = str(entry.get("resetsAt") or "")
+ if raw:
+ try:
+ resets_at = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ except ValueError:
+ continue
+ if resets_at.tzinfo is None:
+ resets_at = resets_at.replace(tzinfo=timezone.utc)
+ if resets_at <= now:
+ continue
+ out.append(entry)
+ return out
+
+
+def probe_limits(token: str) -> dict[str, Any]:
+ result: dict[str, Any] = {
+ "limits": [],
+ "tierLabel": "",
+ "usageStatusText": "",
+ "authHelpText": AUTH_HELP,
+ "live": False,
+ "fetchedAtMs": 0,
+ }
+ result["tierLabel"] = plan_label(token) or AGENT_NAME
+ try:
+ usage = rpc(token, "aiserver.v1.DashboardService/GetCurrentPeriodUsage", {})
+ except urllib.error.HTTPError as error:
+ result["usageStatusText"] = "Waiting for auth" if error.code in (401, 403) else "X Subscription limits unavailable"
+ return result
+ except Exception:
+ result["usageStatusText"] = "X Subscription limits unavailable"
+ return result
+
+ plan_usage = usage.get("planUsage") or {}
+ if not isinstance(plan_usage, dict):
+ plan_usage = {}
+ resets_at = ms_to_iso(usage.get("billingCycleEnd"))
+ auto = pct_to_unit(plan_usage.get("autoPercentUsed"))
+ api = pct_to_unit(plan_usage.get("apiPercentUsed"))
+ total = pct_to_unit(plan_usage.get("totalPercentUsed"))
+ limits: list[dict[str, Any]] = []
+ if auto >= 0:
+ limits.append(
+ {
+ "label": "Included pool (Cursor + Grok)",
+ "percent": auto,
+ "resetsAt": resets_at,
+ "title": "First-party",
+ }
+ )
+ if api > 0:
+ limits.append(
+ {
+ "label": "API / named models",
+ "percent": api,
+ "resetsAt": resets_at,
+ "title": "API",
+ }
+ )
+ if total >= 0 and (auto < 0 or abs(total - auto) > 0.01):
+ limits.append(
+ {
+ "label": "Total included",
+ "percent": total,
+ "resetsAt": resets_at,
+ "title": "Total",
+ }
+ )
+
+ if not limits:
+ result["usageStatusText"] = "X Subscription limits unavailable"
+ return result
+
+ result.update(
+ limits=limits,
+ live=True,
+ fetchedAtMs=round(time.time() * 1000),
+ authHelpText="",
+ usageStatusText="",
+ )
+ return result
+
+
+def collect_limits(token: str | None, force: bool) -> dict[str, Any]:
+ cache = limits_cache_path(token)
+ raw_cached = read_json(cache)
+ cached = raw_cached if isinstance(raw_cached, dict) else {}
+ fallback = usable_cached_limits(cached, token)
+ result: dict[str, Any] = {
+ "limits": [],
+ "tierLabel": str(cached.get("tierLabel") or ""),
+ "usageStatusText": "",
+ "authHelpText": AUTH_HELP,
+ "live": False,
+ "fetchedAtMs": int(cached.get("fetchedAtMs") or 0),
+ }
+
+ if not token:
+ result["limits"] = fallback
+ result["usageStatusText"] = "Cursor auth missing" if not fallback else "Sign-in expired"
+ return result
+
+ fetched_at = int(cached.get("fetchedAtMs") or 0)
+ age_ms = round(time.time() * 1000) - fetched_at
+ if not force and fallback and fetched_at and age_ms <= PROBE_REUSE_SECONDS * 1000:
+ result.update(limits=fallback, live=True, authHelpText="", tierLabel=result["tierLabel"] or AGENT_NAME)
+ return result
+
+ probe = probe_limits(token)
+ if probe["live"]:
+ write_json(
+ cache,
+ {
+ "fetchedAtMs": probe["fetchedAtMs"],
+ "tierLabel": probe["tierLabel"],
+ "limits": probe["limits"],
+ "tokenFingerprint": token_fingerprint(token),
+ },
+ )
+ return probe
+
+ result["limits"] = fallback
+ result["usageStatusText"] = probe["usageStatusText"] or result["usageStatusText"]
+ result["authHelpText"] = probe["authHelpText"]
+ if fallback:
+ result["usageStatusText"] = result["usageStatusText"] or "Showing last known limits"
+ return result
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--force", action="store_true")
+ parser.add_argument("--limits-only", action="store_true")
+ args = parser.parse_args()
+
+ token = load_token()
+ limits = collect_limits(token, args.force)
+ reuse = LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0
+ stats = cached_local_stats(reuse)
+ tokens: dict[str, Any] = {}
+ if token:
+ tokens = cached_remote_token_stats(token, reuse)
+ stats["recentDays"] = tokens.get("recentDays") or stats.get("recentDays")
+ stats["todayTotalTokens"] = int(tokens.get("todayTotalTokens") or 0)
+ stats["todayTokensByModel"] = tokens.get("todayTokensByModel") or {}
+ stats["modelUsage"] = tokens.get("modelUsage") or {}
+ token_days = tokens.get("tokenActiveDates") or []
+ prompt_active_days = int(stats.get("activeDays") or 0)
+ prompt_dates = set(stats.get("activeDates") or [])
+ merged_dates = sorted(prompt_dates | set(token_days))
+ stats["activeDates"] = merged_dates[-30:]
+ # Keep the full prompt-day count; only the date list is capped at 30.
+ stats["activeDays"] = max(prompt_active_days, len(merged_dates))
+ if token_days or stats.get("todayTotalTokens"):
+ stats["hasLocalStats"] = True
+ if tokens.get("tokensStale") and not limits["usageStatusText"]:
+ limits["usageStatusText"] = "Showing last known token totals"
+ ready = bool(limits["limits"]) or bool(
+ stats.get("totalPrompts") or stats.get("totalSessions") or stats.get("todayTotalTokens")
+ )
+ record = {
+ "schemaVersion": 1,
+ "id": AGENT_ID,
+ "name": AGENT_NAME,
+ "updatedAt": datetime.now(timezone.utc).isoformat(),
+ "ready": ready,
+ # Dashboard token totals are account-global; synced machines must not sum them.
+ "scope": "account",
+ "tierLabel": limits["tierLabel"],
+ "usageStatusText": limits["usageStatusText"],
+ "authHelpText": limits["authHelpText"] if not limits["limits"] else "",
+ "limits": limits["limits"],
+ "limitsStale": not limits["live"] or bool(tokens.get("tokensStale") if token else False),
+ "limitsFetchedAt": limits["fetchedAtMs"],
+ }
+ record.update(stats)
+ print(json.dumps(record, separators=(",", ":")))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md
index 26af2f72c6d..d97dae3a8d3 100644
--- a/shell/plugins/agents/README.md
+++ b/shell/plugins/agents/README.md
@@ -75,6 +75,7 @@ light surfaces — and the bar glyph stands in when there is none.
| `codex` | The Codex app-server RPC | native Codex CLI session files on the built-in `openai` provider (plus pi and opencode sessions) |
| `grok` | The credits endpoint behind Grok's `/usage` view (the billing period's included usage) | Each session's `usage.json` (the ledger `grok usage` prints: tokens by model per finished turn), plus `summary.json` for sessions |
| `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days |
+| `x` | Shared first-party pool for The X Subscription (Cursor + Grok) via Cursor's private `DashboardService` RPCs (`GetCurrentPeriodUsage` / `GetPlanInfo` on `api2.cursor.sh`); tokens by day from `GetAggregatedUsageEvents` | Cursor agent transcripts under `~/.cursor/projects/**/agent-transcripts` |
When `~/.local/state/omarchy/agents/accounts/.json`
registers more than one account, the `claude`, `codex`, and `grok` records
@@ -105,6 +106,16 @@ pi stores in `~/.pi/agent/auth.json` when Fireworks is signed in there
(honoring `PI_CODING_AGENT_DIR`, and pi's literal and `$ENV_VAR` key forms),
and finally the key opencode stores in `~/.local/share/opencode/auth.json`.
+X Subscription reads the Cursor sign-in in `~/.config/cursor/auth.json`
+(the same token `cursor-agent login` writes). Its limits describe the shared
+included pool for Cursor and Grok on that plan; tokens by day come from
+`GetAggregatedUsageEvents`; local prompt counts come from Cursor agent
+transcripts. The product is branded under X, but the meter the
+Cursor UI itself uses for that shared pool still comes from Cursor's private
+dashboard RPCs — there is no documented public xAI usage API for the merged
+subscription yet, so this collector will break if those RPCs move. Machines
+with no Cursor sign-in still get an empty record the panel skips.
+
### Fireworks balance
The collector first asks the account's `:getBalance` endpoint for the real
diff --git a/shell/plugins/agents/assets/x-light.svg b/shell/plugins/agents/assets/x-light.svg
new file mode 100644
index 00000000000..ace5bb685a4
--- /dev/null
+++ b/shell/plugins/agents/assets/x-light.svg
@@ -0,0 +1,3 @@
+
diff --git a/shell/plugins/agents/assets/x.svg b/shell/plugins/agents/assets/x.svg
new file mode 100644
index 00000000000..435314cc49c
--- /dev/null
+++ b/shell/plugins/agents/assets/x.svg
@@ -0,0 +1,3 @@
+
diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json
index 7ca52484b12..cceef2041f2 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, Grok, and Fireworks usage, limits, and pace in a native Omarchy bar panel.",
+ "description": "Claude Code, Codex, Grok, Fireworks, and X Subscription usage, limits, and pace in a native Omarchy bar panel.",
"kinds": ["bar-widget"],
"activation": "on-demand",
"entryPoints": {
@@ -22,7 +22,8 @@
"claude": { "enabled": true },
"codex": { "enabled": true },
"grok": { "enabled": true },
- "fireworks": { "enabled": true }
+ "fireworks": { "enabled": true },
+ "x": { "enabled": true }
},
"refreshIntervalSec": 900,
"syncMode": "Off",
diff --git a/test/shell.d/agent-usage-x-test.sh b/test/shell.d/agent-usage-x-test.sh
new file mode 100755
index 00000000000..230937b7d69
--- /dev/null
+++ b/test/shell.d/agent-usage-x-test.sh
@@ -0,0 +1,257 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+require_command jq
+require_command python3
+
+test_tmp=$(mktemp -d)
+trap 'rm -rf "$test_tmp"' EXIT
+
+export HOME="$test_tmp/home"
+export XDG_CONFIG_HOME="$test_tmp/config"
+export XDG_CACHE_HOME="$test_tmp/cache"
+export XDG_STATE_HOME="$test_tmp/state"
+
+mkdir -p "$HOME" "$XDG_CONFIG_HOME/cursor" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
+
+billing_end_ms=$(python3 -c 'import datetime as dt; print(int((dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=12)).timestamp() * 1000))')
+
+signed_in() {
+ jq -nc --arg token "$1" '{accessToken: $token}' >"$XDG_CONFIG_HOME/cursor/auth.json"
+}
+
+# COLLECT_ARGS unset => --force. COLLECT_ARGS="" => no flags (reuse allowed).
+collect() {
+ COLLECTOR="$ROOT/bin/omarchy-agent-usage-x" BILLING_END_MS="$billing_end_ms" \
+ AUTO_PCT="${AUTO_PCT:-37.5}" python3 - "$@" <<'PY'
+import importlib.machinery, importlib.util, io, json, os, sys, urllib.error
+
+loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+
+end = int(os.environ["BILLING_END_MS"])
+auto = float(os.environ["AUTO_PCT"])
+responses = {
+ "aiserver.v1.DashboardService/GetPlanInfo": {
+ "planInfo": {
+ "planName": "Pro",
+ "price": "$20/mo",
+ "includedUsagePeriod": "INCLUDED_USAGE_PERIOD_MONTHLY",
+ }
+ },
+ "aiserver.v1.DashboardService/GetCurrentPeriodUsage": {
+ "billingCycleEnd": end,
+ "planUsage": {
+ "autoPercentUsed": auto,
+ "apiPercentUsed": 0,
+ "totalPercentUsed": auto,
+ },
+ },
+}
+
+calls = {"n": 0}
+
+def urlopen(request, timeout=None):
+ calls["n"] += 1
+ if os.environ.get("X_FAIL_USAGE") == "1":
+ raise urllib.error.HTTPError(request.full_url, 401, "Unauthorized", hdrs=None, fp=None)
+ method = request.full_url.split("://", 1)[-1].split("/", 1)[-1]
+ if method.endswith("GetAggregatedUsageEvents"):
+ if os.environ.get("X_FAIL_AGG") == "1":
+ raise TimeoutError("dashboard day aggregate timed out")
+ # Optional day-token fixture for the tokens-by-day test.
+ rows = json.loads(os.environ.get("X_DAY_AGG", "[]"))
+ class Resp(io.BytesIO):
+ def __enter__(self): return self
+ def __exit__(self, *a): return False
+ return Resp(json.dumps({"aggregations": rows}).encode())
+ # Limits-reuse tests only reject plan/usage probes, not day-token scans.
+ if os.environ.get("X_REJECT_API") == "1":
+ raise AssertionError(f"unexpected API call: {request.full_url}")
+ if method not in responses:
+ raise AssertionError(f"unexpected method {method}")
+ auth = request.get_header("Authorization") or request.get_header("authorization")
+ expected = os.environ.get("X_TOKEN", "token-main")
+ assert auth.split(" ", 1)[1] == expected
+
+ class Resp(io.BytesIO):
+ def __enter__(self): return self
+ def __exit__(self, *a): return False
+
+ return Resp(json.dumps(responses[method]).encode())
+
+collector.urllib.request.urlopen = urlopen
+if "COLLECT_ARGS" in os.environ:
+ args = os.environ["COLLECT_ARGS"].split() if os.environ["COLLECT_ARGS"] else []
+else:
+ args = ["--force"]
+sys.argv = ["omarchy-agent-usage-x"] + args
+collector.main()
+print(f"\n__calls__={calls['n']}", file=sys.stderr)
+PY
+}
+
+record=$(collect)
+[[ $(jq -c '{ready, tierLabel, limits, usageStatusText}' <<<"$record") == '{"ready":false,"tierLabel":"","limits":[],"usageStatusText":"Cursor auth missing"}' ]] ||
+ fail "without Cursor auth the X Subscription record stays empty" "$record"
+pass "without Cursor auth the X Subscription record stays empty"
+
+signed_in token-main
+record=$(collect)
+[[ $(jq -c '{ready, id, name, tierLabel, stale: .limitsStale, label: .limits[0].label, percent: .limits[0].percent}' <<<"$record") == '{"ready":true,"id":"x","name":"X Subscription","tierLabel":"Pro · $20/mo · shared monthly pool","stale":false,"label":"Included pool (Cursor + Grok)","percent":0.375}' ]] ||
+ fail "X Subscription plan and shared pool come from Cursor dashboard APIs" "$record"
+[[ -n $(jq -r '.limits[0].resetsAt' <<<"$record") ]] || fail "the billing window says when the period ends" "$record"
+[[ $(jq -r '.authHelpText' <<<"$record") == "" ]] || fail "a successful probe drops the login hint" "$record"
+pass "X Subscription plan and shared pool come from Cursor dashboard APIs"
+
+# 0–100 API percents at and below 1 stay 1% / 0.5%, not a full bar.
+record=$(AUTO_PCT=1 COLLECT_ARGS="--force" collect)
+[[ $(jq -r '.limits[0].percent' <<<"$record") == "0.01" ]] ||
+ fail "1% used from the API is 0.01, not a full bar" "$record"
+record=$(AUTO_PCT=0.5 COLLECT_ARGS="--force" collect)
+[[ $(jq -r '.limits[0].percent' <<<"$record") == "0.005" ]] ||
+ fail "0.5% used from the API is 0.005" "$record"
+pass "small API percentages stay small"
+
+# Reused limits must not hit the network again.
+record=$(X_REJECT_API=1 COLLECT_ARGS="" collect 2>"$test_tmp/reuse.err")
+[[ $(jq -c '{percent: .limits[0].percent, help: .authHelpText, stale: .limitsStale}' <<<"$record") == '{"percent":0.005,"help":"","stale":false}' ]] ||
+ fail "reused X Subscription limits drop the login hint" "$record"
+grep -q 'unexpected API call' "$test_tmp/reuse.err" &&
+ fail "cache reuse still called the API" "$(cat "$test_tmp/reuse.err")"
+pass "reused X Subscription limits drop the login hint"
+
+# A failed probe keeps the last numbers for this login and says so.
+record=$(collect)
+record=$(X_FAIL_USAGE=1 COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{usageStatusText, first: .limits[0].percent, stale: .limitsStale}' <<<"$record") == '{"usageStatusText":"Waiting for auth","first":0.375,"stale":true}' ]] ||
+ fail "a failed probe keeps the last shared-pool numbers" "$record"
+pass "a failed probe keeps the last shared-pool numbers"
+
+# A different Cursor login never inherits the previous account's cache.
+signed_in token-side
+record=$(X_TOKEN=token-side AUTO_PCT=12 COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{percent: .limits[0].percent, stale: .limitsStale}' <<<"$record") == '{"percent":0.12,"stale":false}' ]] ||
+ fail "a new Cursor login probes its own shared pool" "$record"
+caches=$(find "$XDG_CACHE_HOME/omarchy/agent-usage" -name 'x-limits-*.json' | wc -l)
+((caches >= 2)) || fail "each Cursor login keeps its own limits cache" "caches=$caches"
+pass "a new Cursor login probes its own shared pool"
+
+# Local Cursor transcripts become prompt/session stats from each turn's stamp.
+projects="$HOME/.cursor/projects/demo/agent-transcripts"
+mkdir -p "$projects"
+today_stamp=$(python3 -c 'import datetime as dt; print(dt.date.today().strftime("%A, %b %-d, %Y"))')
+old_stamp="Monday, Jan 5, 2026"
+cat >"$projects/session-a.jsonl" <${old_stamp}, 9:00 AM (UTC-5)\\nold"}]}}
+{"role":"assistant","content":"hi"}
+{"role":"user","message":{"content":[{"type":"text","text":"${today_stamp}, 10:00 AM (UTC-5)\\ntoday"}]}}
+{"role":"user","message":{"content":[{"type":"text","text":"${today_stamp}, 11:00 AM (UTC-5)\\nagain"}]}}
+JSONL
+cat >"$projects/session-b.jsonl" <${today_stamp}, 1:00 PM (UTC-5)\\nother"}]}}
+JSONL
+# Continuing an old file today must not move its undated/old prompts onto today.
+cat >"$projects/session-old.jsonl" <<'JSONL'
+{"role":"user","content":"no stamp here"}
+{"role":"assistant","content":"ok"}
+JSONL
+touch -d "2026-01-02 12:00:00" "$projects/session-old.jsonl"
+touch -d "$(date '+%Y-%m-%d %H:%M:%S')" "$projects/session-a.jsonl" "$projects/session-b.jsonl"
+rm -f "$XDG_CACHE_HOME"/omarchy/agent-usage/x-scan.json
+record=$(X_TOKEN=token-side COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{hasLocalStats, todayPrompts, todaySessions, totalPrompts, totalSessions, weekTokens: ([.recentDays[].messageCount] | add)}' <<<"$record") == '{"hasLocalStats":true,"todayPrompts":3,"todaySessions":2,"totalPrompts":5,"totalSessions":3,"weekTokens":0}' ]] ||
+ fail "X Subscription counts dated prompts and keeps recentDays in tokens" "$record"
+pass "X Subscription counts dated prompts and keeps recentDays in tokens"
+
+# Empty / assistant-only transcripts do not invent activity.
+mkdir -p "$projects/empty"
+: >"$projects/empty/blank.jsonl"
+printf '%s\n' '{"role":"assistant","content":"only reply"}' >"$projects/empty/assistant-only.jsonl"
+rm -f "$XDG_CACHE_HOME"/omarchy/agent-usage/x-scan.json
+record=$(X_TOKEN=token-side COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{totalPrompts, totalSessions, activeDays}' <<<"$record") == '{"totalPrompts":5,"totalSessions":3,"activeDays":2}' ]] ||
+ fail "empty transcripts do not invent prompts or active days" "$record"
+pass "empty transcripts do not invent prompts or active days"
+
+# Stamped offsets convert into the machine's local calendar day.
+python3 - "$ROOT/bin/omarchy-agent-usage-x" <<'PY'
+import importlib.machinery, importlib.util, os, sys
+loader = importlib.machinery.SourceFileLoader("collector", sys.argv[1])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+obj = {
+ "role": "user",
+ "message": {
+ "content": [{
+ "type": "text",
+ "text": "Monday, Oct 12, 2026, 11:30 PM (UTC-5)\nx",
+ }]
+ },
+}
+os.environ["TZ"] = "UTC"
+# Reload local clock helpers after TZ change.
+import time
+time.tzset()
+day = collector.prompt_day(obj)
+assert str(day) == "2026-10-13", day
+os.environ["TZ"] = "America/Chicago"
+time.tzset()
+day = collector.prompt_day(obj)
+assert str(day) == "2026-10-12", day
+iso_day = collector.prompt_day({"role": "user", "createdAt": "2026-10-13T04:30:00Z"})
+assert str(iso_day) == "2026-10-12", iso_day
+print("ok")
+PY
+[[ $? -eq 0 ]] || fail "prompt stamps convert into the local calendar day"
+pass "prompt stamps convert into the local calendar day"
+
+# Dashboard day aggregates fill recentDays.messageCount with tokens.
+agg='[{"modelIntent":"grok-4.7-high-fast","inputTokens":100,"outputTokens":20,"cacheReadTokens":5,"cacheWriteTokens":0}]'
+rm -f "$XDG_CACHE_HOME"/omarchy/agent-usage/x-tokens-*.json
+record=$(X_TOKEN=token-side X_DAY_AGG="$agg" COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{todayTotalTokens, weekTokens: ([.recentDays[].messageCount] | add), model: .todayTokensByModel["grok-4.7-high-fast"]}' <<<"$record") == '{"todayTotalTokens":125,"weekTokens":875,"model":125}' ]] ||
+ fail "dashboard day aggregates fill token counts for the week" "$record"
+[[ $(jq -r '.scope' <<<"$record") == "account" ]] ||
+ fail "dashboard token totals are account-scoped so sync does not double them" "$record"
+pass "dashboard day aggregates fill token counts for the week"
+
+# A timed-out day aggregate keeps the last known week totals.
+record=$(X_TOKEN=token-side X_FAIL_AGG=1 COLLECT_ARGS="--force" collect)
+[[ $(jq -c '{weekTokens: ([.recentDays[].messageCount] | add), today: .todayTotalTokens, stale: .limitsStale, status: .usageStatusText}' <<<"$record") == '{"weekTokens":875,"today":125,"stale":true,"status":"Showing last known token totals"}' ]] ||
+ fail "a failed day aggregate keeps the last known token totals" "$record"
+pass "a failed day aggregate keeps the last known token totals"
+
+# Merging token dates must not shrink a prompt activeDays count past 30.
+python3 - "$ROOT/bin/omarchy-agent-usage-x" <<'PY'
+import importlib.machinery, importlib.util, sys
+loader = importlib.machinery.SourceFileLoader("collector", sys.argv[1])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+# Simulate main()'s merge: 80 prompt days truncated to 30 dates, plus token days.
+prompt_active_days = 80
+prompt_dates = {f"2026-01-{i:02d}" for i in range(1, 31)}
+token_days = ["2026-10-01", "2026-10-02"]
+merged_dates = sorted(prompt_dates | set(token_days))
+active_days = max(prompt_active_days, len(merged_dates))
+assert active_days == 80, active_days
+assert len(merged_dates[-30:]) == 30
+print("ok")
+PY
+[[ $? -eq 0 ]] || fail "activeDays keeps the full prompt-day count when merging token dates"
+pass "activeDays keeps the full prompt-day count when merging token dates"
+
+# limits-only reuses the transcript scan.
+
+mkdir -p "$projects/extra"
+printf '%s\n' "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"${today_stamp}, 2:00 PM (UTC-5)\\nlater\"}]}}" >"$projects/extra/session-c.jsonl"
+record=$(X_TOKEN=token-side COLLECT_ARGS="--limits-only" collect)
+[[ $(jq -r '.totalSessions' <<<"$record") == 3 ]] || fail "a limits-only refresh reuses the transcript scan" "$record"
+pass "a limits-only refresh reuses the transcript scan"