Split agent usage into data files and rename the plugin to omarchy.agents (#6603)

* Add agent usage collectors that write display-ready data files

One omarchy-agent-usage-scan-<agent> collector per AI coding agent prints a
complete display-ready usage record — identity, tier, status, rate limits,
and today/week/all-time stats. omarchy-agent-usage-update runs every
collector it finds and writes the records atomically to
~/.local/state/omarchy/agents/usage/, so anything that displays usage only
ever reads JSON from there.

The Claude collector absorbs what the shell previously did in-process:
transcript scanning, the stats-cache/history fallback, credentials parsing,
and the OAuth limits probe, now with a probe throttle and last-good limits
kept across network failures. The Codex collector is the existing scanner
reshaped to the shared record contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Redo the model-usage plugin as omarchy.agents watching usage data files

The panel is now strictly a display. It discovers the JSON records that
omarchy-agent-usage-update maintains under
~/.local/state/omarchy/agents/usage/, watches them for changes, and draws
whatever appears — so adding an agent means shipping a collector, never
touching the panel. Marks resolve by convention (assets/<id>.svg with an
optional -light twin), the limits meters read a generic limits array, and
the per-provider QML adapters and in-plugin scanner scripts are gone.

Cross-device sync aggregation stays in the shell and keeps the snapshot
field names older versions wrote, so mixed-version fleets still merge in
both directions.

With the provider fan-out gone, the widget takes its real name: the plugin
id becomes omarchy.agents. A migration renames it wherever a user's config
mentions it — layout entries keep their settings and position, a disabled
widget stays disabled — then primes the data files once and drops the old
scanner cache. The migration test also drops a stale assertion that expected
migrations to restart the shell themselves, which c992cdff moved to
omarchy update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: synced-only tabs, limits retry, history fallback

Three data-availability gaps from review. An agent whose records only exist
in synced snapshots — a collector installed on just one machine — now gets
its tab by unioning the synced aggregate into the provider list, with rate
limits blank since those never travel. A Claude limits probe that reaches no
server at all writes retryAdvised into its record, and the shell honors it
with one 30-second retry instead of waiting out the full refresh interval,
restoring the old boot-before-DHCP behavior. And a machine with only
history.jsonl — no transcripts, no stats-cache — still reports today's
prompt and session counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second Codex pass: history-only visibility, targeted retries

Today's prompt and session counts now count toward an agent's presence in
the bar, so a machine whose only Claude source is history.jsonl shows up
without waiting for limits. And the 30-second limits retry passes the
advising agent ids to the updater, so an outage at one provider no longer
puts every other collector on a retry treadmill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop omarchy-cmd-present jq guards from the agents migrations

jq ships in the default package set, which makes it a runtime invariant per
AGENTS.md — call it directly. The migration tests lose their now-unused
omarchy-cmd-present stubs with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the scan infix from the collector command names

Collectors are omarchy-agent-usage-<agent>; the updater skips its own name
when globbing them, and the update test proves it with a decoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the credential store out of the printed usage record

The Claude collector now reads .credentials.json once into three scalars —
the access token, its expiry, and the plan label — instead of passing the
parsed store around. The token reaches nothing but the Authorization header
of the limits probe, and only the plan label may travel into the record,
which is what CodeQL's clear-text-logging alert on the record print was
unable to see when the whole dict flowed through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 15:46:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c3bd4a86ae
commit bb8d2f2cb3
28 changed files with 1375 additions and 1315 deletions
@@ -0,0 +1,54 @@
#!/bin/bash
source "$(dirname "$0")/base-test.sh"
require_command jq
require_command python3
TEST_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME"' EXIT
projects="$TEST_HOME/.claude/projects/example"
mkdir -p "$projects"
timestamp="$(date +%Y-%m-%d)T12:00:00Z"
cat >"$projects/session.jsonl" <<EOF
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-1","message":{"id":"message-1","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":28857,"cache_read_input_tokens":0,"output_tokens":231}}}
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-2","message":{"id":"message-1","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":28857,"cache_read_input_tokens":0,"output_tokens":231}}}
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-3","message":{"id":"message-2","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":454,"cache_read_input_tokens":28857,"output_tokens":390}}}
EOF
result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "58793" ]] ||
fail "Claude collector counts each API message once" "$result"
pass "Claude collector counts each API message once"
[[ $(jq -c '.modelUsage["claude-test"]' <<<"$result") == '{"cacheCreationInputTokens":29311,"cacheReadInputTokens":28857,"inputTokens":4,"outputTokens":621}' ]] ||
fail "Claude collector keeps mutually exclusive token categories" "$result"
pass "Claude collector keeps mutually exclusive token categories"
[[ $(jq -r '.id + "/" + .usageStatusText' <<<"$result") == "claude/Waiting for auth" ]] ||
fail "Claude collector identifies itself and reports missing auth" "$result"
pass "Claude collector identifies itself and reports missing auth"
# A machine with no transcripts and no stats-cache still gets today's counts
# from history.jsonl alone.
HISTORY_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME"' EXIT
mkdir -p "$HISTORY_HOME/.claude"
now_ms=$(($(date +%s) * 1000))
cat >"$HISTORY_HOME/.claude/history.jsonl" <<EOF
{"timestamp":86400000,"sessionId":"old","display":"ancient"}
{"timestamp":$now_ms,"sessionId":"s1","display":"one"}
{"timestamp":$now_ms,"sessionId":"s2","display":"two"}
EOF
result=$(HOME="$HISTORY_HOME" XDG_CACHE_HOME="$HISTORY_HOME/.cache" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '(.todayPrompts|tostring) + "/" + (.todaySessions|tostring)' <<<"$result") == "2/2" ]] ||
fail "Claude collector falls back to history.jsonl without a stats-cache" "$result"
pass "Claude collector falls back to history.jsonl without a stats-cache"
@@ -41,12 +41,16 @@ cat >"$session" <<EOF
EOF
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" PATH="$TEST_HOME/bin:$PATH" \
python3 "$ROOT/shell/plugins/model-usage/scripts/codex_usage_scanner.py")
"$ROOT/bin/omarchy-agent-usage-codex")
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
fail "Codex scanner counts each turn once" "$result"
pass "Codex scanner counts each turn once"
fail "Codex collector counts each turn once" "$result"
pass "Codex collector counts each turn once"
[[ $(jq -c '.modelUsage["gpt-test"]' <<<"$result") == '{"inputTokens":70,"outputTokens":30,"cacheReadInputTokens":110,"cacheCreationInputTokens":0}' ]] ||
fail "Codex scanner does not double-count cache or reasoning tokens" "$result"
pass "Codex scanner does not double-count cache or reasoning tokens"
fail "Codex collector does not double-count cache or reasoning tokens" "$result"
pass "Codex collector does not double-count cache or reasoning tokens"
[[ $(jq -c '.id + "/" + (.limits|tostring)' <<<"$result") == '"codex/[]"' ]] ||
fail "Codex collector identifies itself with an empty limits list" "$result"
pass "Codex collector identifies itself with an empty limits list"
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
source "$(dirname "$0")/base-test.sh"
require_command jq
TEST_HOME=$(mktemp -d)
FAKE_OMARCHY=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$FAKE_OMARCHY"' EXIT
mkdir -p "$FAKE_OMARCHY/bin"
cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-good" <<'EOF'
#!/bin/bash
echo '{"schemaVersion":1,"id":"good","name":"Good Agent","totalPrompts":3}'
EOF
cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-noisy" <<'EOF'
#!/bin/bash
echo "this is not json"
EOF
cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-skipped" <<'EOF'
#!/bin/bash
echo '{"id":"skipped"}'
EOF
# The updater itself lives in the same namespace as the collectors it globs.
cat >"$FAKE_OMARCHY/bin/omarchy-agent-usage-update" <<'EOF'
#!/bin/bash
echo '{"id":"update"}'
EOF
chmod +x "$FAKE_OMARCHY/bin/"omarchy-agent-usage-*
usage_dir="$TEST_HOME/.local/state/omarchy/agents/usage"
HOME="$TEST_HOME" OMARCHY_PATH="$FAKE_OMARCHY" XDG_STATE_HOME="" \
"$ROOT/bin/omarchy-agent-usage-update" --except skipped 2>/dev/null && fail "update reports a failing collector"
pass "update reports a failing collector"
[[ $(jq -r '.name' "$usage_dir/good.json") == "Good Agent" ]] ||
fail "update writes each collector's record to the usage directory"
pass "update writes each collector's record to the usage directory"
[[ ! -e $usage_dir/noisy.json ]] ||
fail "update refuses records that are not valid JSON"
pass "update refuses records that are not valid JSON"
[[ ! -e $usage_dir/skipped.json ]] ||
fail "update skips agents excluded with --except"
pass "update skips agents excluded with --except"
[[ ! -e $usage_dir/update.json ]] ||
fail "update does not treat itself as a collector"
pass "update does not treat itself as a collector"
HOME="$TEST_HOME" OMARCHY_PATH="$FAKE_OMARCHY" XDG_STATE_HOME="" \
"$ROOT/bin/omarchy-agent-usage-update" skipped 2>/dev/null ||
fail "update succeeds when the requested collectors all pass"
pass "update succeeds when the requested collectors all pass"
[[ -e $usage_dir/skipped.json && ! -e $usage_dir/noisy.json ]] ||
fail "update with agent arguments only runs the named collectors"
pass "update with agent arguments only runs the named collectors"
@@ -12,12 +12,6 @@ trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin"
cat >"$test_dir/bin/omarchy-cmd-present" <<'STUB'
#!/bin/bash
command -v "$1" >/dev/null
STUB
cat >"$test_dir/bin/omarchy-restart-shell" <<'STUB'
#!/bin/bash
@@ -44,7 +38,7 @@ write_config() {
jq "${1:-.}" "$ROOT/config/omarchy/shell.json" >"$config"
}
without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.model-usage"))'
without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.agents"))'
ids() {
jq -c --arg section "$1" '[.bar.layout[$section][]? | if type == "object" then .id else . end]' "$config"
@@ -52,21 +46,21 @@ ids() {
# ------------------------------------------------------------------ shipped default
jq -e '[.bar.layout.right[].id] | index("omarchy.model-usage")' "$ROOT/config/omarchy/shell.json" >/dev/null ||
fail "shipped config puts model usage in the bar"
pass "shipped config puts model usage in the bar"
jq -e '[.bar.layout.right[].id] | index("omarchy.agents")' "$ROOT/config/omarchy/shell.json" >/dev/null ||
fail "shipped config puts the agents widget in the bar"
pass "shipped config puts the agents widget in the bar"
# ------------------------------------------------------------------ placement
write_config "$without_widget"
run_migration
[[ $(ids right) == '["omarchy.tray","omarchy.model-usage","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] ||
fail "migration inserts model usage after the tray" "$(ids right)"
pass "migration inserts model usage after the tray"
[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] ||
fail "migration inserts the agents widget after the tray" "$(ids right)"
pass "migration inserts the agents widget after the tray"
(($(wc -l <"$SHELL_RESTARTS") == 1)) || fail "migration restarts the shell"
pass "migration restarts the shell"
(($(wc -l <"$SHELL_RESTARTS") == 0)) || fail "migration leaves the shell restart to omarchy update"
pass "migration leaves the shell restart to omarchy update"
before=$(sha256sum "$config")
run_migration
@@ -77,18 +71,18 @@ pass "migration is idempotent"
# A user who already placed the widget keeps it exactly where they put it, in
# whichever section, and never gets a second copy.
write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.model-usage\" }]"
write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.agents\" }]"
run_migration
[[ $(ids center) == *'"omarchy.model-usage"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)"
[[ $(ids right) != *'"omarchy.model-usage"'* ]] || fail "migration does not add a second copy" "$(ids right)"
[[ $(ids center) == *'"omarchy.agents"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)"
[[ $(ids right) != *'"omarchy.agents"'* ]] || fail "migration does not add a second copy" "$(ids right)"
pass "migration respects a widget the user already placed"
# Layouts written before entries grew options are bare id strings.
write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.model-usage\", \"omarchy.power\"]"
write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.agents\", \"omarchy.power\"]"
run_migration
[[ $(ids right) == '["omarchy.tray","omarchy.model-usage","omarchy.power"]' ]] ||
[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] ||
fail "migration reads string-form entries" "$(ids right)"
pass "migration reads string-form entries"
@@ -96,7 +90,7 @@ pass "migration reads string-form entries"
write_config "$without_widget | del(.bar.layout.right[] | select(.id == \"omarchy.tray\"))"
run_migration
[[ $(ids right) == '["omarchy.model-usage",'* ]] || fail "migration places the widget without a tray" "$(ids right)"
[[ $(ids right) == '["omarchy.agents",'* ]] || fail "migration places the widget without a tray" "$(ids right)"
pass "migration places the widget without a tray"
# ------------------------------------------------------------------ everything else
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
require_command jq
migration="$ROOT/migrations/1786099804.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin"
cat >"$test_dir/bin/omarchy-agent-usage-update" <<'STUB'
#!/bin/bash
echo run >>"$USAGE_UPDATES"
STUB
chmod +x "$test_dir/bin/"*
export USAGE_UPDATES="$test_dir/usage-updates"
home="$test_dir/home"
config="$home/.config/omarchy/shell.json"
run_migration() {
: >"$USAGE_UPDATES"
HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null
}
mkdir -p "$home/.config/omarchy" "$home/.cache/omarchy/model-usage"
cat >"$config" <<'JSON'
{
"bar": {
"layout": {
"center": ["omarchy.model-usage"],
"right": [
{ "id": "omarchy.tray" },
{ "id": "omarchy.model-usage", "syncMode": "On", "syncDir": "~/Sync/agent-usage" }
]
}
},
"disabledPlugins": ["omarchy.model-usage", "omarchy.weather"]
}
JSON
run_migration
[[ $(jq -c '.bar.layout.right[1]' "$config") == '{"id":"omarchy.agents","syncMode":"On","syncDir":"~/Sync/agent-usage"}' ]] ||
fail "migration renames the widget and keeps its settings" "$(cat "$config")"
pass "migration renames the widget and keeps its settings"
[[ $(jq -c '.bar.layout.center' "$config") == '["omarchy.agents"]' ]] ||
fail "migration renames string-form entries" "$(cat "$config")"
pass "migration renames string-form entries"
[[ $(jq -c '.disabledPlugins' "$config") == '["omarchy.agents","omarchy.weather"]' ]] ||
fail "migration keeps a disabled widget disabled" "$(cat "$config")"
pass "migration keeps a disabled widget disabled"
[[ ! -e $home/.cache/omarchy/model-usage ]] ||
fail "migration drops the old scanner cache"
pass "migration drops the old scanner cache"
(($(wc -l <"$USAGE_UPDATES") == 1)) || fail "migration primes the usage data files"
pass "migration primes the usage data files"
before=$(sha256sum "$config")
run_migration
[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(cat "$config")"
pass "migration is idempotent"
# A config the migration cannot parse is left alone rather than truncated.
printf '{ not json' >"$config"
run_migration
[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")"
pass "migration leaves an unparsable config untouched"
@@ -94,11 +94,12 @@ ShellRoot {
if (typeof item.setting === "function") {
root.assertEqual(item.setting("missing", "fallback"), "fallback", entry.id + " exposes setting fallback")
}
if (entry.id === "omarchy.model-usage" && typeof item.iconSourceForProvider === "function") {
var darkIcon = String(item.iconSourceForProvider({ providerId: "codex" }, Qt.color("#1a1b26")))
var lightIcon = String(item.iconSourceForProvider({ providerId: "codex" }, Qt.color("#ffffff")))
root.assertTrue(darkIcon.indexOf("codex.svg") >= 0 && darkIcon.indexOf("codex-light.svg") < 0, entry.id + " uses the dark-theme Codex icon on dark surfaces")
root.assertTrue(lightIcon.indexOf("codex-light.svg") >= 0, entry.id + " uses the light-theme Codex icon on light surfaces")
if (entry.id === "omarchy.agents") {
root.assertTrue(typeof item.iconCandidatesForProvider === "function", entry.id + " resolves provider marks by convention")
var darkIcons = item.iconCandidatesForProvider({ providerId: "codex" }, Qt.color("#1a1b26")).join(" ")
var lightIcons = item.iconCandidatesForProvider({ providerId: "codex" }, Qt.color("#ffffff")).join(" ")
root.assertTrue(darkIcons.indexOf("codex.svg") >= 0 && darkIcons.indexOf("codex-light.svg") < 0, entry.id + " uses the dark-theme Codex icon on dark surfaces")
root.assertTrue(lightIcons.indexOf("codex-light.svg") >= 0, entry.id + " prefers the light-theme Codex icon on light surfaces")
}
safeCall(item, "refresh", entry)
@@ -1,30 +0,0 @@
#!/bin/bash
source "$(dirname "$0")/base-test.sh"
require_command jq
require_command python3
TEST_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME"' EXIT
projects="$TEST_HOME/.claude/projects/example"
mkdir -p "$projects"
timestamp="$(date +%Y-%m-%d)T12:00:00Z"
cat >"$projects/session.jsonl" <<EOF
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-1","message":{"id":"message-1","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":28857,"cache_read_input_tokens":0,"output_tokens":231}}}
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-2","message":{"id":"message-1","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":28857,"cache_read_input_tokens":0,"output_tokens":231}}}
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-3","message":{"id":"message-2","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":454,"cache_read_input_tokens":28857,"output_tokens":390}}}
EOF
result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" \
python3 "$ROOT/shell/plugins/model-usage/scripts/claude_usage_scanner.py" "$TEST_HOME/.claude/projects" --force)
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "58793" ]] ||
fail "Claude scanner counts each API message once" "$result"
pass "Claude scanner counts each API message once"
[[ $(jq -c '.modelUsage["claude-test"]' <<<"$result") == '{"cacheCreationInputTokens":29311,"cacheReadInputTokens":28857,"inputTokens":4,"outputTokens":621}' ]] ||
fail "Claude scanner keeps mutually exclusive token categories" "$result"
pass "Claude scanner keeps mutually exclusive token categories"