No description
  • JavaScript 98.7%
  • Shell 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Headscracher 8163ba53ab
Merge branch 'feat/multi-account-auto-switch'
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.
2026-09-02 09:05:56 +02:00
docs docs: correct the plan text to match the code that shipped 2026-09-02 09:02:58 +02:00
scripts feat: wire the switch sweep into the worker and manifest 2026-09-02 01:44:04 +02:00
src fix: reach the account and Hermes pool detail from the popup command 2026-09-02 08:58:01 +02:00
tests fix: reach the account and Hermes pool detail from the popup command 2026-09-02 08:58:01 +02:00
.gitignore feat: show usage once in a global panel instead of per agent 2026-08-30 23:51:04 +02:00
herdr-plugin.toml feat: wire the switch sweep into the worker and manifest 2026-09-02 01:44:04 +02:00
package.json feat: wire the switch sweep into the worker and manifest 2026-09-02 01:44:04 +02:00
README.md docs: document multi-account auto-switch 2026-09-02 02:14:11 +02:00

usage-tracker

A native Herdr plugin that shows current AI subscription usage for every credential configured in the active Hermes profile.

  • one global Usage split pane summarising every account (claude, gpt, glm, oc-go);
  • a read-only Usage dashboard popup with per-account, per-window detail;
  • an automatic refresh every five minutes, owned by the plugin itself;
  • a manual Refresh usage now action;
  • 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 7089%, 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:

  1. the pane's recent output contains one of limitPatterns, and
  2. 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

  1. 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;
  2. interrupt the agent (Escape, Escape, Ctrl-C — Claude Code cancels a turn on Escape and exits from an empty prompt on Ctrl-C);
  3. wait, bounded at about 5 seconds, for the pane to settle back to a shell;
  4. relaunch as env CLAUDE_CONFIG_DIR=<target> claude --resume <uuid>;
  5. 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;
  6. 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.