claude-multiacc 1.0.21 → 2.0.0

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.
@@ -0,0 +1,396 @@
1
+ # Account and fleet operations
2
+
3
+ ## Accounts
4
+
5
+ ```bash
6
+ claude-accounts list # pool at a glance
7
+ claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
8
+ claude-accounts expired # ONLY the accounts that cannot authenticate, and why (exit 1 if any)
9
+ claude-accounts relogin # sign those back in, one at a time (ids or --all to pick your own)
10
+ ```
11
+
12
+ Every account in the pool is authenticated — `add` cannot register one otherwise.
13
+
14
+ ### When an account stops working
15
+
16
+ Subscription logins expire (the refresh token has a finite life), can be revoked from
17
+ claude.ai, and an organization can disable Claude Code for one of its accounts entirely.
18
+ Such an account is **never selected** — `claude` keeps working on the healthy ones — and
19
+ shows up here:
20
+
21
+ ```bash
22
+ claude-accounts expired
23
+ # acct-01 support@example.com EXPIRED refresh token expired 2026-08-05 (2d ago)
24
+ # fix: claude-accounts relogin acct-01
25
+ # acct-08 dev@example.com BLOCKED this account's organization has disabled
26
+ # Claude Code subscription access
27
+ # fix: claude-accounts relogin acct-08
28
+
29
+ claude-accounts relogin # re-authenticate everything a sign-in can fix
30
+ claude-accounts relogin acct-01 # or just one
31
+ claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
32
+ ```
33
+
34
+ `relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
35
+ clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
36
+ one at any time; a PASS clears the park automatically.
37
+
38
+ `relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
39
+ the wrong email), one account at a time, and syncs to the server once at the end.
40
+ `expired` exits **1** when anything needs a human, so it drops straight into a cron or
41
+ monitoring check. `list` tags such accounts `EXPIRED-LOGIN`, and `status` says
42
+ `selectable: NO` with the reason.
43
+
44
+ ### Adopt this machine's existing login (no browser needed)
45
+
46
+ ```bash
47
+ claude-accounts import you@example.com --id acct-01 --home mac
48
+ claude-accounts adopt acct-01
49
+ ```
50
+
51
+ `adopt` makes the account dir a **symlink to `~/.claude`** — the machine's existing
52
+ login joins the pool with a single credential file (no copy of the OAuth grant exists
53
+ anywhere else, so refreshes can never race). This is how the two current accounts run:
54
+ acct-01 = the Mac's login, acct-02 = the server's login (adopted server-side).
55
+
56
+ ### Add a fresh account (~1 min, one browser sign-in)
57
+
58
+ ```bash
59
+ claude-accounts add # no email needed — read back from the sign-in
60
+ # or name it explicitly (fast-fails on a duplicate, and pre-fills the login page):
61
+ claude-accounts add new@example.com
62
+ ```
63
+
64
+ This runs the **normal full Claude Code login** — the exact same
65
+ `claude auth login` flow the app itself uses (`claude.com/cai/oauth/authorize`, full
66
+ Claude Code scopes). A browser opens (or a sign-in link is printed); sign in to the
67
+ account you're adding, approve, and if a code is shown, paste it back. It draws on the
68
+ subscription — never API keys.
69
+
70
+ Using the full login on purpose: it does **not** hit the *"Sign in again to continue"*
71
+ step-up wall that the narrow long-lived-token grant triggers, and it stores an
72
+ auto-refreshing `.credentials.json`. That credential is **machine-local** (never synced),
73
+ which is what keeps two machines from invalidating each other's refresh token — so an
74
+ account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
75
+ you want it there too. The account is registered **only after the signed-in email is read
76
+ back**; an aborted login leaves nothing behind, and duplicate emails are refused.
77
+
78
+ ```bash
79
+ claude-accounts login acct-NN # complete/refresh an existing account (full login)
80
+ claude-accounts add new@example.com --token # portable setup-token instead: works on BOTH
81
+ # machines and syncs to the server, but needs a
82
+ # recent sign-in (may show the step-up screen)
83
+ claude-accounts login acct-NN --token # same, for an existing account
84
+ ```
85
+
86
+ Use `--token` when you specifically want one account usable on the server via the Mac's
87
+ sync (the ~1-year inference-only token that mirrors over). For everyday adds, the default
88
+ full login is smoother. `status` warns when a token nears end of life; re-run `login`.
89
+
90
+ ### A setup token has no identity — name the account yourself
91
+
92
+ A setup token is minted with scope `user:inference` **alone**, so `claude auth status`
93
+ answers `{loggedIn, authMethod, apiProvider}` and no email — only an OAuth login reports
94
+ `email`/`orgId`/`subscriptionType`. Nothing on this side can therefore learn which account
95
+ approved a `setup-token` grant, and no future check will change that.
96
+
97
+ What follows, and what the CLI does about it:
98
+
99
+ - **`add <email> --token` requires the email** — there is nothing to read the account back
100
+ from. It registers under the name you gave and says so; naming no email is refused
101
+ outright rather than registering an anonymous slot.
102
+ - **`mint <acct-NN>` names the account in its prompt** (`approve in a browser signed in as
103
+ …`) and warns afterwards that the identity is unverifiable. Approving in a window signed
104
+ into a *different* account silently binds that account's subscription to the slot —
105
+ every machine then runs it under the wrong name. Sign in to the right account **first**,
106
+ in a fresh private window, then paste the link.
107
+ - `login <acct-NN> --token` compares against the manifest when an identity is readable
108
+ (it is not, today) and otherwise says what it is trusting.
109
+
110
+ Both accounts currently in the pool are live and verified: acct-01 (support@gowalkae.com,
111
+ Mac) and acct-02 (hasan@gowalkqa.com, server) — each adopted from its machine's existing
112
+ login, so each runs on the machine that holds its credential.
113
+
114
+ ### Remove
115
+
116
+ ```bash
117
+ claude-accounts remove acct-NN # deletes locally, propagates to the server
118
+ ```
119
+
120
+ ## Sync (multi-machine)
121
+
122
+ One machine is the **source of truth**; every other machine is a **target**. Sync is
123
+ one-way, fired automatically by `add`/`import`/`remove`/`mint` on the source and
124
+ manually via:
125
+
126
+ ```bash
127
+ claude-accounts sync # and codex-accounts sync for the codex pool
128
+ ```
129
+
130
+ Targets are the resolved sync target — env override, else the manifest's
131
+ `server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
132
+ below — plus an optional `peers` array for additional machines (e.g. a second Mac):
133
+
134
+ ```json
135
+ "peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
136
+ "repo": "/Users/gas/claude-multiacc"}]
137
+ ```
138
+
139
+ Every target gets the manifest + per-account `server.token` (0600, claude pool) +
140
+ one-time seeds + advisory limit state; **never** `.credentials.json` or codex
141
+ `auth.json` in either direction (their refresh tokens rotate — two machines
142
+ refreshing one grant invalidate each other; codex accounts are signed in per
143
+ machine with the device-code flow instead). Removals propagate to every target; an
144
+ empty/corrupt manifest — or one with an invalid target/peer shape — refuses to sync
145
+ before anything is pushed anywhere. After every push each target re-seeds dirs and
146
+ re-runs its quick verification matrix (`post-sync` hook). Everything logs to
147
+ `sync.log`; failures are loud and non-zero.
148
+
149
+ A target machine's pool carries a `sync-role` file containing `replica`: such a pool
150
+ **never pushes** (manually or via auto-sync) — the source machine owns the account
151
+ set, which is what prevents two machines from overwriting each other's manifests.
152
+ Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
153
+ whichever machine needs the credential.
154
+
155
+ ### Pointing sync somewhere else (or nowhere)
156
+
157
+ The target is no longer baked in. In precedence order:
158
+
159
+ 1. `CLAUDE_MULTIACC_SYNC_TARGET` / `CODEX_MULTIACC_SYNC_TARGET` (or the provider-neutral
160
+ `MULTIACC_SYNC_TARGET`), plus `…_SYNC_ROOT` and `…_SYNC_REPO` — env, so a daemon can
161
+ point one instance at its own target without rewriting a manifest it does not own;
162
+ 2. the manifest's `server` / `server_root` / `server_repo` (what `install.sh --server`
163
+ wrote);
164
+ 3. the historical default — unchanged, so existing installs keep pushing where they did.
165
+
166
+ A pool whose `sync-role` file says `replica` still never pushes, in any mode — the
167
+ marker is honored and reported before anything else, and local-only mode below only
168
+ narrows it further (a local pool pushes nothing whether or not it is a replica).
169
+
170
+ The value **`none`** (also `local`, `off`, `disabled`, empty) selects **local-only** mode:
171
+ there is no ssh target at all because something else — the app-robot panel and its runner
172
+ daemon — distributes the pool. `sync` then validates the manifest, re-seeds account dirs,
173
+ fixes permissions and pushes nowhere; mutations stop auto-pushing (and stop warning about
174
+ a server they are not supposed to reach). One run can force it with `sync --no-server`,
175
+ and a whole install can start that way:
176
+
177
+ ```bash
178
+ ./install.sh --no-server # manifest server = none
179
+ claude-accounts sync # "sync ok (local-only: … nothing pushed)"
180
+ claude-accounts sync --no-server # same, whatever the manifest says
181
+ ```
182
+
183
+ ## Fleet integration (panels, daemons, multiple instances)
184
+
185
+ Everything below is non-interactive and machine-readable: it is what a supervising panel
186
+ (app-robot) or any other daemon needs to run a pool across a fleet of Macs.
187
+
188
+ ### `--json` on `list`, `status` and `limits`
189
+
190
+ ```bash
191
+ claude-accounts list --json # pool + accounts, no refresh
192
+ claude-accounts status --json # the same, plus last_picked and credential detail
193
+ claude-accounts limits --json # refresh usage first, then emit the same document
194
+ codex-accounts list --json # identical schema, provider: "codex"
195
+ ```
196
+
197
+ Stdout is the JSON document and nothing else. One shape for every verb and both
198
+ providers (`lib/report.py`), so a consumer writes one parser:
199
+
200
+ ```jsonc
201
+ {
202
+ "schema": "claude-multiacc/pool.v1", // bumped only on a breaking change
203
+ "provider": "claude", "kind": "list", "generated_at": "…Z",
204
+ "machine": "mac", "host": "my-mini",
205
+ "pool": {
206
+ "root": "/Users/gas/.claude-accounts", "manifest": "…/accounts.json",
207
+ "threshold": 90,
208
+ "sync": {"mode": "server|local", "target": "root@…", "root": "…", "repo": "…",
209
+ "role": "source|replica", "peers": [...]}
210
+ },
211
+ "accounts": [{
212
+ "id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
213
+ "home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
214
+ "status": "active", // active|limited|expired|blocked|missing|remote
215
+ "state": "ok", "label": "OK", "reason": "…", "fix": "…",
216
+ "selectable": true, "needs_login": false,
217
+ "credential_class": "portable", // portable|machine-local|none
218
+ "portable": true,
219
+ "credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
220
+ "oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
221
+ "token_age_days": 39},
222
+ "limited": false, "limit_reset_at": null, "limit_detail": null,
223
+ "usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
224
+ "max_percent": 62, "weekly_percent": 62, "session_percent": 18,
225
+ "buckets": [{"name": "weekly_scoped:Fable", "group": "weekly",
226
+ "percent": 62, "resets_at": "…Z"}]}
227
+ }],
228
+ "summary": {"total": 3, "active": 2, "limited": 1, "needs_login": 0,
229
+ "portable": 3, "selectable": 2},
230
+ "warnings": []
231
+ }
232
+ ```
233
+
234
+ `status` values mean exactly what the shim does: only `active` accounts are selected;
235
+ `limited` authenticates but is parked until `limit_reset_at`; `expired`/`blocked`/
236
+ `missing` need a human (`fix` says what); `remote` is not logged in here on purpose.
237
+ A consumer that only distinguishes *usable / parked / needs-a-human* can read
238
+ `selectable` and `needs_login` and ignore the rest; `state` keeps the raw audit verdict
239
+ without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
240
+ whose reset time has passed does not count, an unreadable one does.
241
+
242
+ ### Which accounts are portable
243
+
244
+ | Credential | Where it lives | Class | Can it be copied to another machine? |
245
+ | --- | --- | --- | --- |
246
+ | claude setup-token | `<acct>/server.token` | **portable** | **Yes**; the fleet-distributed credential |
247
+ | claude OAuth login | `<acct>/.credentials.json` | machine-local | **No**; refresh tokens rotate |
248
+ | codex ChatGPT login | `<acct>/auth.json` | machine-local | **No**; Codex has no portable credential |
249
+
250
+ Non-portable accounts are not a dead end: they show up as `credential_class:
251
+ "machine-local"`, and the way to run one somewhere else is one interactive sign-in there
252
+ (`claude-accounts login <id>`, `codex-accounts login <id>` — the codex device-code flow
253
+ works over SSH). A claude account can be *converted* to the portable class with one
254
+ `claude-accounts mint <id>` on a machine that is already signed in.
255
+
256
+ ### `export-credential` / `import-credential`
257
+
258
+ ```bash
259
+ claude-accounts export-credential acct-05 # blob to stdout
260
+ claude-accounts export-credential acct-05 --out cred.json # …or to a 0600 file
261
+ claude-accounts export-credential acct-05 --identity-only # registry entry, no secret
262
+ cat cred.json | claude-accounts import-credential # install it here
263
+ claude-accounts import-credential --in cred.json --home mac --no-sync
264
+ ```
265
+
266
+ `export-credential` is **read-only on the source pool** — nothing is refreshed, re-minted
267
+ or marked, so exporting a live account cannot disturb it. It refuses anything that is not
268
+ portable, with the reason and the fix, and its exit code is the contract a daemon branches
269
+ on:
270
+
271
+ | Exit | Meaning |
272
+ | --- | --- |
273
+ | 0 | blob written |
274
+ | 2 | usage error / unknown account / malformed blob |
275
+ | 3 | the credential is machine-local — copying it would break both machines |
276
+ | 4 | the account has no credential material here |
277
+ | 5 | credential present but unusable (corrupt, or an API key rather than a setup-token) |
278
+
279
+ The blob is self-contained JSON (`format: "claude-multiacc/credential"`, version 1):
280
+ identity metadata (`id`, `email`, `home`, `added_at`), where it came from, and — for
281
+ class `portable` — the credential itself. **Treat a `portable` blob exactly like the
282
+ credential it contains.**
283
+
284
+ `import-credential` reads it on stdin (or `--in PATH`), creates the account dir and the
285
+ manifest entry as needed, writes the credential 0600, and clears any dead-auth marker.
286
+ It is idempotent **by email**: re-importing an account that is already in the pool
287
+ refreshes it in place rather than adding a second entry, so a daemon can push the same
288
+ pool to a machine over and over. It refuses a blob from the other provider and refuses an
289
+ API key. It also refuses to land an email in a second slot, or to write into a slot that
290
+ belongs to a different account — `--force` settles exactly those identity conflicts, and
291
+ nothing else: an **adopted** (symlinked) account is refused unconditionally, `--force`
292
+ included, because writing a credential through that link would put it outside the pool.
293
+ An `identity` blob registers the account and says which sign-in it still needs. The
294
+ imported account keeps the `added_at` it had on the source machine, so the same account
295
+ reads identically everywhere.
296
+
297
+ For codex, `export-credential` always refuses with exit 3 (there is nothing portable to
298
+ export) and points at the device-code login; `--identity-only` is how a codex account list
299
+ reaches another machine.
300
+
301
+ ### Instance-scoped pools
302
+
303
+ `CLAUDE_ACCOUNTS_ROOT` / `CODEX_ACCOUNTS_ROOT` relocate a pool, so several app-robot
304
+ instances can share one machine and user account without ever seeing each other's
305
+ accounts. Both the CLIs **and the shims** resolve the same root, so a task launched with
306
+ the env set runs under that instance's pool. (The older `…_ACCOUNTS_DIR` spelling still
307
+ works; `…_ROOT` wins when both are set.)
308
+
309
+ ```bash
310
+ export CLAUDE_ACCOUNTS_ROOT=/opt/app-robot/teamA/claude-accounts
311
+ export CODEX_ACCOUNTS_ROOT=/opt/app-robot/teamA/codex-accounts
312
+ ./install.sh --no-server --instance teamA
313
+ ```
314
+
315
+ An install whose roots are not the defaults is an **instance install**: its LaunchAgents
316
+ (and cron lines on Linux) are labelled `com.claude-multiacc.<instance>.*` so instances
317
+ never overwrite each other's agents, and they carry the pool roots in their environment.
318
+ It deliberately does **not** touch the shell rc PATH block, `/etc/profile.d`, or the
319
+ `/usr/local/bin` shims — one interactive PATH cannot serve two pools, and those belong to
320
+ the default install. `--instance NAME` labels it explicitly; otherwise the label is
321
+ derived from the pool root. Uninstalling an instance removes only that instance's agents.
322
+
323
+ ## Pinning & env switches
324
+
325
+ | Env | Effect |
326
+ | --- | --- |
327
+ | `CLAUDE_ACCOUNT=acct-03` | pin this invocation to one account (wins over markers) |
328
+ | `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
329
+ | `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
330
+ | `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | ignore the client's own rate-limit records |
331
+ | `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | clean client-limit scan cache; default 20s |
332
+ | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
333
+ | `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool; legacy spelling is `CLAUDE_ACCOUNTS_DIR` |
334
+ | `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
335
+ | `CLAUDE_MULTIACC_SYNC_ROOT=... / _SYNC_REPO=...` | remote pool root / addon repo that goes with it |
336
+
337
+ The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
338
+ `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
339
+ `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`),
340
+ `CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.
341
+
342
+ ## Verification
343
+
344
+ ```bash
345
+ tests/run-tests.sh # 644 sandboxed compatibility tests, no quota
346
+ claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
347
+ claude-accounts verify --quick# auth presence/expiry only, no inference
348
+ claude-accounts limits # live per-bucket usage incl. the Fable bucket
349
+ claude-accounts health # limits + full verify, logs to health.log, notifies on failure (Mac)
350
+ ```
351
+
352
+ Verified end-to-end on both machines (2026-07-13): full matrix PASS, 20-invocation shim
353
+ spread clean, real project `-p` through the shim OK on Mac (`~/pipeline`) and server
354
+ (`/opt/inapp-onboarding`), uninstall→stock→reinstall roundtrip byte-identical, three
355
+ server systemd services (`vpn-server-management`, `inapp-onboarding`, `vpn-seo-panel`)
356
+ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
357
+
358
+ ## Troubleshooting
359
+
360
+ - **`claude` resolves to the real binary, not the shim** — open a new shell, or check
361
+ that the marked block is the LAST PATH manipulation in your rc file
362
+ (`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
363
+ - **An account never gets picked** — `claude-accounts status`: no auth on this machine,
364
+ a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
365
+ marker (shows bucket + minutes to reset).
366
+ - **"Failed to authenticate: OAuth session expired and could not be refreshed"** — the
367
+ login behind that run is dead. Run `claude-accounts expired` to see which, then
368
+ `claude-accounts relogin`. Accounts in that state are excluded from selection
369
+ automatically, so this only surfaces when *every* account needs a re-login (the shim
370
+ then falls back to the machine's own `~/.claude` login) or when it was pinned with
371
+ `CLAUDE_ACCOUNT`.
372
+ - **Everything marked limited** — the shim still runs (least-utilized fallback);
373
+ check `selection.log` for `all-limited` lines.
374
+ - **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
375
+ `server` (BatchMode — needs key auth).
376
+ - **A service bypasses the shim** — it spawns an absolute path. Point its env
377
+ (`CLAUDE_BIN` etc.) at `/usr/local/bin/claude`. Docker-internal `claude` installs
378
+ (e.g. openclaw live-test containers) are out of scope for a host shim.
379
+ - **`add` sign-in says "Sign in again to continue"** — that's Claude's step-up
380
+ authentication, not a tool error: granting a long-lived token needs a *recent* sign-in.
381
+ Open a fresh incognito window, sign in to the target account at claude.ai **first**,
382
+ then paste the link and approve. The approval scope is `user:inference`
383
+ (*"Contribute to your Claude subscription usage"*) — that's correct and minimal.
384
+ - **Limits look stale** — launchd: `launchctl list | grep claude-multiacc`;
385
+ cron: `crontab -l | grep claude-multiacc`. The shim also kicks a refresh when data
386
+ is >3 min old.
387
+
388
+ ## Security notes & known risks
389
+
390
+ - Tokens and credentials live as 0600 files under `~/.claude-accounts` (and
391
+ `/root/.claude-accounts` on the server). Server compromise = account access.
392
+ - `selection.log` records timestamps/account/cwd only — never prompt text.
393
+ - Rotating multiple subscriptions to spread usage may be flagged by anti-abuse systems;
394
+ accounts can be banned for limit circumvention. Known and accepted by the operator.
395
+ - The usage endpoint is the internal one `/usage` consumes; if it changes shape, limit
396
+ tracking fails open (accounts stay available, error-retry is the backstop).
@@ -0,0 +1,42 @@
1
+ # Unified Claude + Codex selector
2
+
3
+ `multiacc-select` is the canonical policy engine for app-robot's
4
+ `DEFAULT_ENGINE=both` mode. It ranks a database-locked snapshot of both subscription
5
+ pools and returns one concrete provider/account identity. It is deliberately pure: the
6
+ caller owns telemetry collection, row locks, durable reservation, retries and launch.
7
+
8
+ ## Invocation
9
+
10
+ ```sh
11
+ multiacc-select --request-json - --response-json -
12
+ ```
13
+
14
+ The command reads one UTF-8 JSON object from stdin and writes one JSON object to
15
+ stdout. `multiacc-select --version` reports the selector contract version.
16
+
17
+ The request schema is `claude-multiacc/pool-selection.v2` and contains:
18
+
19
+ - a canonical database timestamp and reservation key;
20
+ - the policy (`default_claude`, `default_codex`, `default_both`, `explicit`,
21
+ `producer_retry`, or `reviewer`);
22
+ - any required provider, producer identity and explicit exclusions;
23
+ - the runner generation's candidate telemetry plus exact reservation history.
24
+
25
+ Candidates must be active, fresh, provider-capable, not currently limited, and not
26
+ covered by a live reservation. Percentages are finite decimals clamped to 0–100.
27
+ Canonical account IDs are NFC-normalized and trimmed with case preserved; an
28
+ NFC/case-folded key rejects case-only duplicate identities before ranking.
29
+ Known quota ranks ahead of unknown quota; higher minimum weekly/session headroom
30
+ wins; equal capacity goes to the least-recently-selected account, then a stable
31
+ Claude-before-Codex/account-id tie order.
32
+
33
+ Success returns the concrete engine/account/runner generation, normalized score basis,
34
+ an RFC 8785/SHA-256 candidate-snapshot digest and a selection digest. Stable failures
35
+ are `invalid_request`, `unsupported_schema`, `duplicate_candidate_identity`, and
36
+ `no_candidate`. A reviewer stays on the producer's provider and avoids its account when
37
+ another eligible account exists.
38
+
39
+ The selector does not read `~/.claude-accounts` or `~/.codex-accounts`. That would race
40
+ the panel's transaction and make the returned digest unprovable. The individual
41
+ `claude` and `codex` shims continue selecting directly inside their own pools for
42
+ ordinary interactive use.
@@ -0,0 +1,174 @@
1
+ """Canonical unified Claude/Codex account selection policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from decimal import Decimal, localcontext
7
+
8
+ from selector_primitives import (
9
+ SCHEMA,
10
+ SELECTOR_VERSION,
11
+ canonical_sha256,
12
+ collision_key,
13
+ decimal_value,
14
+ identity,
15
+ request_error,
16
+ timestamp,
17
+ )
18
+
19
+
20
+ def _parse_times(raw: dict) -> tuple[dict, set[str]]:
21
+ parsed: dict[str, tuple[datetime, str] | None] = {}
22
+ invalid: set[str] = set()
23
+ for field in ("seen_at", "limited_until", "last_selected_at", "resets_at"):
24
+ try:
25
+ parsed[field] = timestamp(raw[field]) if raw.get(field) is not None else None
26
+ except ValueError:
27
+ parsed[field] = None
28
+ invalid.add(field)
29
+ return parsed, invalid
30
+
31
+
32
+ def _reservation_state(history: dict, now: datetime) -> tuple[str | None, bool]:
33
+ value = history.get("active_expires_at")
34
+ if value is None:
35
+ return None, False
36
+ parsed, canonical = timestamp(value)
37
+ return canonical, parsed > now
38
+
39
+
40
+ def normalize_candidate(raw: dict, now: datetime, ordinal: int) -> dict:
41
+ runner, generation, engine, account, valid_identity = identity(raw)
42
+ weekly, weekly_text = decimal_value(raw.get("weekly_pct"))
43
+ session, session_text = decimal_value(raw.get("session_pct"))
44
+ quota_known = weekly is not None and session is not None
45
+ decimals = [value for value in (weekly, session) if value is not None]
46
+ precision = max([6] + [len(value.as_tuple().digits)
47
+ + max(0, -value.as_tuple().exponent) for value in decimals])
48
+ with localcontext() as context:
49
+ context.prec = precision + 3
50
+ weekly_left = Decimal(100) - weekly if weekly is not None else None
51
+ session_left = Decimal(100) - session if session is not None else None
52
+ effective = min(weekly_left, session_left) if quota_known else None
53
+ history = raw["reservation_history"]
54
+ timed = {**raw, "last_selected_at": history["last_selected_at"]}
55
+ parsed, invalid = _parse_times(timed)
56
+ active_expiry, reserved = _reservation_state(history, now)
57
+ seen, limited, last = parsed["seen_at"], parsed["limited_until"], parsed["last_selected_at"]
58
+ status = str(raw.get("status") or "").strip().lower()
59
+ conditions = [
60
+ ("invalid_identity", not valid_identity), ("status_not_active", status != "active"),
61
+ ("invalid_seen_at", "seen_at" in invalid),
62
+ ("future_telemetry", bool(seen and (seen[0] - now).total_seconds() > 30)),
63
+ ("stale_telemetry", bool(seen and (now - seen[0]).total_seconds() > 300)),
64
+ ("invalid_limited_until", "limited_until" in invalid),
65
+ ("account_limited", bool(limited and limited[0] > now)),
66
+ ("invalid_last_selected_at", "last_selected_at" in invalid),
67
+ ("future_last_selected_at", bool(last and (last[0] - now).total_seconds() > 30)),
68
+ ("provider_incapable", raw.get("provider_capable") is not True),
69
+ ("active_reservation", reserved)]
70
+ exclusion = next((code for code, applies in conditions if applies), None)
71
+ canon = lambda value: decimal_value(value)[1] if value is not None else None
72
+ return {
73
+ "input_ordinal": ordinal, "identity_valid": valid_identity, "runner_id": runner,
74
+ "runner_generation": generation, "engine": engine, "account_id": account, "status": status,
75
+ "weekly_pct": weekly_text, "session_pct": session_text, "quota_known": quota_known,
76
+ "effective_headroom": canon(effective), "weekly_remaining": canon(weekly_left),
77
+ "session_remaining": canon(session_left),
78
+ "resets_at": parsed["resets_at"][1] if parsed["resets_at"] else None,
79
+ "limited_until": limited[1] if limited else None, "seen_at": seen[1] if seen else None,
80
+ "provider_capable": raw.get("provider_capable") is True,
81
+ "reservation_history": {"active_expires_at": active_expiry,
82
+ "last_selected_at": last[1] if last else None},
83
+ "last_selected_at": last[1] if last else None, "eligible": exclusion is None,
84
+ "exclusion_code": exclusion}
85
+
86
+
87
+ def _policy_context(request: dict, rows: list[dict]) -> tuple[str | None, tuple, set[tuple], int]:
88
+ policy, required = request["policy"], request.get("required_engine")
89
+ if policy == "default_claude":
90
+ required = "claude"
91
+ elif policy == "default_codex":
92
+ required = "codex"
93
+ elif policy not in {"explicit", "producer_retry", "reviewer"}:
94
+ required = None
95
+ producer = identity(request.get("producer_identity") or {})[:4]
96
+ excluded = {identity(value)[:4] for value in request.get("excluded_identities", [])}
97
+ alternatives = sum(
98
+ row["eligible"] and (required is None or row["engine"] == required)
99
+ and _row_identity(row) != producer and _row_identity(row) not in excluded for row in rows)
100
+ return required, producer, excluded, alternatives
101
+
102
+
103
+ def _row_identity(row: dict) -> tuple:
104
+ return tuple(row[name] for name in ("runner_id", "runner_generation", "engine", "account_id"))
105
+
106
+
107
+ def _apply_policy(request: dict, rows: list[dict]) -> tuple[tuple, int]:
108
+ required, producer, excluded, alternatives = _policy_context(request, rows)
109
+ for row in rows:
110
+ key = _row_identity(row)
111
+ policy_excluded = required is not None and row["engine"] != required
112
+ policy_excluded |= key in excluded
113
+ policy_excluded |= request["policy"] == "reviewer" and alternatives > 0 and key == producer
114
+ if row["eligible"] and policy_excluded:
115
+ row["eligible"] = False
116
+ row["exclusion_code"] = "policy_excluded"
117
+ return producer, alternatives
118
+
119
+
120
+ def _winner_key(row: dict) -> tuple:
121
+ descending_headroom = Decimal(row["effective_headroom"] or "-1").copy_negate()
122
+ return (
123
+ not row["quota_known"], descending_headroom,
124
+ row["last_selected_at"] is not None, row["last_selected_at"] or "",
125
+ row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
126
+
127
+
128
+ def _snapshot_key(row: dict) -> tuple:
129
+ if row["identity_valid"]:
130
+ return 0, row["runner_id"], row["runner_generation"], row["engine"], row["account_id"]
131
+ return 1, row["input_ordinal"], 0, "", ""
132
+
133
+
134
+ def _error(code: str, detail: dict) -> dict:
135
+ return {"schema": SCHEMA, "selector_version": SELECTOR_VERSION, "ok": False,
136
+ "error_code": code, "error_detail": detail}
137
+
138
+
139
+ def select(request: object) -> dict:
140
+ if error := request_error(request):
141
+ return _error(error[0], error[1])
142
+ assert isinstance(request, dict)
143
+ now, canonical_now = timestamp(request["database_now"])
144
+ rows = [normalize_candidate(item, now, index)
145
+ for index, item in enumerate(request["candidates"])]
146
+ identities: dict[tuple, list[int]] = {}
147
+ for row in rows:
148
+ if row["identity_valid"]:
149
+ identities.setdefault(collision_key(_row_identity(row)), []).append(row["input_ordinal"])
150
+ duplicate = next((items for items in identities.values() if len(items) > 1), None)
151
+ if duplicate:
152
+ return _error("duplicate_candidate_identity", {"input_ordinals": duplicate})
153
+ producer, alternatives = _apply_policy(request, rows)
154
+ eligible = [row for row in rows if row["eligible"]]
155
+ if not eligible:
156
+ return _error("no_candidate", {"eligible_count": 0})
157
+ winner = min(eligible, key=_winner_key)
158
+ rows.sort(key=_snapshot_key)
159
+ snapshot = canonical_sha256(rows)
160
+ chosen = dict(zip(("runner_id", "runner_generation", "engine", "account_id"), _row_identity(winner)))
161
+ digest_input = {
162
+ "schema": SCHEMA, "selector_version": SELECTOR_VERSION,
163
+ "database_now": canonical_now, "policy": request["policy"],
164
+ "required_engine": request.get("required_engine"), "reservation_key": request["reservation_key"],
165
+ "candidate_snapshot_digest": snapshot, "selected_identity": chosen}
166
+ score_fields = ("quota_known", "weekly_pct", "session_pct", "weekly_remaining",
167
+ "session_remaining", "effective_headroom", "last_selected_at")
168
+ response = {"schema": SCHEMA, "selector_version": SELECTOR_VERSION, "ok": True, **chosen,
169
+ "score_basis": {name: winner[name] for name in score_fields},
170
+ "candidate_snapshot_digest": snapshot,
171
+ "selection_digest": canonical_sha256(digest_input)}
172
+ if request["policy"] == "reviewer" and not alternatives and _row_identity(winner) == producer:
173
+ response["fallback_reason"] = "sole_eligible_account"
174
+ return response