claude-multiacc 1.0.7 → 1.0.8

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
@@ -1,16 +1,23 @@
1
1
  # claude-multiacc
2
2
 
3
- Multi-account addon for Claude Code: every `claude` / `claude -p` invocation runs under a
3
+ Multi-account addon for **Claude Code AND OpenAI Codex CLI**: every `claude` /
4
+ `claude -p` invocation — and every `codex` / `codex exec` invocation — runs under a
4
5
  randomly picked **subscription** account with limit headroom (no API keys, ever). The
5
- account set mirrors automatically from the Mac (source of truth) to the deploy server.
6
+ account sets mirror automatically from the Mac (source of truth) to the deploy server.
6
7
 
7
- Installs as a PATH shim + helper CLI from this repo. **Never touches the Claude Code app**:
8
- no binary edits, no install-dir writes, survives `claude update` — the real binary is
9
- resolved dynamically at exec time.
8
+ Installs as PATH shims + helper CLIs from this repo. **Never touches the Claude Code or
9
+ Codex apps**: no binary edits, no install-dir writes, survives `claude update` /
10
+ `codex update` — the real binaries are resolved dynamically at exec time.
10
11
 
11
- Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207) and Ubuntu 24.04
12
- (bash 5.2, Claude Code 2.1.201). Test suite: `tests/run-tests.sh` (221 sandboxed tests,
13
- no network / no quota passes on both platforms).
12
+ One install covers both: the same `install.sh` / `npm i -g claude-multiacc` wires up
13
+ the `claude` shim + `claude-accounts` CLI (pool at `~/.claude-accounts`) and the
14
+ `codex` shim + `codex-accounts` CLI (pool at `~/.codex-accounts`). The two pools are
15
+ completely independent — separate manifests, credentials, telemetry, and logs — so
16
+ either provider can be used, re-authenticated, or emptied without touching the other.
17
+
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
20
+ quota — passes on both platforms).
14
21
 
15
22
  ---
16
23
 
@@ -130,6 +137,65 @@ an account, and if one slips through it returns to the pool by itself. Output is
130
137
  never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
131
138
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
132
139
 
140
+ ## Codex support (OpenAI Codex CLI)
141
+
142
+ The exact same machinery, instantiated a second time for Codex. Everything documented
143
+ above about the claude pool holds for the codex pool with these translations:
144
+
145
+ | Claude side | Codex side |
146
+ | --- | --- |
147
+ | `bin/claude` shim, `claude-accounts` CLI | `bin/codex` shim, `codex-accounts` CLI |
148
+ | pool `~/.claude-accounts`, server `/root/.claude-accounts` | pool `~/.codex-accounts`, server `/root/.codex-accounts` |
149
+ | `CLAUDE_CONFIG_DIR` per-account dirs | `CODEX_HOME` per-account dirs |
150
+ | `.credentials.json` (OAuth, machine-local) | `auth.json` (ChatGPT OAuth, machine-local) |
151
+ | `claude -p` auto-retry | `codex exec` auto-retry |
152
+ | Anthropic OAuth usage endpoint, per-model Fable bucket | `chatgpt.com/backend-api/codex/usage`, per-model buckets (e.g. `GPT-5.3-Codex-Spark:5h/7d`) |
153
+ | `CLAUDE_ACCOUNT` / `CLAUDE_SHIM_RETRY` / `CLAUDE_MULTIACC_DISABLE` / … | `CODEX_ACCOUNT` / `CODEX_SHIM_RETRY` / `CODEX_MULTIACC_DISABLE` / … |
154
+
155
+ Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
156
+ exclusion), same marker semantics (`.limited` cooldowns, `.expired` parks with
157
+ credential/policy scoping and soft expiry), same fail-open guarantees, same sync
158
+ safety guards. The `codex` shim engages the buffered auto-retry only for
159
+ `codex exec` runs with finite stdin, exactly like `-p` on the claude side.
160
+
161
+ Codex-specific notes:
162
+
163
+ - **Auth is the Codex device-code sign-in by default**: `codex-accounts add` prints
164
+ a URL + one-time code you can open in ANY browser (this machine, your laptop, a
165
+ phone), so it works identically on a local Mac, over SSH, and on servers — there
166
+ is no portable setup-token equivalent for Codex, so a server account is signed in
167
+ ON the server the same way. `--browser` opts into the localhost browser-callback
168
+ flow instead (only works when the browser runs on the same machine — the callback
169
+ goes to `localhost:1455`). Either way the login runs with `CODEX_HOME` pointed at
170
+ the account dir, and identity is verified offline from the id-token before
171
+ anything is registered.
172
+ - **`auth.json` is never synced** in either direction, for the same reason
173
+ `.credentials.json` never is: the refresh token rotates, and two machines
174
+ refreshing one grant strand each other. Sync pushes manifest + `config.toml`
175
+ seeds + advisory limit state only.
176
+ - **Windows are classified by length**, not by name: any window ≤6h counts as the
177
+ self-healing session signal, anything longer as durable/weekly. A hard
178
+ `limit_reached`/`allowed:false` verdict marks the account even if no window shows
179
+ ≥90%, and a reshaped payload is still scanned recursively for window-shaped
180
+ objects (fail open if nothing parses).
181
+ - **Idle-account telemetry** stays fresh the same way: a long-expired access token is
182
+ renewed via the OAuth refresh-token grant (`auth.openai.com/oauth/token`, the CLI's
183
+ own public client id) and the rotated credential is persisted 0600. Overrides for
184
+ tests: `CODEX_MULTIACC_TOKEN_URL`, `CODEX_MULTIACC_CLIENT_ID`,
185
+ `CODEX_MULTIACC_USAGE_URL`.
186
+ - **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
187
+ addon's no-API-keys rule.
188
+
189
+ ```bash
190
+ codex-accounts add # sign in a new ChatGPT account (device code —
191
+ # open the URL in any browser, enter the code)
192
+ codex-accounts add --browser # localhost browser-callback flow instead
193
+ codex-accounts import <email> --id acct-01 && codex-accounts adopt acct-01
194
+ # adopt this machine's existing ~/.codex login
195
+ codex-accounts list | status | expired | relogin | verify | limits | health | sync
196
+ claude-multiacc codex <cmd> # same commands via the npm wrapper
197
+ ```
198
+
133
199
  ## Install / update / uninstall
