claude-multiacc 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ completely independent — separate manifests, credentials, telemetry, and logs
16
16
  either provider can be used, re-authenticated, or emptied without touching the other.
17
17
 
18
18
  Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04
19
- (bash 5.2). Test suite: `tests/run-tests.sh` (348 sandboxed tests, no network / no
19
+ (bash 5.2). Test suite: `tests/run-tests.sh` (525 sandboxed tests, no network / no
20
20
  quota — passes on both platforms).
21
21
 
22
22
  ---
@@ -44,8 +44,11 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
44
44
  selection.
45
45
  4. Among the accounts that remain valid **on this machine** (`.credentials.json` or
46
46
  `server.token` present) and not limit-excluded, pick the one with the **most remaining
47
- headroom** (see below). Ties break randomly to spread load. `CLAUDE_SHIM_SELECT=random`
48
- restores plain uniform-random selection.
47
+ headroom** (see below). On equal headroom the pool **rotates away from the account it
48
+ just handed out** and samples the rest at random — so quitting a session and starting
49
+ another one moves you along the pool, while a burst of parallel `claude -p` runs still
50
+ spreads (a strict "oldest first" order would send every member of the burst to the same
51
+ account). `CLAUDE_SHIM_SELECT=random` restores plain uniform-random selection.
49
52
  5. If every account is limit-excluded → the highest-headroom *authenticable* account
50
53
  anyway + a warning in `selection.log` (degraded beats down: the 100% rule).
51
54
  6. If nothing is usable at all → stock passthrough, with the reason in `selection.log`
@@ -98,9 +101,9 @@ The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
98
101
  byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
99
102
  even an unset `HOME`) it fails **open** into plain passthrough.
100
103
 
101
- **Limit-aware marking.** `claude-accounts limits` (every 60s via launchd on the Mac,
104
+ **Limit-aware marking.** `claude-accounts limits` (every 15 min via launchd on the Mac,
102
105
  cron on the server, plus an opportunistic non-blocking kick from the shim when data is
103
- >3 min stale) reads each account's own OAuth usage endpoint — the same data `/usage`
106
+ >10 min stale) reads each account's own OAuth usage endpoint — the same data `/usage`
104
107
  shows. Every bucket the endpoint returns is tracked separately: `session`, `weekly_all`,
105
108
  and per-model scoped buckets — **`weekly_scoped:Fable` is its own bucket**. Any bucket
106
109
  ≥ 90% ⇒ the account gets a `.limited` marker (with the bucket's reset time) and leaves the
@@ -114,9 +117,38 @@ skipped, an all-legacy payload falls back to `five_hour`/`seven_day`, and a payl
114
117
  code cannot read at all degrades that one account (fail open), never the run.
115
118
 
116
119
  Telemetry failures never block work: no fresh data ⇒ account treated as available. The
117
- endpoint rate-limits per account, so the refresher skips accounts fetched in the last 45s
118
- and backs off exponentially (honoring `Retry-After`) on a 429 — `limits --force` overrides
119
- both. If an account's OAuth access token has been expired for a while (idle account,
120
+ endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
121
+ and backs off on **every** non-2xx, not just a 429 — honoring `Retry-After`, and parking
122
+ for 6 h on a refusal the server marks `x-should-retry: false`. `limits --force` overrides
123
+ all of it. The cadence is deliberately unhurried: several machines polling the same
124
+ accounts every minute earns a 429 with `Retry-After: 3600`, and telemetry then goes stale
125
+ for an hour at a time — which is exactly when every account starts scoring *neutral* and
126
+ the picker loses its ability to tell them apart.
127
+
128
+ > **The usage endpoint needs an OAuth login, not a setup token.** A portable
129
+ > `server.token` (`sk-ant-oat01-…`) authenticates *inference* forever, but the usage
130
+ > endpoint refuses it with `403 — OAuth token does not meet scope requirement
131
+ > user:profile`: setup tokens are minted without that scope. So an account whose
132
+ > `.credentials.json` grant has lapsed keeps working perfectly while going **permanently
133
+ > dark for telemetry**, and a pool where that happens to every account ranks everything
134
+ > neutral and picks at random. `claude-accounts status` says `RANKING IS BLIND` when the
135
+ > pool is in that state, and the shim prints an hourly warning on a terminal. The fix is
136
+ > a real sign-in on the machine that polls (`claude-accounts login <acct-NN>`); since
137
+ > `limits.json` is one of the things `sync` pushes, only the **source** machine needs it —
138
+ > the server and peers inherit the telemetry.
139
+
140
+ **Two freshness windows, on purpose.** Ranking trusts telemetry for an hour
141
+ (`CLAUDE_MULTIACC_STALE_AFTER`, default 3600 — matching the `Retry-After: 3600` the
142
+ endpoint itself hands out, so a healthy pool is not "stale" for 45 minutes of every
143
+ hour). The ≥90% *exclusion* keeps the tighter 15-minute window: ranking chooses between
144
+ working accounts, while the cutoff declares one unusable, and an account reading 89% an
145
+ hour ago may be well past 90% now. When nothing is in-window at all, a stale weekly
146
+ reading is still used **if its bucket has not reset yet** (a weekly bucket only rises
147
+ until then, so the number remains a true lower bound) — logged as `ranking=DEGRADED`.
148
+ Only when even that is unavailable does selection fall back to neutral, logged as
149
+ `ranking=BLIND`.
150
+
151
+ If an account's OAuth access token has been expired for a while (idle account,
120
152
  nothing ran claude under it for hours), the refresher renews it directly via the OAuth
