claude-multiacc
v2.0.32
Published
Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.
Maintainers
Readme
claude-multiacc
Multi-account addon for Claude Code AND OpenAI Codex CLI: every claude /
claude -p invocation — and every codex / codex exec invocation — runs under a
randomly picked subscription account with limit headroom (no API keys, ever). The
account sets mirror automatically from the Mac (source of truth) to the deploy server.
Installs as PATH shims + helper CLIs from this repo. Never touches the Claude Code or
Codex apps: no binary edits, no install-dir writes, survives claude update /
codex update — the real binaries are resolved dynamically at exec time.
One install covers both: the same install.sh / npm i -g claude-multiacc wires up
the claude shim + claude-accounts CLI (pool at ~/.claude-accounts) and the
codex shim + codex-accounts CLI (pool at ~/.codex-accounts). The two pools are
completely independent — separate manifests, credentials, telemetry, and logs — so
either provider can be used, re-authenticated, or emptied without touching the other.
Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04 (bash 5.2). The compatibility suite exercises both providers with no network/quota use; the unified selector and packed npm commands have their own contract suites.
Unified selector for app-robot
DEFAULT_ENGINE=both uses one pure selector across the Claude and Codex pools. It
does not change either interactive shim and never launches an agent itself:
multiacc-select --request-json - --response-json -
# equivalent npm dispatcher:
claude-multiacc select --request-json - --response-json -The command reads one strict UTF-8 JSON request and emits one JSON response using
the versioned claude-multiacc/pool-selection.v2 protocol. It ranks the caller's
locked candidate snapshot with the same two cuts as the shims — a session-headroom gate,
then a band on weekly headroom — and returns one concrete provider/account plus canonical
snapshot and selection digests. both is a policy,
never an execution engine. The caller remains responsible for durable reservations,
database locks, retries, and launching the selected CLI.
Successful responses also carry eligible_count and eligible_alternative_count,
so callers can persist proof that sole-account reviewer fallback was unavoidable.
Run multiacc-select --version for the selector protocol version (currently
2.0.1). The npm package version is independent and remains available through
claude-multiacc --version.
See the complete selector contract for the request fields,
ranking rules, stable errors, and caller boundary.
How it works
MAC (source of truth) SERVER (mirror)
~/.claude-accounts/ /root/.claude-accounts/
accounts.json <- manifest accounts.json (synced)
acct-01 -> ~/.claude ("adopted") acct-01/server.token (synced, 600, once minted)
acct-02/ (registered) acct-02 -> /root/.claude ("adopted")
server.token / .credentials.json
(or the login lives in the macOS Keychain — see below)
selection.log sync.log health.log /usr/local/bin/claude -> repo shim
repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)Shim selection order (identical file on both machines, bin/claude):
CLAUDE_CONFIG_DIRorCLAUDE_CODE_OAUTH_TOKENalready set → pass straight through.CLAUDE_ACCOUNT=acct-NN→ use that account (even if limit-marked — explicit wins).- Drop every account that cannot serve a request — a dead login (expired refresh token, revoked grant, a run that failed to authenticate) or an org-blocked one (Claude Code disabled for that account). Those can only fail, so they never enter selection.
- Among the accounts that remain valid on this machine (an OAuth login —
.credentials.json, or a macOS Keychain item this session can open — or aserver.tokenthat passes its first inference preflight) and not limit-excluded, rank in two cuts (see below). Session gate first: keep only the accounts whose 5-hour bucket is known-fresh and at most 50% used — an account about to be rejected on its session does not deserve a launch whatever its weekly headroom. If nobody clears the gate it steps aside and every candidate stays in: the gate compares, it never empties the pool. Then the weekly band: among the accounts that cleared the gate, form a 30-percentage-point weekly-headroom band around the one with the most remaining weekly headroom. The pool rotates away from the account it just handed out and samples the other accounts in that band at random. This prevents one nominally emptiest account from taking every launch while near-peers idle, while accounts more than 30 points behind remain protected.CLAUDE_MULTIACC_SESSION_GATE=100turns the gate off (default50);CLAUDE_MULTIACC_HEADROOM_BAND=0restores strict best-weekly selection (default30);CLAUDE_SHIM_SELECT=randomuses the whole eligible pool. Every pick records both cuts inselection.log:band=/band-count=(the band width and how many peers it held) andsession-gate=/session-ok=(the gate and how many candidates cleared it —session-ok=0means nobody did and the gate stepped aside; inCLAUDE_SHIM_SELECT=randommode the line readssession-gate=off, because no cut was made). - If every account is limit-excluded → the still-serving limited accounts (a bucket at
90–99%, not an exhausted one) go through the same two cuts — session gate, then strict
best-weekly — and one is handed out anyway, with an
all-limited fallback=line inselection.log(degraded beats down: the 100% rule). - If nothing is usable at all → stock passthrough, with the reason in
selection.log(and, on a terminal, one line naming the accounts to fix).
Which account has "the most headroom"? A Max subscription has three limits that a Fable request consumes at once: the 5-hour session bucket, the weekly all-models bucket, and the weekly Fable bucket. Anthropic's docs confirm these reset on very different horizons — the session bucket refills every ~5 hours, but weekly buckets only refill on the account's fixed weekly reset (days away). The two buckets therefore answer two different questions, and the picker asks them in that order (operator's decision, 2026-09-03: "among accounts where high session limits it must choose randomly from ones where highest weekly limits").
- Session is a gate, not a tiebreaker. A candidate clears the gate when its 5-hour
usage is known (fresh telemetry) and at most
CLAUDE_MULTIACC_SESSION_GATEpoints spent — default50,100disables the gate. Unknown session usage never clears it. An account that is 85% through its session is minutes from a rejection, so it waits while anyone fresher is available; if nobody clears the gate, everybody is ranked. - Weekly headroom picks the winner. Among the accounts that cleared the gate, the
picker ranks on weekly headroom (the peak of the durable buckets) and treats
everything within
CLAUDE_MULTIACC_HEADROOM_BANDpoints of the leader — default30,0is strict best-weekly-only — as a peer, spreading launches over that band at random. Session usage is not a tiebreaker inside the band: it acts only through the gate. So an account at 10% weekly stays protected from one at 70% weekly, but 10% weekly with an 85%-spent session loses to 70% weekly with a 20% one, because the first never reaches the band.
Exclusion still fires on any bucket ≥ 90% — a full session bucket really does block right now — but that marker expires when the session resets, not days later.
Unusable accounts are excluded, not rotated into. An account that cannot serve a request is dropped from selection before anything else is considered — it is not eligible as the all-limited fallback either, because a limit-marked account is degraded while this one is down, and handing work to it guarantees a hard failure. Two kinds:
- Dead login (
EXPIRED) — refresh token expired, grant revoked, or a real call that came back "OAuth session expired and could not be refreshed". Fixed byclaude-accounts relogin. - Rejected setup-token (
EXPIRED) — token presence andclaude auth statusdo not prove inference works. Before a portable token carries its first real command on a machine, the shim privately verifies it. A 401 parks that exact account and reselects, so direct, TUI, and--resumecalls do not expose the rejected token's failure. - Org-blocked (
BLOCKED) — the account authenticates fine, but the run comes back "Your organization has disabled Claude subscription access for Claude Code". Handled like any other dead login (claude-accounts reloginre-issues the grant and normally clears it); if it comes back BLOCKED after a re-login, an admin has to enable Claude Code for that account. Telemetry cannot see this — those accounts' usage endpoint answers normally — so only a real call (verify, or a-prun) detects it, and the park deliberately survives credential refreshes (a new access token says nothing about an org policy).
claude-accounts expired lists what is excluded and also labels a token-only account
UNVERIFIED until claude-accounts verify or the shim's first-use preflight proves it.
Exclusion self-heals, but only against the evidence it was based on: a credential
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
by another process — or when a usage fetch authenticates; a policy park (org block)
ignores both, because a fresh access token says nothing about whether the org re-enabled
Claude Code, and only a passing real call or a re-login lifts it. Parks the shim
writes from a single failed run additionally carry a soft_until stamp and expire on
their own — the shim's guess never outlives the evidence for it — while parks
claude-accounts writes from proof (an invalid_grant refresh response, a failed real
call) stay until the account works again. If every account is unusable the shim still
fails open into stock claude, and logs why to selection.log.
The shim prints nothing, logs timestamp account cwd (never prompt text) to
selection.log, and execs the real binary — stdin/stdout/exit codes pass through
byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
even an unset HOME) it fails open into plain passthrough.
Limit-aware marking. claude-accounts limits (every 15 min via launchd on the Mac,
cron on the server, plus an opportunistic non-blocking kick from the shim when data is
10 min stale) reads each account's own OAuth usage endpoint — the same data
/usageshows. Every bucket the endpoint returns is tracked separately:session,weekly_all, and per-model scoped buckets —weekly_scoped:Fableis its own bucket. Any bucket ≥ 90% ⇒ the account gets a.limitedmarker (with the bucket's reset time) and leaves the random pool until that bucket resets or a refresh shows it back under threshold.
Bucket parsing is shape-agnostic: buckets are derived from whatever the endpoint
returns (kind, plus scope.model when present), so if Anthropic renames a bucket,
reshapes the payload, or drops the per-model Fable separation entirely, the remaining
buckets keep being tracked and the ≥90% rule keeps working. Unparseable entries are
skipped, an all-legacy payload falls back to five_hour/seven_day, and a payload the
code cannot read at all degrades that one account (fail open), never the run.
A 0% bucket with no reset window is no data, not an empty account. Real usage always
carries the window it resets in, so percent: 0 with a null resets_at is the endpoint
declining to answer — and taking it at face value put two weekly-exhausted accounts at the
top of the ranking for 31 of ~60 picks on 2026-09-04. Only informative buckets (percent
above 0, or a parseable reset time) are aggregated, per signal: weekly_percent is the
peak over informative weekly buckets, session_percent over informative session ones,
and each is simply omitted when its own group has nothing informative in it — a weekly
figure is never borrowed from the session bucket. An account with no informative bucket at
all gets a limits.json with none of max_percent/weekly_percent/session_percent,
marked "no_data": true. Either way the account ranks unknown — selection needs BOTH
readings, so a half-filled document is as unknown as an empty one: never the band leader,
never past the session gate, never able to clear a marker, and a pool made only of them
logs ranking=BLIND instead of quietly tying every account. Accounts that really are idle
are untouched: 0% with a real reset window is informative and still ranks as empty, and one
blank per-model bucket beside real ones leaves the real ones in charge.
Telemetry failures never block work: no fresh data ⇒ account treated as available. The
endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
and backs off on every non-2xx, not just a 429 — honoring Retry-After, and parking
for 6 h on a refusal the server marks x-should-retry: false. limits --force overrides
all of it. The cadence is deliberately unhurried: several machines polling the same
accounts every minute earns a 429 with Retry-After: 3600, and telemetry then goes stale
for an hour at a time — which is exactly when every account starts scoring unknown and
the picker loses its ability to tell them apart.
The usage endpoint needs an OAuth login, not a setup token. A portable
server.token(sk-ant-oat01-…) authenticates inference forever, but the usage endpoint refuses it with403 — OAuth token does not meet scope requirement user:profile: setup tokens are minted without that scope. So an account whose.credentials.jsongrant has lapsed keeps working perfectly while going permanently dark for telemetry, and a pool where that happens to every account ranks everything unknown and picks at random.claude-accounts statussaysRANKING IS BLINDwhen the pool is in that state, and the shim prints an hourly warning on a terminal. The fix is a real sign-in on the machine that polls (claude-accounts login <acct-NN>); sincelimits.jsonis one of the thingssyncpushes, only the source machine needs it — the server and peers inherit the telemetry.
Two freshness windows, on purpose. Ranking trusts telemetry for an hour
(CLAUDE_MULTIACC_STALE_AFTER, default 3600 — matching the Retry-After: 3600 the
endpoint itself hands out, so a healthy pool is not "stale" for 45 minutes of every
hour). The ≥90% exclusion keeps the tighter 15-minute window: ranking chooses between
working accounts, while the cutoff declares one unusable, and an account reading 89% an
hour ago may be well past 90% now. When nothing is in-window at all, a stale weekly
reading is still used if its bucket has not reset yet (a weekly bucket only rises
until then, so the number remains a true lower bound) — logged as ranking=DEGRADED.
Only when even that is unavailable does selection rank the account last, logged as
ranking=BLIND.
If an account's OAuth access token has been expired for a while (idle account,
nothing ran claude under it for hours), the refresher renews it directly via the OAuth
refresh-token grant — the same endpoint and public client id Claude Code itself uses —
and atomically persists the rotated credential back to wherever it came from: that
account's .credentials.json (0600), or its macOS Keychain item.
Where a claude OAuth login actually lives (macOS). Claude Code writes each config dir's login to the login Keychain (service
Claude Code-credentials-<sha256(dir)[:8]>) from any session that can open it — a Terminal window, a launchd agent — and then deletes.credentials.json. Sessions that cannot open the keychain (ssh, tmux from ssh, background jobs) fall back to the plaintext file, and they also cannot READ a Keychain-held login: those accounts showKEYCHAIN LOCKEDthere and are excluded from that session's selection, while working normally from the Mac's own session. So a login made over ssh migrates into the Keychain the first time a GUI-session process refreshes its token — the pool reads both places (lib/keychain.py), and an account that must work from everywhere should carry a portable token (claude-accounts mint). Override:CLAUDE_MULTIACC_KEYCHAIN=0disables the lookup. This is what keeps idle accounts' telemetry fresh so they win selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle account would lose to a busy-but-fresh one. Refresh failures fail open and back off via<acct>/.oauth-refresh.json(10 min transient, 6 h when the grant looks revoked — the log then says re-login is needed). Overrides:CLAUDE_MULTIACC_TOKEN_URL,CLAUDE_MULTIACC_CLIENT_ID(used by the sandboxed tests; defaults are correct for real use).
Limits the client reports itself. The usage endpoint is not the only source of truth, and it is the one that fails exactly when it matters. Claude Code records every rejection in its session transcript:
{"error":"rate_limit","apiErrorStatus":429,
"quotaLimits":{"status":"rejected","resetsAt":1787333400,"rateLimitType":"five_hour"}}That record is free, offline, instant, and carries the real reset time the API handed
out — so the shim reads it. This is what makes an interactive session take its own
account out of the pool: auto-retry below only ever sees -p runs, so before this a 5-hour
limit hit in a tmux session left no trace at all, and the next claude could walk straight
back into the same dead account.
Transcripts are not account-scoped (<acct>/projects is a shared symlink by design), so
the session → account mapping comes from <acct>/sessions/<pid>.json, which the client
maintains only while a run is alive. The shim spawns a short detached poll before exec
(the pid survives exec, so it knows the filename) that records the session id — with the
session's own start time — in <acct>/.sessions-index; selection then tail-scans those
transcripts.
Everything about the rule is built to under-claim rather than over-claim, because a wrongly parked account is worse than a missed limit:
- One owner per session.
claude --continueresumes the same session id under whichever account the pool hands out next (only--fork-sessionmints a new one), and the transcript is shared. Claiming an id releases it from every other account — and if two accounts end up holding the same id anyway (a crossed race, corrupt state), the ambiguity means neither of them answers for it. - Only from the handover. A rejection recorded before this account took the session over stays with the account that earned it; a record that cannot be dated is not attributed at all.
- Only a private session tree. If
<acct>/sessions— or the account dir itself, or a directory nested inside it — is a symlink into somewhere shared, the runs in it prove nothing about who spent the quota, so the scan stays off for that account entirely. - A rejection whose
resetsAthas already passed is history and excludes nothing; a hostile or unreadable index entry is skipped;CLAUDE_MULTIACC_CLIENT_LIMITS=0turns the whole scan off.
The resulting marker is tagged reason=client-rate-limit and outlives a clean telemetry
pass while its window is still open — first-hand evidence beats a usage payload that
disagrees. A weekly rejection is absolute: nothing — neither a shim invocation nor the
scheduled limits pass — retires it before its reset epoch, since a weekly bucket cannot
fall from a server-proven 100% back under the threshold inside that window. Claude names
that bucket from the API's own type (bucket=client:seven_day); codex has no such name in
its rollout files, only primary/secondary keys whose meaning moves, so it labels the
marker from the window itself — window_minutes >= 1440 ⇒ client:7d, a shorter window ⇒
client:5h, and a window with no window_minutes at all stays client:<key> and remains
clearable (fail open, bounded by its own reset). A five-hour marker can
still be retired by telemetry fetched well after it
(CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY, 300s), which is what keeps a fresh account
from being stranded by an old session limit — before 2026-09-04 that carve-out also let
fake-zero telemetry retire weekly markers, re-admitting an exhausted account six times a
day. Cost is bounded: the three newest sessions per account, a tail read rather than a whole
transcript, and a clean result memoized for 20s
(CLAUDE_MULTIACC_CLIENT_SCAN_TTL), which on a three-account pool of large live
transcripts is ~2 ms per run amortized.
The codex shim does the same from rollout files
($CODEX_HOME/sessions/<Y>/<M>/<D>/rollout-*.jsonl carry
"rate_limits":{"primary":{"used_percent":…,"resets_at":…}}, read newest-first), under the
same ownership rule. Note that the installed layout deliberately shares that tree
(<acct>/sessions -> ~/.codex/sessions, so codex resume finds every session), so on a
default codex pool this scan stays off and the usage endpoint remains codex's only limit
signal. Claude accounts keep a private sessions/ registry, so it is fully active there.
A shared tree also gets one shared rollout index. codex refuses to start until its
state_<schema>.sqlite has indexed every rollout under $CODEX_HOME/sessions (each file
read whole), so a private index per account meant one full scan of the shared tree per
account — 10 GB × 12 on a busy Mac — and any launch that met another process's unfinished
scan died after 30 s with "timed out waiting for state db backfill … (status: running)",
surfaced as the CLI's "local database appears to be damaged". A scan cut short (a probe with
a timeout, Ctrl-C) also left its 15-minute worker lease behind, keeping the account
unstartable long after its killer was gone. Since 2.0.27 <acct>/state_N.sqlite is a symlink
to ~/.codex/state_N.sqlite, exactly like the tree it describes: SQLite resolves the link
before naming its -wal/-shm companions, so every process shares one lock set — the same
multi-process mode a single CODEX_HOME already runs in. The shim applies this at every
launch, and the index name the installed binary carries is linked ahead of time, so a codex
schema bump still costs one backfill per machine rather than one per account.
An account with no index of its own — every newly added one, and the case the change
exists for — is simply linked. Nothing is renamed and nothing about the shared index's state
can block it. An account that already has one is only ever moved while nothing can open
it (no -wal and no -shm beside it). codex holds the state database through an sqlx pool
whose connections are opened lazily and by path (max_connections(5),
create_if_missing(true)), so renaming an index under a live holder would leave its first
connection on the old inode while every later one follows the new link — one process, two
databases. A codex killed mid-write leaves the pair behind too, and the next clean session on
that account removes it, so an account excluded this way rejoins on its own. Once it is
closed, the account's index is either retired beside the link as *.private (never
deleted, never overwritten — a second retirement gets its own suffix) when the home already
has one, or promoted into the home when it does not. The promote goes through link(2),
which refuses an existing target: two shims racing to be the first to promote could otherwise
rename one's fresh symlink onto the file the other had just promoted, leaving the shared index
pointing at itself — ELOOP, every account on the machine unable to start, and repaired by
neither codex nor the shim.
Two things stop the sharing entirely. An account whose sessions/ is a real directory keeps a
private index — the rule is structural, not a heuristic. And codex's own corruption recovery
has the last word: when it judges a database damaged it renames it (with its -wal/-shm)
into <CODEX_HOME>/db-backups/sqlite-<ts>-<n>/ and rebuilds, which under a link renames the
link. A link sitting in that folder pointing at the shared file is codex's verdict on that
file, so the account keeps the index codex rebuilt for it and that name is left alone —
handing the link back would hand the damage back.
Auto-retry (-p/--print only, default on, CLAUDE_SHIM_RETRY=0 disables): on an
auth- or rate-limit-looking failure the shim retries once on a different account and
marks the failed one — a 10-minute cooldown for a rate limit (it heals on its own),
or an .expired park for an authentication failure / org block (they do not). The
park patterns are deliberately narrow and carry a soft_until stamp, because this grep
also sees the model's own answer: a -p run that merely mentions a 403 must never cost
an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
never double-emits. Only engages when stdin is finite (tty / regular file / /dev/null)
and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
Codex support (OpenAI Codex CLI)
Both pools use the same selection policy. See Codex account commands and behavior.
Install / update / uninstall
npm install -g claude-multiacc@latest
codex-accounts verifynpm installs claude-multiacc, claude-accounts, codex-accounts, and multiacc-select
into its global bin directory, so account commands work in the current shell. Open a
new shell for the claude and codex shims. If auto-setup was skipped, run
claude-multiacc install to initialize the pools and shell setup.
See installation, updates, removal, and command-not-found recovery for npm, npx, git checkout, and server instructions.
Account lifecycle, synchronization, fleet integration, environment switches, verification, troubleshooting, and security guidance continue in Account and fleet operations.
