# 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:

```bash
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](docs/UNIFIED_SELECTOR.md) 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 block + hook)  /root/claude-multiacc/  (addon repo)
```

**Shim selection order** (identical file on both machines, `bin/claude`):

1. `CLAUDE_CONFIG_DIR` or `CLAUDE_CODE_OAUTH_TOKEN` already set → pass straight through.
2. `CLAUDE_ACCOUNT=acct-NN` → use that account (even if limit-marked — explicit wins).
3. 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.
4. Among the accounts that remain valid **on this machine** (an OAuth login —
   `.credentials.json`, or a macOS Keychain item this session can open — or a
   `server.token` that 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=100` turns the gate off
   (default `50`); `CLAUDE_MULTIACC_HEADROOM_BAND=0` restores strict best-weekly selection
   (default `30`); `CLAUDE_SHIM_SELECT=random` uses the whole eligible pool. Every pick
   records both cuts in `selection.log`: `band=`/`band-count=` (the band width and how many
   peers it held) and `session-gate=`/`session-ok=` (the gate and how many candidates
   cleared it — `session-ok=0` means nobody did and the gate stepped aside; in
   `CLAUDE_SHIM_SELECT=random` mode the line reads `session-gate=off`, because no cut was made).
5. 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 in
   `selection.log` (degraded beats down: the 100% rule).
6. 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_GATE` points
  spent — default `50`, `100` disables 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_BAND` points of the leader — default `30`,
  `0` is 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 by
  `claude-accounts relogin`.
- **Rejected setup-token** (`EXPIRED`) — token presence and `claude auth status` do 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 `--resume` calls 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 relogin` re-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 `-p` run) 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 `exec`s 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. In tmux, an interactive
launch also starts a detached [auto-resume](#auto-resume) watcher just before that `exec`;
the `exec` itself does not change.

**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 `/usage`
shows. Every bucket the endpoint returns is tracked separately: `session`, `weekly_all`,
and per-model scoped buckets — **`weekly_scoped:Fable` is its own bucket**. Any bucket
≥ 90% ⇒ the account gets a `.limited` marker (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 parked account gets its limit reset automatically.** Claude
Code 2.1.280 added `/limit-reset`: an account can hold a *grant* (the Opus 5.5 launch gave
every Pro/Max account one reset, usable until 2026-10-22) that refills its five-hour and
weekly limits at once while the weekly reset day stays put. The same usage call carries the
grant's status (`?cedar_ember=1&skip_spend=1`, Claude Code's own read, so it costs nothing
against the endpoint's hourly budget), and the limits pass claims it with
`POST /api/organizations/<org>/reset_rate_limits` whenever the pool has **parked** the account on a
window the grant refills — a bucket at/over the pool's own threshold (90%), a limit the server
reports exhausted, or an active park (a client 429, or the app-robot panel's own park) — and holds
it only when every such park lifts on its own within the hour (`CLAUDE_MULTIACC_RESET_MIN_HORIZON`,
seconds). The claim carries a request id derived
from the account, grant, remaining count and week, persisted before the POST, so a lost
response is retried with the same id and fleet peers converge on one claim; a claim whose
answer never came back is settled by the grant's own remaining count dropping, and a claim
the server refuses outright backs off (honouring `Retry-After`) instead of being re-sent every
pass. A confirmed reset zeroes the pre-reset reading's `fetched_at`, withdraws its numbers,
and drops the account's `.limited` marker — including a client-reported weekly one, which no
telemetry can otherwise lift — with the same `.client-limit-cleared` watermark a telemetry
clear writes. Because peers are pushed markers but never their deletion, the reset also rides
in `limits.json` for 8 days (`reset_redeemed_at`, `reset_cleared`): every machine's shim,
`list`/`status` and the app-robot panel treat any park written before it, for a window it
refilled, as lifted. A reset claimed elsewhere (a peer, or `/limit-reset` by hand) enters the
same record when one grant's own count drops. Only an OAuth login can
claim (the same credential the usage read proved). `reset_credits_available` /
`reset_credits_fetched_at` report the measured allowance exactly as the codex pool does. The
server answers eligibility by client (`ineligible_reason: surface` to anything that is not
the CLI), so the writer sends Claude Code's own User-Agent with the installed version and
`client-app/claude-multiacc`; with no readable version it names itself plainly and claims
nothing. `CLAUDE_MULTIACC_AUTO_RESET=0` is the kill switch. The separate `juniper_tide`
session reset (it spends weekly budget) is never touched.

**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 with `403 — OAuth token does not meet scope requirement
> user:profile`: setup tokens are minted without that scope. So an account whose
> `.credentials.json` grant 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 status` says `RANKING IS BLIND` when 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>`); since
> `limits.json` is one of the things `sync` pushes, 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 show `KEYCHAIN LOCKED` there 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=0` disables 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:

```json
{"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 --continue` resumes the *same* session id under
  whichever account the pool hands out next (only `--fork-session` mints 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 `resetsAt` has already passed is history and excludes nothing; a
  hostile or unreadable index entry is skipped; `CLAUDE_MULTIACC_CLIENT_LIMITS=0` turns
  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.
Interactive sessions are not retried this way. They get auto-resume, described next.

## Auto-resume

An interactive `claude` or `codex` session in **tmux** can stop on a usage limit, a failed
login, an API error or (claude) a crash. It is then **resumed automatically, same session
id**, on another pooled account with headroom. There is no `/exit`, no `--resume` and no
"continue" to type. It covers the everyday launches:

- `claude --dangerously-skip-permissions`, `--resume <id>` and `--model …`;
- `codex --dangerously-bypass-approvals-and-sandbox` and `codex resume …`.

Every other launch, including `-p` and `codex exec`, runs exactly as before.

**How it works.**

- **Launch.** The shim's `exec` is unchanged. Just before it, a detached watcher starts
  (`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
  rollout).
- **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
  another account has headroom. If none does, it **holds** and touches nothing, and
  Claude's own "continuing automatically at …" carries on.
- **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
  prompt. It then types a one-line relaunch of the shim into that **shell**. It never
  sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
  `/limit-reset`.
- **Resume.** The relaunched shim parks the old account with the usual markers, then runs
  normal selection minus the account it just left. The session resumes with a short
  prompt telling the model it was restarted and should continue.

Every step appends an `autoresume <event>` line to `selection.log`.

```bash
touch ~/.claude-accounts/autoresume.off   # claude pool off, already-running sessions included
touch ~/.codex-accounts/autoresume.off    # codex pool off
CLAUDE_MULTIACC_AUTORESUME=0 claude       # one launch (CODEX_MULTIACC_AUTORESUME=0 codex)
```

See [auto-resume: error classes, gates, budgets, logs and limitations](docs/AUTORESUME.md).

## MCP servers for every account

Both clients keep MCP servers in the **config dir** — `<dir>/.claude.json` for Claude Code,
`<CODEX_HOME>/config.toml` for Codex — so under the pool there is one MCP configuration per
account, and a stock `claude mcp add` lands in **one random account**. That is how
`appinspire-mcp` was connected in some sessions and missing in others (2026-09-22). The pool
now owns a **registry**, `<pool>/mcp-servers.json`, that is reconciled into every account:

```bash
claude-accounts mcp add docs-search -- node /path/docs-search.mjs serve     # both pools
claude-accounts mcp add --provider claude local-dev -e KEY=v -- node /path/server.mjs serve
claude-accounts mcp add --scope project --project "$HOME/design-lab/run1" appinspire -e APPINSPIRE_LIBRARY_DIR="$HOME/.appinspire-mcp/library" -- node "$HOME/appinspire-mcp/bin/appinspire-mcp.mjs" serve
claude-accounts mcp remove adspower-local-api    # retire EVERYWHERE: every account, every project entry
claude-accounts mcp remove foo --user-only       # user scope only; project-local copies stay
claude-accounts mcp list [--json]
claude-accounts mcp apply                        # re-apply now (repair drift)
```

- **Default `--provider both`**: an MCP server is not provider-specific, so `claude-accounts
  mcp add` also writes the codex pool's registry (and `codex-accounts mcp add` the claude
  one); `--provider claude|codex` narrows it, and an explicit provider whose pool is missing
  on this machine is an error (the default is silent about it). `-e KEY=VAL` takes one
  variable per flag; write `$HOME/...`, never `~/...`, inside a value (zsh does not expand
  `~` after `=` in a word).
- **Tombstones**: an explicit `mcp remove NAME` retires the server **everywhere** — user
  scope and every project entry, in every account, on every Mac after sync — even a copy
  that was only ever hand-added to a single account (registry key `retired`). `--user-only`
  records a user-scope-only tombstone (`retiredUser`). A stock removal mirrored from the shim
  is scope-limited: `claude mcp remove -s user X` retires X in user scope only, a `-s local`
  removal retires it in that one project only. Re-adding a name lifts its tombstone.
- **Exit status 3** from `mcp add|add-json|remove` means the registry was saved but some
  account could not be reconciled (a corrupt `.claude.json`, a `config.toml` layout the
  editor refuses): the change still syncs and still reaches the sibling pool; fix the account
  and run `mcp apply`. Exit 1 means nothing was saved.
- **Where it is applied**: at seed time (`add`/`import`/`sync`/`post-sync`, so a new account or
  a fleet Mac gets it), and by the **shim right before exec** — the picked account is
  reconciled when the registry, the machine-local overlay or the account's own config changed
  since the last verified reconcile (stamp in `<acct>/.mcp-applied`); Claude Code rewrites
  `.claude.json` during most sessions, so on the claude side that check usually costs one
  short python start-up per launch. Fail-open: a missing python or lib never breaks a launch.
- **Stock commands are mirrored**: `claude mcp add|add-json|remove` and `codex mcp add|remove`
  run under the shim execute against the picked account as usual, and the shim then copies the
  outcome into the registry and every other account (`learn`). This includes a command run
  from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
  an agent): the session's config dir is a pool account, so the same mirror applies. A
  config dir outside the pool is plain passthrough.
- **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
  each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
  registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
  the synced entry wins and every Mac launches one machine's Node and package paths. Never
  `mcp remove` it either: the tombstone deletes the managed entry on every Mac. Drop a stray
  registry entry without a tombstone, then `mcp apply` and `sync`.
- **Replicas**: on a pool whose `sync-role` file says `replica`, `add`/`remove` and the
  shim's mirror land in the machine-local overlay (owner `local`) instead of the synced
  registry, because the source's next push would overwrite them. Make registry changes on
  the source machine.
- **Project scope is claude-only** (`--scope project --project PATH`, or a stock `-s local`
  add): Codex 0.156 reads no per-project MCP config, so a project entry is applied to every
  claude account's `projects[PATH]` and skipped for codex.
- **Fleet**: the **claude** registry rides with the manifest — `sync` pushes it to the server
  and every manifest peer (all runner Macs), and the claude limits pass fans it out with the
  telemetry, so a mirrored add reaches every Mac within one cadence; each target's
  `post-sync` re-seeds and applies it. The **codex** registry reaches only codex sync
  targets (the server, gas-mini): runner Macs' codex pools are local-only and get their
  servers from app-robot's runner overlay (`codex-accounts mcp import-local --owner
  app-robot`, app-robot PR #1870) or from a direct `codex-accounts mcp add --provider codex …`
  on that Mac. The **machine-local overlay** `mcp-servers.local.json` is applied on its Mac
  and **never synced**; the synced registry wins on a name both hold.
- **Codex translation**: a Claude-style block becomes `[mcp_servers.<name>]`; unrelated
  tables, their comments and blank lines are left alone. `KEY=${KEY}` env references become
  `env_vars` (codex forwards a variable under its own name only); `KEY=${OTHER}` and
  `${VAR:-default}` cannot be expressed, so that variable is left out with a warning. A stdio
  server gets `startup_timeout_sec = 60` unless the block sets one (a cold `npx` download must
  not trip Codex's 10 s default); SSE transports are skipped; codex-only keys (`oauth`,
  `enabled_tools`, …) ride along in the registry block and never reach `.claude.json`.
- **Kill switches**: `CLAUDE_MULTIACC_MCP=0` / `CODEX_MULTIACC_MCP=0` stop the reconcile at
  seed and launch and the shim's mirror; `claude-accounts mcp …`, sync and the fan-out still
  work.

app-robot tasks are different: the panel launches Claude with `--strict-mcp-config` and a
merged MCP document (`agent_mcp.servers_for`) and Codex with the same document as `-c`
overrides, so a server every task must see is added there — never with `claude mcp add` on a
Mac.

## Codex support (OpenAI Codex CLI)

Both pools use the same selection policy. See [Codex account commands and behavior](docs/CODEX.md).

## Install / update / uninstall

```bash
npm install -g claude-multiacc@latest
codex-accounts verify
```

npm 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.

The shims shadow the real binaries by PATH order alone, so the installer's rc block
(`~/.zshenv`, `~/.zprofile`, `~/.zshrc`, `~/.profile` and the bash rc files when they
exist) ends with a prompt hook that puts the shim dir back in front before every
prompt: an rc file interrupted with Ctrl-C, or a tool that rewrites PATH, can no
longer leave `~/.local/bin/claude` first — that launch would run on the machine's own
`~/.claude` login and never reach the pool. `claude-accounts status` probes fresh
`zsh`/`bash`/`sh` login shells with your rc files and prints `BYPASSED -> <path>` for
any that still resolve something other than the shim; the weekly `health` check fails
on it.

See [installation, updates, removal, and command-not-found recovery](docs/INSTALLATION.md)
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](docs/ACCOUNT_OPERATIONS.md).
