* 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>
139 lines
8.3 KiB
Bash
Executable File
139 lines
8.3 KiB
Bash
Executable File
#!/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_STATE_HOME="$test_tmp/state"
|
|
export XDG_CACHE_HOME="$test_tmp/cache"
|
|
|
|
future=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=6)).isoformat())')
|
|
past=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1)).isoformat())')
|
|
period_end=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=9)).isoformat())')
|
|
|
|
# A home signed in the way the Grok CLI leaves it.
|
|
signed_in() {
|
|
mkdir -p "$1"
|
|
jq -nc --arg token "$2" --arg user "$3" --arg expires "$4" \
|
|
'{"https://auth.x.ai::client": {key: $token, user_id: $user, email: ($user + "@example.com"), expires_at: $expires}}' >"$1/auth.json"
|
|
printf '{"payload":"{\\"settings\\":{\\"subscription_tier_display\\":\\"%s\\"}}"}\n' "$5" >"$1/settings_cache.json"
|
|
}
|
|
|
|
# xAI answers per token, so each home's probe is told apart by its sign-in.
|
|
collect() {
|
|
COLLECTOR="$ROOT/bin/omarchy-agent-usage-grok" python3 - "$@" <<'PY'
|
|
import importlib.machinery, importlib.util, io, json, os, sys
|
|
|
|
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)
|
|
|
|
used = {"token-main": 42.0, "token-side": 7.0, "token-fresh": 0}
|
|
end = os.environ["PERIOD_END"]
|
|
|
|
# Shaped like xAI's answer: protobuf JSON under config, which drops a
|
|
# percentage of zero.
|
|
def urlopen(request, timeout=None):
|
|
assert request.full_url == collector.CREDITS_URL
|
|
token = request.get_header("Authorization").split(" ", 1)[1]
|
|
config = {"currentPeriod": {"type": "USAGE_PERIOD_TYPE_WEEKLY", "end": end}, "billingPeriodEnd": end}
|
|
if used.get(token):
|
|
config["creditUsagePercent"] = used[token]
|
|
return io.BytesIO(json.dumps({"config": config}).encode())
|
|
|
|
collector.urllib.request.urlopen = urlopen
|
|
sys.argv = ["omarchy-agent-usage-grok"] + (os.environ.get("COLLECT_ARGS") or "--force").split()
|
|
collector.main()
|
|
PY
|
|
}
|
|
export PERIOD_END="$period_end"
|
|
|
|
record=$(collect)
|
|
[[ $(jq -c '{ready, tierLabel, limits}' <<<"$record") == '{"ready":false,"tierLabel":"","limits":[]}' ]] ||
|
|
fail "a machine nobody signed in to Grok on gives an empty record" "$record"
|
|
pass "a machine nobody signed in to Grok on gives an empty record"
|
|
|
|
# A home chosen with GROK_HOME is the one read, as the CLI itself does.
|
|
signed_in "$test_tmp/custom-grok" token-main u-main "$future" "SuperGrok"
|
|
record=$(GROK_HOME="$test_tmp/custom-grok" collect)
|
|
[[ $(jq -c '{ready, tierLabel, percent: .limits[0].percent}' <<<"$record") == '{"ready":true,"tierLabel":"SuperGrok","percent":0.42}' ]] ||
|
|
fail "a single Grok account in GROK_HOME is read from there" "$record"
|
|
pass "a single Grok account in GROK_HOME is read from there"
|
|
rm -f "$XDG_CACHE_HOME"/omarchy/agent-usage/grok-limits-*.json
|
|
|
|
signed_in "$HOME/.grok" token-main u-main "$future" "X Premium+"
|
|
record=$(collect)
|
|
[[ $(jq -c '{ready, tierLabel, stale: .limitsStale, label: .limits[0].label, percent: .limits[0].percent}' <<<"$record") == '{"ready":true,"tierLabel":"X Premium+","stale":false,"label":"Weekly","percent":0.42}' ]] ||
|
|
fail "Grok's plan and credits come from its own home" "$record"
|
|
[[ -n $(jq -r '.limits[0].resetsAt' <<<"$record") ]] || fail "the credits window says when the period ends" "$record"
|
|
pass "Grok's plan and credits come from its own home"
|
|
|
|
signed_in "$HOME/.grok" token-main u-main "$past" "X Premium+"
|
|
record=$(collect)
|
|
[[ $(jq -c '{usageStatusText, first: .limits[0].percent, stale: .limitsStale}' <<<"$record") == '{"usageStatusText":"Sign-in expired","first":0.42,"stale":true}' ]] ||
|
|
fail "a lapsed sign-in keeps the last credits and says so" "$record"
|
|
pass "a lapsed sign-in keeps the last credits and says so"
|
|
|
|
# A period with nothing used yet comes without a percentage.
|
|
signed_in "$HOME/.grok" token-fresh u-main "$future" "X Premium+"
|
|
rm -f "$XDG_CACHE_HOME"/omarchy/agent-usage/grok-limits-*.json
|
|
record=$(collect)
|
|
[[ $(jq -c '.limits[0] | {label, percent}' <<<"$record") == '{"label":"Weekly","percent":0.0}' ]] ||
|
|
fail "an untouched period reads as 0% rather than nothing known" "$record"
|
|
pass "an untouched period reads as 0% rather than nothing known"
|
|
|
|
signed_in "$HOME/.grok" token-main u-main "$future" "X Premium+"
|
|
side="$XDG_STATE_HOME/omarchy/agents/accounts/grok/side"
|
|
signed_in "$side" token-side u-side "$future" "SuperGrok"
|
|
jq -n --arg side "$side" '{active: "side", switch: "auto", threshold: 90, accounts: [
|
|
{id: "main", label: "Main", home: "", primary: true},
|
|
{id: "side", label: "Side", home: $side, primary: false}
|
|
]}' >"$XDG_STATE_HOME/omarchy/agents/accounts/grok.json"
|
|
record=$(collect)
|
|
[[ $(jq -c '{tierLabel, first: .limits[0].percent, accounts: [.accounts[] | {id, active, plan, percent: .limits[0].percent}], switch: .accountSwitch}' <<<"$record") == '{"tierLabel":"SuperGrok","first":0.07,"accounts":[{"id":"main","active":false,"plan":"X Premium+","percent":0.42},{"id":"side","active":true,"plan":"SuperGrok","percent":0.07}],"switch":{"mode":"auto","threshold":90}}' ]] ||
|
|
fail "every Grok account reports its own plan and credits" "$record"
|
|
pass "every Grok account reports its own plan and credits"
|
|
|
|
# Sessions come from each session's summary; tokens and today's prompts from
|
|
# its usage.json, one entry per finished turn, with cached input kept apart.
|
|
sessions="$HOME/.grok/sessions/%2Fhome%2Fme"
|
|
now=$(python3 -c 'import datetime as dt; print(dt.datetime.now(dt.timezone.utc).isoformat())')
|
|
mkdir -p "$sessions/today-1" "$sessions/today-2" "$sessions/old"
|
|
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 3}' >"$sessions/today-1/summary.json"
|
|
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 2}' >"$sessions/today-2/summary.json"
|
|
jq -n '{last_active_at: "2026-01-02T10:00:00Z", num_messages: 5}' >"$sessions/old/summary.json"
|
|
jq -n --arg at "$now" '{turns: [
|
|
{endedAt: $at, modelUsage: {"grok-4.7-build": {inputTokens: 1000, cachedReadTokens: 800, outputTokens: 50, cacheCreationTokens: 0, totalTokens: 1050}}},
|
|
{endedAt: $at, modelUsage: {"grok-4.7-build": {inputTokens: 200, cachedReadTokens: 100, outputTokens: 10, cacheCreationTokens: 5, totalTokens: 210}}}
|
|
]}' >"$sessions/today-1/usage.json"
|
|
jq -n '{turns: [{endedAt: "2026-01-02T10:00:00Z", modelUsage: {"grok-4.6": {inputTokens: 500, cachedReadTokens: 0, outputTokens: 20, cacheCreationTokens: 0, totalTokens: 520}}}]}' >"$sessions/old/usage.json"
|
|
record=$(collect)
|
|
[[ $(jq -c '{hasLocalStats, todayPrompts, todaySessions, totalPrompts, totalSessions, activeDays, todayTotalTokens, todayTokensByModel, today: .recentDays[6].messageCount}' <<<"$record") == '{"hasLocalStats":true,"todayPrompts":2,"todaySessions":2,"totalPrompts":10,"totalSessions":3,"activeDays":2,"todayTotalTokens":1265,"todayTokensByModel":{"grok-4.7-build":1265},"today":1265}' ]] ||
|
|
fail "Grok counts sessions, prompts, and tokens from its session files" "$record"
|
|
[[ $(jq -cS '.modelUsage' <<<"$record") == '{"grok-4.6":{"cacheCreationInputTokens":0,"cacheReadInputTokens":0,"inputTokens":500,"outputTokens":20},"grok-4.7-build":{"cacheCreationInputTokens":5,"cacheReadInputTokens":900,"inputTokens":300,"outputTokens":60}}' ]] ||
|
|
fail "Grok's tokens by model keep cached input apart" "$record"
|
|
pass "Grok counts sessions, prompts, and tokens from its session files"
|
|
|
|
# A limits-only refresh reuses the last scan rather than reading every summary.
|
|
mkdir -p "$sessions/later"
|
|
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 1}' >"$sessions/later/summary.json"
|
|
record=$(COLLECT_ARGS="--limits-only" collect)
|
|
[[ $(jq -r '.totalSessions' <<<"$record") == 3 ]] || fail "a limits-only refresh reuses the session scan" "$record"
|
|
pass "a limits-only refresh reuses the session scan"
|
|
|
|
# A scan from another day is never reused, so yesterday's sessions don't
|
|
# count as today's after midnight.
|
|
jq '.day = "2000-01-01"' "$XDG_CACHE_HOME/omarchy/agent-usage/grok-stats.json" >"$test_tmp/stats.json"
|
|
mv "$test_tmp/stats.json" "$XDG_CACHE_HOME/omarchy/agent-usage/grok-stats.json"
|
|
record=$(COLLECT_ARGS="--limits-only" collect)
|
|
[[ $(jq -r '.totalSessions' <<<"$record") == 4 ]] || fail "a scan from another day is made again" "$record"
|
|
pass "a scan from another day is made again"
|