- JavaScript 98.7%
- Shell 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Multi-account auto-switch for Claude Code: an account registry, limit detection from screen evidence plus cache agreement, pane restart with transcript resume, deferred switching when every account is exhausted, and Hermes credential pool reporting. |
||
| docs | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| herdr-plugin.toml | ||
| package.json | ||
| README.md | ||
usage-tracker
A native Herdr plugin that shows current AI subscription usage for every credential configured in the active Hermes profile.
- one global
Usagesplit pane summarising every account (claude,gpt,glm,oc-go); - a read-only
Usage dashboardpopup with per-account, per-window detail; - an automatic refresh every five minutes, owned by the plugin itself;
- a manual
Refresh usage nowaction; - explicit unavailable/stale states instead of invented numbers;
- optional multi-account switching for Claude Code: when the active account hits its limit, the agent's pane is restarted under the next registered account and the conversation resumed.
Requires Linux, Node.js 24 or newer, and Herdr 0.8.2 or newer. There are no runtime dependencies outside the Node standard library.
Install
herdr plugin link ~/projects/usage_tracker
herdr plugin list
herdr plugin link must report zero warnings. Re-linking the same plugin id
replaces the existing registry entry rather than duplicating it.
The startup hook runs once the session is restored, so republish the sidebar tokens after linking by triggering a refresh:
herdr plugin action invoke tobi.usage-tracker.refresh
herdr plugin log list --plugin tobi.usage-tracker
herdr plugin action invoke is asynchronous and returns status: "running"
within milliseconds — it says nothing about success. Always confirm the exit
code and stderr with herdr plugin log list.
The usage panel
Usage is an account-level fact, not a per-agent one, so it is shown once:
a single Usage split pane, no matter how many agents are running.
Usage 21:47
claude ███████░ 93% 3d
gpt ████████ 100%
glm ████████ 100% 1d
oc-go ████████ available
The header clock is the cache's refresh time, not the wall clock, so a
worker that stopped refreshing shows up as a frozen clock rather than as
convincingly fresh numbers. Each row summarises the provider's most restrictive
window. A stale row is marked stale next to its last known numbers, and a
failed provider reads ? unavailable — neither is ever rendered as a plain
percentage.
The pane redraws from the sanitized cache every five seconds and on terminal
resize. It is strictly read-only: it never calls a provider and never triggers a
refresh, which the five-minute worker owns. Below about 27 columns it drops
reset times, then shortens labels; above about 44 it adds per-window detail
rows. It never truncates a state word, and it runs until it is killed — Herdr
restores split panes with respawn_shell_on_exit, so an exit would leave a bare
shell in the pane.
Auto-open
The startup hook opens the panel automatically, at most once. Herdr persists
split panes with their launch argv and re-runs them on restore, and it does not
dedupe plugin panes, so a naive auto-open would add another panel at every
restart, forever. Instead the running panel marks its own pane with a
usage_panel metadata token, and the startup hook reads herdr api snapshot
and opens a panel only when no pane carries that token. The panel marks a pane
only when Herdr launched it as the usage entry point (HERDR_PANE_ID and
HERDR_PLUGIN_ENTRYPOINT_ID = usage), so running node src/cli.js panel by
hand inside another pane renders normally without hijacking that check. Because
Herdr metadata is in-memory and is cleared by a restart or live handoff, the
panel re-reports its token on a timer.
Disable auto-open without unlinking the plugin by writing to
$HERDR_PLUGIN_CONFIG_DIR/config.env
(~/.config/herdr/plugins/tobi.usage-tracker/config.env):
USAGE_PANEL_AUTO_OPEN=0
The flag defaults to on; 0, false, no and off all disable it.
Open (or reopen) the panel by hand at any time:
herdr plugin pane open --plugin tobi.usage-tracker --entrypoint usage \
--placement split --direction right --no-focus
--no-focus matters: the panel must never steal focus from the agent you are
typing into. --placement split splits whichever pane is currently focused.
Or invoke the equivalent action, which is idempotent in the same way (it will not stack a second panel on one that is already open):
herdr plugin action invoke tobi.usage-tracker.usage-panel
Optionally bind that action to a key, using the same plugin_action form as the
dashboard binding below:
[[keys.command]]
key = "prefix+U"
type = "plugin_action"
command = "tobi.usage-tracker.usage-panel"
description = "usage panel"
Sidebar configuration
The plugin still reports one metadata token per provider to every agent pane:
$usage_claude, $usage_gpt, $usage_glm, $usage_oc_go. Herdr renders a
token only where it is referenced in the sidebar row configuration, and the
managed block below deliberately references none of them: with several
agents the same four rows repeated under every entry, which is what the panel
replaced. The tokens are kept so a per-agent row can be restored by adding
[{ token = "$usage_claude", dim = true }] (and friends) back to rows.
Herdr rewrites ~/.config/herdr/config.toml itself whenever the in-app Settings
UI changes theme, sound, toast, or sort settings, so the file must stay mutable
and must never be replaced wholesale. Add exactly the following managed block to
the live config, preserving every user setting outside the markers:
# BEGIN tobi.usage-tracker
[ui.sidebar.agents]
rows = [["state_icon", "workspace", "tab"], ["agent"], ["$topic"]]
# END tobi.usage-tracker
Apply and verify it:
herdr server reload-config # expect {"diagnostics":[],"status":"applied"}
herdr api snapshot | grep usage_
A provider whose refresh failed shows ? unavailable rather than disappearing,
so a broken credential stays visible instead of silently looking like it was
never configured.
Usage
| Surface | How |
|---|---|
| Usage panel | automatic on startup, or the pane open command above |
| Detail popup | herdr plugin action invoke tobi.usage-tracker.usage-dashboard |
| Manual refresh | herdr plugin action invoke tobi.usage-tracker.refresh |
| Account list | herdr plugin action invoke tobi.usage-tracker.accounts-list |
| Switch account now | herdr plugin action invoke tobi.usage-tracker.switch-now |
| Cancel pending switch | herdr plugin action invoke tobi.usage-tracker.cancel-switch |
The last three only do anything once two or more Claude accounts are registered; see Multiple accounts.
The popup is a singleton read-only pane. It lists every account, every
provider-supplied window with a threshold-coloured bar (normal below 70%,
warning 70–89%, critical at 90% or above), reset times, fetch time, and the
provider-specific reason whenever a result is stale or unavailable. Window ids
are rendered as the provider names them (five_hour, seven_day,
seven_day_sonnet, …); an id that is not a short lowercase identifier, or that
looks credential-shaped, renders as other window instead.
Optionally bind the dashboard to a key:
[[keys.command]]
key = "prefix+u"
type = "plugin_action"
command = "tobi.usage-tracker.usage-dashboard"
description = "usage dashboard"
Multiple accounts
When a Claude Code account hits its usage limit, this plugin can restart that agent's pane under a different account and resume the conversation where it stopped. It is off until you register a second account: with fewer than two entries nothing changes, no sweep decides anything, and the panel renders exactly as before.
Only Claude Code is switched this way. Hermes is a different mechanism and is described in Hermes is not switched below.
Registering accounts
node src/cli.js accounts-add primary ~/.claude
node src/cli.js accounts-add work
node src/cli.js accounts-list
node src/cli.js accounts-remove work
accounts-add creates the config directory with mode 0700 (it is about to
hold an OAuth credential store), appends the entry, and prints the login command
you must then run yourself:
CLAUDE_CONFIG_DIR=/home/you/.claude-work claude /login
The plugin never logs anything in for you — it only ever reads
<configDir>/.credentials.json, and never refreshes or rotates a token
underneath a running CLI.
The name must be letters, digits, dot, dash or underscore, must start with a
letter or digit, and at most 32 characters; the config directory must be
absolute (a relative argument is resolved against your current directory before
it is stored, never stored relative, because a relative path would resolve
against whatever working directory the worker happens to have). An invalid name,
an invalid directory or a duplicate is refused before anything is created or
written, so you are never told an account was added that the registry then
silently drops. Omitting the directory defaults to ~/.claude-<name>.
accounts-remove drops the registry entry and leaves the config directory in
place: it holds a credential store this plugin did not create and must not
destroy.
The registry is a plain file you can edit by hand,
$HERDR_PLUGIN_CONFIG_DIR/accounts.json
(~/.config/herdr/plugins/tobi.usage-tracker/accounts.json):
{
"claude": {
"accounts": [
{ "name": "primary", "configDir": "/home/you/.claude" },
{ "name": "work", "configDir": "/home/you/.claude-work" }
],
"limitPatterns": ["usage limit reached"]
}
}
Declared order is switch order. The rotation starts after the active account
and wraps, so a three-account registry visits each one before repeating. A
malformed entry, a non-absolute configDir or a duplicate name is dropped on
read rather than guessed at.
Once two or more accounts are registered, the registry becomes authoritative for
Claude: the Hermes-discovered anthropic row is replaced by the registry rows,
because Hermes only ever sees ~/.claude and keeping both would render the same
account twice under two different labels. Every other provider is untouched.
When a switch fires
Two conditions must hold at the same time:
- the pane's recent output contains one of
limitPatterns, and - the cached usage state shows that account's most restrictive window at 100% or above, on a fresh reading.
Either condition alone is a way to restart a working agent for no reason, so neither is trusted by itself. A stale cache row can never trigger a switch — the numbers are the last known ones, and treating them as proof of exhaustion would let a dead refresh worker restart agents forever.
limitPatterns are matched as literal, case-insensitive substrings, never
compiled as regexes: the list is user-editable, and a stray .* in it would
otherwise match every pane and restart every agent. A pattern containing no
letter or digit at all (.*, ^$) is ignored entirely, so it can neither match
everything nor be matched as literal text.
The direct consequence is worth stating plainly: if Anthropic rewords the limit message, the feature stops switching rather than switching wrongly. That is a silent degradation. Nothing announces it — panes simply sit at their limit. See Limitations for how to fix it when it happens.
What a switch actually does
- find the pane's most recent transcript under the current account's config dir and copy it into the target account's matching project directory;
- interrupt the agent (Escape, Escape, Ctrl-C — Claude Code cancels a turn on Escape and exits from an empty prompt on Ctrl-C);
- wait, bounded at about 5 seconds, for the pane to settle back to a shell;
- relaunch as
env CLAUDE_CONFIG_DIR=<target> claude --resume <uuid>; - wait for the agent to actually become ready — a launched command is not a working agent, and reporting success for an account that failed to start would be worse than reporting the failure;
- notify.
If the transcript copy fails the switch still happens, without --resume,
and the notification says so ("Started a fresh session on the new account")
rather than implying the history came along. Being unblocked on a working
account beats staying stuck on an exhausted one.
If the relaunch fails or the agent never becomes ready, the pane is left at a shell, a failure notification names the reason, and the active account is deliberately not moved — the agent is not running under the new account, and recording that it is would make every later quota lookup read the wrong account's numbers.
Safety rules
| Rule | Why |
|---|---|
never switches a pane whose agent is working |
killing a mid-turn agent loses work the transcript has not recorded yet |
only acts on panes Herdr reports as agent claude |
there is nothing to resume in anything else |
| one switch attempt per pane per 10 minutes | a failed attempt starts the cooldown too, so a broken relaunch cannot restart-loop a pane |
| a pane whose output cannot be read is skipped | half the trigger is missing; switching on the cache's word alone is switching blind |
| a deferred switch also needs the pane unfocused and idle for 5 minutes | it can fire hours later, and must not land while you are working in that pane |
A focused pane counts as in use even when its agent reports idle — you may be reading it, or typing a prompt you have not submitted.
switch-now bypasses the 10-minute cooldown, and only that. The cooldown exists
to stop a misfiring pattern restarting the same agent in a loop, and an explicit
human request is not that loop. Every other condition — the limit banner, the
fresh 100% reading, the working check — still gates it.
When every account is exhausted
Nothing is restarted. Restarting onto another exhausted account would cost the conversation and buy nothing. Instead the plugin records each account's reset time, notifies once, and records a pending switch that fires automatically after the earliest reset passes — still subject to every safety rule above.
While a switch is pending the panel shows it. If a different account recovers first, the pending switch is re-targeted to that one rather than waiting for its original target. It is cancelled automatically if the pane closes or the target account leaves the registry, and by hand with:
node src/cli.js cancel-switch
herdr plugin action invoke tobi.usage-tracker.cancel-switch
Keybindings
Both switch actions bind with the same plugin_action form as the dashboard:
[[keys.command]]
key = "prefix+s"
type = "plugin_action"
command = "tobi.usage-tracker.switch-now"
description = "switch claude account now"
[[keys.command]]
key = "prefix+S"
type = "plugin_action"
command = "tobi.usage-tracker.cancel-switch"
description = "cancel pending account switch"
What the surfaces show
The panel gains an accounts block below the quota rows: every registered
account, a * on the active one, full plus a reset time for an exhausted one,
and a pending -> <account> line when a deferred switch is waiting. It is not
rendered at all below two accounts, since a one-row list implies a choice that
does not exist. If the registry or switch state cannot be read the block is
simply omitted — account state is decoration on top of the quota rows and must
never be able to blank them.
The popup does not yet show account state. The renderer for it exists and is
tested (accountDetail in src/accounts-view.js, which separates
claude accounts (switched by this plugin) from hermes pool (rotated by Hermes)), but the popup command does not pass it any account data yet, so the
dashboard renders exactly as it did before. Use the panel or accounts-list for
account state until that is wired up.
Config directories are never rendered on any surface. A path on screen tells anyone reading over your shoulder where your credentials live, so only names are shown; a name that looks credential-shaped is redacted like any other label. This applies to the Herdr action log too, which is where sweep output lands: it records account names and pane ids and never a config directory.
Hermes is not switched
Hermes has its own native credential pool. It rotates in-process on a 429,
402 or 401 with no pane restart at all. This plugin never rotates, controls or
restarts Hermes, because doing so from here would fight your own session for the
active credential. The only Hermes command anything in this plugin runs is
hermes auth list — a read.
So the two mechanisms are asymmetric:
| Claude Code accounts | Hermes credentials | |
|---|---|---|
| Who switches | this plugin | Hermes itself |
| How | pane restart plus --resume |
in-process on 429/402/401 |
| Trigger | limit banner and fresh 100% reading | HTTP status from the provider |
| This plugin's role | decides and performs the switch | observes only |
The pool reader (src/hermes-pool.js) is written and tested but, like the popup
account block, is not yet wired into any surface — nothing currently
displays which Hermes credential is live. Check that with Hermes directly:
hermes auth list
Adding a second Hermes account is likewise a Hermes operation, not a plugin one:
hermes auth add anthropic --type oauth --label work
Limitations
The shipped limitPatterns are best guesses. No real Claude Code
usage-limit message has been observed and captured yet, so until one is, the
trigger may simply never fire. This is exactly why the list lives in
accounts.json instead of being hardcoded. The next time you hit a limit,
capture the real wording:
herdr pane read <pane> --source recent --lines 40
and put a distinctive substring of it into limitPatterns. That is the one step
that turns the feature from plausible to working.
Resume can miss the transcript for a very deep working directory. Claude
Code derives its transcript directory name from the working directory by
replacing every non-alphanumeric character with - (verified against the
installed 2.1.245 binary). It additionally truncates very long paths and
appends a hash; this plugin does not implement that truncation. For a working
directory deep enough to be truncated, the transcript will not be found — the
switch still happens, just without --resume.
A switch restarts the agent process. Anything not in the transcript — in-flight state the agent had not written down — is lost. The transcript is the only thing that crosses the boundary.
OpenCode Go limitation
OpenCode Go publishes monetary caps but exposes no public live account-usage API, so this plugin never computes a dollar spend locally and never converts one into a percentage.
The published caps are not rendered anywhere. Every provider tracked here is
a non-API subscription plan, so a published cap is a constant the user already
agreed to when subscribing — it says nothing about remaining usage, and
showing it beside real quota bars invites reading it as consumption. oc-go
therefore reads available with a filled "plan active" bar in the panel, and
live quota unavailable in the popup, and nothing else. The adapter still
returns the caps in its result; no surface reads them.
If the provider does start returning quota windows, the same entry switches to real percentages and reset times automatically.
Data handling
Access and refresh tokens exist only in process memory. They are never written
to plugin state, passed as command-line arguments, logged, or rendered. Account
labels are safe derived labels; any label resembling credential material is
replaced. Cached state is written atomically through an exclusively-created
0600 temporary file under $HERDR_PLUGIN_STATE_DIR
(~/.local/state/herdr/plugins/tobi.usage-tracker/), and every cached object is
recursively stripped of credential-shaped properties first. Every rendered line
is bounded to Herdr's 80-character metadata limit.
Two more files exist once multi-account switching is in use. The account
registry, accounts.json, lives under $HERDR_PLUGIN_CONFIG_DIR — it is
configuration you are meant to edit, and it holds only account names and config
directory paths, never a credential. Switch bookkeeping, switch-state.json,
lives under $HERDR_PLUGIN_STATE_DIR alongside the usage cache and records the
active account, per-account exhaustion and reset times, the last switch time per
pane, and any pending switch. It goes through the same sanitizer and the same
atomic 0600 write. Account names and pane ids are persisted as values rather
than as object keys, because the sanitizer drops any key matching
token|secret|key|authorization|cookie|credential — an account legitimately
named monkey used to have its state silently deleted on every write, so the
switcher rotated back into a rate-limited account forever.
How refresh works
Herdr has no periodic plugin hook, so the [[startup]] command claims and
spawns a plugin-owned detached worker (scripts/plugin.sh worker), records its
PID under $HERDR_PLUGIN_STATE_DIR, and returns immediately. The worker runs
two cadences from one loop:
| Every | What | Wrapper | Lock |
|---|---|---|---|
| 300s | refresh usage from every provider | scripts/run-locked-refresh.sh |
refresh.lock |
| 30s | switch sweep (multi-account only) | scripts/run-locked-sweep.sh |
sweep.lock |
They hold separate locks on purpose, so neither ever blocks the other: they touch different state, and the sweep runs ten times per refresh. The sweep runs on the shorter cadence because a blocked agent should not sit idle for minutes waiting to be moved.
run-locked-sweep.sh takes its lock non-blocking and exits 0 in silence
when it collides with a sweep already in progress. A queued sweep would be
redundant at best and harmful at worst — the running one may be mid-restart of
an agent — and the next tick is only thirty seconds away.
The refresh wrapper has two modes so the two callers get the behaviour each needs:
| Mode | Caller | Lock held by someone else |
|---|---|---|
background (default) |
periodic worker | silent no-op, exit 0 |
manual |
Refresh usage now action |
waits briefly, then prints refresh skipped: another refresh is already running to both stdout and stderr and exits 75 |
So a background sweep colliding with a running refresh stays cheap, while a
manual refresh never claims success for a refresh that did not run — check the
outcome with herdr plugin log list --plugin tobi.usage-tracker.
The manual wait defaults to 10 seconds and can be overridden with
USAGE_TRACKER_LOCK_WAIT (whole seconds). A non-numeric value is rejected with
a warning on stderr and the 10-second default is used, so a typo can never turn
into a spurious "another refresh is already running".
Metadata tokens live in Herdr server memory only, so a restart or live handoff
clears them. The startup hook republishes the sanitized cache, and the
pane.created / pane.agent_detected hooks reapply that same cache to new
panes without contacting any provider.
Commands
src/cli.js is the single entry point; scripts/plugin.sh is the argv-only
glue the manifest invokes (Herdr manifest commands are argv arrays, never a
shell).
| Command | Behaviour |
|---|---|
node src/cli.js startup |
publish cached tokens, ensure the worker, then auto-open the panel |
node src/cli.js attach-pane |
reapply cached tokens to panes only |
node src/cli.js refresh |
run one real refresh and await its outcome |
node src/cli.js popup |
render the cached dashboard to stdout |
node src/cli.js panel |
long-lived: redraw the panel until killed |
node src/cli.js open-panel |
open the panel unless one already exists |
node src/cli.js accounts-add <name> [configDir] |
create the config dir 0700, register the account, print the claude /login command |
node src/cli.js accounts-list |
list registered accounts, marking the active one and each one's state |
node src/cli.js accounts-remove <name> |
drop the registry entry; the config directory is left in place |
node src/cli.js switch-sweep |
run one switch sweep (what the 30-second worker tick calls) |
node src/cli.js switch-now |
run one sweep with the per-pane cooldown ignored |
node src/cli.js cancel-switch |
clear a pending deferred switch |
All of them require HERDR_PLUGIN_STATE_DIR; everything that talks back to
Herdr also requires the injected HERDR_BIN_PATH. There is no herdr-on-PATH
fallback, because a long-lived Herdr server can outlive the PATH it started with.
panel additionally uses HERDR_PANE_ID and HERDR_PLUGIN_ENTRYPOINT_ID.
Herdr injects both for the panes it launches from [[panes]] (never for
popups, and never for a process you start by hand in a terminal), and the panel
marks its pane only when the entry point is usage. Without that proof the
panel still renders — it simply does not advertise itself to the auto-open
check, so a hand-run preview can never impersonate the panel.
The account commands and the panel additionally read
HERDR_PLUGIN_CONFIG_DIR, since that is where accounts.json lives;
accounts-add fails outright without it rather than writing a registry
somewhere the reader will never look.
Development
npm test # node --test tests/*.test.js
npm run check # syntax checks
Tests use node:test and node:assert only. Adapter tests inject a fake
fetchJson; Herdr and auto-open tests inject a fake execFile. The panel
renderer is tested as a pure function of (state, columns), and the redraw loop
is driven through an injected AbortSignal so no test ever depends on a real
long-lived process being killed by a timeout. Nothing in the suite performs real
network or real Herdr calls.