claude-multiacc 1.0.13 → 1.0.14

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 5 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,12 @@ 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
120
+ endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
118
121
  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,
122
+ both. The cadence is deliberately unhurried: several machines polling the same accounts
123
+ every minute earns a 429 with `Retry-After: 3600`, and telemetry then goes stale for an
124
+ hour at a time — which is exactly when every account starts scoring *neutral* and the
125
+ picker loses its ability to tell them apart. If an account's OAuth access token has been expired for a while (idle account,
120
126
  nothing ran claude under it for hours), the refresher renews it directly via the OAuth
121
127
  **refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
122
128
  and atomically persists the rotated credential (0600) back to that account's
@@ -127,6 +133,61 @@ account would lose to a busy-but-fresh one. Refresh failures fail open and back
127
133
  then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
128
134
  `CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
129
135
 
136
+ **Limits the client reports itself.** The usage endpoint is not the only source of
137
+ truth, and it is the one that fails exactly when it matters. Claude Code records every
138
+ rejection in its session transcript:
139
+
140
+ ```json
141
+ {"error":"rate_limit","apiErrorStatus":429,
142
+ "quotaLimits":{"status":"rejected","resetsAt":1787333400,"rateLimitType":"five_hour"}}
143
+ ```
144
+
145
+ That record is free, offline, instant, and carries the **real** reset time the API handed
146
+ out — so the shim reads it. This is what makes an **interactive** session take its own
147
+ account out of the pool: auto-retry below only ever sees `-p` runs, so before this a 5-hour
148
+ limit hit in a tmux session left no trace at all, and the next `claude` could walk straight
149
+ back into the same dead account.
150
+
151
+ Transcripts are not account-scoped (`<acct>/projects` is a shared symlink by design), so
152
+ the session → account mapping comes from `<acct>/sessions/<pid>.json`, which the client
153
+ maintains only while a run is alive. The shim spawns a short detached poll before `exec`
154
+ (the pid survives `exec`, so it knows the filename) that records the session id — with the
155
+ session's own start time — in `<acct>/.sessions-index`; selection then tail-scans those
156
+ transcripts.
157
+
158
+ Everything about the rule is built to under-claim rather than over-claim, because a
159
+ wrongly parked account is worse than a missed limit:
160
+
161
+ - **One owner per session.** `claude --continue` resumes the *same* session id under
162
+ whichever account the pool hands out next (only `--fork-session` mints a new one), and
163
+ the transcript is shared. Claiming an id releases it from every other account — and if
164
+ two accounts end up holding the same id anyway (a crossed race, corrupt state), the
165
+ ambiguity means neither of them answers for it.
166
+ - **Only from the handover.** A rejection recorded before this account took the session
167
+ over stays with the account that earned it; a record that cannot be dated is not
168
+ attributed at all.
169
+ - **Only a private session tree.** If `<acct>/sessions` — or the account dir itself, or a
170
+ directory nested inside it — is a symlink into somewhere shared, the runs in it prove
171
+ nothing about who spent the quota, so the scan stays off for that account entirely.
172
+ - A rejection whose `resetsAt` has already passed is history and excludes nothing; a
173
+ hostile or unreadable index entry is skipped; `CLAUDE_MULTIACC_CLIENT_LIMITS=0` turns
174
+ the whole scan off.
175
+
176
+ The resulting marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
177
+ pass while its window is still open — first-hand evidence beats a usage payload that
178
+ disagrees. Cost is bounded: the three newest sessions per account, a tail read rather than
179
+ a whole transcript, and a clean result memoized for 20s
180
+ (`CLAUDE_MULTIACC_CLIENT_SCAN_TTL`), which on a three-account pool of large live
181
+ transcripts is ~2 ms per run amortized.
182
+
183
+ The codex shim does the same from rollout files
184
+ (`$CODEX_HOME/sessions/<Y>/<M>/<D>/rollout-*.jsonl` carry
185
+ `"rate_limits":{"primary":{"used_percent":…,"resets_at":…}}`, read newest-first), under the
186
+ same ownership rule. Note that the **installed layout deliberately shares that tree**
187
+ (`<acct>/sessions -> ~/.codex/sessions`, so `codex resume` finds every session), so on a
188
+ default codex pool this scan stays off and the usage endpoint remains codex's only limit
189
+ signal. Claude accounts keep a private `sessions/` registry, so it is fully active there.
190
+
130
191
  **Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
131
192
  auth- or rate-limit-looking failure the shim retries once on a different account and
132
193
  marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
@@ -153,7 +214,7 @@ above about the claude pool holds for the codex pool with these translations:
153
214
  | `CLAUDE_ACCOUNT` / `CLAUDE_SHIM_RETRY` / `CLAUDE_MULTIACC_DISABLE` / … | `CODEX_ACCOUNT` / `CODEX_SHIM_RETRY` / `CODEX_MULTIACC_DISABLE` / … |
154
215
 
155
216
  Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
156
- exclusion), same marker semantics (`.limited` cooldowns, `.expired` parks with
217
+ exclusion, equal scores rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
157
218
  credential/policy scoping and soft expiry), same fail-open guarantees, same sync
158
219
  safety guards. The `codex` shim engages the buffered auto-retry only for
159
220
  `codex exec` runs with finite stdin, exactly like `-p` on the claude side.
@@ -252,7 +313,7 @@ What install does (all reversible, nothing else):
252
313
  - **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
253
314
  (+ bash rc files if present) — end-of-file placement matters because those files
254
315
  re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
255
- `.codex-limits` (60s), `.health` + `.codex-health` (weekly Mon morning), and
316
+ `.codex-limits` (5m), `.health` + `.codex-health` (weekly Mon morning), and
256
317
  `.update` (daily 04:07). Warns if this Mac stores credentials in the Keychain
257
318
  (file-based credentials are required for per-dir logins).
258
319
  - **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`,
