Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
375 changes: 375 additions & 0 deletions bin/omarchy-agent-usage-antigravity

Large diffs are not rendered by default.

52 changes: 45 additions & 7 deletions bin/omarchy-agent-usage-update
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,55 @@ wanted() {
return 1
}

collect() {
published_sequence() {
local value
value=$(jq -r '._collectionSequence // 0' "$1" 2>/dev/null) || value=0
if [[ $value =~ ^[0-9]{1,15}$ ]]; then
printf '%s\n' "$((10#$value))"
else
printf '0\n'
fi
}

collect() (
local collector="$1" agent="$2"
local record tmp
if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then
local record tmp sequence_tmp="" generation=0 published
local sequence="$USAGE_DIR/.$agent.sequence" lock="$USAGE_DIR/.$agent.lock"
tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX") || return 1
trap 'rm -f "$tmp" "$sequence_tmp"' EXIT
sequence_tmp=$(mktemp "$USAGE_DIR/.$agent.sequence.XXXXXX") || return 1

# Overlapping refreshes may finish in either order, including across
# account changes. Assign request order before probing, then publish only
# if no later request has already published a valid record. A collector
# that crashes cannot prevent another valid result from landing. Hold the
# lock only for file operations, never through the network calls.
{
flock 9 || return 1
[[ -f $sequence ]] && read -r generation <"$sequence"
[[ $generation =~ ^[0-9]{1,15}$ ]] || generation=0
generation=$((10#$generation))
published=$(published_sequence "$USAGE_DIR/$agent.json")
(( published > generation )) && generation=$published
(( ++generation ))
printf '%s\n' "$generation" >"$sequence_tmp" && mv "$sequence_tmp" "$sequence"
} 9>"$lock" || return 1

if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -es 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 <<<"$record"; then
echo "omarchy-agent-usage-update: $agent collector failed" >&2
return 1
fi
tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX")
printf '%s\n' "$record" >"$tmp"
mv "$tmp" "$USAGE_DIR/$agent.json"
}
# Stamp the sequence into the same file as the data so the final rename
# publishes both atomically, even if the updater exits just after it.
jq --argjson sequence "$generation" '._collectionSequence = $sequence' <<<"$record" >"$tmp" || return 1
{
flock 9 || return 1
published=$(published_sequence "$USAGE_DIR/$agent.json")
if (( generation > published )); then
mv "$tmp" "$USAGE_DIR/$agent.json" || return 1
fi
} 9>"$lock"
)

# 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.
Expand Down
2 changes: 1 addition & 1 deletion manual/17-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre

### The agents panel

The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box.
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Antigravity, Claude Code, Codex, Grok, and Fireworks are covered out of the box. Antigravity needs CLI 1.1.11 or newer and a sign-in through `agy`; its section shows subscription limits and contributes local prompt/session counts, but its saved transcripts do not provide token counts.

Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings.

Expand Down
20 changes: 18 additions & 2 deletions shell/plugins/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,23 @@ Each agent is one JSON record in `~/.local/state/omarchy/agents/usage/`,
written by `omarchy-agent-usage-update`. That command runs one
`omarchy-agent-usage-<agent>` collector per agent; the widget invokes it
on its refresh timer and whenever you ask for a refresh, and picks up any
record that lands in the directory regardless of who wrote it.
record that lands in the directory regardless of who wrote it. Overlapping
updates reserve publication before collecting: a sequence assigned before collection
prevents a slow older result from replacing a newer published record. A crashed
collector or malformed response does not cancel a valid in-flight result; a
newer valid error record, such as a sign-out, does. The sequence and record
are published together atomically. The publication lock is released during
collection.

Adding an agent therefore never touches this plugin: ship a collector that
prints the record contract (see the `claude` and `codex` collectors in
`bin/`), and the panel gains a tab. An `assets/<id>.svg` mark is optional —
`bin/`), and the panel gains a section. An `assets/<id>.svg` mark is optional —
with an `assets/<id>-light.svg` twin if the mark needs a dark variant for
light surfaces — and the bar glyph stands in when there is none.

| Collector | Limits | Local stats |
|---|---|---|
| `antigravity` | The Antigravity CLI’s read-only `/usage` command (1.1.11 or newer) | Prompt/session counts from transcripts, history and conversation summaries under `~/.gemini/antigravity-cli/` (`AGY_DIR`); token counts are unavailable |
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback |
| `codex` | The Codex app-server RPC | native Codex CLI session files (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 |
Expand Down Expand Up @@ -101,6 +108,14 @@ again. Fireworks reads
opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is
signed in there.

### Antigravity quotas and history

The collector runs `agy -p "/usage" --output-format json` after checking that the installed CLI is at least 1.1.11. That version introduced read-only slash-command output without an agent turn or quota spend; an older or unrecognized version is never sent `/usage`. It resolves an existing mise installation without invoking Omarchy’s installer wrapper. Antigravity itself handles credential storage, token refresh and the account’s quota request.

Session and weekly meters use the returned quota windows, including unused Claude/GPT allowances. Missing percentages are unknown, not zero usage. Failed checks show a status and help text; network failures request an early retry. Every refresh asks the CLI again. Because the response provides no account identity, quota results and failures are never cached: switching accounts or signing in again takes effect on the next check, and a failed check leaves the meters empty. `AGY_DIR` selects another app data directory with its own history cache.

The CLI’s transcripts do not contain token usage or model IDs. Local history contributes prompt and session counts on local calendar dates, without character-based token estimates or invented model rows. History and transcript prompt events are combined individually, matching the complete prompt text and timestamps within one second (and conversation IDs when present) to avoid counting the same prompt twice. Transcript `<USER_REQUEST>` wrappers are recognized and surrounding metadata is excluded; unmatched events from either source and repeated prompts at different times are retained. The `/usage` response does not include a subscription tier, so the plan stays unknown unless you supply `AGY_TIER` or `{ "tier": "Ultra" }` in `~/.config/omarchy/agents/antigravity.json` (honoring `XDG_CONFIG_HOME`). Sign in through `agy`; the panel’s built-in account switching remains for Claude, Codex and Grok.

### Fireworks balance

The collector first asks the account's `:getBalance` endpoint for the real
Expand Down Expand Up @@ -177,6 +192,7 @@ edit `shell.json` directly):

```bash
omarchy bar set omarchy.agents providers '{
"antigravity": { "enabled": true },
"claude": { "enabled": true },
"codex": { "enabled": false },
"fireworks": { "enabled": true }
Expand Down
1 change: 1 addition & 0 deletions shell/plugins/agents/assets/antigravity-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions shell/plugins/agents/assets/antigravity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion shell/plugins/agents/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Antigravity, Claude Code, Codex, Grok, and Fireworks usage, limits, and pace in a native Omarchy bar panel.",
"kinds": ["bar-widget"],
"activation": "on-demand",
"entryPoints": {
Expand All @@ -19,6 +19,7 @@
"allowMultiple": false,
"defaults": {
"providers": {
"antigravity": { "enabled": true },
"claude": { "enabled": true },
"codex": { "enabled": true },
"grok": { "enabled": true },
Expand Down
102 changes: 102 additions & 0 deletions test/shell.d/agent-usage-antigravity-limits-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/bash

set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command python3

python3 - <<'PY'
import importlib.machinery
import importlib.util
import json
import os
import unittest
from pathlib import Path
from unittest.mock import patch

root = Path(os.environ['ROOT'])
loader = importlib.machinery.SourceFileLoader('collector', str(root / 'bin/omarchy-agent-usage-antigravity'))
spec = importlib.util.spec_from_loader(loader.name, loader)
collector = importlib.util.module_from_spec(spec)
loader.exec_module(collector)
fixture = json.loads((root / 'test/shell.d/fixtures/antigravity/usage.json').read_text())

class Limits(unittest.TestCase):
def test_real_cli_shape_and_reversed_groups(self):
data = fixture['command']['data']
limits = collector.extract_limits(data)
self.assertEqual([v['title'] for v in limits], ['Session', 'Weekly', 'Claude/GPT Session', 'Claude/GPT Weekly'])
self.assertEqual([v['percent'] for v in limits], [0, .27, 0, .03])
reversed_data = {'groups': list(reversed(data['groups']))}
self.assertEqual(limits, collector.extract_limits(reversed_data))
self.assertEqual(limits[0]['resetsAt'], '2099-10-02T07:42:57+00:00')

def test_missing_invalid_and_exhausted_buckets(self):
for remaining in [None, 'bad', True, float('nan'), float('inf'), -1, 2]:
data = {'groups': [{'name': 'Gemini Models', 'buckets': [{'window': '5h', 'remaining_fraction': remaining}]}]}
self.assertEqual(collector.extract_limits(data), [], remaining)
data['groups'][0]['buckets'][0]['remaining_fraction'] = 0
self.assertEqual(collector.extract_limits(data)[0]['percent'], 1)
del data['groups'][0]['buckets'][0]['remaining_fraction']
self.assertEqual(collector.extract_limits(data), [])
for data in [{}, {'groups': None}, {'groups': [None, {'buckets': 7}]}]:
self.assertEqual(collector.extract_limits(data), [])

def test_only_scoped_group_keeps_its_identity(self):
data = {'groups': [fixture['command']['data']['groups'][0]]}
self.assertEqual([limit['title'] for limit in collector.extract_limits(data)], ['Claude/GPT Session', 'Claude/GPT Weekly'])
self.assertEqual(collector.extract_limits(data)[0]['percent'], 0)

def test_version_gate_never_prompts_an_old_or_unknown_cli(self):
for version in ['1.1.10', '1.0.99', 'unknown', '1.1.11-preview', '']:
with patch.object(collector, 'run_cli', return_value=(0, version, '')) as run:
result = collector.probe_limits('agy')
self.assertEqual(run.call_count, 1)
self.assertEqual(result['usageStatusText'], 'Antigravity update required')
for version in ['1.1.11', '1.2.14', '2.0.0', 'agy 1.2.14']:
self.assertTrue(collector.supported_version(version))

def test_failures_and_malformed_success(self):
cases = [('401 unauthenticated', 'Antigravity sign-in required', False),
('403 PermissionDenied', 'Antigravity sign-in required', False),
('429 RESOURCE_EXHAUSTED', 'Antigravity limits rate limited', False),
('dial tcp: no such host', 'Antigravity limits unavailable', True),
('refresh credentials: dial tcp: connection refused', 'Antigravity limits unavailable', True),
('500 internal server error', 'Antigravity limits unavailable', False)]
for error, status, retry in cases:
with patch.object(collector, 'run_cli', side_effect=[(0, '1.2.14', ''), (1, '', error)]):
result = collector.probe_limits('agy')
self.assertEqual(result['usageStatusText'], status)
self.assertEqual(result['retryAdvised'], retry)
self.assertNotIn(error, result['authHelpText'])
for output in ['invalid', '[]', '{}', '{"status":"SUCCESS","response":"pretend usage"}',
'{"status":"SUCCESS","command":{"name":"usage","data":{}}}']:
with patch.object(collector, 'run_cli', side_effect=[(0, '1.2.14', ''), (0, output, '')]):
self.assertTrue(collector.probe_limits('agy')['usageStatusText'])
with patch.object(collector, 'run_cli', side_effect=collector.subprocess.TimeoutExpired('agy', 20)):
self.assertTrue(collector.probe_limits('agy')['retryAdvised'])

def test_every_check_reads_the_current_sign_in(self):
good = {'limits': collector.extract_limits(fixture['command']['data']), 'usageStatusText': '', 'authHelpText': ''}
other = {'limits': [{'title': 'Session', 'percent': .8, 'resetsAt': '2099-01-01T00:00:00Z'}], 'usageStatusText': '', 'authHelpText': ''}
failure = collector.probe_failure('network unavailable')
auth = collector.probe_failure('401')
with patch.object(collector, 'probe_limits', side_effect=[good, other, failure, auth, good]) as probe:
first = collector.collect_limits('agy')
second = collector.collect_limits('agy')
self.assertEqual(first['limits'], good['limits'])
self.assertEqual(second['limits'], other['limits'])
self.assertGreater(second['limitsFetchedAt'], 0)
offline = collector.collect_limits('agy')
self.assertEqual(offline['limits'], [])
self.assertTrue(offline['retryAdvised'])
self.assertEqual(offline['limitsFetchedAt'], 0)
signed_out = collector.collect_limits('agy')
self.assertEqual(signed_out['limits'], [])
signed_in = collector.collect_limits('agy')
self.assertEqual(signed_in['usageStatusText'], '')
self.assertEqual(signed_in['limits'], good['limits'])
self.assertEqual(probe.call_count, 5)

unittest.main(verbosity=2)
PY
pass "Antigravity quota parsing, version safety, failures and account changes"
Loading