134
200
 
135
201
  ### npm (recommended)
@@ -308,6 +374,10 @@ failures are loud and non-zero.
308
374
  | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
309
375
  | `CLAUDE_ACCOUNTS_DIR=...` | relocate the pool (used by the test suite) |
310
376
 
377
+ The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
378
+ `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
379
+ `CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_DIR`, `CODEX_MULTIACC_THRESHOLD`.
380
+
311
381
  ## Verification
312
382
 
313
383
  ```bash
package/bin/cli.mjs CHANGED
@@ -42,24 +42,31 @@ function runAccounts(args) {
42
42
  return sh('bash', [join(ROOT, 'bin', 'claude-accounts'), ...args]);
43
43
  }
44
44
 
45
+ function runCodexAccounts(args) {
46
+ return sh('bash', [join(ROOT, 'bin', 'codex-accounts'), ...args]);
47
+ }
48
+
45
49
  const HELP = `claude-multiacc ${pkg.version}
46
50
 
47
- Every 'claude' / 'claude -p' runs under a randomly-picked subscription account with the
48
- most usage headroom; the account set mirrors to a deploy server. No API keys.
51
+ Every 'claude' / 'claude -p' AND every 'codex' / 'codex exec' runs under a
52
+ randomly-picked subscription account with the most usage headroom; the account sets
53
+ mirror to a deploy server. No API keys — Claude and ChatGPT subscription logins only.
49
54
 
50
55
  USAGE
51
56
  claude-multiacc [install] install or update the addon (idempotent)
52
57
  claude-multiacc uninstall [--purge-data]
53
58
  claude-multiacc self-update update to the latest npm release + re-install
54
- claude-multiacc doctor show environment + account health
59
+ claude-multiacc doctor show environment + account health (both pools)
55
60
  claude-multiacc <cmd> [args...] run a claude-accounts command, e.g.
56
61
  list | status | add <email> | login <acct-NN> |
57
62
  remove <acct-NN> | sync | verify | limits | health
63
+ claude-multiacc codex <cmd> [args...] same commands for the Codex pool
64
+ (runs codex-accounts <cmd>)
58
65
  claude-multiacc -v | --version
59
66
  claude-multiacc -h | --help
60
67
 
61
68
  INSTALL
62
- npm install -g claude-multiacc # postinstall wires up the PATH shim + schedules
69
+ npm install -g claude-multiacc # postinstall wires up the PATH shims + schedules
63
70
  # or, no global install:
64
71
  npx claude-multiacc # runs the installer once
65
72
 
@@ -91,8 +98,17 @@ switch (cmd) {
91
98
  console.log('Updated. Open a new shell to pick up any PATH changes.');
92
99
  break;
93
100
  }
94
- case 'doctor':
95
- status = runAccounts(['status']);
101
+ case 'doctor': {
102
+ console.log('== claude pool ==');
103
+ const claudeStatus = runAccounts(['status']);
104
+ console.log('\n== codex pool ==');
105
+ const codexStatus = runCodexAccounts(['status']);
106
+ status = claudeStatus || codexStatus;
107
+ break;
108
+ }
109
+ case 'codex':
110
+ // Codex-pool passthrough: claude-multiacc codex list | status | add | ...
111
+ status = argv.length > 1 ? runCodexAccounts(argv.slice(1)) : runCodexAccounts(['help']);
96
112
  break;
97
113
  case '-v':
98
114
  case '--version':
package/bin/codex ADDED
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env bash
2
+ # codex-multiacc-shim — PATH-shadows the real `codex` binary; never replaces or edits it.
3
+ # Every invocation runs under a randomly picked ChatGPT subscription account with limit
4
+ # headroom. Self-contained on purpose: no sourcing, so a broken repo file can never
5
+ # break `codex`.
6
+ # Selection: CODEX_HOME passthrough > CODEX_ACCOUNT pin > most-headroom among
7
+ # limit-eligible accounts > least-utilized fallback (degraded beats down).
8
+ # Accounts whose login is DEAD (a `.expired` marker from a failed refresh/verify/run)
9
+ # are never selected — not even as the all-limited fallback — because they fail every
10
+ # call outright; `codex-accounts expired` / `relogin` fix them.
11
+
12
+ set -u
13
+
14
+ # ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
15
+ # must still fail OPEN into plain passthrough, never abort on an unbound variable.
16
+ ACC_ROOT="${CODEX_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.codex-accounts}"
17
+ MANIFEST="$ACC_ROOT/accounts.json"
18
+
19
+ canon_path() {
20
+ local p="$1" t i=0 d b
21
+ case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
22
+ while [ -L "$p" ] && [ "$i" -lt 40 ]; do
23
+ t="$(readlink "$p")" || break
24
+ case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
25
+ i=$((i+1))
26
+ done
27
+ d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
28
+ b="$(basename "$p")"
29
+ if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
30
+ }
31
+
32
+ # 'multiacc-shim' matches this file AND bin/claude — a shim must never exec a shim.
33
+ is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }
34
+
35
+ SELF="$(canon_path "$0")"
36
+ SELF_DIR="$(dirname "$SELF")"
37
+
38
+ find_real() {
39
+ local cand c d
40
+ local oldifs="$IFS"
41
+ IFS=':'; set -f
42
+ # shellcheck disable=SC2086
43
+ set -- $PATH
44
+ IFS="$oldifs"; set +f
45
+ for d in "$@"; do
46
+ [ -n "$d" ] || continue
47
+ cand="$d/codex"
48
+ [ -f "$cand" ] && [ -x "$cand" ] || continue
49
+ c="$(canon_path "$cand")"
50
+ [ "$c" = "$SELF" ] && continue
51
+ case "$c" in "$ACC_ROOT"/*) continue ;; esac
52
+ is_shim_file "$c" && continue
53
+ printf '%s\n' "$cand"; return 0
54
+ done
55
+ # Fallbacks: resolved dynamically at exec time, so `codex update`/reinstalls keep working.
56
+ for cand in "${HOME:-/nonexistent}/.local/bin/codex" /usr/local/bin/codex /opt/homebrew/bin/codex /usr/bin/codex; do
57
+ [ -f "$cand" ] && [ -x "$cand" ] || continue
58
+ c="$(canon_path "$cand")"
59
+ [ "$c" = "$SELF" ] && continue
60
+ is_shim_file "$c" && continue
61
+ printf '%s\n' "$cand"; return 0
62
+ done
63
+ return 1
64
+ }
65
+
66
+ REAL="$(find_real)" || {
67
+ printf 'codex-multiacc shim: real codex binary not found (PATH or fallback locations)\n' >&2
68
+ exit 127
69
+ }
70
+
71
+ # Fast passthrough: caller pinned a config dir, addon disabled, recursion guard, or
72
+ # no account data yet. Byte-identical behavior to stock codex.
73
+ if [ -n "${CODEX_HOME:-}" ] \
74
+ || [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CODEX_SHIM_ACTIVE:-}" ] \
75
+ || [ ! -f "$MANIFEST" ]; then
76
+ exec "$REAL" "$@"
77
+ fi
78
+
79
+ now="$(date +%s)"
80
+
81
+ # Threshold used by the telemetry backstop below; the manifest is the source of
82
+ # truth, but a corrupt/unreadable manifest must never break selection => plain
83
+ # sed with a safe default, never a JSON parse.
84
+ if [ -z "${CODEX_MULTIACC_THRESHOLD:-}" ]; then
85
+ CODEX_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
86
+ case "$CODEX_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CODEX_MULTIACC_THRESHOLD=90 ;; esac
87
+ fi
88
+
89
+ if [ "$(uname -s)" = "Darwin" ]; then
90
+ file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
91
+ else
92
+ file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
93
+ fi
94
+
95
+ sel_log() {
96
+ printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log" 2>/dev/null || true
97
+ }
98
+
99
+ marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
100
+ local m="$1/.limited" reset=""
101
+ [ -f "$m" ] || return 1
102
+ IFS= read -r reset < "$m" 2>/dev/null || reset=""
103
+ case "$reset" in
104
+ ''|*[!0-9]*)
105
+ # Empty/partial/garbled marker — e.g. read during a concurrent rewrite.
106
+ # Treat as ACTIVE and never delete: deleting here could destroy a marker
107
+ # another process is mid-write. The next limits refresh rewrites or clears it.
108
+ return 0 ;;
109
+ esac
110
+ if [ "$now" -ge "$reset" ]; then
111
+ rm -f "$m" 2>/dev/null
112
+ return 1
113
+ fi
114
+ return 0
115
+ }
116
+
117
+ STALE_AFTER=900
118
+
119
+ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
120
+ local f="$1/limits.json" fetched v
121
+ [ -f "$f" ] || return 1
122
+ fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
123
+ case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
124
+ [ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
125
+ v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
126
+ case "$v" in ''|*[!0-9]*) return 1 ;; esac
127
+ printf '%s\n' "$v"
128
+ }
129
+
130
+ # RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
131
+ # window only refills on its multi-day reset, while the ~5h window self-heals, so
132
+ # session is a mild tiebreaker only (same reset asymmetry as the claude pool).
133
+ # score = weekly%*1000 + session% weekly,session in [0,100]
134
+ # Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free".
135
+ sel_score_of() { # $1 = acct dir
136
+ local w s
137
+ w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=50
138
+ s="$(fresh_field "$1" session_percent)" || s=50
139
+ printf '%s\n' $((w * 1000 + s))
140
+ }
141
+
142
+ # Backstop for a lost/failed marker write: fresh telemetry with ANY bucket at/over the
143
+ # threshold excludes the account even if .limited is missing. Stale/unreadable => not
144
+ # over (fail open — telemetry must never invent exclusions).
145
+ over_threshold() { # $1 = acct dir
146
+ local v
147
+ v="$(fresh_field "$1" max_percent)" || return 1
148
+ [ "$v" -ge "${CODEX_MULTIACC_THRESHOLD:-90}" ]
149
+ }
150
+
151
+ # Auth is a ChatGPT login: auth.json carrying a non-empty access token. An API-key-only
152
+ # auth.json is NOT auth here (subscription-only by design), and an empty file is NOT
153
+ # auth (an interrupted write must not make a dead account selectable).
154
+ has_auth() {
155
+ [ -s "$1/auth.json" ] \
156
+ && LC_ALL=C grep -q '"access_token"[[:space:]]*:[[:space:]]*"[^"]' "$1/auth.json" 2>/dev/null
157
+ }
158
+
159
+ # `.expired` is the persistent "this account needs a re-login" marker: written by
160
+ # codex-accounts (refresh grant expired/revoked, failed verify) and by the retry path
161
+ # below when a real call fails with an auth error. It SELF-HEALS: any auth.json written
162
+ # after the marker (successful re-login, or a refresh by another process) clears it.
163
+ # A marker written by the SHIM (a guess from one failed run) also carries
164
+ # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
165
+ # a misread never costs an account permanently. Markers written by codex-accounts —
166
+ # a refresh grant that answered invalid_grant, a failed real call — carry no soft_until
167
+ # and stay until the account provably works again.
168
+ expired_marked() { # $1 = acct dir
169
+ local m="$1/.expired" mt soft reason
170
+ [ -f "$m" ] || return 1
171
+ reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
172
+ # CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
173
+ # evidence they were about. A POLICY park (org-blocked: a workspace admin turned
174
+ # Codex off for the account) is about the account, not the credential: refreshing
175
+ # its token does not re-enable Codex, so only a passing real call or a re-login
176
+ # lifts it.
177
+ if [ "$reason" != "org-blocked" ]; then
178
+ mt="$(file_mtime "$m")"
179
+ if [ -f "$1/auth.json" ] && [ "$(file_mtime "$1/auth.json")" -gt "$mt" ]; then
180
+ rm -f "$m" 2>/dev/null
181
+ return 1
182
+ fi
183
+ fi
184
+ soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
185
+ case "$soft" in
186
+ ''|*[!0-9]*) return 0 ;; # no soft stamp => proven dead, keep
187
+ *) [ "$now" -lt "$soft" ] && return 0
188
+ rm -f "$m" 2>/dev/null # soft window elapsed: give it another go
189
+ return 1 ;;
190
+ esac
191
+ }
192
+
193
+ # Codex access-token expiry lives inside a JWT (not scrapeable with sed), so unlike
194
+ # the claude shim there is no plaintext creds_dead check here: the codex CLI refreshes
195
+ # a stale token itself at startup, and a DEAD refresh grant is detected by the limits
196
+ # refresher / verify / the retry path below — all of which write `.expired`.
197
+ auth_dead() { expired_marked "$1"; }
198
+
199
+ # Explicit pin wins over everything — markers, and even missing auth: the
200
+ # add/login ceremony pins to a dir that has no credentials yet, and the login
201
+ # must land exactly there, never in a randomly selected account's dir.
202
+ if [ -n "${CODEX_ACCOUNT:-}" ]; then
203
+ d="$ACC_ROOT/$CODEX_ACCOUNT"
204
+ if [ -d "$d" ]; then
205
+ sel_log "$CODEX_ACCOUNT pinned pwd=$PWD"
206
+ export CODEX_HOME="$d"
207
+ export CODEX_SHIM_ACTIVE=1
208
+ exec "$REAL" "$@"
209
+ fi
210
+ sel_log "pin-invalid account=$CODEX_ACCOUNT (no such dir; random fallback)"
211
+ fi
212
+
213
+ valid=()
214
+ eligible=()
215
+ expired=()
216
+ for d in "$ACC_ROOT"/acct-*; do
217
+ [ -d "$d" ] || continue
218
+ has_auth "$d" || continue
219
+ # Expired logins are excluded BEFORE anything else: unlike a limit marker (degraded
220
+ # but working), dead auth guarantees a hard failure, so it can never be the
221
+ # "degraded beats down" fallback either.
222
+ if auth_dead "$d"; then
223
+ expired+=("$d")
224
+ continue
225
+ fi
226
+ valid+=("$d")
227
+ marker_active "$d" && continue
228
+ over_threshold "$d" && continue
229
+ eligible+=("$d")
230
+ done
231
+
232
+ if [ "${#expired[@]}" -gt 0 ]; then
233
+ ids=""
234
+ for d in "${expired[@]}"; do ids="$ids $(basename "$d")"; done
235
+ sel_log "skipped-expired:$ids (unusable — see: codex-accounts expired)"
236
+ # One actionable line, at most hourly, and only on a terminal — a service-spawned
237
+ # `codex exec` must keep its stderr byte-clean.
238
+ if [ -t 2 ]; then
239
+ n="$ACC_ROOT/.expired-notice"
240
+ last=0
241
+ [ -f "$n" ] && last="$(file_mtime "$n")"
242
+ if [ $((now - last)) -gt 3600 ]; then
243
+ : > "$n" 2>/dev/null || true
244
+ printf 'codex-multiacc: %s account(s) unusable (%s) — see: codex-accounts expired\n' \
245
+ "${#expired[@]}" "${ids# }" >&2
246
+ fi
247
+ fi
248
+ fi
249
+
250
+ # No usable accounts => stock behavior (fail open, never block work), but say WHY when
251
+ # the pool is merely un-authenticated: this is the one case the user can actually fix.
252
+ if [ "${#valid[@]}" -eq 0 ]; then
253
+ if [ "${#expired[@]}" -gt 0 ]; then
254
+ sel_log "all-expired: falling back to the default login (see: codex-accounts expired)"
255
+ # Terminal only: a service-spawned `codex exec` must keep its stderr byte-clean,
256
+ # and the fallback may well succeed on the machine's own login.
257
+ [ -t 2 ] && printf 'codex-multiacc: no pool account is usable (%s) — see: codex-accounts expired, then: codex-accounts relogin\n' \
258
+ "${ids# }" >&2
259
+ fi
260
+ exec "$REAL" "$@"
261
+ fi
262
+
263
+ # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
264
+ # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
265
+ # Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
266
+ # user's codex arguments.
267
+ PICK_DIR=""
268
+ PICK_SCORE=""
269
+ pick_best() { # args: candidate dirs
270
+ local d v best="" bestv=1000000 ties=1
271
+ for d in "$@"; do
272
+ v="$(sel_score_of "$d")"
273
+ if [ "$v" -lt "$bestv" ]; then
274
+ bestv="$v"; best="$d"; ties=1
275
+ elif [ "$v" -eq "$bestv" ]; then
276
+ ties=$((ties + 1))
277
+ [ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
278
+ fi
279
+ done
280
+ PICK_DIR="$best"
281
+ PICK_SCORE="$bestv"
282
+ }
283
+
284
+ if [ "${#eligible[@]}" -gt 0 ]; then
285
+ if [ "${CODEX_SHIM_SELECT:-headroom}" = "random" ]; then
286
+ PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
287
+ else
288
+ pick_best "${eligible[@]}"
289
+ fi
290
+ else
291
+ # Every account is limit-marked: degraded service beats a hard failure (100% rule).
292
+ pick_best "${valid[@]}"
293
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
294
+ fi
295
+ pick="$PICK_DIR"
296
+
297
+ # Opportunistic limits refresh: non-blocking, throttled, backgrounded.
298
+ kick="$ACC_ROOT/.limits-kick"
299
+ stale=0
300
+ for d in "${valid[@]}"; do
301
+ f="$d/limits.json"
302
+ if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
303
+ done
304
+ if [ "$stale" = 1 ] && [ -x "$SELF_DIR/codex-accounts" ]; then
305
+ last=0
306
+ [ -f "$kick" ] && last="$(file_mtime "$kick")"
307
+ if [ $((now - last)) -gt 120 ]; then
308
+ : > "$kick" 2>/dev/null || true
309
+ ( "$SELF_DIR/codex-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
310
+ fi
311
+ fi
312
+
313
+ acct="$(basename "$pick")"
314
+ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
315
+
316
+ export CODEX_SHIM_ACTIVE=1
317
+
318
+ # Auto-retry applies only to `codex exec` runs with an alternative account available,
319
+ # and only when stdin is finite (tty, regular file, or char device like /dev/null).
320
+ # A service-spawned pipe that never EOFs must take the plain exec path, or the
321
+ # stdin pre-buffering below would hang the call.
322
+ wants_retry=0
323
+ if [ "${CODEX_SHIM_RETRY:-1}" != "0" ] && [ "${#eligible[@]}" -ge 2 ]; then
324
+ for a in "$@"; do
325
+ case "$a" in exec|e) wants_retry=1; break ;; esac
326
+ done
327
+ if [ "$wants_retry" = "1" ]; then
328
+ # A TTY cannot be buffered or replayed: `codex exec` with no prompt argument reads
329
+ # stdin, and the retry path would hand it /dev/null. Plain exec instead —
330
+ # stdin is inherited untouched. A pipe that never EOFs would hang the pre-buffer,
331
+ # so only finite stdin (regular file, /dev/null-style char device) takes the retry
332
+ # path; everything else execs directly.
333
+ if [ -t 0 ]; then
334
+ wants_retry=0
335
+ elif [ -f /dev/fd/0 ] || [ -c /dev/fd/0 ]; then
336
+ :
337
+ else
338
+ wants_retry=0
339
+ fi
340
+ fi
341
+ fi
342
+
343
+ if [ "$wants_retry" = "0" ]; then
344
+ export CODEX_HOME="$pick"
345
+ exec "$REAL" "$@"
346
+ fi
347
+
348
+ # Retry path: buffer stdio so a retried call never double-emits partial output.
349
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
350
+ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
351
+ export CODEX_HOME="$pick"
352
+ exec "$REAL" "$@"
353
+ }
354
+ trap 'rm -rf "$tmpd"' EXIT
355
+
356
+ # The output buffers must be writable BEFORE the run: if redirection failed at exec
357
+ # time (disk full), the real binary would never launch and the shim would exit
358
+ # nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
359
+ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
360
+ export CODEX_HOME="$pick"
361
+ exec "$REAL" "$@"
362
+ fi
363
+
364
+ stdin_file=""
365
+ if [ ! -t 0 ]; then
366
+ stdin_file="$tmpd/in"
367
+ # If buffering fails midway (disk full) stdin is already partly consumed and cannot
368
+ # be rewound — keep whatever landed rather than silently substituting /dev/null.
369
+ cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
370
+ fi
371
+
372
+ # ERRPAT decides whether to RETRY at all (deliberately broad). The two PARK patterns
373
+ # below decide whether the failed account is also taken out of the pool, and they are
374
+ # deliberately NARROW: this grep also sees the model's own answer on stdout (an exec
375
+ # run that merely *discusses* a 401 must not cost an account).
376
+ # PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
377
+ # is parked until a re-login / a refresh that works clears it.
378
+ # PARK_ORG — a workspace admin turned Codex access off for the account; no
379
+ # re-login fixes that.
380
+ # Both shim-written parks carry a soft_until stamp, so even a false positive returns to
381
+ # the pool on its own — the shim's guess must never outlive the evidence for it.
382
+ PARK_AUTH='not (logged|signed) in|authentication (required|failed)|please run `?codex login|run `?codex login`? to|401 unauthorized|token .{0,12}(expired|revoked)|refresh token.{0,20}(expired|invalid|revoked)|invalid_grant|could not refresh'
383
+ PARK_ORG='disabled by (your )?(workspace )?admin|admin (has )?disabled|(workspace|organization) has disabled (codex|chatgpt)|codex.{0,20}disabled for (your|this) (workspace|organization)'
384
+ LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|too many requests|"?429"?|quota exceeded|hit your usage limit'
385
+ ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
386
+ PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
387
+ PARK_SOFT_ORG=21600 # 6h: a workspace policy will not change in minutes
388
+
389
+ attempt=1
390
+ cur="$pick"
391
+ rc=0
392
+ while :; do
393
+ if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
394
+ CODEX_HOME="$cur" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
395
+ rc=$?
396
+ exec 3<&-
397
+ if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
398
+ && grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
399
+ # Atomic marker writes: a reader must never observe a half-written marker
400
+ # (it would parse as garbage and, before, could be deleted as "expired").
401
+ # A rate limit wins the classification: a limit message that happens to mention an
402
+ # auth word must get the self-expiring cooldown, never a park.
403
+ park_reason=""
404
+ park_soft=0
405
+ if grep -qiE "$LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
406
+ :
407
+ elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
408
+ park_reason="org-blocked"
409
+ park_detail="a workspace admin has disabled Codex access for the account"
410
+ park_soft=$((now + PARK_SOFT_ORG))
411
+ elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
412
+ park_reason="auth-error"
413
+ park_detail="run failed to authenticate"
414
+ park_soft=$((now + PARK_SOFT_AUTH))
415
+ fi
416
+ if [ -n "$park_reason" ]; then
417
+ {
418
+ echo "$now"
419
+ echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
420
+ } > "$cur/.expired.$$" 2>/dev/null \
421
+ && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
422
+ || rm -f "$cur/.expired.$$" 2>/dev/null || true
423
+ sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: codex-accounts expired"
424
+ else
425
+ {
426
+ echo $((now + 600))
427
+ echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
428
+ } > "$cur/.limited.$$" 2>/dev/null \
429
+ && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
430
+ || rm -f "$cur/.limited.$$" 2>/dev/null || true
431
+ fi
432
+ next=""
433
+ n="${#eligible[@]}"
434
+ start=$((RANDOM % n))
435
+ i=0
436
+ while [ "$i" -lt "$n" ]; do
437
+ c="${eligible[$(((start + i) % n))]}"
438
+ if [ "$c" != "$cur" ]; then next="$c"; break; fi
439
+ i=$((i+1))
440
+ done
441
+ if [ -n "$next" ]; then
442
+ sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
443
+ cur="$next"
444
+ attempt=2
445
+ continue
446
+ fi
447
+ fi
448
+ break
449
+ done
450
+
451
+ cat "$tmpd/out"
452
+ cat "$tmpd/err" >&2
453
+ exit "$rc"