@@ -368,8 +429,9 @@ manually via:
368
429
  claude-accounts sync # and codex-accounts sync for the codex pool
369
430
  ```
370
431
 
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):
432
+ Targets are the resolved sync target env override, else the manifest's
433
+ `server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
434
+ below — plus an optional `peers` array for additional machines (e.g. a second Mac):
373
435
 
374
436
  ```json
375
437
  "peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
@@ -392,6 +454,174 @@ set, which is what prevents two machines from overwriting each other's manifests
392
454
  Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
393
455
  whichever machine needs the credential.
394
456
 
457
+ ### Pointing sync somewhere else (or nowhere)
458
+
459
+ The target is no longer baked in. In precedence order:
460
+
461
+ 1. `CLAUDE_MULTIACC_SYNC_TARGET` / `CODEX_MULTIACC_SYNC_TARGET` (or the provider-neutral
462
+ `MULTIACC_SYNC_TARGET`), plus `…_SYNC_ROOT` and `…_SYNC_REPO` — env, so a daemon can
463
+ point one instance at its own target without rewriting a manifest it does not own;
464
+ 2. the manifest's `server` / `server_root` / `server_repo` (what `install.sh --server`
465
+ wrote);
466
+ 3. the historical default — unchanged, so existing installs keep pushing where they did.
467
+
468
+ A pool whose `sync-role` file says `replica` still never pushes, in any mode — the
469
+ marker is honored and reported before anything else, and local-only mode below only
470
+ narrows it further (a local pool pushes nothing whether or not it is a replica).
471
+
472
+ The value **`none`** (also `local`, `off`, `disabled`, empty) selects **local-only** mode:
473
+ there is no ssh target at all because something else — the app-robot panel and its runner
474
+ daemon — distributes the pool. `sync` then validates the manifest, re-seeds account dirs,
475
+ fixes permissions and pushes nowhere; mutations stop auto-pushing (and stop warning about
476
+ a server they are not supposed to reach). One run can force it with `sync --no-server`,
477
+ and a whole install can start that way:
478
+
479
+ ```bash
480
+ ./install.sh --no-server # manifest server = none
481
+ claude-accounts sync # "sync ok (local-only: … nothing pushed)"
482
+ claude-accounts sync --no-server # same, whatever the manifest says
483
+ ```
484
+
485
+ ## Fleet integration (panels, daemons, multiple instances)
486
+
487
+ Everything below is non-interactive and machine-readable: it is what a supervising panel
488
+ (app-robot) or any other daemon needs to run a pool across a fleet of Macs.
489
+
490
+ ### `--json` on `list`, `status` and `limits`
491
+
492
+ ```bash
493
+ claude-accounts list --json # pool + accounts, no refresh
494
+ claude-accounts status --json # the same, plus last_picked and credential detail
495
+ claude-accounts limits --json # refresh usage first, then emit the same document
496
+ codex-accounts list --json # identical schema, provider: "codex"
497
+ ```
498
+
499
+ Stdout is the JSON document and nothing else. One shape for every verb and both
500
+ providers (`lib/report.py`), so a consumer writes one parser:
501
+
502
+ ```jsonc
503
+ {
504
+ "schema": "claude-multiacc/pool.v1", // bumped only on a breaking change
505
+ "provider": "claude", "kind": "list", "generated_at": "…Z",
506
+ "machine": "mac", "host": "my-mini",
507
+ "pool": {
508
+ "root": "/Users/gas/.claude-accounts", "manifest": "…/accounts.json",
509
+ "threshold": 90,
510
+ "sync": {"mode": "server|local", "target": "root@…", "root": "…", "repo": "…",
511
+ "role": "source|replica", "peers": [...]}
512
+ },
513
+ "accounts": [{
514
+ "id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
515
+ "home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
516
+ "status": "active", // active|limited|expired|blocked|missing|remote
517
+ "state": "ok", "label": "OK", "reason": "…", "fix": "…",
518
+ "selectable": true, "needs_login": false,
519
+ "credential_class": "portable", // portable|machine-local|none
520
+ "portable": true,
521
+ "credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
522
+ "oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
523
+ "token_age_days": 39},
524
+ "limited": false, "limit_reset_at": null, "limit_detail": null,
525
+ "usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
526
+ "max_percent": 62, "weekly_percent": 62, "session_percent": 18,
527
+ "buckets": [{"name": "weekly_scoped:Fable", "group": "weekly",
528
+ "percent": 62, "resets_at": "…Z"}]}
529
+ }],
530
+ "summary": {"total": 3, "active": 2, "limited": 1, "needs_login": 0,
531
+ "portable": 3, "selectable": 2},
532
+ "warnings": []
533
+ }
534
+ ```
535
+
536
+ `status` values mean exactly what the shim does: only `active` accounts are selected;
537
+ `limited` authenticates but is parked until `limit_reset_at`; `expired`/`blocked`/
538
+ `missing` need a human (`fix` says what); `remote` is not logged in here on purpose.
539
+ A consumer that only distinguishes *usable / parked / needs-a-human* can read
540
+ `selectable` and `needs_login` and ignore the rest; `state` keeps the raw audit verdict
541
+ without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
542
+ whose reset time has passed does not count, an unreadable one does.
543
+
544
+ ### Which accounts are portable
545
+
546
+ | Credential | Where it lives | Class | Can it be copied to another machine? |
547
+ | --- | --- | --- | --- |
548
+ | claude setup-token | `<acct>/server.token` (`sk-ant-oat…`, ~1 year, inference-only) | **portable** | **Yes** — this is the only credential the fleet distributes |
549
+ | 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 |
550
+ | codex ChatGPT login | `<acct>/auth.json` | machine-local | **No** — same rotating-refresh-token reason. Codex has **no** portable credential type |
551
+
552
+ Non-portable accounts are not a dead end: they show up as `credential_class:
553
+ "machine-local"`, and the way to run one somewhere else is one interactive sign-in there
554
+ (`claude-accounts login <id>`, `codex-accounts login <id>` — the codex device-code flow
555
+ works over SSH). A claude account can be *converted* to the portable class with one
556
+ `claude-accounts mint <id>` on a machine that is already signed in.
557
+
558
+ ### `export-credential` / `import-credential`
559
+
560
+ ```bash
561
+ claude-accounts export-credential acct-05 # blob to stdout
562
+ claude-accounts export-credential acct-05 --out cred.json # …or to a 0600 file
563
+ claude-accounts export-credential acct-05 --identity-only # registry entry, no secret
564
+ cat cred.json | claude-accounts import-credential # install it here
565
+ claude-accounts import-credential --in cred.json --home mac --no-sync
566
+ ```
567
+
568
+ `export-credential` is **read-only on the source pool** — nothing is refreshed, re-minted
569
+ or marked, so exporting a live account cannot disturb it. It refuses anything that is not
570
+ portable, with the reason and the fix, and its exit code is the contract a daemon branches
571
+ on:
572
+
573
+ | Exit | Meaning |
574
+ | --- | --- |
575
+ | 0 | blob written |
576
+ | 2 | usage error / unknown account / malformed blob |
577
+ | 3 | the credential is machine-local — copying it would break both machines |
578
+ | 4 | the account has no credential material here |
579
+ | 5 | credential present but unusable (corrupt, or an API key rather than a setup-token) |
580
+
581
+ The blob is self-contained JSON (`format: "claude-multiacc/credential"`, version 1):
582
+ identity metadata (`id`, `email`, `home`, `added_at`), where it came from, and — for
583
+ class `portable` — the credential itself. **Treat a `portable` blob exactly like the
584
+ credential it contains.**
585
+
586
+ `import-credential` reads it on stdin (or `--in PATH`), creates the account dir and the
587
+ manifest entry as needed, writes the credential 0600, and clears any dead-auth marker.
588
+ It is idempotent **by email**: re-importing an account that is already in the pool
589
+ refreshes it in place rather than adding a second entry, so a daemon can push the same
590
+ pool to a machine over and over. It refuses a blob from the other provider and refuses an
591
+ API key. It also refuses to land an email in a second slot, or to write into a slot that
592
+ belongs to a different account — `--force` settles exactly those identity conflicts, and
593
+ nothing else: an **adopted** (symlinked) account is refused unconditionally, `--force`
594
+ included, because writing a credential through that link would put it outside the pool.
595
+ An `identity` blob registers the account and says which sign-in it still needs. The
596
+ imported account keeps the `added_at` it had on the source machine, so the same account
597
+ reads identically everywhere.
598
+
599
+ For codex, `export-credential` always refuses with exit 3 (there is nothing portable to
600
+ export) and points at the device-code login; `--identity-only` is how a codex account list
601
+ reaches another machine.
602
+
603
+ ### Instance-scoped pools
604
+
605
+ `CLAUDE_ACCOUNTS_ROOT` / `CODEX_ACCOUNTS_ROOT` relocate a pool, so several app-robot
606
+ instances can share one machine and user account without ever seeing each other's
607
+ accounts. Both the CLIs **and the shims** resolve the same root, so a task launched with
608
+ the env set runs under that instance's pool. (The older `…_ACCOUNTS_DIR` spelling still
609
+ works; `…_ROOT` wins when both are set.)
610
+
611
+ ```bash
612
+ export CLAUDE_ACCOUNTS_ROOT=/opt/app-robot/teamA/claude-accounts
613
+ export CODEX_ACCOUNTS_ROOT=/opt/app-robot/teamA/codex-accounts
614
+ ./install.sh --no-server --instance teamA
615
+ ```
616
+
617
+ An install whose roots are not the defaults is an **instance install**: its LaunchAgents
618
+ (and cron lines on Linux) are labelled `com.claude-multiacc.<instance>.*` so instances
619
+ never overwrite each other's agents, and they carry the pool roots in their environment.
620
+ It deliberately does **not** touch the shell rc PATH block, `/etc/profile.d`, or the
621
+ `/usr/local/bin` shims — one interactive PATH cannot serve two pools, and those belong to
622
+ the default install. `--instance NAME` labels it explicitly; otherwise the label is
623
+ derived from the pool root. Uninstalling an instance removes only that instance's agents.
624
+
395
625
  ## Pinning & env switches
396
626
 
397
627
  | Env | Effect |
@@ -399,17 +629,22 @@ whichever machine needs the credential.
399
629
  | `CLAUDE_ACCOUNT=acct-03` | pin this invocation to one account (wins over markers) |
400
630
  | `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
401
631
  | `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
632
+ | `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | do not read the client's own rate-limit records (same for `CODEX_MULTIACC_CLIENT_LIMITS`) |
633
+ | `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
634
  | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
403
- | `CLAUDE_ACCOUNTS_DIR=...` | relocate the pool (used by the test suite) |
635
+ | `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool one isolated pool per app-robot instance (legacy spelling: `CLAUDE_ACCOUNTS_DIR`) |
636
+ | `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
637
+ | `CLAUDE_MULTIACC_SYNC_ROOT=... / _SYNC_REPO=...` | remote pool root / addon repo that goes with it |
404
638
 
405
639
  The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
406
640
  `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
407
- `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_DIR`, `CODEX_MULTIACC_THRESHOLD`.
641
+ `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`),
642
+ `CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.
408
643
 
409
644
  ## Verification
410
645
 
411
646
  ```bash
412
- tests/run-tests.sh # 221 sandboxed tests, no quota
647
+ tests/run-tests.sh # 525 sandboxed tests, no quota
413
648
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
414
649
  claude-accounts verify --quick# auth presence/expiry only, no inference
415
650
  claude-accounts limits # live per-bucket usage incl. the Fable bucket