Bring Grok up to par with Claude and Codex in the agents panel (#13992)

* Let Grok updates through mise take effect

Grok's npm launcher runs ~/.grok/bin/grok whenever it exists, and the
install script that repoints it at a new release doesn't run under mise,
so every machine kept running the release it first unpacked. Updating
mise tools now drops a link to an older release and the old binary, and
lets the launcher unpack the installed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep several Grok accounts, the way Claude and Codex do

Grok honors GROK_HOME, so an added Grok account gets its own home holding
only its login and settings cache, with the CLI binary, sessions, skills,
plugins, memory, and config linked back to ~/.grok. The account commands,
the add flow (a second account signs in through a private window), the
launcher, and a grok shell function all take it like the others, and its
identity and plan come from the files Grok writes at login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Grok's plan and credits in the agents panel

A grok collector reads each account's plan from the settings Grok caches
and its credits from the endpoint behind Grok's own /usage view, so Grok
gets a section, per-account limits, and autoswitch like Claude and Codex.
A signed-in agent with a plan now shows before its first numbers, so a
lapsed sign-in has somewhere to say so. Grok's mark joins the assets, and
with every agent able to take another account the add screen no longer
needs to dim one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document Grok accounts and limits alongside Claude and Codex

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Grok's credits the way xAI actually answers

The answer nests under config, names a weekly period with its end, and
as protobuf JSON leaves the usage percentage out while it's zero. The
collector now reads that shape, so a fresh week shows as 0% until it
resets rather than as nothing known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count Grok's prompts and sessions from its session summaries

Grok keeps a summary beside each session with when it was last active
and how many prompts it had, so its section and the hero's summary get
today's prompts and sessions and its active days. It records no token
counts there, so none are claimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give each Grok limits cache write its own temporary file

Two overlapping collector runs wrote the same cache through one .tmp
path, so one rename could leave the other's failing and its record not
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a single Grok account from GROK_HOME when it's set

The launcher and the CLI honor GROK_HOME, but the collector always read
~/.grok, so a Grok signed in only in a custom home showed nothing. With
one account, the collector now reads the home the CLI would; with
several registered, the primary stays ~/.grok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop guesses the real Grok credits answer made unnecessary

The collector carried fallbacks for fields and shapes guessed from the
binary before xAI's actual answer was seen, a numeric timestamp branch
nothing writes, the panel's default for prompt stats, and guards that an
empty sign-in already covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the working Grok release until its update is in place

The update dropped the link and older binaries before the new release was
unpacked and ignored a failed unpack, which could leave every Grok
account without a CLI. The old release now stays until the new one is
linked, and its link comes back if it never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count only Grok sessions for today, not prompts

A session's summary holds all its prompts and only when it was last
active, so a resumed session put its whole history on today. Today now
counts the sessions active in it; prompts stay an all-time total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reuse the Grok session scan on a limits-only refresh

Opening the panel and near-limit checks only need fresh limits, so they
reuse a scan up to 15 minutes old, as the Codex collector does; --force
still rescans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-10-01 23:37:56 +02:00
1 parent c05d90196f
commit f45461a38f
23 files changed
+636 -105

No files matched your search

+13 -10
View File
@@ -136,16 +136,19 @@ pi)
;;
esac
# Claude and Codex start as the active subscription account, the same as the
# shell functions in default/bash/fns/agent-accounts. The home travels in the
# command itself, since a terminal launched through uwsm doesn't inherit this
# process's environment.
if [[ $agent == "claude" && -z ${CLAUDE_CONFIG_DIR:-} ]]; then
home=$(omarchy-agent-account-home claude)
[[ -n $home ]] && command=(env "CLAUDE_CONFIG_DIR=$home" "${command[@]}")
elif [[ $agent == "codex" && -z ${CODEX_HOME:-} ]]; then
home=$(omarchy-agent-account-home codex)
[[ -n $home ]] && command=(env "CODEX_HOME=$home" "${command[@]}")
# Claude, Codex, and Grok start as the active subscription account, the same
# as the shell functions in default/bash/fns/agent-accounts. The home travels
# in the command itself, since a terminal launched through uwsm doesn't
# inherit this process's environment.
case "$agent" in
claude) home_env="CLAUDE_CONFIG_DIR" ;;
codex) home_env="CODEX_HOME" ;;
grok) home_env="GROK_HOME" ;;
*) home_env="" ;;
esac
if [[ -n $home_env && -z ${!home_env:-} ]]; then
home=$(omarchy-agent-account-home "$agent")
[[ -n $home ]] && command=(env "$home_env=$home" "${command[@]}")
fi
if [[ $inline == "true" ]]; then
+10 -27
View File
@@ -32,21 +32,15 @@ done
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
primary_signed_in() {
if [[ $1 == "grok" ]]; then
[[ -s $HOME/.grok/auth.json ]]
else
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
fi
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
}
if [[ $check == "true" ]]; then
for p in claude codex grok; do
if ! primary_signed_in "$p"; then
echo "$p first"
elif [[ $p == "grok" ]]; then
echo "$p unsupported"
else
if primary_signed_in "$p"; then
echo "$p additional"
else
echo "$p first"
fi
done
exit 0
@@ -60,9 +54,9 @@ if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
fi
case "${1:-}" in
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude" ;;
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex" ;;
grok) provider="grok"; name="Grok"; site="x.ai"; package="npm:@xai-official/grok" ;;
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
grok) provider="grok"; name="Grok"; site="x.ai"; package="npm:@xai-official/grok"; home_env="GROK_HOME" ;;
*)
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
exit 1
@@ -151,7 +145,7 @@ login() {
# primary home through the normal browser, an added one through a private
# window. Its home is never recreated or removed.
if [[ -n $reauth ]]; then
if [[ $provider == "grok" || $reauth == ":primary" ]]; then
if [[ $reauth == ":primary" ]]; then
account_home="$HOME/.$provider"
else
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
@@ -164,11 +158,7 @@ if [[ -n $reauth ]]; then
else
say "Sign in again as this account in the private window that opens."
private_browser
if [[ $provider == "claude" ]]; then
interruptible env BROWSER="$browser" CLAUDE_CONFIG_DIR="$account_home" claude auth login || fail "The $name sign-in didn't finish."
else
interruptible env BROWSER="$browser" CODEX_HOME="$account_home" codex login || fail "The $name sign-in didn't finish."
fi
interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "The $name sign-in didn't finish."
fi
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
@@ -193,8 +183,6 @@ if ! primary_signed_in "$provider"; then
else
fail "The $name sign-in didn't finish."
fi
elif [[ $provider == "grok" ]]; then
fail "Grok is already signed in, and a second Grok account isn't supported yet."
else
if [[ -z $label && $events == "false" && -t 0 ]]; then
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
@@ -216,12 +204,7 @@ EOF
home=$(omarchy-agent-account-state begin "$provider") || fail "Couldn't set up a home for the new account."
private_browser
if [[ $provider == "claude" ]]; then
interruptible env BROWSER="$browser" CLAUDE_CONFIG_DIR="$home" claude auth login
else
interruptible env BROWSER="$browser" CODEX_HOME="$home" codex login
fi
interruptible env BROWSER="$browser" "$home_env=$home" "${login_command[@]}"
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
home=""
+4 -4
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Print the config home of the active Claude or Codex account
# omarchy:args=<claude|codex>
# omarchy:summary=Print the config home of the active Claude, Codex, or Grok account
# omarchy:args=<claude|codex|grok>
# omarchy:hidden=true
# Prints nothing while the primary account (~/.claude, ~/.codex) is active, so
@@ -10,8 +10,8 @@
# every `claude` and `codex` typed at a prompt.
registry="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts/${1:-}.json"
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
echo "Usage: omarchy-agent-account-home <claude|codex>" >&2
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
echo "Usage: omarchy-agent-account-home <claude|codex|grok>" >&2
exit 1
elif [[ -f $registry ]]; then
exec omarchy-agent-account-state home "$1"
+5 -5
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=List Claude and Codex subscription accounts and their limits
# omarchy:args=[claude|codex] [--json]
# omarchy:summary=List Claude, Codex, and Grok subscription accounts and their limits
# omarchy:args=[claude|codex|grok] [--json]
# omarchy:examples=omarchy agent account list | omarchy agent account list claude --json
set -uo pipefail
@@ -10,10 +10,10 @@ provider=""
json=false
for arg in "$@"; do
case "$arg" in
claude | codex) provider="$arg" ;;
claude | codex | grok) provider="$arg" ;;
--json) json=true ;;
*)
echo "Usage: omarchy-agent-account-list [claude|codex] [--json]" >&2
echo "Usage: omarchy-agent-account-list [claude|codex|grok] [--json]" >&2
exit 1
;;
esac
@@ -26,7 +26,7 @@ accounts=$(omarchy-agent-account-state list $provider) || exit 1
# a single account the collectors keep its limits at the record's top level.
accounts=$(jq -c --arg dir "$usage_dir" '
map(. as $p | .accounts |= map(. + {limits: []}))' <<<"$accounts")
for p in claude codex; do
for p in claude codex grok; do
record="$usage_dir/$p.json"
[[ -f $record ]] || continue
accounts=$(jq -c --arg p "$p" --slurpfile record "$record" '
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# omarchy:summary=Switch Claude or Codex accounts automatically near a limit, or only notify
# omarchy:args=[claude|codex] [manual|auto] [threshold]
# omarchy:summary=Switch Claude, Codex, or Grok accounts automatically near a limit, or only notify
# omarchy:args=[claude|codex|grok] [manual|auto] [threshold]
# omarchy:examples=omarchy agent account mode auto | omarchy agent account mode auto 90 | omarchy agent account mode codex manual
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
echo "Usage: omarchy-agent-account-mode [claude|codex] [manual|auto] [threshold]" >&2
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
echo "Usage: omarchy-agent-account-mode [claude|codex|grok] [manual|auto] [threshold]" >&2
exit 1
fi
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# omarchy:summary=Forget an added Claude or Codex account
# omarchy:args=[claude|codex] <account>
# omarchy:summary=Forget an added Claude, Codex, or Grok account
# omarchy:args=[claude|codex|grok] <account>
# omarchy:examples=omarchy agent account remove work | omarchy agent account remove codex side
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]] || [[ -z ${2:-} ]]; then
echo "Usage: omarchy-agent-account-remove [claude|codex] <account>" >&2
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || [[ -z ${2:-} ]]; then
echo "Usage: omarchy-agent-account-remove [claude|codex|grok] <account>" >&2
exit 1
fi
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# omarchy:summary=Rename a Claude or Codex subscription account
# omarchy:args=[claude|codex] <account> <name>
# omarchy:summary=Rename a Claude, Codex, or Grok subscription account
# omarchy:args=[claude|codex|grok] <account> <name>
# omarchy:examples=omarchy agent account rename primary Hey | omarchy agent account rename codex side Side project
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account rename primary Hey`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]] || (( $# < 3 )); then
echo "Usage: omarchy-agent-account-rename [claude|codex] <account> <name>" >&2
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || (( $# < 3 )); then
echo "Usage: omarchy-agent-account-rename [claude|codex|grok] <account> <name>" >&2
exit 1
fi
+38 -7
View File
@@ -1,11 +1,12 @@
#!/usr/bin/python3
# omarchy:summary=Read and change the registry of Claude and Codex subscription accounts
# omarchy:summary=Read and change the registry of Claude, Codex, and Grok subscription accounts
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
# omarchy:hidden=true
"""The account registry behind `omarchy agent account`.
An account is a Claude Code or Codex login Omarchy keeps a home for. The
login you already have stays where the CLI put it (~/.claude, ~/.codex) and
An account is a Claude Code, Codex, or Grok login Omarchy keeps a home for.
The login you already have stays where the CLI put it (~/.claude, ~/.codex,
~/.grok) and
is the primary account; added accounts get a home of their own under
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
account's credentials and links everything else back to the primary home, so
@@ -51,13 +52,22 @@ PROVIDERS = {
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
"carried_keys": [],
},
"grok": {
"name": "Grok",
"home_env": "GROK_HOME",
# bin holds the unpacked CLI itself: sharing it spares every added account
# its own 160MB copy, which the npm launcher unpacks into an empty home.
"shared_dirs": ["bin", "sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
"shared_files": ["config.toml", "settings.json", "lsp.json", "pager.toml", "sandbox.toml", "trusted_folders.toml", "statusline.sh", "worktrees.db"],
"carried_keys": [],
},
}
DEFAULT_THRESHOLD = 95
# Ids an account can't take because something else already answers to them:
# `use next` cycles, a provider name is read as the provider when it comes
# first, and the menu's Add Account row is `.add`.
RESERVED_IDS = {"next", "add", "claude", "codex", "primary"}
RESERVED_IDS = {"next", "add", "claude", "codex", "grok", "primary"}
SWITCH_MODES = ("manual", "auto")
@@ -85,11 +95,12 @@ def usage_record_path(provider: str) -> Path:
# The primary home is where the CLI keeps a login when nothing tells it
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME:
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME /
# GROK_HOME:
# those are how an added account is selected, so honoring them here would make
# whichever account launched this command look like the primary.
def primary_home(provider: str) -> Path:
return Path.home() / (".claude" if provider == "claude" else ".codex")
return Path.home() / f".{provider}"
def account_home(provider: str, account: dict[str, Any]) -> Path:
@@ -147,6 +158,9 @@ def identity(provider: str, home: Path) -> dict[str, str]:
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
}
if provider == "grok":
return grok_identity(home)
auth = read_json(home / "auth.json") or {}
tokens = auth.get("tokens") or {}
claims = jwt_claims(str(tokens.get("id_token") or ""))
@@ -160,6 +174,23 @@ def identity(provider: str, home: Path) -> dict[str, str]:
}
# Grok keeps one login per issuer in auth.json, and the plan only in the
# settings it caches from xAI, as a JSON string inside that file.
def grok_identity(home: Path) -> dict[str, str]:
auth = read_json(home / "auth.json") or {}
login = next((v for v in auth.values() if isinstance(v, dict)), {}) if isinstance(auth, dict) else {}
try:
settings = json.loads((read_json(home / "settings_cache.json") or {}).get("payload") or "{}").get("settings") or {}
except Exception:
settings = {}
return {
"accountId": str(login.get("user_id") or ""),
"email": str(login.get("email") or ""),
"org": "",
"plan": str(settings.get("subscription_tier_display") or ""),
}
# ---------------------------------------------------------------- registry
@@ -655,7 +686,7 @@ def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
def provider_arg(args: list[str]) -> str:
if not args or args[0] not in PROVIDERS:
raise AccountError("Name a provider: claude or codex")
raise AccountError("Name a provider: claude, codex, or grok")
return args[0]
+6 -5
View File
@@ -1,23 +1,24 @@
#!/bin/bash
# omarchy:summary=Choose which Claude or Codex account new sessions use
# omarchy:args=[claude|codex] [account|next]
# omarchy:summary=Choose which Claude, Codex, or Grok account new sessions use
# omarchy:args=[claude|codex|grok] [account|next]
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
case "${1:-}" in
claude) name="Claude" ;;
codex) name="Codex" ;;
grok) name="Grok" ;;
*)
echo "Usage: omarchy-agent-account-use [claude|codex] [account|next]" >&2
echo "Usage: omarchy-agent-account-use [claude|codex|grok] [account|next]" >&2
exit 1
;;
esac
+290
View File
@@ -0,0 +1,290 @@
#!/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 and prompts come from the summary Grok
keeps beside each session; it records no token counts there, nor when each
prompt was sent, so neither tokens nor today's prompts are claimed. 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, 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's summary, under <home>/sessions/<folder>/<session>/. Added
# accounts link their sessions to the primary's, so this covers all of them.
# A resumed session's prompts can't be told apart by day, so today counts
# sessions only.
def local_stats(home):
today = datetime.now().astimezone().date().isoformat()
prompts = sessions = today_sessions = 0
days = set()
for summary_path in (Path(home) / "sessions").glob("*/*/summary.json"):
summary = read_json(summary_path)
if not isinstance(summary, dict):
continue
active = parse_time(summary.get("last_active_at"))
if not active:
continue
day = active.astimezone().date().isoformat()
sessions += 1
prompts += int(summary.get("num_messages") or 0)
days.add(day)
if day == today:
today_sessions += 1
return {
"hasLocalStats": sessions > 0,
"todaySessions": today_sessions,
"totalPrompts": prompts,
"totalSessions": sessions,
"activeDays": len(days),
"activeDates": sorted(days),
}
def cached_local_stats(home, max_age):
cache = cache_root() / "grok-stats.json"
cached = read_json(cache) or {}
if cached.get("home") == str(home) and time.time() - float(cached.get("at") or 0) < max_age:
return cached["stats"]
stats = local_stats(home)
write_json(cache, {"home": str(home), "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()
+2 -2
View File
@@ -54,7 +54,7 @@ collect() {
# Accounts can be signed in to someone else since they were added; the
# records name them from the registry, so bring it up to date first.
accounts_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts"
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json ]]; then
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json || -f $accounts_dir/grok.json ]]; then
"$OMARCHY_PATH/bin/omarchy-agent-account-state" refresh
fi
@@ -76,7 +76,7 @@ done
# Fresh limits are when switching accounts near a limit gets decided. A switch
# changes which account the record's own limits describe, so that record is
# collected once more; the limits probes it repeats are still cached.
for agent in claude codex; do
for agent in claude codex grok; do
if wanted "$agent" && [[ -f $accounts_dir/$agent.json ]]; then
switched=$("$OMARCHY_PATH/bin/omarchy-agent-account-state" autoswitch "$agent")
if [[ -n $switched ]]; then
+21
View File
@@ -9,4 +9,25 @@ if omarchy-cmd-present mise; then
# days after they ship. Running omarchy update is the user asking for current
# versions now, which is the same call the mup alias makes by hand.
MISE_MINIMUM_RELEASE_AGE=0 mise up
# Grok's npm launcher runs ~/.grok/bin/grok whenever it exists, and the
# package's install script that points it at a new release doesn't run
# under mise, so updates never took effect. Dropping a link to an older
# release lets the launcher unpack the installed one, which --version does
# now rather than on the next session. The old release stays until the new
# one is in place, and comes back if it never arrives.
if grok_dir=$(mise where npm:@xai-official/grok 2>/dev/null) && [[ -n $grok_dir ]]; then
grok_link="$HOME/.grok/bin/grok"
grok_current="grok-${grok_dir##*/}"
grok_previous=$(readlink "$grok_link" 2>/dev/null)
if [[ -L $grok_link && $grok_previous != "$grok_current" ]]; then
rm -f "$grok_link"
mise x npm:@xai-official/grok -- grok --version >/dev/null 2>&1
if [[ $(readlink "$grok_link" 2>/dev/null) == "$grok_current" ]]; then
find "$HOME/.grok/bin" -maxdepth 1 -name 'grok-*' ! -name "$grok_current" -delete
else
ln -sfn "$grok_previous" "$grok_link"
fi
fi
fi
fi