* Let the agents in the panel be put in any order Drag an agent by its mark to move its section; the header it will land on lights up, and the move happens on release. Each agent's header is now a keyboard stop with its own highlight, and Ctrl+Up/Down moves the agent the cursor is in. The order is kept in agents/order.json beside the usage records. The key catcher turns Ctrl+Up/Down into a reorder only for panels that opt in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Light only an agent's mark when the cursor or a drag is on it The mark is the handle the agent moves by, so the keyboard cursor and the drop spot while dragging box it alone rather than the header line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep a lit agent mark's box from being clipped at the panel's edge The box overhangs the content's left edge, which the scrolling area clipped. The scrolling area now reaches a little into the panel's padding with the content shifted back, so nothing moves and the box draws whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reuse a Grok session scan only on the day it was made A limits-only refresh just after midnight reused a scan from the evening before, which counted yesterday's sessions as today's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep Ctrl+Up/Down from moving an agent when the cursor is outside one The hero's buttons and the starter tiles carry indices too, and the lookup read them as accounts, so with several accounts Ctrl+Up/Down on one of them moved an unrelated agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Report a failed mise update even after the Grok upkeep runs The Grok block ran after `mise up` and its last command set the script's status, so a failed tool update could read as a success to callers that warn about it. The update's own status is now the script's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Unpack a Grok update into ~/.grok whatever GROK_HOME says The upkeep replaces ~/.grok's link, but the npm launcher unpacks into GROK_HOME when it's set, so with a custom home the link never came back at the new release and the old one was restored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hold off reordering agents while an account name is being edited Ctrl+Up/Down reached the key catcher during an inline rename, and moving the agent rebuilt its section, dropping the unfinished name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Install Grok through mise's first-party package The npm launcher keeps an old ~/.grok/bin binary because mise skips its postinstall. Use mise's grok tool, and drop the npm tool so its shim does not stay ahead of the stub. * Drop the npm Grok workarounds now that mise installs Grok itself With Grok installed through mise's first-party package, the CLI is the binary mise manages, so the update upkeep that repointed ~/.grok/bin and the shared bin directory for added Grok accounts have nothing left to do. omarchy-update-mise is back to running mise up and nothing else, and adding a first Grok account installs the same package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count Grok's tokens and prompts from its usage ledger Each Grok session keeps a usage.json, the ledger `grok usage` prints, with every finished turn's end time and tokens by model. The collector now reads it for tokens today, by day for the last week, and by model, with cached input kept apart, and counts today's prompts by the turns that ended today. Found in #12352's research. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count cache writes in Grok's daily token totals Grok's totalTokens leaves cache writes out while the per-model buckets count them, so a turn with cache writes added less to its day than to its model. The day's total is now the sum of those same buckets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove the npm launcher's old Grok binaries when moving to mise's Grok Omarchy's npm wrapper ran the binary the launcher unpacked into ~/.grok/bin, where x.ai's installer also puts a copy with a PATH entry ahead of mise. Once the wrapper is replaced, a binary left there would keep shadowing the mise tool, so the migration removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jesse Miller <jmiller@jmiller.com>
335 lines
12 KiB
Python
Executable File
335 lines
12 KiB
Python
Executable File
#!/usr/bin/python3
|
|
# omarchy:summary=Print the Grok usage record as JSON
|
|
# omarchy:args=[--force] [--limits-only]
|
|
# omarchy:hidden=true
|
|
"""Collect Grok usage into one display-ready JSON record.
|
|
|
|
The plan comes from the settings Grok caches from xAI, and the limit from the
|
|
credits endpoint the Grok CLI itself reads for its /usage view, asked with
|
|
each account's own sign-in. Sessions come from the summary Grok keeps beside
|
|
each session, and tokens and prompts from its usage.json, the ledger `grok
|
|
usage` prints: one entry per finished turn, with when it ended and its tokens
|
|
by model. The agents panel only ever reads the JSON this prints.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
AGENT_ID = "grok"
|
|
AGENT_NAME = "Grok"
|
|
AUTH_HELP = "Start Grok, or run `grok login`, to sign in."
|
|
CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
|
|
# Grok's own CLI watches its subscription once a minute; a panel opened and
|
|
# shut repeatedly reuses an answer for about that long.
|
|
PROBE_REUSE_SECONDS = 60
|
|
# A limits-only refresh needs fresh limits, not a fresh history scan.
|
|
LIMITS_ONLY_SCAN_REUSE_SECONDS = 900
|
|
|
|
|
|
def read_json(path):
|
|
try:
|
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# A temporary file of its own per write, so overlapping runs can't rename
|
|
# each other's away.
|
|
def write_json(path, payload):
|
|
path = Path(path)
|
|
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():
|
|
return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
|
|
|
|
|
|
def parse_time(value):
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
# The home Grok uses when nothing selects an account: GROK_HOME when set,
|
|
# as the CLI itself reads it, else ~/.grok.
|
|
def default_home():
|
|
return Path(os.environ.get("GROK_HOME") or (Path.home() / ".grok"))
|
|
|
|
|
|
# Every session under <home>/sessions/<folder>/<session>/: its summary says
|
|
# when it was last active and how many prompts it had, and its usage.json
|
|
# lists each finished turn with its tokens by model. Added accounts link
|
|
# their sessions to the primary's, so this covers all of them. Grok counts
|
|
# cached input inside inputTokens; the record keeps the two apart, as the
|
|
# other collectors do.
|
|
def local_stats(home):
|
|
now = datetime.now().astimezone()
|
|
today = now.date().isoformat()
|
|
week = [(now.date() - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
|
|
prompts = sessions = today_sessions = today_prompts = today_tokens = 0
|
|
days = set()
|
|
by_day = {day: 0 for day in week}
|
|
today_by_model = {}
|
|
models = {}
|
|
for session in (Path(home) / "sessions").glob("*/*/"):
|
|
summary = read_json(session / "summary.json")
|
|
if isinstance(summary, dict):
|
|
active = parse_time(summary.get("last_active_at"))
|
|
if active:
|
|
day = active.astimezone().date().isoformat()
|
|
sessions += 1
|
|
prompts += int(summary.get("num_messages") or 0)
|
|
days.add(day)
|
|
if day == today:
|
|
today_sessions += 1
|
|
|
|
usage = read_json(session / "usage.json")
|
|
turns = usage.get("turns") if isinstance(usage, dict) else None
|
|
for turn in turns if isinstance(turns, list) else []:
|
|
ended = parse_time(turn.get("endedAt")) if isinstance(turn, dict) else None
|
|
if not ended:
|
|
continue
|
|
day = ended.astimezone().date().isoformat()
|
|
days.add(day)
|
|
if day == today:
|
|
today_prompts += 1
|
|
for model, bucket in (turn.get("modelUsage") or {}).items():
|
|
if not isinstance(bucket, dict):
|
|
continue
|
|
# Grok's totalTokens leaves cache writes out; counting them here keeps
|
|
# the day's total equal to the sum of its model's buckets.
|
|
cached = int(bucket.get("cachedReadTokens") or 0)
|
|
uncached = max(0, int(bucket.get("inputTokens") or 0) - cached)
|
|
output = int(bucket.get("outputTokens") or 0)
|
|
created = int(bucket.get("cacheCreationTokens") or 0)
|
|
tokens = uncached + cached + output + created
|
|
totals = models.setdefault(model, {"inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0})
|
|
totals["inputTokens"] += uncached
|
|
totals["outputTokens"] += output
|
|
totals["cacheReadInputTokens"] += cached
|
|
totals["cacheCreationInputTokens"] += created
|
|
if day in by_day:
|
|
by_day[day] += tokens
|
|
if day == today:
|
|
today_tokens += tokens
|
|
today_by_model[model] = today_by_model.get(model, 0) + tokens
|
|
|
|
return {
|
|
"hasLocalStats": sessions > 0,
|
|
"todayPrompts": today_prompts,
|
|
"todaySessions": today_sessions,
|
|
"todayTotalTokens": today_tokens,
|
|
"todayTokensByModel": today_by_model,
|
|
"recentDays": [{"date": day, "messageCount": by_day[day]} for day in week],
|
|
"totalPrompts": prompts,
|
|
"totalSessions": sessions,
|
|
"activeDays": len(days),
|
|
"activeDates": sorted(days),
|
|
"modelUsage": models,
|
|
}
|
|
|
|
|
|
# A scan from before midnight would count yesterday's sessions as today's.
|
|
def cached_local_stats(home, max_age):
|
|
cache = cache_root() / "grok-stats.json"
|
|
cached = read_json(cache) or {}
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
if cached.get("home") == str(home) and cached.get("day") == today and time.time() - float(cached.get("at") or 0) < max_age:
|
|
return cached["stats"]
|
|
stats = local_stats(home)
|
|
write_json(cache, {"home": str(home), "day": today, "at": time.time(), "stats": stats})
|
|
return stats
|
|
|
|
|
|
# One login per issuer in auth.json; the first is the CLI's own.
|
|
def login(home):
|
|
auth = read_json(Path(home) / "auth.json")
|
|
if not isinstance(auth, dict):
|
|
return {}
|
|
return next((v for v in auth.values() if isinstance(v, dict)), {})
|
|
|
|
|
|
def plan(home):
|
|
cached = read_json(Path(home) / "settings_cache.json") or {}
|
|
try:
|
|
settings = json.loads(cached.get("payload") or "{}").get("settings") or {}
|
|
except Exception:
|
|
return ""
|
|
return str(settings.get("subscription_tier_display") or "")
|
|
|
|
|
|
# The credits answer as one limit window: how much of this period's included
|
|
# usage is gone, and when the period ends. The answer is protobuf JSON, which
|
|
# leaves out a field holding zero, so a period with nothing used yet has no
|
|
# percentage at all.
|
|
def credits_limit(data):
|
|
config = data["config"]
|
|
period = config["currentPeriod"]
|
|
return {
|
|
"label": "Weekly" if "WEEK" in str(period.get("type")) else "Monthly",
|
|
"percent": max(0.0, min(1.0, float(config.get("creditUsagePercent") or 0) / 100)),
|
|
"resetsAt": parse_time(period["end"]).isoformat(),
|
|
}
|
|
|
|
|
|
def collect_limits(home, key, force):
|
|
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0}
|
|
entry = login(home)
|
|
token = str(entry.get("key") or "")
|
|
if not token:
|
|
result["usageStatusText"] = "Waiting for auth"
|
|
return result
|
|
|
|
cache = cache_root() / f"grok-limits-{key}.json"
|
|
cached = read_json(cache) or {}
|
|
fetched_at = float(cached.get("fetchedAtMs") or 0)
|
|
result["fetchedAtMs"] = fetched_at
|
|
|
|
# The CLI refreshes its token while it runs; one left to lapse can't be
|
|
# used until Grok starts again.
|
|
expires = parse_time(entry.get("expires_at"))
|
|
if expires and expires.timestamp() <= time.time():
|
|
result["limits"] = cached.get("limits") or []
|
|
result["usageStatusText"] = "Sign-in expired"
|
|
result["authHelpText"] = "Grok's saved sign-in expired. Start Grok to refresh it."
|
|
return result
|
|
|
|
if cached.get("limits") and not force and time.time() - fetched_at / 1000 < PROBE_REUSE_SECONDS:
|
|
result.update(limits=cached["limits"], live=True)
|
|
return result
|
|
|
|
request = urllib.request.Request(CREDITS_URL, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=8) as response:
|
|
limit = credits_limit(json.loads(response.read()))
|
|
except urllib.error.HTTPError as error:
|
|
limit = None
|
|
if error.code in (401, 403):
|
|
result["usageStatusText"] = "Waiting for auth"
|
|
except Exception:
|
|
limit = None
|
|
|
|
if limit:
|
|
result.update(limits=[limit], live=True, fetchedAtMs=round(time.time() * 1000))
|
|
write_json(cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": result["limits"]})
|
|
else:
|
|
result["limits"] = cached.get("limits") or []
|
|
if not result["usageStatusText"] and not result["limits"]:
|
|
result["usageStatusText"] = "Grok limits unavailable"
|
|
return result
|
|
|
|
|
|
# The accounts `omarchy agent account` registered, read straight from its
|
|
# registry. Only a registry holding a second account matters: with one, the
|
|
# record describes ~/.grok alone.
|
|
def registered_accounts():
|
|
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
|
registry = read_json(state / "omarchy" / "agents" / "accounts" / "grok.json") or {}
|
|
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
|
if len(accounts) < 2:
|
|
return []
|
|
active_id = registry.get("active") or accounts[0]["id"]
|
|
if not any(a["id"] == active_id for a in accounts):
|
|
active_id = accounts[0]["id"]
|
|
for account in accounts:
|
|
account["active"] = account["id"] == active_id
|
|
account["switch"] = {
|
|
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
|
"threshold": registry.get("threshold") or 95,
|
|
}
|
|
return accounts
|
|
|
|
|
|
def home_of(account):
|
|
return Path(account.get("home") or (Path.home() / ".grok"))
|
|
|
|
|
|
# Keyed by who the home is signed in as, so a home signed in to someone else
|
|
# never shows the last one's numbers.
|
|
def cache_key(home, fallback):
|
|
user = str(login(home).get("user_id") or "")
|
|
return re.sub(r"[^A-Za-z0-9_-]", "", user) or fallback
|
|
|
|
|
|
def account_limits(account, force):
|
|
home = home_of(account)
|
|
limits = collect_limits(home, cache_key(home, account["id"]), force)
|
|
return {
|
|
"id": account["id"],
|
|
"label": str(account.get("label") or account["id"]),
|
|
"email": str(account.get("email") or ""),
|
|
"plan": plan(home) or str(account.get("plan") or ""),
|
|
"active": account["active"],
|
|
"primary": bool(account.get("primary")),
|
|
"limits": limits["limits"],
|
|
"stale": not limits["live"],
|
|
"fetchedAt": limits["fetchedAtMs"],
|
|
"usageStatusText": limits["usageStatusText"],
|
|
"authHelpText": limits["authHelpText"],
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--force", action="store_true")
|
|
parser.add_argument("--limits-only", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
registered = registered_accounts()
|
|
accounts = [account_limits(account, args.force) for account in registered]
|
|
current = next((a for a in accounts if a["active"]), None)
|
|
if current:
|
|
tier = current["plan"]
|
|
limits = current
|
|
stale, fetched_at = current["stale"], current["fetchedAt"]
|
|
else:
|
|
home = default_home()
|
|
tier = plan(home)
|
|
limits = collect_limits(home, cache_key(home, "main"), args.force)
|
|
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
|
|
|
# Nobody signed in to Grok here: an empty record, which the panel skips.
|
|
# With accounts registered, the primary is ~/.grok by definition.
|
|
stats_home = Path.home() / ".grok" if accounts else default_home()
|
|
signed_in = bool(accounts) or bool(login(stats_home))
|
|
record = {
|
|
"schemaVersion": 1,
|
|
"id": AGENT_ID,
|
|
"name": AGENT_NAME,
|
|
"updatedAt": datetime.now(timezone.utc).isoformat(),
|
|
"ready": signed_in,
|
|
"tierLabel": tier,
|
|
"usageStatusText": limits["usageStatusText"],
|
|
"authHelpText": limits["authHelpText"],
|
|
"limits": limits["limits"],
|
|
"limitsStale": stale,
|
|
"limitsFetchedAt": fetched_at,
|
|
}
|
|
record.update(cached_local_stats(stats_home, LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0))
|
|
if accounts:
|
|
record["accountSwitch"] = registered[0]["switch"]
|
|
record["accounts"] = accounts
|
|
print(json.dumps(record, separators=(",", ":")))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|