121
153
  **refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
122
154
  and atomically persists the rotated credential (0600) back to that account's
@@ -127,6 +159,61 @@ account would lose to a busy-but-fresh one. Refresh failures fail open and back
127
159
  then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
128
160
  `CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
129
161
 
162
+ **Limits the client reports itself.** The usage endpoint is not the only source of
163
+ truth, and it is the one that fails exactly when it matters. Claude Code records every
164
+ rejection in its session transcript:
165
+
166
+ ```json
167
+ {"error":"rate_limit","apiErrorStatus":429,
168
+ "quotaLimits":{"status":"rejected","resetsAt":1787333400,"rateLimitType":"five_hour"}}
169
+ ```
170
+
171
+ That record is free, offline, instant, and carries the **real** reset time the API handed
172
+ out — so the shim reads it. This is what makes an **interactive** session take its own
173
+ account out of the pool: auto-retry below only ever sees `-p` runs, so before this a 5-hour
174
+ limit hit in a tmux session left no trace at all, and the next `claude` could walk straight
175
+ back into the same dead account.
176
+
177
+ Transcripts are not account-scoped (`<acct>/projects` is a shared symlink by design), so
178
+ the session → account mapping comes from `<acct>/sessions/<pid>.json`, which the client
179
+ maintains only while a run is alive. The shim spawns a short detached poll before `exec`
180
+ (the pid survives `exec`, so it knows the filename) that records the session id — with the
181
+ session's own start time — in `<acct>/.sessions-index`; selection then tail-scans those
182
+ transcripts.
183
+
184
+ Everything about the rule is built to under-claim rather than over-claim, because a
185
+ wrongly parked account is worse than a missed limit:
186
+
187
+ - **One owner per session.** `claude --continue` resumes the *same* session id under
188
+ whichever account the pool hands out next (only `--fork-session` mints a new one), and
189
+ the transcript is shared. Claiming an id releases it from every other account — and if
190
+ two accounts end up holding the same id anyway (a crossed race, corrupt state), the
191
+ ambiguity means neither of them answers for it.
192
+ - **Only from the handover.** A rejection recorded before this account took the session
193
+ over stays with the account that earned it; a record that cannot be dated is not
194
+ attributed at all.
195
+ - **Only a private session tree.** If `<acct>/sessions` — or the account dir itself, or a
196
+ directory nested inside it — is a symlink into somewhere shared, the runs in it prove
197
+ nothing about who spent the quota, so the scan stays off for that account entirely.
198
+ - A rejection whose `resetsAt` has already passed is history and excludes nothing; a
199
+ hostile or unreadable index entry is skipped; `CLAUDE_MULTIACC_CLIENT_LIMITS=0` turns
200
+ the whole scan off.
201
+
202
+ The resulting marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
203
+ pass while its window is still open — first-hand evidence beats a usage payload that
204
+ disagrees. Cost is bounded: the three newest sessions per account, a tail read rather than
205
+ a whole transcript, and a clean result memoized for 20s
206
+ (`CLAUDE_MULTIACC_CLIENT_SCAN_TTL`), which on a three-account pool of large live
207
+ transcripts is ~2 ms per run amortized.
208
+
209
+ The codex shim does the same from rollout files
210
+ (`$CODEX_HOME/sessions/<Y>/<M>/<D>/rollout-*.jsonl` carry
211
+ `"rate_limits":{"primary":{"used_percent":…,"resets_at":…}}`, read newest-first), under the
212
+ same ownership rule. Note that the **installed layout deliberately shares that tree**
213
+ (`<acct>/sessions -> ~/.codex/sessions`, so `codex resume` finds every session), so on a
214
+ default codex pool this scan stays off and the usage endpoint remains codex's only limit
215
+ signal. Claude accounts keep a private `sessions/` registry, so it is fully active there.
216
+
130
217
  **Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
131
218
  auth- or rate-limit-looking failure the shim retries once on a different account and
132
219
  marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
@@ -153,7 +240,7 @@ above about the claude pool holds for the codex pool with these translations:
153
240
  | `CLAUDE_ACCOUNT` / `CLAUDE_SHIM_RETRY` / `CLAUDE_MULTIACC_DISABLE` / … | `CODEX_ACCOUNT` / `CODEX_SHIM_RETRY` / `CODEX_MULTIACC_DISABLE` / … |
154
241
 
155
242
  Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
156
- exclusion), same marker semantics (`.limited` cooldowns, `.expired` parks with
243
+ exclusion, equal scores rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
157
244
  credential/policy scoping and soft expiry), same fail-open guarantees, same sync
158
245
  safety guards. The `codex` shim engages the buffered auto-retry only for
159
246
  `codex exec` runs with finite stdin, exactly like `-p` on the claude side.
@@ -252,7 +339,7 @@ What install does (all reversible, nothing else):
252
339
  - **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
253
340
  (+ bash rc files if present) — end-of-file placement matters because those files
254
341
  re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
255
- `.codex-limits` (60s), `.health` + `.codex-health` (weekly Mon morning), and
342
+ `.codex-limits` (5m), `.health` + `.codex-health` (weekly Mon morning), and
256
343
  `.update` (daily 04:07). Warns if this Mac stores credentials in the Keychain
257
344
  (file-based credentials are required for per-dir logins).
258
345
  - **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`,
