Switch between several Claude and Codex subscriptions, and build apps the Omarchy way (#13770)

* Plan multiple Claude and Codex accounts with manual or automatic switching

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

* Keep several Claude and Codex accounts and start new sessions as the active one

Each added account gets its own home holding only its login, with history,
settings and skills linked back to ~/.claude or ~/.codex so --continue works
across a switch. Accounts are added through the CLI's own login, and cx, cy,
plain claude/codex and omarchy-agent all start as the active account unless
CLAUDE_CONFIG_DIR or CODEX_HOME is already set.

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

* Report limits for every Claude and Codex account in the usage records

Each registered account is probed with its own sign-in and cached on its own,
and a parked account whose sign-in lapsed keeps its last-known numbers marked
stale. The record's top-level limits keep describing the active account.

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

* Switch accounts automatically near a limit, or notify with a one-click switch

After each usage update, an active account at or over its provider's
threshold (95% by default) moves new sessions to the account with the most
headroom in auto mode, or offers that switch as a notification in manual mode.
It never flaps back to an account that just reset, and says once when every
account is over.

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

* Show every subscription account's limits in the agents panel

With several Claude or Codex accounts, the limits become one card per account
with the active one badged. Pick a card with its number and press Enter (or
click Use) to move new sessions to it, press a to add an account, and m to
toggle automatic switching. Limits refresh every minute while an active
account is above 80%.

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

* Add Setup > Agent Accounts to list, switch and add Claude and Codex accounts

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

* Document switching between several Claude and Codex subscriptions

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

* Count a rested parked account as available, and pin Main to its own home

A parked Claude account whose windows all reset now reads as 0% instead of
unknown, so switching can pick it. Main's Codex limits come from ~/.codex even
when CODEX_HOME is set, and duplicate logins are checked against who each home
is signed in as now.

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

* Key limits caches by subscription and report when an account truly frees up

An added account's limits cache follows its account id, so a new account
reusing a removed one's label never inherits its allowance. The all-accounts
notice now names when an account's blocking windows have all reset, not the
earliest reset of any window.

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

* Keep account ids clear of routing keywords and refresh the panel after removal

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

* Share Codex plugins and hooks across accounts, and key Claude caches by current sign-in

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

* Drop the key hint from the Add account button

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

* Sign new agent accounts in through a private browser window

The main browser is almost certainly signed in to the account you already
have, and the login would silently reuse it. Both CLIs open their login page
through $BROWSER, so it now points at omarchy-launch-browser --private.

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

* Let agent account commands default to your default agent's provider

With Claude or Codex as the default agent, the provider can be left out:
omarchy agent account use work, and primary names the primary account.

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

* Drop the primary alias, which shadowed an account named Primary

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

* Replace the auto switch button with a small Notify / Autoswitch toggle

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

* Add accounts from a small + beside the switch toggle

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

* Fix the punctuation of the switch toggle's README line

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

* Drop the border around the add account +

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

* Rename Claude and Codex accounts without moving their homes

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

* Lay out agent accounts on accent rails instead of boxed cards

The active account gets an accent rail and the others a quiet one, each
window is a single compact line, and the switch toggle, add, and Use are text.
Clicking an account's name renames it in place. A window without a reset time
no longer leaves an empty line, and last-known numbers are only red when the
sign-in needs attention.

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

* Read Codex limits without waiting on account/read

Codex 0.158's app-server can leave account/read unanswered, and asking it
first lost the limits whenever it did, leaving the agents panel showing
"Codex limits unavailable". The limits name the plan themselves, so they're
asked first and account/read is only a short fallback when they don't.

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

* Switch agent providers from their marks in the panel header

The row of provider buttons gives way to a small mark per provider in the
hero's corner, the selected one at full strength, with the add account +
beside them in place of the + by the switch toggle and the full-width Add
account button.

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

* Keep the agents panel's scrollbar off its contents

The panel's content narrows to leave the scrollbar its own strip whenever it
scrolls, and the add account + leads the provider marks instead of sitting at
the very edge.

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

* Add Claude, Codex, or Grok subscriptions from one Add Account menu

The first account for a provider installs its CLI if needed and signs in to
the CLI's own home through the normal browser; only additional accounts get a
home of their own and a private window. Grok signs in its first account.

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

* Reveal Autoswitch beside Use instead of a Notify / Autoswitch row

The panel's + now opens the Add Account menu for any provider.

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

* Split agent accounts with plain separators instead of rails

ACTIVE already marks the account new sessions use.

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

* Only call Claude limits stale once they're old, and poll near a limit less

Anthropic rate-limits its usage endpoint, and polling two accounts every
minute near a limit got every re-check refused, so both accounts read
"Last known" with numbers a minute old. A refused check of numbers under
15 minutes old now counts as current, older ones say how old they are,
and near-limit polling is every three minutes.

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

* Show every agent's limits on one page under an Agents hero

The panel stops switching between one provider at a time and lists every
agent and account with its limits, dropping the tokens-by-day and by-model
charts. The hero carries the agents robot and rotates through what the token
counts add up to: tokens this week and today, the most used model, the
busiest day, and today's prompts and sessions. Middle-clicking the bar icon
refreshes, since there's no provider left to advance to.

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

* Report Codex's free rate-limit resets in its usage record

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

* Give the agents panel more room, and show Codex's free resets

Limit lines are a notch larger with more space between them, each account's
limits sit a clear step below its name, and sections breathe. Codex has one
limit on Pro, so its section now also says how many free full resets are
waiting and when the next one lapses.

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

* Mark Claude's Fable limit on the Weekly meter instead of its own row

A model-scoped allowance on the same clock as a base window is drawn as a
tick on that window's meter and named in the row's tooltip.

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

* Let omarchy-default-agent set the default without launching it

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

* Let the agents panel drive adding an account without a terminal

--check says whether adding one now would be each provider's first sign-in
or an additional account, and --events reports progress as tagged lines
alongside the CLI's own output, notifies with the result, and cleans up the
login and its scratch home when cancelled.

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

* Add subscriptions and pick the default agent right in the agents panel

The + is a proper accent button, and it swaps the list for a picker of
Claude, Codex, and Grok that signs in without a terminal: name a further
account, then follow the sign-in with its status, Grok's confirmation code, a
field for Claude's pasted code, and a link to reopen the page. A dropdown at
the bottom sets the default agent without launching it.

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

* Draw the Fable tick in its meter's own color

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

* Revert "Let omarchy-default-agent set the default without launching it"

This reverts commit da96261f7d26bb76774cbdc1cb9e0abde92bb841.

* Make the first agent signed in the default when none is picked

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

* Keep the agents panel in the bar, with setup, starters, and adding in it

A machine with no agent opens the panel on setting one up. Once set up, the
list ends with starter prompts for a new theme, plugin, or app, and a quiet
Add a subscription. The panel no longer sets the default agent, and the hero
drops its add button.

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

* Offer the starter prompts as tiles, and adding as a row beneath them

Theme, Plugin, and App each get a tile with its glyph, and Add a
subscription a matching row with the + in a tinted square. The panel is
allowed to grow tall enough to show it all without scrolling.

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

* Put the tinted add button in the agents panel's hero corner

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

* Show when a limit resets in small type under its meter

The percentage keeps the right edge to itself, so the meter runs longer.

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

* Revert "Show when a limit resets in small type under its meter"

This reverts commit 108d9de59da1af12cddbf232b0b5333ad8994c64.

* Put each account's plan beside its name, and its email in a tooltip

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

* Stack each limit's percentage over its time left, so the meter runs longer

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

* Show only each limit's time left, with the exact percentage on hover

The meter already says how full a window is. Dropping the percentage beside
it leaves one calm figure per row, and the row's tooltip carries the number.

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

* Sign an existing account in again with omarchy-agent-account-add --reauth

It signs in where the account already lives: the CLI's own home (:primary)
through the normal browser, an added one through a private window. The usage
records now say which account is the primary.

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

* Offer Sign-in required in the agents panel instead of how old the numbers are

A lapsed sign-in shows as a link that signs that account in again right in
the panel. The "as of" and "last known" notes are gone; trouble that isn't
about signing in still shows as text.

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

* Keep the separator dot out of the Sign-in required link

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

* Drop the Setup > Agent Accounts menu and the stale plan

The agents panel now lists, switches, and adds accounts, so the menu's
duplicate of it goes, and the plan written before the design settled no
longer describes it.

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

* Install the compiler and Qt pieces for building Omarchy-style apps

base-devel plus qt6-base, qt6-declarative, qt6-multimedia, and qt6-wayland,
which is what Hype, Monologue, and Omacut build with through qmake6 and make.
Qt was only there as a dependency of those apps, and the compiler not at all.

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

* Add an omarchy-app agent skill for building apps the Omarchy way

It teaches how Hype, Monologue, and Omacut are built: C++ and Qt Quick in
one flat project, qmake6 and make into a single binary, a theme that follows
Omarchy's accent live, portal dialogs, keyboard-first conventions, Qt Test
offscreen, and a PKGBUILD that puts the app in the launcher, with starter
files that build and pass their tests as written. The agents panel's App
starter uses it.

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

* Link the omarchy-app skill on existing installs

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

* Let Cancel stop a waiting login, and list a lone account's limits

Bash holds a trap until the foreground command finishes, so a login waiting
on the browser outlived Cancel. It now runs behind an interruptible wait.
With one account the usage record keeps its limits at the top level, which
omarchy agent account list now reads for the primary.

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

* Re-read identity even when signed out, and count Grok as set up

A home whose login is gone no longer keeps the identity the registry saved,
so it reads as signed out and can be added again. The agents panel leaves its
setup screen once any agent is signed in, not only once one has usage to
show, since Grok has no usage collector.

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

* Save each account's current identity before collecting usage

A home signed in to someone else since it was added kept the old email in
the registry, which the usage records name accounts by. The usage update now
refreshes the registry from each home first.

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

* Offer Claude's paste field from the start, and keep providers with accounts

Claude's login prompts for a pasted code without a newline, so the panel's
line reader never saw it; the field is simply there for Claude sign-ins. A
provider with accounts stays listed even when the active one's limits are
unavailable, so its other accounts can still be switched to.

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

* Never autoswitch to a signed-out account, and report Codex's missing sign-in

An account nobody is signed in to is left out of switching, however much
room its cached numbers show. Codex answers a home without a login with an
error, which now reads as Waiting for auth, so the panel offers Sign-in
required for Codex accounts too.

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

* Link shared files before they exist, and only call every account over when it is

A shared file the primary home didn't have yet was left unlinked, so an
added account made its own copy and the two diverged for good; it's linked
up front now, and written in the primary home by whichever account writes it
first. The all-accounts-over notice waits until every account's limits are
actually known.

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

* Make the agent starter tiles compact and call the section Make something cool

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

* Hold the agents hero line until its next fade

Every usage record that landed rebuilt the summary phrases, and the hero
indexed that live list, so opening the panel could swap the line several
times between fades. It now keeps what it shows until the timed swap.

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

* Start the default agent from the agents panel hero

A console button beside the add button runs the same launcher as the
right click: the default agent, or the picker when none is set. It hides
while adding a subscription, where the add button becomes the way back.

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

* Put the add button before the agent launcher in the hero corner

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

* Bring keyboard navigation back to the agents panel

The one-page redesign dropped left and right with the per-agent pages,
leaving the arrows only to scroll. They now walk everything that does
something, row by row: the hero's add and launch buttons, each
switchable account, and the starter tiles. Enter acts on the cursor,
the cursor scrolls into view, and hovering moves the same cursor so only
one thing is lit. Number keys still jump to an account.

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

* Let adding an account take over the agents hero

While adding, the hero's line reads "Add an account" in place of the
rotating summary, and the X in its corner is the only way back, so the
add view drops its own title and Back link. Each agent to add is just its
mark and name; one that can't be added is dimmed and says why on hover.
The arrows walk that list too, and Enter picks one.

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

* Show the agents to add as large marks, three across

Each agent is a large mark over its name with no box around it, in one
row the arrows move along. The chosen one turns accent and grows a touch.
The reason an agent can't be added is shortened to fit inside the panel.

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

* Move between Autoswitch and Use on an account with the arrows

An account that isn't active is now two stops, Autoswitch then Use, so
left and right move between them and Enter acts on the one that's lit.

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

* Land on Use when moving up or down onto an account

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

* Hide Use on an autoswitching account until you're on its line

With Autoswitch on, the line shows only Autoswitch. Use appears when the
line is hovered anywhere or the keyboard is on it.

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

* Start adding an account with the first agent focused

Opening the add screen puts the keyboard cursor on the first agent, so
Enter picks it straight away. A focused agent lights up even before the
check says whether it can be added.

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

* Drop the Sign in link under the account name field

Enter in the field already starts the sign-in.

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

* Key the primary Claude account's limits cache by its subscription

The primary home always used claude-limits.json, so signing it in to
another subscription could carry the old one's numbers over when the
first probe failed, and autoswitch would act on them. Once the home says
who it's signed in as, its cache is keyed by that like every other
account's.

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

* Leave Qt Multimedia out of the base packages again

Quattro dropped it once the shell no longer needed it, and the app skill
already has an app that plays audio or video add it and list it in its
own depends. The compiler and the rest of Qt stay.

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

* Render the agents panel's dynamic text as plain text

Sign-in status, help text, and provider names come from outside the
shell, so none of them should be read as markup.

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

* Keep an account's plan and trouble clear of its Use and Autoswitch links

The details beside an account's name grew to their full width, so a long
plan or warning could run under the links on the right. They now shorten
with an ellipsis instead, and Sign-in required keeps its whole width.

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

* Let primary name the first account, as the manual says

`omarchy agent account rename primary Hey` was documented but failed,
since the primary account's id is main and lookups took exact ids only.
primary now reaches the account marked primary, whatever it's been
renamed to, and no new account can take primary as its id.

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

* Install everything an Omarchy app needs by default

The app skill builds with Qt Multimedia, SVG icons, and ffmpeg as well as
the compiler and the rest of Qt, so qt6-multimedia, qt6-svg, and ffmpeg
join the base packages and the migration. The shell still plays no video
through Qt Multimedia, which is what its test now checks.

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

* Dim agent limits kept from an earlier check, with their age on hover

When a Claude probe fails, the last numbers carry on and looked just like
fresh ones. Those meters now dim, and their tooltip says how old they
are. The record carries limitsStale and limitsFetchedAt for this.

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

* Give the app skill templates for every file its build needs

The templates named src/backend.{h,cpp} and a test file without showing
them, so each app had to invent its own. They're now templated, with a
starter icon, a note that LICENSE and the icon must exist for package(),
and qt6-wayland in the PKGBUILD's depends. Scaffolded from the templates
alone, the app builds and its tests pass.

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

* Start the agents panel cursor over when the agent list comes or goes

Right after the shell starts, the panel can briefly look like a first
setup and focus the first agent to add. When the records land the rows
change under the cursor, which then lit an account nobody had picked.

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

* Drop dead hover and limit checks from the agents panel

The tiles and hero buttons fell back on their own hover when there were
no keyboard rows, but the hero always has one, so hover only ever moves
the cursor. Stale meters only exist when there are limits, so neither
the panel nor the record needs to check for some.

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

* Drop the a hotkey for adding an agent subscription

The + in the hero is the way in, by mouse or by arrowing to it. The
plugin README also catches up with the panel: the launcher, dimmed stale
limits, the new add screen, and the arrows walking a cursor rather than
scrolling.

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

* List a command family that sits under a flags-only command

`omarchy agent account` reached omarchy-agent, which takes only flags,
and failed on the word instead of listing the account commands. When the
command matched so far takes only flags and the next word names visible
commands, the router now lists them. `omarchy update aur` likewise lists
the update aur commands rather than running a full update.

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

* Refuse to remove an agent account a running session uses

Removing an account deleted its home at once, pulling the login out from
under any session started in it, though running sessions are never meant
to be touched. It now says to quit that session first.

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

* Prefer an account checked just now when switching near a limit

Autoswitch weighed numbers kept from an earlier check like fresh ones.
A parked account's sign-in lapses within hours, so a stale one stays a
candidate, but an account checked just now wins over it.

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

* Leave a user's own omarchy-app skill in place when linking ours

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

* Reach Sign-in required with the keyboard in the agents panel

Both the link on a lapsed account and the one on a single-account agent
are now stops in the cursor's walk, and Enter signs in again. A picked
link scrolls into view.

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

* Bring the manual's agent accounts paragraph up to date

Near-limit checks run every three minutes, not every minute, and
Autoswitch now appears on hovering an account's line and takes Use's
place while it's on.

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

* Keep the agents panel cursor on things you can act on

The active account was a stop with nothing to do, so the cursor seemed to
vanish there. It now only stops on it to sign in again. The hero's
buttons also show the cursor plainly, with an accent border and a deeper
tint instead of a shade's difference.

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

* Never leave an added agent account's login outside the registry

Registering moved the login into its home before carrying settings over
and saving the registry, so a failure in either stranded a home holding
a sign-in that the account commands couldn't see. Settings are now
carried while the login is still pending, and a failed save moves the
home back there for the add command to clean up.

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

* Put room before freshness when picking an account to switch to

Preferring accounts checked just now outright could pick a fresh one at
94% over a stale one at 10%. An account within 15 points of the
threshold now counts as near its limit however fresh, so accounts with
room come first and freshness only decides among them.

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

* Check agent limits more often relative to the switch threshold

Faster checks started at a fixed 80%, so a threshold set lower could be
crossed and wait out the 15-minute interval. They now start 15 points
below the threshold, which is still 80% at the default 95%.

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 13:36:22 +02:00
1 parent 8b4eae66da
commit c05d90196f
32 files changed
+4646 -617

No files matched your search

@@ -0,0 +1,202 @@
#!/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
mkdir -p "$test_tmp/bin" "$test_tmp/home/.claude"
notifications="$test_tmp/notifications"
cat >"$test_tmp/bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$OMARCHY_TEST_NOTIFICATIONS"
SH
chmod +x "$test_tmp/bin/omarchy-notification-send"
export HOME="$test_tmp/home"
export XDG_STATE_HOME="$test_tmp/state"
export PATH="$test_tmp/bin:$ROOT/bin:$PATH"
export OMARCHY_TEST_NOTIFICATIONS="$notifications"
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
usage="$XDG_STATE_HOME/omarchy/agents/usage"
mkdir -p "$accounts/claude/work" "$usage"
soon=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=72)).isoformat())')
later=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=4)).isoformat())')
gone=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=5)).isoformat())')
registry() {
cat >"$accounts/claude.json" <<JSON
{
"active": "$1",
"switch": "$2",
"threshold": ${3:-95},
"alert": "",
"accounts": [
{"id": "main", "label": "Main", "home": "", "primary": true},
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false}
]
}
JSON
}
# Session percent and reset for each account, as the usage record carries them.
record() {
jq -nc --argjson main "$1" --arg mainReset "$2" --argjson work "$3" --arg workReset "$4" '{
id: "claude",
accounts: [
{id: "main", limits: [{label: "Session (5-hour)", percent: $main, resetsAt: $mainReset}]},
{id: "work", limits: [{label: "Session (5-hour)", percent: $work, resetsAt: $workReset}]}
]
}' >"$usage/claude.json"
}
autoswitch() {
: >"$notifications"
omarchy-agent-account-state autoswitch claude
}
active() {
jq -r .active "$accounts/claude.json"
}
# ---------------------------------------------------------------- manual mode
registry main manual
record 0.97 "$soon" 0.12 "$later"
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "manual mode never moves the active account"
grep -q "Main is at 97% of a Claude limit Work is at 12%. Click to switch new sessions to it. --exec omarchy-agent-account-use claude work" "$notifications" ||
fail "manual mode offers the switch as the notification's click" "$(cat "$notifications")"
pass "manual mode notifies with a one-click switch"
autoswitch >/dev/null
[[ ! -s $notifications ]] || fail "manual mode says it once" "$(cat "$notifications")"
pass "manual mode doesn't repeat itself"
# ------------------------------------------------------------------ auto mode
registry main auto
record 0.949 "$soon" 0.12 "$later"
[[ -z $(autoswitch) && ! -s $notifications ]] || fail "an account under the threshold stays put"
pass "an account under the threshold stays put"
record 0.96 "$soon" 0.12 "$later"
[[ $(autoswitch) == "claude" && $(active) == "work" ]] || fail "auto mode moves to the account with headroom"
grep -q "New Claude sessions now use Work (12%). Running sessions stay on Main." "$notifications" ||
fail "auto mode says where new sessions went" "$(cat "$notifications")"
pass "auto mode switches at the threshold"
# Main is still at 96%, but only the active account crossing starts anything.
record 0.96 "$soon" 0.30 "$later"
[[ -z $(autoswitch) && $(active) == "work" && ! -s $notifications ]] || fail "auto mode never flaps back"
pass "auto mode leaves a healthy active account alone"
# A stale 99% whose window already reset is an untouched allowance.
registry main auto
record 0.97 "$soon" 0.99 "$gone"
[[ $(autoswitch) == "claude" && $(active) == "work" ]] || fail "a window past its reset counts as empty"
pass "a window past its reset counts as empty"
# ------------------------------------------------------------------- exhausted
registry main auto
record 0.97 "$later" 0.98 "$soon"
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "with nowhere to go, auto mode stays put"
grep -q "All Claude accounts are over 95% Staying on Main. Work resets in 1h 1[12]m." "$notifications" ||
fail "exhaustion names the account that frees up first" "$(cat "$notifications")"
autoswitch >/dev/null
[[ ! -s $notifications ]] || fail "exhaustion is said once"
pass "all accounts over the threshold is said once"
# Work's session is nearly empty and resets soon, but its full weekly window
# is what holds it over the threshold, so that reset is when it frees up.
registry main auto
jq -nc --arg later "$later" --arg soon "$soon" --arg week "$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=6)).isoformat())')" '{
id: "claude",
accounts: [
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
{id: "work", limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $soon}, {label: "Weekly (7-day)", percent: 1.0, resetsAt: $week}]}
]
}' >"$usage/claude.json"
autoswitch >/dev/null
grep -q "Main resets in 3h 5[89]m." "$notifications" || fail "exhaustion ignores a reset that doesn't free the account" "$(cat "$notifications")"
pass "exhaustion names when an account actually frees up"
record 0.20 "$later" 0.98 "$soon"
autoswitch >/dev/null
[[ $(jq -r .alert "$accounts/claude.json") == "" ]] || fail "dropping back under the threshold re-arms the alert"
pass "dropping back under the threshold re-arms the alert"
registry main auto 80
record 0.85 "$soon" 0.12 "$later"
[[ $(autoswitch) == "claude" ]] || fail "the threshold is configurable"
pass "the threshold is configurable"
# An account nobody is signed in to is never switched to, however much room
# its old numbers show.
registry main auto
record 0.97 "$soon" 0.10 "$later"
jq '.accounts[1].usageStatusText = "Waiting for auth"' "$usage/claude.json" >"$test_tmp/record.json"
mv "$test_tmp/record.json" "$usage/claude.json"
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "a signed-out account is never switched to"
pass "a signed-out account is never switched to"
# With another account unknown, nobody can say every account is over.
registry main auto
record 0.97 "$later" 0.98 "$soon"
jq '.accounts[1].usageStatusText = "Waiting for auth"' "$usage/claude.json" >"$test_tmp/record.json"
mv "$test_tmp/record.json" "$usage/claude.json"
autoswitch >/dev/null
[[ ! -s $notifications ]] || fail "an unknown account keeps exhaustion unsaid" "$(cat "$notifications")"
pass "exhaustion is only said when every account is known to be over"
# Numbers kept from an earlier check may be out of date, so an account checked
# just now wins over a stale one showing more room; a stale one still counts
# when it's the only place left to go.
mkdir -p "$accounts/claude/side"
cat >"$accounts/claude.json" <<JSON
{
"active": "main",
"switch": "auto",
"threshold": 95,
"alert": "",
"accounts": [
{"id": "main", "label": "Main", "home": "", "primary": true},
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false},
{"id": "side", "label": "Side", "home": "$accounts/claude/side", "primary": false}
]
}
JSON
jq -nc --arg later "$later" '{
id: "claude",
accounts: [
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
{id: "work", stale: true, limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $later}]},
{id: "side", stale: false, limits: [{label: "Session (5-hour)", percent: 0.40, resetsAt: $later}]}
]
}' >"$usage/claude.json"
autoswitch >/dev/null
[[ $(active) == "side" ]] || fail "an account checked just now wins over a stale one" "$(active)"
pass "an account checked just now wins over a stale one"
# Room comes first: a fresh account within 15 points of the threshold loses to
# a stale one with plenty left.
jq '.active = "main" | .alert = ""' "$accounts/claude.json" >"$test_tmp/registry.json"
mv "$test_tmp/registry.json" "$accounts/claude.json"
jq -nc --arg later "$later" '{
id: "claude",
accounts: [
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
{id: "work", stale: true, limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $later}]},
{id: "side", stale: false, limits: [{label: "Session (5-hour)", percent: 0.94, resetsAt: $later}]}
]
}' >"$usage/claude.json"
autoswitch >/dev/null
[[ $(active) == "work" ]] || fail "a stale account with room wins over a fresh one near the limit" "$(active)"
pass "a stale account with room wins over a fresh one near the limit"
+402
View File
@@ -0,0 +1,402 @@
#!/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
mock_bin="$test_tmp/bin"
notifications="$test_tmp/notifications"
mkdir -p "$mock_bin" "$test_tmp/home/.claude" "$test_tmp/home/.codex"
cat >"$mock_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$OMARCHY_TEST_NOTIFICATIONS"
SH
# The CLIs report which home they were started in, and a login writes the
# identity a real one would into whichever home it was given.
cat >"$mock_bin/claude" <<'SH'
#!/bin/bash
if [[ ${1:-} == "auth" && ${2:-} == "login" ]]; then
"${BROWSER:-omarchy-test-default-browser}" "https://claude.com/oauth/authorize"
if [[ -n ${OMARCHY_TEST_LOGIN_HANGS:-} ]]; then
echo $$ >"$OMARCHY_TEST_LOGIN_HANGS"
exec sleep 30
fi
[[ -n ${OMARCHY_TEST_LOGIN_UUID:-} ]] || exit 1
home=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
account_file="$home/.claude.json"
[[ -z ${CLAUDE_CONFIG_DIR:-} ]] && account_file="$HOME/.claude.json"
printf '{"oauthAccount":{"accountUuid":"%s","emailAddress":"%s","organizationName":"Work"}}\n' \
"$OMARCHY_TEST_LOGIN_UUID" "$OMARCHY_TEST_LOGIN_EMAIL" >"$account_file"
echo '{"claudeAiOauth":{"rateLimitTier":"default_claude_max_5x","subscriptionType":"max"}}' >"$home/.credentials.json"
exit 0
fi
echo "claude home=${CLAUDE_CONFIG_DIR:-default} args=$*"
SH
cat >"$mock_bin/codex" <<'SH'
#!/bin/bash
if [[ ${1:-} == "login" ]]; then
"${BROWSER:-omarchy-test-default-browser}" "https://auth.openai.com/oauth/authorize"
claims=$(printf '{"email":"%s","https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}' "$OMARCHY_TEST_LOGIN_EMAIL" | base64 -w0 | tr '+/' '-_' | tr -d '=')
printf '{"auth_mode":"chatgpt","tokens":{"account_id":"%s","id_token":"h.%s.s"}}\n' "$OMARCHY_TEST_LOGIN_UUID" "$claims" >"${CODEX_HOME:-$HOME/.codex}/auth.json"
exit 0
fi
echo "codex home=${CODEX_HOME:-default} args=$*"
SH
cat >"$mock_bin/grok" <<'SH'
#!/bin/bash
if [[ ${1:-} == "login" ]]; then
"${BROWSER:-omarchy-test-default-browser}" "https://auth.x.ai/oauth/authorize"
mkdir -p "${GROK_HOME:-$HOME/.grok}"
echo '{"token":"t"}' >"${GROK_HOME:-$HOME/.grok}/auth.json"
fi
SH
cat >"$mock_bin/omarchy-test-default-browser" <<'SH'
#!/bin/bash
printf 'default %s\n' "$*" >>"$OMARCHY_TEST_BROWSER_LOG"
SH
cat >"$mock_bin/omarchy-agent-usage-update" <<'SH'
#!/bin/bash
SH
cat >"$mock_bin/omarchy-default-agent" <<'SH'
#!/bin/bash
echo "${OMARCHY_TEST_DEFAULT_AGENT-claude}"
SH
cat >"$mock_bin/omarchy-launch-browser" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$OMARCHY_TEST_BROWSER_LOG"
SH
chmod +x "$mock_bin"/*
export HOME="$test_tmp/home"
export XDG_STATE_HOME="$test_tmp/state"
export PATH="$mock_bin:$ROOT/bin:$PATH"
export OMARCHY_PATH="$ROOT"
export OMARCHY_TEST_NOTIFICATIONS="$notifications"
export OMARCHY_TEST_BROWSER_LOG="$test_tmp/browser"
unset CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME BROWSER
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
echo '{"oauthAccount":{"accountUuid":"u-main","emailAddress":"me@example.com","organizationName":"Me"},"mcpServers":{"docs":{"command":"docs-mcp"}}}' >"$HOME/.claude.json"
echo '{"claudeAiOauth":{"rateLimitTier":"default_claude_max_20x","subscriptionType":"max"}}' >"$HOME/.claude/.credentials.json"
echo '{"theme":"custom:omarchy"}' >"$HOME/.claude/settings.json"
# ---------------------------------------------------------------- single account
[[ -z $(omarchy-agent-account-home claude) ]] || fail "a machine with one account routes nowhere"
[[ ! -e $accounts/claude.json ]] || fail "reading the active home never creates a registry"
pass "a machine with one account launches exactly as before"
list=$(omarchy-agent-account-list claude --json)
[[ $(jq -c '.[0].accounts | map({id, label, plan, email, active, primary})' <<<"$list") == '[{"id":"main","label":"Main","plan":"Max 20x","email":"me@example.com","active":true,"primary":true}]' ]] ||
fail "the existing login is listed as the primary account" "$list"
pass "the existing login is listed as the primary account"
# ------------------------------------------------------------------------- add
OMARCHY_TEST_LOGIN_UUID=u-work OMARCHY_TEST_LOGIN_EMAIL=work@example.com \
omarchy-agent-account-add claude Work </dev/null >"$test_tmp/add-output"
grep -q "Added Work (work@example.com)" "$test_tmp/add-output" || fail "adding an account reports who signed in" "$(cat "$test_tmp/add-output")"
grep -q "private window that opens" "$test_tmp/add-output" || fail "adding an account says to sign in within the private window"
[[ $(head -1 "$OMARCHY_TEST_BROWSER_LOG") == "--private https://claude.com/oauth/authorize" ]] ||
fail "a Claude login opens in a private window" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
pass "adding an account signs in through the CLI's own login"
work="$accounts/claude/work"
[[ -f $work/.credentials.json && ! -L $work/.credentials.json ]] || fail "an added account keeps its own credentials"
[[ $(readlink "$work/projects") == "$HOME/.claude/projects" ]] || fail "an added account shares conversation history with the primary"
[[ $(readlink "$work/settings.json") == "$HOME/.claude/settings.json" ]] || fail "an added account shares settings with the primary"
[[ $(readlink "$work/CLAUDE.md") == "$HOME/.claude/CLAUDE.md" && ! -e $HOME/.claude/CLAUDE.md ]] ||
fail "a shared file the primary doesn't have yet is linked, so it's written there when it is"
[[ $(jq -c .mcpServers "$work/.claude.json") == '{"docs":{"command":"docs-mcp"}}' ]] || fail "an added account carries the primary's MCP servers"
[[ $(stat -c %a "$work") == 700 && $(stat -c %a "$accounts/claude.json") == 600 ]] || fail "account homes and the registry are private"
pass "an added account shares everything but its login with the primary"
if OMARCHY_TEST_LOGIN_UUID=u-work OMARCHY_TEST_LOGIN_EMAIL=work@example.com \
omarchy-agent-account-add claude Again </dev/null >"$test_tmp/dup-output" 2>&1; then
fail "adding the same account twice fails"
fi
grep -q "That's Work" "$test_tmp/dup-output" || fail "a duplicate login names the account it already is" "$(cat "$test_tmp/dup-output")"
[[ -z $(ls -A "$accounts/claude/.pending") ]] || fail "a duplicate login leaves no scratch home behind"
pass "adding an account that's already there is refused"
# Main was signed in to another account by hand since the registry was made;
# adding that login again is still the same subscription.
jq '.oauthAccount.accountUuid = "u-relogged"' "$HOME/.claude.json" >"$test_tmp/relogged.json"
cp "$HOME/.claude.json" "$test_tmp/original.json"
mv "$test_tmp/relogged.json" "$HOME/.claude.json"
if OMARCHY_TEST_LOGIN_UUID=u-relogged OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
omarchy-agent-account-add claude Twin </dev/null >"$test_tmp/twin-output" 2>&1; then
fail "a login matching Main's current sign-in is refused"
fi
grep -q "That's Main" "$test_tmp/twin-output" || fail "a login matching Main's current sign-in names Main" "$(cat "$test_tmp/twin-output")"
mv "$test_tmp/original.json" "$HOME/.claude.json"
pass "duplicates are judged by who each home is signed in as now"
if OMARCHY_TEST_LOGIN_UUID="" omarchy-agent-account-add claude Nope </dev/null >/dev/null 2>&1; then
fail "an abandoned login adds nothing"
fi
[[ $(omarchy-agent-account-list claude --json | jq '.[0].accounts | length') == 2 ]] || fail "an abandoned login adds nothing"
pass "an abandoned login adds nothing"
OMARCHY_TEST_LOGIN_UUID=u-next OMARCHY_TEST_LOGIN_EMAIL=next@example.com \
omarchy-agent-account-add claude Next </dev/null >/dev/null
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.label == "Next") | .id') == "next-2" ]] ||
fail "an account label can't take an id that routing already answers to"
omarchy-agent-account-remove claude next-2 </dev/null >/dev/null
pass "account ids stay clear of routing keywords"
OMARCHY_TEST_LOGIN_UUID=acct-1 OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
omarchy-agent-account-add codex </dev/null >"$test_tmp/first-codex"
[[ -f $HOME/.codex/auth.json && ! -d $accounts/codex ]] || fail "the first Codex account signs in to ~/.codex itself"
grep -qx "default https://auth.openai.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
fail "the first Codex account signs in through the normal browser" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
[[ ! -e $HOME/.config/omarchy/defaults/agent ]] || fail "a first sign-in leaves an existing default agent alone"
pass "the first account of a provider signs in to its own home in the normal browser"
OMARCHY_TEST_LOGIN_UUID=acct-2 OMARCHY_TEST_LOGIN_EMAIL=side@example.com \
omarchy-agent-account-add codex Side </dev/null >/dev/null
[[ $(omarchy-agent-account-list codex --json | jq -c '.[0].accounts[1] | {id, email, plan}') == '{"id":"side","email":"side@example.com","plan":"Pro"}' ]] ||
fail "a Codex account reads its identity from the login's token claims"
[[ $(readlink "$accounts/codex/side/sessions") == "$HOME/.codex/sessions" ]] || fail "a Codex account shares sessions with the primary"
grep -qx -- "--private https://auth.openai.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
fail "a Codex login opens in a private window" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
pass "Codex accounts are added the same way"
OMARCHY_TEST_DEFAULT_AGENT="" omarchy-agent-account-add grok </dev/null >/dev/null
[[ $(cat "$HOME/.config/omarchy/defaults/agent") == "grok" ]] ||
fail "the first agent signed in on a machine with no default becomes the default"
[[ -s $HOME/.grok/auth.json ]] && grep -qx "default https://auth.x.ai/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
fail "a first Grok sign-in lands in ~/.grok through the normal browser"
if omarchy-agent-account-add grok Second </dev/null >"$test_tmp/grok-second" 2>&1; then
fail "a second Grok account says it isn't supported yet"
fi
grep -q "isn't supported yet" "$test_tmp/grok-second" || fail "a second Grok account says why it stops" "$(cat "$test_tmp/grok-second")"
pass "Grok signs in its first account"
# ---------------------------------------------------------------------- routing
omarchy-agent-account-use claude work >/dev/null
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "the active account's home is what launches use"
grep -q "New Claude sessions now use Work (Max 5x)" "$notifications" || fail "switching says where new sessions go"
pass "use makes an account active and says so"
source "$ROOT/default/bash/fns/agent-accounts"
[[ $(claude --version) == "claude home=$work args=--version" ]] || fail "claude at a prompt starts as the active account"
[[ $(CLAUDE_CONFIG_DIR=/elsewhere claude) == "claude home=/elsewhere args=" ]] || fail "an explicit CLAUDE_CONFIG_DIR wins over the active account"
[[ $(codex) == "codex home=default args=" ]] || fail "codex stays on its primary until switched"
pass "shell launches follow the active account"
[[ $(OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent --inline) == "claude home=$work args=--permission-mode auto" ]] ||
fail "omarchy-agent starts Claude as the active account"
omarchy-agent-account-use codex side >/dev/null
[[ $(OMARCHY_TEST_DEFAULT_AGENT=codex omarchy-agent --inline) == "codex home=$accounts/codex/side args=--approve-for-me" ]] ||
fail "omarchy-agent starts Codex as the active account"
pass "omarchy-agent follows the active account"
omarchy-agent-account-use claude next >/dev/null
[[ -z $(omarchy-agent-account-home claude) ]] || fail "next cycles back to the primary"
pass "next cycles through accounts"
# With Claude as the default agent, the provider can be left out.
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-use work >/dev/null
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "use without a provider picks the default agent's account"
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-use main >/dev/null
[[ -z $(omarchy-agent-account-home claude) ]] || fail "use without a provider switches back to Main"
OMARCHY_TEST_DEFAULT_AGENT=codex omarchy-agent-account-use main >/dev/null
[[ -z $(omarchy-agent-account-home codex) ]] || fail "the default agent decides which provider a short use means"
if OMARCHY_TEST_DEFAULT_AGENT=pi omarchy-agent-account-use work >/dev/null 2>&1; then
fail "a default agent with no accounts still needs the provider named"
fi
pass "the provider defaults to your default agent"
# ----------------------------------------------------------------------- rename
omarchy-agent-account-use claude work >/dev/null
omarchy-agent-account-rename claude work Day job >/dev/null
[[ $(omarchy-agent-account-list claude --json | jq -c '.[0] | {active, renamed: (.accounts[] | select(.id == "day-job") | .label)}') == '{"active":"day-job","renamed":"Day job"}' ]] ||
fail "renaming changes the label and the id it answers to, and the active account follows"
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "renaming leaves the account's home where running sessions expect it"
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-rename day-job Work >/dev/null
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "a renamed account can be renamed back"
omarchy-agent-account-rename claude main Personal >/dev/null
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.primary) | "\(.id) \(.home)"') == "personal $HOME/.claude" ]] ||
fail "the primary account can be renamed and keeps ~/.claude"
omarchy-agent-account-rename claude personal Main >/dev/null
# `primary` reaches the first login whatever it's called, as the manual says.
omarchy-agent-account-rename claude primary Hey >/dev/null ||
fail "primary names the primary account"
omarchy-agent-account-rename claude primary Main >/dev/null
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.primary) | .id') == "main" ]] ||
fail "primary still reaches the primary account after a rename"
if omarchy-agent-account-rename claude work "" >/dev/null 2>&1; then
fail "an account can't be renamed to nothing"
fi
pass "rename relabels an account without moving it"
# ------------------------------------------------------------ mode and remove
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-mode auto 90 >/dev/null
[[ $(jq -c '{switch, threshold}' "$accounts/claude.json") == '{"switch":"auto","threshold":90}' ]] || fail "mode sets switching and threshold"
if omarchy-agent-account-mode claude sometimes >/dev/null 2>&1; then
fail "mode refuses an unknown switch mode"
fi
pass "mode sets how switching happens"
if omarchy-agent-account-remove claude main </dev/null >/dev/null 2>&1; then
fail "the primary account can't be removed"
fi
omarchy-agent-account-use claude work >/dev/null
# A session still running in the account keeps its login until it quits.
CLAUDE_CONFIG_DIR="$work" sleep 30 &
session=$!
if omarchy-agent-account-remove claude work </dev/null >/dev/null 2>"$test_tmp/in-use"; then
fail "an account a running session uses can't be removed"
fi
[[ -d $work ]] && grep -q "Quit it first" "$test_tmp/in-use" || fail "removing an account in use leaves it and says why" "$(cat "$test_tmp/in-use")"
kill "$session"; wait "$session" 2>/dev/null || true
omarchy-agent-account-remove claude work </dev/null >/dev/null
[[ ! -e $work && -d $HOME/.claude/projects && -f $HOME/.claude/settings.json ]] || fail "removing an account deletes its home and nothing it links to"
[[ -z $(omarchy-agent-account-home claude) ]] || fail "removing the active account falls back to the primary"
pass "remove forgets an added account without touching shared files"
# A registry that can't be saved leaves the new login pending, where the add
# command cleans it up, rather than in a home nothing can manage.
STATE="$ROOT/bin/omarchy-agent-account-state" python3 - <<'PY' || fail "a failed registration rolls the new home back to pending"
import importlib.machinery, importlib.util, json, os, sys
loader = importlib.machinery.SourceFileLoader("state", os.environ["STATE"])
spec = importlib.util.spec_from_loader(loader.name, loader)
state = importlib.util.module_from_spec(spec)
loader.exec_module(state)
pending = state.begin("claude")
(pending / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "u-stranded", "emailAddress": "stranded@example.com"}}))
def broken_save(provider, registry):
raise OSError("disk full")
state.save = broken_save
try:
state.register("claude", "Stranded", pending)
except OSError:
pass
else:
sys.exit("register should fail when the registry can't be saved")
assert pending.is_dir(), "the login goes back to pending"
assert not (state.accounts_root() / "claude" / "stranded").exists(), "no home is left outside the registry"
# The add command would remove it on exit; this test stands in for it.
import shutil
shutil.rmtree(pending)
PY
pass "a failed registration rolls the new home back to pending"
# ------------------------------------------------------------ panel add flow
[[ $(omarchy-agent-account-add --check) == $'claude additional\ncodex additional\ngrok unsupported' ]] ||
fail "--check says what adding would mean for each provider" "$(omarchy-agent-account-add --check)"
pass "--check says what adding would mean for each provider"
: >"$notifications"
OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
omarchy-agent-account-add --events claude Events </dev/null >"$test_tmp/events-output"
grep -qx "@@omarchy status Sign in as the account you're adding in the private window that opens." "$test_tmp/events-output" ||
fail "--events reports progress as tagged lines" "$(cat "$test_tmp/events-output")"
grep -qx "@@omarchy done Added Events (events@example.com)." "$test_tmp/events-output" ||
fail "--events reports the result as a tagged line" "$(cat "$test_tmp/events-output")"
grep -q "Added Events (events@example.com)." "$notifications" || fail "--events also notifies, in case the panel closed"
if OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
omarchy-agent-account-add --events claude Again </dev/null >"$test_tmp/events-dup" 2>&1; then
fail "--events fails a duplicate"
fi
grep -q "^@@omarchy error That's Events" "$test_tmp/events-dup" || fail "--events reports a failure as a tagged line" "$(cat "$test_tmp/events-dup")"
pass "--events reports progress and results for the panel"
# ------------------------------------------------------------------- reauth
: >"$OMARCHY_TEST_BROWSER_LOG"
before=$(omarchy-agent-account-list claude --json | jq '.[0].accounts | length')
rm -f "$accounts/claude/events/.credentials.json"
OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
omarchy-agent-account-add --events --reauth events claude </dev/null >"$test_tmp/reauth-output"
grep -qx "@@omarchy done Signed in to Claude again." "$test_tmp/reauth-output" || fail "--reauth reports the sign-in" "$(cat "$test_tmp/reauth-output")"
[[ -s $accounts/claude/events/.credentials.json ]] || fail "--reauth signs in to the account's own home"
[[ $(omarchy-agent-account-list claude --json | jq '.[0].accounts | length') == "$before" ]] || fail "--reauth adds no account"
grep -qx -- "--private https://claude.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" || fail "--reauth of an added account uses a private window"
: >"$OMARCHY_TEST_BROWSER_LOG"
OMARCHY_TEST_LOGIN_UUID=u-main OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
omarchy-agent-account-add --reauth main claude </dev/null >/dev/null
grep -qx "default https://claude.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" || fail "--reauth of the primary uses the normal browser"
if omarchy-agent-account-add --reauth nobody claude </dev/null >/dev/null 2>&1; then
fail "--reauth of an unknown account fails"
fi
pass "--reauth signs an existing account in again where it lives"
# ------------------------------------------------------------------- cancel
# Cancelling from the panel stops a login that's waiting on the browser, right
# away, and leaves no half-made account behind.
OMARCHY_TEST_LOGIN_HANGS="$test_tmp/login.pid" omarchy-agent-account-add --events claude Slow </dev/null >/dev/null 2>&1 &
adding=$!
sleep 1
started=$(date +%s)
kill -TERM "$adding"
wait "$adding" || true
(( $(date +%s) - started < 3 )) || fail "cancelling stops a waiting login at once"
login_pid=$(cat "$test_tmp/login.pid")
for _ in {1..20}; do
kill -0 "$login_pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$login_pid" 2>/dev/null; then
kill "$login_pid"
fail "cancelling stops the login itself"
fi
[[ -z $(ls -A "$accounts/claude/.pending") ]] || fail "cancelling leaves no half-made account"
pass "cancelling a sign-in stops the login and cleans up"
# ------------------------------------------------------------------ one account
# With one account, the usage record keeps its limits at the top level, and
# the list still shows them.
solo="$test_tmp/solo"
mkdir -p "$solo/omarchy/agents/usage"
echo '{"id":"claude","limits":[{"label":"Session (5-hour)","percent":0.4,"resetsAt":""}]}' >"$solo/omarchy/agents/usage/claude.json"
[[ $(XDG_STATE_HOME="$solo" omarchy-agent-account-list claude --json | jq -c '.[0].accounts[0].limits[0].percent') == "0.4" ]] ||
fail "a single account's limits are listed from the record's top level"
pass "a single account's limits are listed"
# ------------------------------------------------------------- signed out
# A login that's gone is gone, whatever the registry remembered about it.
rm -f "$HOME/.codex/auth.json"
[[ $(omarchy-agent-account-add --check | grep '^codex ') == "codex first" ]] ||
fail "a signed-out primary counts as a first sign-in again" "$(omarchy-agent-account-add --check)"
pass "a signed-out account no longer counts as signed in"
# --------------------------------------------------------------- refresh
# A home signed in to someone else since it was added is saved as who it is
# now, which is what the usage records name it by.
jq '.oauthAccount.emailAddress = "switched@example.com"' "$accounts/claude/events/.claude.json" >"$test_tmp/switched.json"
mv "$test_tmp/switched.json" "$accounts/claude/events/.claude.json"
omarchy-agent-account-state refresh claude
[[ $(jq -r '.accounts[] | select(.id == "events") | .email' "$accounts/claude.json") == "switched@example.com" ]] ||
fail "refresh saves who each home is signed in as now"
pass "refresh saves who each home is signed in as now"
+235
View File
@@ -0,0 +1,235 @@
#!/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"
export XDG_DATA_HOME="$test_tmp/data"
unset CLAUDE_CONFIG_DIR CODEX_HOME
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
mkdir -p "$HOME/.claude/projects" "$HOME/.codex/sessions" "$accounts/claude/work" "$accounts/claude/old" "$accounts/codex/side"
future_ms=$(( ($(date +%s) + 3600) * 1000 ))
open_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=3)).isoformat())')
credentials() {
printf '{"claudeAiOauth":{"accessToken":"%s","expiresAt":%s,"rateLimitTier":"%s","subscriptionType":"max"}}\n' "$1" "$2" "$3"
}
credentials token-main "$future_ms" default_claude_max_20x >"$HOME/.claude/.credentials.json"
credentials token-work "$future_ms" default_claude_max_5x >"$accounts/claude/work/.credentials.json"
credentials token-old 1000 default_claude_max_5x >"$accounts/claude/old/.credentials.json"
# A parked account whose sign-in lapsed still has its last numbers on disk.
mkdir -p "$XDG_CACHE_HOME/omarchy/agent-usage"
jq -nc --arg open "$open_at" '{fetchedAtMs: 1, limits: [{label: "Weekly (7-day)", percent: 0.4, resetsAt: $open}]}' \
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-old.json"
cat >"$accounts/claude.json" <<JSON
{
"active": "work",
"switch": "auto",
"threshold": 95,
"accounts": [
{"id": "main", "label": "Main", "home": "", "primary": true, "email": "me@example.com"},
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false, "email": "work@example.com", "accountId": "u-work"},
{"id": "old", "label": "Old", "home": "$accounts/claude/old", "primary": false, "email": "old@example.com"}
]
}
JSON
# Anthropic answers per token, so each account's probe is told apart by the
# credential it carried.
claude_record=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" 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)
usage = {"token-main": 97.0, "token-work": 12.0}
def urlopen(request, timeout=None):
token = request.get_header("Authorization").split(" ", 1)[1]
return io.BytesIO(json.dumps({"five_hour": {"utilization": usage[token]}, "seven_day": {"utilization": 5.0}}).encode())
collector.urllib.request.urlopen = urlopen
collector.scan_pi_usage = lambda age: None
collector.scan_opencode_usage = lambda age: None
sys.argv = ["omarchy-agent-usage-claude", "--force"]
collector.main()
PY
)
[[ $(jq -c '[.accounts[] | {id, active, plan, stale}]' <<<"$claude_record") == '[{"id":"main","active":false,"plan":"Max 20x","stale":false},{"id":"work","active":true,"plan":"Max 5x","stale":false},{"id":"old","active":false,"plan":"Max 5x","stale":true}]' ]] ||
fail "Claude record lists every account with its own plan" "$claude_record"
pass "Claude record lists every registered account"
[[ $(jq -c '[.accounts[] | .limits[0].percent]' <<<"$claude_record") == '[0.97,0.12,0.4]' ]] ||
fail "Claude record probes each account with its own sign-in" "$claude_record"
pass "Claude record probes each account with its own sign-in"
[[ $(jq -r '.accounts[2].usageStatusText' <<<"$claude_record") == "Sign-in expired" ]] ||
fail "a lapsed account keeps its last-known limits and says why" "$claude_record"
pass "a lapsed account keeps its last-known limits and says why"
[[ $(jq -c '{tierLabel, first: .limits[0].percent}' <<<"$claude_record") == '{"tierLabel":"Max 5x","first":0.12}' ]] ||
fail "the record's own limits describe the active account" "$claude_record"
pass "the record's own limits describe the active account"
[[ -f $XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json && -f $XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-u-work.json ]] ||
fail "each account keeps its own limits cache, keyed by subscription"
pass "each account keeps its own limits cache"
# Once every window a lapsed account last saw has reset, it has its whole
# allowance back: that reads as 0%, not as nothing known.
past_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=5)).isoformat())')
jq -nc --arg past "$past_at" '{fetchedAtMs: 1, limits: [{label: "Session (5-hour)", percent: 0.99, resetsAt: $past}]}' \
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-old.json"
rested=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" 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)
collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(b'{"five_hour": {"utilization": 30.0}}')
collector.scan_pi_usage = lambda age: None
collector.scan_opencode_usage = lambda age: None
sys.argv = ["omarchy-agent-usage-claude", "--force"]
collector.main()
PY
)
[[ $(jq -c '.accounts[2] | {stale, limits: [.limits[] | {label, empty: (.percent == 0)}]}' <<<"$rested") == '{"stale":true,"limits":[{"label":"Session (5-hour)","empty":true}]}' ]] ||
fail "a lapsed account whose windows all reset reads as untouched" "$rested"
pass "a lapsed account whose windows all reset reads as untouched"
# Signing the primary home in to another subscription must not inherit the
# last one's numbers when the first probe for the new one fails.
printf '{"oauthAccount":{"accountUuid":"u-new"}}\n' >"$HOME/.claude.json"
now_ms=$(( $(date +%s) * 1000 ))
jq -nc --arg open "$open_at" --argjson now "$now_ms" '{fetchedAtMs: $now, limits: [{label: "Session (5-hour)", percent: 0.55, resetsAt: $open}]}' \
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json"
resigned=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" 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)
def urlopen(request, timeout=None):
if request.get_header("Authorization").endswith("token-main"):
raise collector.urllib.error.URLError("down")
return io.BytesIO(b'{"five_hour": {"utilization": 12.0}}')
collector.urllib.request.urlopen = urlopen
collector.scan_pi_usage = lambda age: None
collector.scan_opencode_usage = lambda age: None
sys.argv = ["omarchy-agent-usage-claude", "--force"]
collector.main()
PY
)
[[ $(jq -c '.accounts[0].limits | map(.percent)' <<<"$resigned") != *0.55* ]] ||
fail "a primary home signed in to another subscription doesn't inherit its limits" "$resigned"
rm -f "$HOME/.claude.json"
pass "a primary home signed in to another subscription doesn't inherit its limits"
# A registry with one account changes nothing about the record.
jq '.accounts |= [.[0]] | .active = "main"' "$accounts/claude.json" >"$test_tmp/one.json"
mv "$test_tmp/one.json" "$accounts/claude.json"
single=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" 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)
collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(b'{"five_hour": {"utilization": 30.0}}')
collector.scan_pi_usage = lambda age: None
collector.scan_opencode_usage = lambda age: None
sys.argv = ["omarchy-agent-usage-claude", "--force"]
collector.main()
PY
)
[[ $(jq 'has("accounts")' <<<"$single") == false && $(jq '.limits[0].percent' <<<"$single") == 0.3 ]] ||
fail "a single account record carries no accounts list" "$single"
pass "a single account record is unchanged"
[[ $(jq -c '{limitsStale, fresh: (.limitsFetchedAt > 0)}' <<<"$single") == '{"limitsStale":false,"fresh":true}' ]] ||
fail "limits checked just now aren't stale" "$single"
# A failed probe falls back on hour-old numbers, and the record says so.
hour_ago_ms=$(( ($(date +%s) - 3600) * 1000 ))
jq -nc --arg open "$open_at" --argjson at "$hour_ago_ms" '{fetchedAtMs: $at, limits: [{label: "Session (5-hour)", percent: 0.42, resetsAt: $open}]}' \
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json"
kept=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
import importlib.machinery, importlib.util, 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)
def urlopen(request, timeout=None):
raise collector.urllib.error.URLError("down")
collector.urllib.request.urlopen = urlopen
collector.scan_pi_usage = lambda age: None
collector.scan_opencode_usage = lambda age: None
sys.argv = ["omarchy-agent-usage-claude", "--force"]
collector.main()
PY
)
[[ $(jq -c --argjson at "$hour_ago_ms" '{limitsStale, at: (.limitsFetchedAt == $at), first: .limits[0].percent}' <<<"$kept") == '{"limitsStale":true,"at":true,"first":0.42}' ]] ||
fail "kept limits are marked stale with when they were measured" "$kept"
pass "the record says when its limits are kept from an earlier check"
# ------------------------------------------------------------------------ codex
# A stand-in app-server that answers for whichever home it was started in.
mkdir -p "$test_tmp/bin"
cat >"$test_tmp/bin/codex" <<'PY'
#!/usr/bin/python3
import json, os, sys
used = 91 if os.environ.get("CODEX_HOME", "").endswith("/side") else 40
for line in sys.stdin:
message = json.loads(line)
if "id" not in message:
continue
result = {}
if message["method"] == "account/rateLimits/read":
result = {"rateLimits": {"planType": "pro", "primary": {"usedPercent": used, "windowDurationMins": 300}}}
print(json.dumps({"id": message["id"], "result": result}), flush=True)
PY
chmod +x "$test_tmp/bin/codex"
cat >"$accounts/codex.json" <<JSON
{
"active": "main",
"accounts": [
{"id": "main", "label": "Main", "home": "", "primary": true},
{"id": "side", "label": "Side", "home": "$accounts/codex/side", "primary": false}
]
}
JSON
codex_record=$(PATH="$test_tmp/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --force)
[[ $(jq -c '[.accounts[] | {id, active, used: .limits[0].percent}]' <<<"$codex_record") == '[{"id":"main","active":true,"used":0.4},{"id":"side","active":false,"used":0.91}]' ]] ||
fail "Codex record asks each account's own app-server" "$codex_record"
[[ $(jq '.limits[0].percent' <<<"$codex_record") == 0.4 ]] || fail "Codex record's own limits describe the active account" "$codex_record"
pass "Codex record lists every registered account"
inherited=$(CODEX_HOME="$accounts/codex/side" PATH="$test_tmp/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --force)
[[ $(jq -c '[.accounts[] | .limits[0].percent]' <<<"$inherited") == '[0.4,0.91]' ]] ||
fail "Main's Codex limits come from ~/.codex whatever CODEX_HOME says" "$inherited"
pass "Main's Codex limits come from ~/.codex whatever CODEX_HOME says"
@@ -240,4 +240,53 @@ assertDeepEqual(
[{ title: 'Weekly', percent: 0.12, resetAt: '' }],
'agents panel still reads a window out of a label that carries no title'
)
// A model's weekly allowance rides under the Weekly row; one whose window has
// no row of its own keeps a row.
assertDeepEqual(
displayWindows({ limits: [
{ label: 'Session (5-hour)', percent: 0.94, resetsAt: '' },
{ label: 'Weekly (7-day)', percent: 0.25, resetsAt: 'w' },
{ label: 'Fable Weekly', title: 'Fable Weekly', percent: 0.09, resetsAt: 'w' },
{ label: 'Opus Monthly', title: 'Opus Monthly', percent: 0.5, resetsAt: '' }
] }),
[
{ title: 'Session', percent: 0.94, resetAt: '', scoped: [] },
{ title: 'Weekly', percent: 0.25, resetAt: 'w', scoped: [{ title: 'Fable', percent: 0.09, resetAt: 'w' }] },
{ title: 'Opus Monthly', percent: 0.5, resetAt: '', scoped: [] }
],
'agents panel shows a model-scoped window under the window it runs on'
)
JS
# Anthropic rate-limits its usage endpoint readily. A refused re-check of
# numbers measured minutes ago still describes the account; only older numbers
# count as stale, and either way the record says when they were measured.
rate_limited() {
COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" FETCHED_AGO="$1" XDG_CACHE_HOME="$CACHE_HOME" python3 - <<'PY'
import datetime as dt, importlib.machinery, importlib.util, json, os, time, urllib.error
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)
reset = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=3)).isoformat()
fetched = round((time.time() - float(os.environ["FETCHED_AGO"])) * 1000)
(collector.cache_root() / "claude-limits-rate.json").write_text(json.dumps(
{"fetchedAtMs": fetched, "limits": [{"label": "Session (5-hour)", "percent": 0.4, "resetsAt": reset}]}))
def refused(request, timeout=None):
raise urllib.error.HTTPError(request.full_url, 429, "Too Many Requests", {}, None)
collector.urllib.request.urlopen = refused
result = collector.collect_limits("token", int((time.time() + 3600) * 1000), True, "claude-limits-rate.json")
print(json.dumps({"live": result["live"], "percent": result["limits"][0]["percent"], "fetched": result["fetchedAtMs"] == fetched}))
PY
}
[[ $(rate_limited 60) == '{"live": true, "percent": 0.4, "fetched": true}' ]] ||
fail "a refused re-check of numbers a minute old still counts as current" "$(rate_limited 60)"
[[ $(rate_limited 1800) == '{"live": false, "percent": 0.4, "fetched": true}' ]] ||
fail "a refused re-check of half-hour-old numbers counts as stale" "$(rate_limited 1800)"
pass "a rate-limited check only goes stale once the numbers are old"
+25 -1
View File
@@ -31,7 +31,12 @@ while read -r request; do
jq -cn --argjson id "$id" '{id: $id, result: {account: {}}}'
;;
account/rateLimits/read)
jq -cn --argjson id "$id" --argjson limits "${CODEX_RATE_LIMITS:-{\}}" '{id: $id, result: {rateLimits: $limits}}'
if [[ -n ${CODEX_LIMITS_ERROR:-} ]]; then
jq -cn --argjson id "$id" --arg message "$CODEX_LIMITS_ERROR" '{id: $id, error: {code: -32600, message: $message}}'
continue
fi
jq -cn --argjson id "$id" --argjson limits "${CODEX_RATE_LIMITS:-{\}}" --argjson credits "${CODEX_RESET_CREDITS:-null}" \
'{id: $id, result: {rateLimits: $limits, rateLimitResetCredits: $credits}}'
;;
esac
done
@@ -616,3 +621,22 @@ result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_H
[[ $(jq -c '{tierLabel, usageStatusText, limits: [.limits[] | {label, percent}]}' <<<"$result") == '{"tierLabel":"pro","usageStatusText":"","limits":[{"label":"Weekly (7-day)","percent":0.36}]}' ]] ||
fail "Codex collector reads limits even when account/read never answers" "$result"
pass "Codex collector reads limits even when account/read never answers"
# Free full resets ride along with the limits: only available ones count, and
# the soonest to lapse is the one worth mentioning.
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
CODEX_RATE_LIMITS='{"planType":"pro","primary":{"usedPercent":42,"windowDurationMins":10080}}' \
CODEX_RESET_CREDITS='{"availableCount":2,"credits":[{"status":"available","expiresAt":2000000000},{"status":"available","expiresAt":1900000000},{"status":"used","expiresAt":1800000000}]}' \
"$ROOT/bin/omarchy-agent-usage-codex" --limits-only)
[[ $(jq -c '.resetCredits' <<<"$result") == '{"available":2,"nextExpiresAt":"2030-03-17T17:46:40+00:00"}' ]] ||
fail "Codex collector reports its available free resets" "$result"
pass "Codex collector reports its available free resets"
# A home nobody is signed in to answers with an error, which reads as a
# sign-in to restore rather than as missing numbers.
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
CODEX_LIMITS_ERROR="codex account authentication required to read rate limits" \
"$ROOT/bin/omarchy-agent-usage-codex" --limits-only)
[[ $(jq -r '.usageStatusText' <<<"$result") == "Waiting for auth" ]] ||
fail "Codex collector reports a missing sign-in as one" "$result"
pass "Codex collector reports a missing sign-in as one"
+20 -1
View File
@@ -11,7 +11,26 @@ const panelSource = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'u
assert(/function launchAgent\(\)/.test(panelSource), 'agents panel launches the default agent')
assert(/root\.bar\.run\("omarchy-agent --pick"\)/.test(panelSource), 'agents panel uses the desktop agent launcher')
assert(/if \(buttonCode === Qt\.RightButton\) root\.launchAgent\(\)/.test(panelSource), 'agents right click launches the agent')
assert(/else if \(buttonCode === Qt\.MiddleButton\) root\.selectProvider\(root\.providerIndex \+ 1\)/.test(panelSource), 'agents middle click still advances the subscription')
assert(/else if \(buttonCode === Qt\.MiddleButton\) root\.refreshNow\(\)/.test(panelSource), 'agents middle click refreshes the limits')
assert(/else root\.toggle\(\)/.test(panelSource), 'agents left click still toggles the panel')
assert(!/if \(buttonCode === Qt\.RightButton\) root\.refreshNow\(\)/.test(panelSource), 'agents right click no longer refreshes')
assert(/if \(root\.addStage === "" \|\| root\.picking\) root\.moveKey\(dx, dy\)/.test(panelSource), 'arrows move the agents panel cursor on the page and while picking an agent to add')
assert(/target\.kind === "choice"\) chooseAddProvider\(/.test(panelSource), 'Enter on an agent to add chooses it')
assert(!/text: root\.addStage === "running" \? "Cancel" : "Back"/.test(panelSource), 'the hero X is the only way back from adding')
assert(/hasCursor: root\.hasKey\("add"\)/.test(panelSource) && /hasCursor: root\.hasKey\("launch"\)/.test(panelSource), 'the hero buttons take the keyboard cursor')
assert(/hasCursor: root\.hasKey\("starter", index\)/.test(panelSource), 'the starter tiles take the keyboard cursor')
assert(/root\.pointAt\("account", Number\(t\) - 1\)/.test(panelSource), 'number keys move the cursor to an account')
assert(/target\.kind === "launch"\) launchAgent\(\)/.test(panelSource), 'Enter on the launcher starts the default agent')
assert(/row\.push\(\{ kind: "autoswitch", index: entry \}\)/.test(panelSource), 'an inactive account offers Autoswitch and Use as separate stops')
assert(/kind: "signin", index: entry/.test(panelSource) && /kind: "providerSignin", index: p/.test(panelSource), 'Sign-in required links are keyboard stops')
assert(/target\.kind === "signin"\) signInAgain\(/.test(panelSource) && /target\.kind === "providerSignin"\) signInAgain\(/.test(panelSource), 'Enter on Sign-in required signs in again')
assert(/target\.kind === "autoswitch"\) setSwitchMode\(/.test(panelSource), 'Enter on Autoswitch flips the switch mode')
assert(/keyColumn = use >= 0 \? use : /.test(panelSource), 'moving up or down onto an account lands on Use')
assert(/Qt\.callLater\(function\(\) \{ if \(picking\) pointAt\("choice", 0\) \}\)/.test(panelSource), 'picking an agent to add starts with the first one focused')
assert(/opacity: stale \? 0\.5 : 1\.0/.test(panelSource) && /"As of " \+ root\.formatDuration/.test(panelSource), 'limits kept from an earlier check dim and say how old they are on hover')
assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes')
assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in')
assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop')
const mainSource = fs.readFileSync(root + '/shell/plugins/agents/Main.qml', 'utf8')
assert(/var from = Math\.min\(0\.8, \(threshold - 15\) \/ 100\)/.test(mainSource), 'faster checks start 15 points below the switch threshold')
JS
+2 -1
View File
@@ -275,7 +275,8 @@ timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type
grep -qx 'owe' "$ROOT/install/omarchy-base.packages" || fail "OWE is a base package"
grep -qx 'owe-lockfeed' "$ROOT/install/omarchy-base.packages" || fail "the OWE lock feed module is a base package"
if grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages"; then
# Qt Multimedia ships for building apps; the shell itself plays video through OWE.
if grep -rqs 'import QtMultimedia' "$ROOT/shell"; then
fail "Qt Multimedia is no longer needed by the shell"
fi