@@ -368,8 +455,9 @@ manually via:
368
455
  claude-accounts sync # and codex-accounts sync for the codex pool
369
456
  ```
370
457
 
371
- Targets are the manifest's `server`/`server_root`/`server_repo` (the Linux box) plus
372
- an optional `peers` array for additional machines (e.g. a second Mac):
458
+ Targets are the resolved sync target env override, else the manifest's
459
+ `server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
460
+ below — plus an optional `peers` array for additional machines (e.g. a second Mac):
373
461
 
374
462
  ```json
375
463
  "peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
@@ -392,6 +480,174 @@ set, which is what prevents two machines from overwriting each other's manifests
392
480
  Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
393
481
  whichever machine needs the credential.
394
482
 
483
+ ### Pointing sync somewhere else (or nowhere)
484
+
485
+ The target is no longer baked in. In precedence order:
486
+
487
+ 1. `CLAUDE_MULTIACC_SYNC_TARGET` / `CODEX_MULTIACC_SYNC_TARGET` (or the provider-neutral
488
+ `MULTIACC_SYNC_TARGET`), plus `…_SYNC_ROOT` and `…_SYNC_REPO` — env, so a daemon can
489
+ point one instance at its own target without rewriting a manifest it does not own;
490
+ 2. the manifest's `server` / `server_root` / `server_repo` (what `install.sh --server`
491
+ wrote);
492
+ 3. the historical default — unchanged, so existing installs keep pushing where they did.
493
+
494
+ A pool whose `sync-role` file says `replica` still never pushes, in any mode — the
495
+ marker is honored and reported before anything else, and local-only mode below only
496
+ narrows it further (a local pool pushes nothing whether or not it is a replica).
497
+
498
+ The value **`none`** (also `local`, `off`, `disabled`, empty) selects **local-only** mode:
499
+ there is no ssh target at all because something else — the app-robot panel and its runner
500
+ daemon — distributes the pool. `sync` then validates the manifest, re-seeds account dirs,
501
+ fixes permissions and pushes nowhere; mutations stop auto-pushing (and stop warning about
502
+ a server they are not supposed to reach). One run can force it with `sync --no-server`,
503
+ and a whole install can start that way:
504
+
505
+ ```bash
506
+ ./install.sh --no-server # manifest server = none
507
+ claude-accounts sync # "sync ok (local-only: … nothing pushed)"
508
+ claude-accounts sync --no-server # same, whatever the manifest says
509
+ ```
510
+
511
+ ## Fleet integration (panels, daemons, multiple instances)
512
+
513
+ Everything below is non-interactive and machine-readable: it is what a supervising panel
514
+ (app-robot) or any other daemon needs to run a pool across a fleet of Macs.
515
+
516
+ ### `--json` on `list`, `status` and `limits`
517
+
518
+ ```bash
519
+ claude-accounts list --json # pool + accounts, no refresh
520
+ claude-accounts status --json # the same, plus last_picked and credential detail
521
+ claude-accounts limits --json # refresh usage first, then emit the same document
522
+ codex-accounts list --json # identical schema, provider: "codex"
523
+ ```
524
+
525
+ Stdout is the JSON document and nothing else. One shape for every verb and both
526
+ providers (`lib/report.py`), so a consumer writes one parser:
527
+
528
+ ```jsonc
529
+ {
530
+ "schema": "claude-multiacc/pool.v1", // bumped only on a breaking change
531
+ "provider": "claude", "kind": "list", "generated_at": "…Z",
532
+ "machine": "mac", "host": "my-mini",
533
+ "pool": {
534
+ "root": "/Users/gas/.claude-accounts", "manifest": "…/accounts.json",
535
+ "threshold": 90,
536
+ "sync": {"mode": "server|local", "target": "root@…", "root": "…", "repo": "…",
537
+ "role": "source|replica", "peers": [...]}
538
+ },
539
+ "accounts": [{
540
+ "id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
541
+ "home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
542
+ "status": "active", // active|limited|expired|blocked|missing|remote
543
+ "state": "ok", "label": "OK", "reason": "…", "fix": "…",
544
+ "selectable": true, "needs_login": false,
545
+ "credential_class": "portable", // portable|machine-local|none
546
+ "portable": true,
547
+ "credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
548
+ "oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
549
+ "token_age_days": 39},
550
+ "limited": false, "limit_reset_at": null, "limit_detail": null,
551
+ "usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
552
+ "max_percent": 62, "weekly_percent": 62, "session_percent": 18,
553
+ "buckets": [{"name": "weekly_scoped:Fable", "group": "weekly",
554
+ "percent": 62, "resets_at": "…Z"}]}
555
+ }],
556
+ "summary": {"total": 3, "active": 2, "limited": 1, "needs_login": 0,
557
+ "portable": 3, "selectable": 2},
558
+ "warnings": []
559
+ }
560
+ ```
561
+
562
+ `status` values mean exactly what the shim does: only `active` accounts are selected;
563
+ `limited` authenticates but is parked until `limit_reset_at`; `expired`/`blocked`/
564
+ `missing` need a human (`fix` says what); `remote` is not logged in here on purpose.
565
+ A consumer that only distinguishes *usable / parked / needs-a-human* can read
566
+ `selectable` and `needs_login` and ignore the rest; `state` keeps the raw audit verdict
567
+ without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
568
+ whose reset time has passed does not count, an unreadable one does.
569
+
570
+ ### Which accounts are portable
571
+
572
+ | Credential | Where it lives | Class | Can it be copied to another machine? |
573
+ | --- | --- | --- | --- |
574
+ | claude setup-token | `<acct>/server.token` (`sk-ant-oat…`, ~1 year, inference-only) | **portable** | **Yes** — this is the only credential the fleet distributes |
575
+ | claude OAuth login | `<acct>/.credentials.json` | machine-local | **No.** Its refresh token rotates on every refresh; a second machine refreshing the same grant strands the first |
576
+ | codex ChatGPT login | `<acct>/auth.json` | machine-local | **No** — same rotating-refresh-token reason. Codex has **no** portable credential type |
577
+
578
+ Non-portable accounts are not a dead end: they show up as `credential_class:
579
+ "machine-local"`, and the way to run one somewhere else is one interactive sign-in there
580
+ (`claude-accounts login <id>`, `codex-accounts login <id>` — the codex device-code flow
581
+ works over SSH). A claude account can be *converted* to the portable class with one
582
+ `claude-accounts mint <id>` on a machine that is already signed in.
583
+
584
+ ### `export-credential` / `import-credential`
585
+
586
+ ```bash
587
+ claude-accounts export-credential acct-05 # blob to stdout
588
+ claude-accounts export-credential acct-05 --out cred.json # …or to a 0600 file
589
+ claude-accounts export-credential acct-05 --identity-only # registry entry, no secret
590
+ cat cred.json | claude-accounts import-credential # install it here
591
+ claude-accounts import-credential --in cred.json --home mac --no-sync
592
+ ```
593
+
594
+ `export-credential` is **read-only on the source pool** — nothing is refreshed, re-minted
595
+ or marked, so exporting a live account cannot disturb it. It refuses anything that is not
596
+ portable, with the reason and the fix, and its exit code is the contract a daemon branches
597
+ on:
598
+
599
+ | Exit | Meaning |
600
+ | --- | --- |
601
+ | 0 | blob written |
602
+ | 2 | usage error / unknown account / malformed blob |
603
+ | 3 | the credential is machine-local — copying it would break both machines |
604
+ | 4 | the account has no credential material here |
605
+ | 5 | credential present but unusable (corrupt, or an API key rather than a setup-token) |
606
+
607
+ The blob is self-contained JSON (`format: "claude-multiacc/credential"`, version 1):
608
+ identity metadata (`id`, `email`, `home`, `added_at`), where it came from, and — for
609
+ class `portable` — the credential itself. **Treat a `portable` blob exactly like the
610
+ credential it contains.**
611
+
612
+ `import-credential` reads it on stdin (or `--in PATH`), creates the account dir and the
613
+ manifest entry as needed, writes the credential 0600, and clears any dead-auth marker.
614
+ It is idempotent **by email**: re-importing an account that is already in the pool
615
+ refreshes it in place rather than adding a second entry, so a daemon can push the same
616
+ pool to a machine over and over. It refuses a blob from the other provider and refuses an
617
+ API key. It also refuses to land an email in a second slot, or to write into a slot that
618
+ belongs to a different account — `--force` settles exactly those identity conflicts, and
619
+ nothing else: an **adopted** (symlinked) account is refused unconditionally, `--force`
620
+ included, because writing a credential through that link would put it outside the pool.
621
+ An `identity` blob registers the account and says which sign-in it still needs. The
622
+ imported account keeps the `added_at` it had on the source machine, so the same account
623
+ reads identically everywhere.
624
+
625
+ For codex, `export-credential` always refuses with exit 3 (there is nothing portable to
626
+ export) and points at the device-code login; `--identity-only` is how a codex account list
627
+ reaches another machine.
628
+
629
+ ### Instance-scoped pools
630
+
631
+ `CLAUDE_ACCOUNTS_ROOT` / `CODEX_ACCOUNTS_ROOT` relocate a pool, so several app-robot
632
+ instances can share one machine and user account without ever seeing each other's
633
+ accounts. Both the CLIs **and the shims** resolve the same root, so a task launched with
634
+ the env set runs under that instance's pool. (The older `…_ACCOUNTS_DIR` spelling still
635
+ works; `…_ROOT` wins when both are set.)
636
+
637
+ ```bash
638
+ export CLAUDE_ACCOUNTS_ROOT=/opt/app-robot/teamA/claude-accounts
639
+ export CODEX_ACCOUNTS_ROOT=/opt/app-robot/teamA/codex-accounts
640
+ ./install.sh --no-server --instance teamA
641
+ ```
642
+
643
+ An install whose roots are not the defaults is an **instance install**: its LaunchAgents
644
+ (and cron lines on Linux) are labelled `com.claude-multiacc.<instance>.*` so instances
645
+ never overwrite each other's agents, and they carry the pool roots in their environment.
646
+ It deliberately does **not** touch the shell rc PATH block, `/etc/profile.d`, or the
647
+ `/usr/local/bin` shims — one interactive PATH cannot serve two pools, and those belong to
648
+ the default install. `--instance NAME` labels it explicitly; otherwise the label is
649
+ derived from the pool root. Uninstalling an instance removes only that instance's agents.
650
+
395
651
  ## Pinning & env switches
396
652
 
397
653
  | Env | Effect |
@@ -399,17 +655,22 @@ whichever machine needs the credential.
399
655
  | `CLAUDE_ACCOUNT=acct-03` | pin this invocation to one account (wins over markers) |
400
656
  | `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
401
657
  | `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
658
+ | `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | do not read the client's own rate-limit records (same for `CODEX_MULTIACC_CLIENT_LIMITS`) |
659
+ | `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | how long a clean client-limit scan is memoized (default 20s; same for `CODEX_MULTIACC_CLIENT_SCAN_TTL`) |
402
660
  | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
403
- | `CLAUDE_ACCOUNTS_DIR=...` | relocate the pool (used by the test suite) |
661
+ | `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool one isolated pool per app-robot instance (legacy spelling: `CLAUDE_ACCOUNTS_DIR`) |
662
+ | `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
663
+ | `CLAUDE_MULTIACC_SYNC_ROOT=... / _SYNC_REPO=...` | remote pool root / addon repo that goes with it |
404
664
 
405
665
  The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
406
666
  `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
407
- `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_DIR`, `CODEX_MULTIACC_THRESHOLD`.
667
+ `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`),
668
+ `CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.
408
669
 
409
670
  ## Verification
410
671
 
411
672
  ```bash
412
- tests/run-tests.sh # 221 sandboxed tests, no quota
673
+ tests/run-tests.sh # 525 sandboxed tests, no quota
413
674
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
414
675
  claude-accounts verify --quick# auth presence/expiry only, no inference
415
676
  claude-accounts limits # live per-bucket usage incl. the Fable bucket