claude-multiacc 2.0.41 → 2.0.43

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.
Files changed (33) hide show
  1. package/README.md +60 -2
  2. package/bin/claude +414 -5
  3. package/bin/claude-accounts +36 -11
  4. package/bin/codex +406 -4
  5. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  6. package/docs/AUTORESUME.md +321 -0
  7. package/docs/CODEX.md +12 -1
  8. package/docs/VERIFICATION.md +9 -1
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/autoresume.py +2271 -0
  20. package/lib/common.sh +24 -1
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  30. package/tests/fake_security.sh +90 -0
  31. package/tests/run-tests.sh +1178 -67
  32. package/tests/test_autoresume.py +2565 -0
  33. package/tests/test_keychain.py +323 -50
package/README.md CHANGED
@@ -160,7 +160,9 @@ fails open into stock `claude`, and logs why to `selection.log`.
160
160
  The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
161
161
  `selection.log`, and `exec`s the real binary — stdin/stdout/exit codes pass through
162
162
  byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
163
- even an unset `HOME`) it fails **open** into plain passthrough.
163
+ even an unset `HOME`) it fails **open** into plain passthrough. In tmux, an interactive
164
+ launch also starts a detached [auto-resume](#auto-resume) watcher just before that `exec`;
165
+ the `exec` itself does not change.
164
166
 
165
167
  **Limit-aware marking.** `claude-accounts limits` (every 15 min via launchd on the Mac,
166
168
  cron on the server, plus an opportunistic non-blocking kick from the shim when data is
@@ -272,6 +274,17 @@ account's `.credentials.json` (0600), or its macOS Keychain item.
272
274
  > login made over ssh migrates into the Keychain the first time a GUI-session process
273
275
  > refreshes its token — the pool reads both places (`lib/keychain.py`), and an account
274
276
  > that must work from everywhere should carry a portable token (`claude-accounts mint`).
277
+ > The item's **account name** is the client's `$USER` — and a client started without USER
278
+ > files it under `unknown`, beside the older item, where the next client never looks. So
279
+ > `claude-accounts`/`codex-accounts` and the shim fill `USER`/`LOGNAME` from `id -un` when
280
+ > the caller's env has none, and the pool judges only the item under that canonical name —
281
+ > the one the client reads (one `security` call per lookup). A login found only under
282
+ > another name counts nowhere until the scheduled limits pass moves it under the canonical
283
+ > name. A token refresh writes back under the canonical name and drops the siblings it
284
+ > made stale (the same grant, a dead one) but keeps another live grant; a sign-in whose
285
+ > identity checked out prunes every sibling; `remove` deletes every item.
286
+ > `python3 lib/keychain.py prune <acct dir>` prunes by hand (only when the canonical item
287
+ > is a live login; it never writes).
275
288
  > Override: `CLAUDE_MULTIACC_KEYCHAIN=0` disables the lookup. This is what keeps idle accounts' telemetry fresh so they win
276
289
  selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
277
290
  account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
@@ -394,6 +407,45 @@ also sees the model's own answer: a `-p` run that merely *mentions* a 403 must n
394
407
  an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
395
408
  never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
396
409
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
410
+ Interactive sessions are not retried this way. They get auto-resume, described next.
411
+
412
+ ## Auto-resume
413
+
414
+ An interactive `claude` or `codex` session in **tmux** can stop on a usage limit, a failed
415
+ login, an API error or (claude) a crash. It is then **resumed automatically, same session
416
+ id**, on another pooled account with headroom. There is no `/exit`, no `--resume` and no
417
+ "continue" to type. It covers the everyday launches:
418
+
419
+ - `claude --dangerously-skip-permissions`, `--resume <id>` and `--model …`;
420
+ - `codex --dangerously-bypass-approvals-and-sandbox` and `codex resume …`.
421
+
422
+ Every other launch, including `-p` and `codex exec`, runs exactly as before.
423
+
424
+ **How it works.**
425
+
426
+ - **Launch.** The shim's `exec` is unchanged. Just before it, a detached watcher starts
427
+ (`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
428
+ rollout).
429
+ - **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
430
+ another account has headroom. If none does, it **holds** and touches nothing, and
431
+ Claude's own "continuing automatically at …" carries on.
432
+ - **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
433
+ prompt. It then types a one-line relaunch of the shim into that **shell**. It never
434
+ sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
435
+ `/limit-reset`.
436
+ - **Resume.** The relaunched shim parks the old account with the usual markers, then runs
437
+ normal selection minus the account it just left. The session resumes with a short
438
+ prompt telling the model it was restarted and should continue.
439
+
440
+ Every step appends an `autoresume <event>` line to `selection.log`.
441
+
442
+ ```bash
443
+ touch ~/.claude-accounts/autoresume.off # claude pool off, already-running sessions included
444
+ touch ~/.codex-accounts/autoresume.off # codex pool off
445
+ CLAUDE_MULTIACC_AUTORESUME=0 claude # one launch (CODEX_MULTIACC_AUTORESUME=0 codex)
446
+ ```
447
+
448
+ See [auto-resume: error classes, gates, budgets, logs and limitations](docs/AUTORESUME.md).
397
449
 
398
450
  ## MCP servers for every account
399
451
 
@@ -404,7 +456,7 @@ account, and a stock `claude mcp add` lands in **one random account**. That is h
404
456
  now owns a **registry**, `<pool>/mcp-servers.json`, that is reconciled into every account:
405
457
 
406
458
  ```bash
407
- claude-accounts mcp add appinspire-mcp -- npx -y appinspire-mcp@latest serve # both pools
459
+ claude-accounts mcp add docs-search -- node /path/docs-search.mjs serve # both pools
408
460
  claude-accounts mcp add --provider claude local-dev -e KEY=v -- node /path/server.mjs serve
409
461
  claude-accounts mcp add --scope project --project "$HOME/design-lab/run1" appinspire -e APPINSPIRE_LIBRARY_DIR="$HOME/.appinspire-mcp/library" -- node "$HOME/appinspire-mcp/bin/appinspire-mcp.mjs" serve
410
462
  claude-accounts mcp remove adspower-local-api # retire EVERYWHERE: every account, every project entry
@@ -441,6 +493,12 @@ claude-accounts mcp apply # re-apply now (repair drift)
441
493
  from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
442
494
  an agent): the session's config dir is a pool account, so the same mirror applies. A
443
495
  config dir outside the pool is plain passthrough.
496
+ - **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
497
+ each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
498
+ registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
499
+ the synced entry wins and every Mac launches one machine's Node and package paths. Never
500
+ `mcp remove` it either: the tombstone deletes the managed entry on every Mac. Drop a stray
501
+ registry entry without a tombstone, then `mcp apply` and `sync`.
444
502
  - **Replicas**: on a pool whose `sync-role` file says `replica`, `add`/`remove` and the
445
503
  shim's mirror land in the machine-local overlay (owner `local`) instead of the synced
446
504
  registry, because the source's next push would overwrite them. Make registry changes on
package/bin/claude CHANGED
@@ -12,6 +12,17 @@
12
12
 
13
13
  set -u
14
14
 
15
+ # Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
16
+ # USER calls the user "unknown" — so a caller whose env lacks it (app-robot's panel
17
+ # ceremony, 2026-09-24) gets a login filed under a name the next client never reads
18
+ # (lib/keychain.py). Every client this shim starts, passthrough included, inherits a
19
+ # real name; lib/common.sh does the same for claude-accounts.
20
+ if [ -z "${USER:-}" ]; then
21
+ USER="$(id -un 2>/dev/null || true)"
22
+ if [ -n "$USER" ]; then export USER; else unset USER; fi
23
+ fi
24
+ if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
25
+
15
26
  # ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
16
27
  # must still fail OPEN into plain passthrough, never abort on an unbound variable.
17
28
  # CLAUDE_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CLAUDE_ACCOUNTS_DIR
@@ -177,10 +188,14 @@ mcp_nested_mirror() {
177
188
  }
178
189
 
179
190
  # Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
180
- # guard, or no account data yet. Byte-identical behavior to stock claude.
191
+ # guard, or no account data yet. Byte-identical behavior to stock claude (a USER the
192
+ # caller left out is filled at the top — see there).
181
193
  if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
182
194
  || [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
183
195
  || [ ! -f "$MANIFEST" ]; then
196
+ # An auto-resume probe asks who WOULD be picked and must never start a client; a
197
+ # passthrough has no pool pick to report (see ---- auto-resume ----).
198
+ [ "${CLAUDE_MULTIACC_AR_PROBE:-}" = "1" ] && { printf 'pick= tier=none\n'; exit 3; }
184
199
  mcp_nested_mirror "$@" || true
185
200
  exec "$REAL" "$@"
186
201
  fi
@@ -213,6 +228,11 @@ case "${CLAUDE_MULTIACC_KEYCHAIN:-}" in
213
228
  *) if [ "$(uname -s)" = "Darwin" ]; then KC_ON=1; else KC_ON=0; fi ;;
214
229
  esac
215
230
  if [ "$KC_ON" = "1" ] && ! command -v security >/dev/null 2>&1; then KC_ON=0; fi
231
+ # The client reads ONLY the item under its own account name — $USER (filled above),
232
+ # anything outside [A-Za-z0-9._-] becoming "claude-code-user" — so that one item is what
233
+ # this shim judges: a sibling under another name is invisible to the client it launches.
234
+ KC_ACCOUNT="${USER:-}"
235
+ case "$KC_ACCOUNT" in ''|*[!A-Za-z0-9._-]*) KC_ACCOUNT=claude-code-user ;; esac
216
236
  kc_service() { # $1 = acct dir -> the keychain service name the client uses for it
217
237
  local h=""
218
238
  if command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
@@ -227,7 +247,7 @@ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but
227
247
  [ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
228
248
  if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
229
249
  local out rc
230
- out="$(security find-generic-password -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
250
+ out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
231
251
  KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
232
252
  case "$rc" in
233
253
  0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
@@ -239,7 +259,7 @@ kc_mtime() { # $1 = acct dir -> epoch of the item's last write (0 when none); at
239
259
  # are readable even when the secret is locked, so a marker still self-heals
240
260
  [ "$KC_ON" = "1" ] || { echo 0; return 0; }
241
261
  local stamp
242
- stamp="$(security find-generic-password -s "$(kc_service "$1")" 2>/dev/null \
262
+ stamp="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" 2>/dev/null \
243
263
  | LC_ALL=C sed -n 's/.*"mdat"<timedate>=0x[0-9A-Fa-f]* *"\([0-9]\{14\}\)Z.*/\1/p' | head -1)"
244
264
  case "$stamp" in
245
265
  [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])
@@ -262,6 +282,379 @@ sel_log() {
262
282
  printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
263
283
  }
264
284
 
285
+ # ---- auto-resume ----------------------------------------------------------------
286
+ # An interactive session that stops on a usage limit, a failed login, an API error or a
287
+ # crash is resumed — same session id, no keystroke — on another pooled account. The TUI
288
+ # at a limit does not exit; it idles on "continuing automatically at <reset>", which can
289
+ # be days away. The shim's share of the job is small on purpose and never blocks:
290
+ # - the plain interactive exec stays byte-identical; just before it, a detached watcher
291
+ # (lib/autoresume.py) is spawned for the pid the client is about to become;
292
+ # - when the watcher moves a session it stops the client, writes a single-use relaunch
293
+ # file and types ` CLAUDE_MULTIACC_AR=<token>:<sid> [CLAUDE_ACCOUNTS_ROOT=<root>] <this
294
+ # shim's path>` into the pane's SHELL — never into the TUI, whose limit menu can "Add
295
+ # funds" or spend the one-shot /limit-reset — and that command lands HERE: the token
296
+ # names the file, which holds the verdict to mark on the old account BEFORE selection,
297
+ # the argv to resume with, and the chain state (depth/avoid/hist) the next watcher
298
+ # carries on; the pool root is typed only when it is not the default one;
299
+ # - a probe run (CLAUDE_MULTIACC_AR_PROBE=1) is how the watcher asks "who would be
300
+ # picked now?" — the real candidate loop, then `pick=<acct> tier=<t>` and exit, so the
301
+ # watcher only kills a session there is somewhere to move to.
302
+ # Everything fails OPEN: a missing python, a non-tmux terminal or an argv this code does
303
+ # not recognise all mean "exec exactly as before, unsupervised". The one exception is a
304
+ # typed relaunch whose token is gone: it names the session to resume and exits 2, because
305
+ # its own (empty) argv would start a fresh session in the stopped one's place.
306
+ # Tokens and relaunch/state files live in $ACC_ROOT/tmp/autoresume; the watcher prunes.
307
+ AR_DIR="$ACC_ROOT/tmp/autoresume"
308
+ AR_PROBE=0
309
+ [ "${CLAUDE_MULTIACC_AR_PROBE:-}" = "1" ] && AR_PROBE=1
310
+ AR_DEPTH=0 # relaunches so far in this chain (the watcher caps it)
311
+ AR_AVOID="" # acct-NN:<until epoch>,... — accounts this chain just left
312
+ AR_HIST="" # class:epoch,... — the watcher's hourly budgets
313
+ AR_CHAIN="" # opaque chain id, minted and read by the watcher
314
+ AR_LOADED=0
315
+ AR_ARGV=()
316
+ AR_ORIG_ARGV=()
317
+ AR_R_CLASS="" AR_R_ACCT="" AR_R_RESET="" AR_R_RTYPE="" AR_R_MARKED="" AR_R_SID=""
318
+ AR_TRUST=0 # this run continues a relaunch: its cwd is trusted in the picked account
319
+ AR_PY=""
320
+
321
+ # A chain field copied from a file (or the probe's env) into the next state file: one
322
+ # line, a narrow charset, bounded. Anything else is dropped — the chain restarts, and
323
+ # nothing but the watcher's budgets depends on it.
324
+ ar_field_ok() { # $1 value
325
+ [ "${#1}" -le 2048 ] || return 1
326
+ case "$1" in *[!A-Za-z0-9:,._-]*) return 1 ;; esac
327
+ return 0
328
+ }
329
+ # The probe gets the watcher's AVOID list from its env; a normal run only ever takes it
330
+ # from a relaunch file.
331
+ [ "$AR_PROBE" = 1 ] && ar_field_ok "${CLAUDE_MULTIACC_AR_AVOID:-}" \
332
+ && AR_AVOID="${CLAUDE_MULTIACC_AR_AVOID:-}"
333
+
334
+ # Load a relaunch token. Runs BEFORE RUN_MODEL reads argv below (the relaunch argv may
335
+ # carry --model) and before anything else consults $PWD. sess_id_ok and the markers are
336
+ # defined further down, so the id class is spelled inline and the verdict is applied
337
+ # later, by ar_relaunch_apply, still ahead of any selection.
338
+ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields set
339
+ local tok="$1" rf af line k v n=0 a="" ok=0 ver="" prov="" sid="" cwd=""
340
+ local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain=""
341
+ # Alphanumerics only: the token becomes part of a path, so no '/' and no '..'.
342
+ case "$tok" in *[!A-Za-z0-9]*) return 1 ;; esac
343
+ [ "${#tok}" -ge 8 ] && [ "${#tok}" -le 64 ] || return 1
344
+ rf="$AR_DIR/r-$tok.relaunch"
345
+ af="$AR_DIR/r-$tok.argv"
346
+ # -f before every read: a FIFO planted under that name would block the read forever,
347
+ # a hang before exec. Unknown keys are ignored; known ones are validated one by one.
348
+ if [ -f "$rf" ]; then
349
+ while IFS= read -r line || [ -n "$line" ]; do
350
+ n=$((n + 1)); [ "$n" -le 64 ] || break
351
+ case "$line" in *=*) ;; *) line=""; continue ;; esac
352
+ k="${line%%=*}"; v="${line#*=}"; line=""
353
+ case "$k" in
354
+ v) ver="$v" ;;
355
+ provider) prov="$v" ;;
356
+ class) case "$v" in ''|*[!a-z_]*) ;; *) cls="$v" ;; esac ;;
357
+ acct) case "$v" in acct-*[!0-9]*|acct-) ;; acct-*) acct="$v" ;; esac ;;
358
+ reset) num_ok "$v" && reset="$v" ;;
359
+ rtype) case "$v" in five_hour|seven_day) rtype="$v" ;; esac ;;
360
+ marked) case "$v" in ''|*[!0-9TZ:.+-]*) ;; *) marked="$v" ;; esac ;;
361
+ sid) case "$v" in ''|-*|*[!0-9a-fA-F-]*) ;; *) sid="$v" ;; esac ;;
362
+ cwd) case "$v" in /*) cwd="$v" ;; esac ;;
363
+ depth) num_ok "$v" && depth="$v" ;;
364
+ avoid) ar_field_ok "$v" && avoid="$v" ;;
365
+ hist) ar_field_ok "$v" && hist="$v" ;;
366
+ chain) ar_field_ok "$v" && [ "${#v}" -le 64 ] && chain="$v" ;;
367
+ esac
368
+ done < "$rf"
369
+ if [ "$ver" = 1 ] && [ "$prov" = claude ] && [ -f "$af" ] \
370
+ && [ $((now - $(file_mtime "$rf"))) -le 600 ]; then
371
+ AR_ARGV=()
372
+ while IFS= read -r -d '' a; do
373
+ AR_ARGV+=("$a"); a=""
374
+ [ "${#AR_ARGV[@]}" -lt 512 ] || break
375
+ done < "$af"
376
+ # A writer that omits the final NUL still means its last element.
377
+ [ -n "$a" ] && AR_ARGV+=("$a")
378
+ [ "${#AR_ARGV[@]}" -gt 0 ] && ok=1
379
+ fi
380
+ fi
381
+ # Single use, whatever the outcome: a token is never honoured twice.
382
+ rm -f "$rf" "$af" 2>/dev/null
383
+ if [ "$ok" != 1 ]; then
384
+ AR_ARGV=()
385
+ AR_R_SID="$sid"
386
+ return 1
387
+ fi
388
+ AR_LOADED=1
389
+ AR_R_CLASS="$cls"; AR_R_ACCT="$acct"; AR_R_RESET="$reset"; AR_R_RTYPE="$rtype"
390
+ AR_R_MARKED="$marked"
391
+ AR_DEPTH="$depth"; AR_HIST="$hist"; AR_CHAIN="$chain"; AR_AVOID="$avoid"
392
+ # The pane's shell sits wherever the session was launched, but the session's own cwd is
393
+ # what --resume looks the transcript up by. Everything already resolved against the old
394
+ # directory (a relative pool root or PATH entry) has to survive the cd.
395
+ if [ -n "$cwd" ] && [ -d "$cwd" ] && [ "$cwd" != "$PWD" ]; then
396
+ case "$ACC_ROOT" in /*) ;; *) ACC_ROOT="$PWD/$ACC_ROOT"; MANIFEST="$ACC_ROOT/accounts.json"; AR_DIR="$ACC_ROOT/tmp/autoresume" ;; esac
397
+ case "$REAL" in /*) ;; *) REAL="$PWD/$REAL" ;; esac
398
+ cd -- "$cwd" 2>/dev/null || true
399
+ fi
400
+ return 0
401
+ }
402
+
403
+ # A typed relaunch whose token cannot be honoured (missing, expired, malformed). The
404
+ # watcher has already stopped the session and the typed line carries no argv of its own,
405
+ # so running on would open a FRESH session in its place: say how to get it back, and stop.
406
+ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
407
+ local sid="$1"
408
+ case "$sid" in
409
+ ''|-*|*[!0123456789abcdefABCDEF-]*) sid="" ;;
410
+ *-*) [ "${#sid}" -le 64 ] || sid="" ;;
411
+ *) sid="" ;;
412
+ esac
413
+ sel_log "autoresume expired sid=${sid:--}"
414
+ if [ -n "$sid" ]; then
415
+ printf 'claude-multiacc: auto-resume could not continue automatically (the resume token expired). Resume with: claude --resume %s\n' "$sid" >&2
416
+ else
417
+ printf 'claude-multiacc: auto-resume could not continue automatically (the resume token expired).\n' >&2
418
+ fi
419
+ exit 2
420
+ }
421
+
422
+ # The verdict the watcher saw, written through the SAME writers the transcript scans use
423
+ # (never shortening a longer park), so the old account is out of the pool before this
424
+ # very selection runs — and for every other launch too. Other classes (model-scoped,
425
+ # transient, blocked, crash) leave no marker: the watcher's AVOID list covers them.
426
+ ar_relaunch_apply() {
427
+ local d=""
428
+ [ "$AR_LOADED" = 1 ] || return 0
429
+ [ -n "$AR_R_ACCT" ] && [ -d "$ACC_ROOT/$AR_R_ACCT" ] && d="$ACC_ROOT/$AR_R_ACCT"
430
+ if [ -n "$d" ]; then
431
+ case "$AR_R_CLASS" in
432
+ quota)
433
+ if num_ok "$AR_R_RESET" && [ "$AR_R_RESET" -gt "$now" ] && [ -n "$AR_R_RTYPE" ]; then
434
+ mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
435
+ fi ;;
436
+ auth) mark_client_auth_dead "$d" ;;
437
+ esac
438
+ fi
439
+ # Field 2 is the word `autoresume`, never an account id: lib/report.py and the
440
+ # *-accounts tools read an acct-NN in field 2 as a pick.
441
+ sel_log "autoresume relaunch chain=${AR_CHAIN:--} from=${AR_R_ACCT:--}" \
442
+ "class=${AR_R_CLASS:--} depth=$AR_DEPTH"
443
+ return 0
444
+ }
445
+
446
+ # AVOID: accounts this chain just left, each until its own epoch. Filters `eligible` only
447
+ # — never `valid` — so a pool with nowhere else to go still reaches the all-limited
448
+ # fallback exactly as before. Builtins only: this runs once per candidate.
449
+ ar_avoided() { # $1 acct dir
450
+ local rest e id until
451
+ [ -n "$AR_AVOID" ] || return 1
452
+ rest="$AR_AVOID,"
453
+ while [ -n "$rest" ]; do
454
+ e="${rest%%,*}"; rest="${rest#*,}"
455
+ id="${e%%:*}"; until="${e#*:}"
456
+ [ "$id" = "${1##*/}" ] || continue
457
+ num_ok "$until" && [ "$until" -gt "$now" ] && return 0
458
+ done
459
+ return 1
460
+ }
461
+
462
+ # Probe answer, and the end of a probe run. `none` (exit 3) means nothing would be picked
463
+ # at all; otherwise the tier says which cut the pick came from.
464
+ ar_probe_exit() { # $1 = none, or empty to derive the tier from the selection just made
465
+ local t="${1:-}"
466
+ if [ -z "$t" ]; then
467
+ if [ "${#eligible[@]}" -gt 0 ]; then t=eligible
468
+ elif [ "${#soft[@]}" -gt 0 ]; then t=soft
469
+ else t=hard; fi
470
+ fi
471
+ if [ "$t" = none ] || [ -z "${pick:-}" ]; then
472
+ printf 'pick= tier=none\n'
473
+ exit 3
474
+ fi
475
+ printf 'pick=%s tier=%s\n' "${pick##*/}" "$t"
476
+ exit 0
477
+ }
478
+
479
+ # The watcher only understands sessions it can resume faithfully: an interactive TUI with
480
+ # these flags and at most one prompt. A single word may be a subcommand (`claude doctor`,
481
+ # `claude mcp …`), so a positional counts as a prompt only when it contains a space.
482
+ # Anything else — -p, --fork-session, a bare --resume picker — runs unsupervised.
483
+ ar_argv_ok() { # args: the session's argv
484
+ local a want="" pos=0
485
+ for a in "$@"; do
486
+ if [ -n "$want" ]; then
487
+ case "$want" in
488
+ uuid) sess_id_ok "$a" || return 1
489
+ case "$a" in *-*) ;; *) return 1 ;; esac ;;
490
+ *) case "$a" in ''|-*) return 1 ;; esac ;;
491
+ esac
492
+ want=""
493
+ continue
494
+ fi
495
+ case "$a" in
496
+ --dangerously-skip-permissions|--allow-dangerously-skip-permissions|-c|--continue) ;;
497
+ -r|--resume) want=uuid ;;
498
+ --resume=*) a="${a#--resume=}"
499
+ sess_id_ok "$a" || return 1
500
+ case "$a" in *-*) ;; *) return 1 ;; esac ;;
501
+ --model|--effort|--permission-mode) want=value ;;
502
+ --model=*) [ -n "${a#--model=}" ] || return 1 ;;
503
+ -*) return 1 ;;
504
+ *' '*) pos=$((pos + 1)); [ "$pos" -le 1 ] || return 1 ;;
505
+ *) return 1 ;;
506
+ esac
507
+ done
508
+ [ -z "$want" ]
509
+ }
510
+
511
+ # The relaunch line is typed into the pane's shell unquoted — this shim's own path, and the
512
+ # pool root when it is not the default — so only paths that never need quoting qualify.
513
+ # Spelled out, never ranges: under a UTF-8 locale bash matches `[a-z]` by collation.
514
+ ar_path_ok() { # $1 absolute path
515
+ case "$1" in
516
+ /*) ;;
517
+ *) return 1 ;;
518
+ esac
519
+ case "$1" in
520
+ *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/._+-]*) return 1 ;;
521
+ esac
522
+ return 0
523
+ }
524
+
525
+ # A python the watcher can run on. /usr/bin/python3 on a Mac without developer tools is a
526
+ # stub that pops an "install the command line tools" dialog and fails — only trusted when
527
+ # what it forwards to is there. File tests only: xcode-select itself would be a fork.
528
+ ar_python() {
529
+ local py="${CLAUDE_MULTIACC_PYTHON:-}"
530
+ if [ -n "$py" ] && [ -f "$py" ] && [ -x "$py" ]; then AR_PY="$py"; return 0; fi
531
+ py="$(command -v python3 2>/dev/null)" || return 1
532
+ case "$py" in /*) ;; *) return 1 ;; esac
533
+ if [ "$py" = /usr/bin/python3 ]; then
534
+ case "${OSTYPE:-}" in
535
+ darwin*)
536
+ [ -x /Library/Developer/CommandLineTools/usr/bin/python3 ] \
537
+ || [ -x /var/db/xcode_select_link/usr/bin/python3 ] \
538
+ || [ -x "${DEVELOPER_DIR:-/nonexistent}/usr/bin/python3" ] \
539
+ || return 1 ;;
540
+ esac
541
+ fi
542
+ AR_PY="$py"
543
+ }
544
+
545
+ ar_start_watcher() { # $1 provider, $2 picked acct dir
546
+ local prov="$1" dir="$2" lib st tmp launched root nl=$'\n'
547
+ # Kill switches: the env for one shell, the file for the whole pool (the watcher
548
+ # re-reads the file every tick, so it also stops sessions already running).
549
+ case "${CLAUDE_MULTIACC_AUTORESUME:-1}" in 0|false|no|off) return 1 ;; esac
550
+ [ -e "$ACC_ROOT/autoresume.off" ] && return 1
551
+ # A pin is the caller overriding selection. A valid one execs long before this; an
552
+ # invalid one fell through to a random pick, and moving that session is not ours to do.
553
+ [ -z "${CLAUDE_ACCOUNT:-}" ] || return 1
554
+ if [ "${CLAUDE_MULTIACC_AR_TEST_TTY:-}" != 1 ]; then
555
+ [ -t 0 ] && [ -t 1 ] || return 1
556
+ fi
557
+ # The relaunch is typed into the pane's shell: no tmux, nothing to type into.
558
+ [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ] || return 1
559
+ ar_argv_ok ${AR_ORIG_ARGV[@]+"${AR_ORIG_ARGV[@]}"} || return 1
560
+ ar_python || return 1
561
+ lib="$SELF_DIR/../lib/autoresume.py"
562
+ [ -f "$lib" ] || return 1
563
+ # The state file is key=value lines; a value with a newline cannot be carried.
564
+ case "$PWD$dir$ACC_ROOT$SELF$TMUX$TMUX_PANE" in *"$nl"*) return 1 ;; esac
565
+ # The watcher and the relaunch run elsewhere: hand them absolute pool paths.
566
+ root="$ACC_ROOT"
567
+ case "$root" in /*) ;; *) root="$PWD/$root"; dir="$PWD/$dir" ;; esac
568
+ ar_path_ok "$SELF" && ar_path_ok "$root" || return 1
569
+ [ -d "$AR_DIR" ] || mkdir -p "$AR_DIR" 2>/dev/null || return 1
570
+ # The ORIGINAL argv (before the fallback-model pin): the watcher rebuilds the relaunch
571
+ # from it, and the relaunch re-runs selection, which pins again for the NEW pick.
572
+ if [ "${#AR_ORIG_ARGV[@]}" -gt 0 ]; then
573
+ printf '%s\0' "${AR_ORIG_ARGV[@]}" 2>/dev/null > "$AR_DIR/$$.argv" || return 1
574
+ else
575
+ : 2>/dev/null > "$AR_DIR/$$.argv" || return 1
576
+ fi
577
+ launched="$(date +%s)"
578
+ num_ok "$launched" || launched="$now"
579
+ # $$ is the pid the client keeps across exec — the name of its sessions/<pid>.json.
580
+ # Written atomically: the watcher must never parse half a state file.
581
+ st="$AR_DIR/$$.state"
582
+ tmp="$AR_DIR/.$$.state.tmp"
583
+ printf '%s\n' "v=1" "provider=$prov" "pid=$$" "ppid=$PPID" "acct=${dir##*/}" \
584
+ "acct_dir=$dir" "cwd=$PWD" "launched=$launched" "tmux=$TMUX" "pane=$TMUX_PANE" \
585
+ "self=$SELF" "acc_root=$root" "depth=$AR_DEPTH" "avoid=$AR_AVOID" \
586
+ "hist=$AR_HIST" "chain=$AR_CHAIN" 2>/dev/null > "$tmp" \
587
+ && mv -f "$tmp" "$st" 2>/dev/null \
588
+ || { rm -f "$tmp" 2>/dev/null; return 1; }
589
+ # Detached and never waited for; the watcher setsid()s itself at once. It inherits
590
+ # this environment on purpose (the probe strips the account-specific part).
591
+ ( trap '' INT QUIT HUP TSTP
592
+ exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 &
593
+ ) >/dev/null 2>&1
594
+ return 0
595
+ }
596
+
597
+ # A relaunch may land on an account that never opened this directory, and Claude asks "Is
598
+ # this a project you trust?" there even under --dangerously-skip-permissions: a dialog
599
+ # nobody is watching would stall the resumed session. So the relaunch (and only it) marks
600
+ # its cwd trusted in the picked account's .claude.json. Bounded file work, every failure
601
+ # ignored; a probe never gets here.
602
+ ar_trust() { # $1 picked acct dir
603
+ [ "$AR_TRUST" = 1 ] || return 0
604
+ [ -f "$1/.claude.json" ] || return 0
605
+ ar_python || return 0
606
+ "$AR_PY" -I "$SELF_DIR/../lib/autoresume.py" trust --acct-dir "$1" --cwd "$PWD" \
607
+ </dev/null >/dev/null 2>&1 || true
608
+ return 0
609
+ }
610
+
611
+ # Called right before the plain interactive exec. Whatever the gate decided, the TUI
612
+ # never sees an auto-resume variable (a nested `claude` must not inherit a probe flag).
613
+ ar_spawn_watcher() { # $1 provider, $2 picked acct dir
614
+ local rc=0 v
615
+ ar_start_watcher "$@" || rc=$?
616
+ for v in ${!CLAUDE_MULTIACC_AR_*}; do unset "$v"; done
617
+ unset CLAUDE_MULTIACC_AR
618
+ return "$rc"
619
+ }
620
+
621
+ # The relaunch entry itself: CLAUDE_MULTIACC_AR=<token>[:<session id>]. The id rides along
622
+ # so a token that cannot be honoured can still name the session. A probe never consumes a
623
+ # token: that belongs to the relaunch.
624
+ if [ -n "${CLAUDE_MULTIACC_AR+x}" ]; then
625
+ ar_tok="$CLAUDE_MULTIACC_AR"
626
+ unset CLAUDE_MULTIACC_AR
627
+ if [ "$AR_PROBE" != 1 ] && [ -n "$ar_tok" ]; then
628
+ ar_sid=""
629
+ case "$ar_tok" in *:*) ar_sid="${ar_tok#*:}"; ar_tok="${ar_tok%%:*}" ;; esac
630
+ ar_relaunch_load "$ar_tok" || ar_token_dead "${ar_sid:-$AR_R_SID}"
631
+ set -- "${AR_ARGV[@]}"
632
+ AR_TRUST=1
633
+ unset ar_sid
634
+ fi
635
+ unset ar_tok
636
+ fi
637
+ # The token preflight below can prove the pick's token dead and re-enter this shim with
638
+ # `exec "$SELF"`. A relaunch has spent its token by then, so it hands its chain (depth,
639
+ # the accounts it left, its budgets) over in the environment — without it the re-entered
640
+ # selection could land right back on the account the session just left. Only such a
641
+ # re-entry (PREFLIGHT_DEPTH set) may use it, and never a probe or a fresh token.
642
+ if [ -n "${CLAUDE_MULTIACC_AR_CARRY+x}" ]; then
643
+ ar_c="$CLAUDE_MULTIACC_AR_CARRY"
644
+ unset CLAUDE_MULTIACC_AR_CARRY
645
+ if [ "$AR_PROBE" != 1 ] && [ "$AR_LOADED" != 1 ] && [ -n "${CLAUDE_MULTIACC_PREFLIGHT_DEPTH:-}" ]; then
646
+ ar_d="${ar_c%%|*}"; ar_c="${ar_c#*|}"
647
+ ar_a="${ar_c%%|*}"; ar_c="${ar_c#*|}"
648
+ ar_h="${ar_c%%|*}"; ar_c="${ar_c#*|}"
649
+ if num_ok "$ar_d" && ar_field_ok "$ar_a" && ar_field_ok "$ar_h" && ar_field_ok "$ar_c" \
650
+ && [ "${#ar_c}" -le 64 ]; then
651
+ AR_DEPTH="$ar_d"; AR_AVOID="$ar_a"; AR_HIST="$ar_h"; AR_CHAIN="$ar_c"; AR_TRUST=1
652
+ fi
653
+ fi
654
+ unset ar_c ar_d ar_a ar_h
655
+ fi
656
+ AR_ORIG_ARGV=(${1+"$@"})
657
+
265
658
  # ---- model-scoped limits ------------------------------------------------------
266
659
  # A usage bucket can be scoped to ONE MODEL ("weekly_scoped:Fable"), and such a bucket
267
660
  # says nothing about the account's ability to serve a DIFFERENT model. The limits
@@ -1310,12 +1703,16 @@ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid
1310
1703
  return 0
1311
1704
  }
1312
1705
 
1706
+ # A relaunch marks the account it left before anything reads the pool (auto-resume).
1707
+ ar_relaunch_apply
1708
+
1313
1709
  # Explicit pin wins over everything — markers, and even missing auth: the
1314
1710
  # add/login ceremony pins to a dir that has no credentials yet, and the login
1315
1711
  # must land exactly there, never in a randomly selected account's dir.
1316
1712
  if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1317
1713
  d="$ACC_ROOT/$CLAUDE_ACCOUNT"
1318
1714
  if [ -d "$d" ]; then
1715
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit none
1319
1716
  sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
1320
1717
  export CLAUDE_CONFIG_DIR="$d"
1321
1718
  export CLAUDE_SHIM_ACTIVE=1
@@ -1379,6 +1776,7 @@ for d in "$ACC_ROOT"/acct-*; do
1379
1776
  continue
1380
1777
  fi
1381
1778
  over_threshold "$d" && continue
1779
+ ar_avoided "$d" && continue
1382
1780
  eligible+=("$d")
1383
1781
  done
1384
1782
 
@@ -1403,6 +1801,7 @@ fi
1403
1801
  # No usable accounts => stock behavior (fail open, never block work), but say WHY when
1404
1802
  # the pool is merely un-authenticated: this is the one case the user can actually fix.
1405
1803
  if [ "${#valid[@]}" -eq 0 ]; then
1804
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit none
1406
1805
  if [ "${#expired[@]}" -gt 0 ]; then
1407
1806
  sel_log "all-expired: falling back to the default login (see: claude-accounts expired)"
1408
1807
  # Terminal only: a service-spawned `claude -p` must keep its stderr byte-clean, and
@@ -1611,7 +2010,8 @@ else
1611
2010
  PICK_DIR="$d"; best_reset="$r"
1612
2011
  fi
1613
2012
  done
1614
- sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
2013
+ [ "$AR_PROBE" = 1 ] \
2014
+ || sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1615
2015
  # Every candidate rejects right now, so this pick WILL fail: say so on a terminal
1616
2016
  # instead of letting the operator read the client's bare limit error as a bad
1617
2017
  # choice by the pool (2026-08-29). Throttled, and never on a service's stderr.
@@ -1632,7 +2032,8 @@ else
1632
2032
  # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
1633
2033
  # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
1634
2034
  # reading only this event concluded the choice had no usage input at all.
1635
- if [ "${#soft[@]}" -gt 0 ]; then
2035
+ # (A probe picks nothing: its fallback lines would read as picks that never ran.)
2036
+ if [ "${#soft[@]}" -gt 0 ] && [ "$AR_PROBE" != 1 ]; then
1636
2037
  if [ "$degraded" = 1 ]; then
1637
2038
  sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
1638
2039
  elif [ "$blind" = 1 ]; then
@@ -1643,6 +2044,9 @@ else
1643
2044
  fi
1644
2045
  fi
1645
2046
  pick="$PICK_DIR"
2047
+ # An auto-resume probe ends here: before the token preflight (a real inference), the
2048
+ # model pin, remember_pick, the limits kick and the pick line — it chose nothing.
2049
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit
1646
2050
 
1647
2051
  # The preflight can prove the selected token dead without exposing its 401 to the
1648
2052
  # caller. Re-enter selection so the new .expired marker is applied and another
@@ -1654,6 +2058,9 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
1654
2058
  num_ok "$preflight_depth" || preflight_depth=0
1655
2059
  if [ "$preflight_depth" -lt 64 ]; then
1656
2060
  export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
2061
+ # An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
2062
+ # outside every chain field's charset, so it separates them unambiguously.
2063
+ [ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
1657
2064
  exec "$SELF" "$@"
1658
2065
  fi
1659
2066
  fi
@@ -1793,6 +2200,8 @@ if [ "$wants_retry" = "0" ]; then
1793
2200
  tok="$(acct_token "$pick")"
1794
2201
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1795
2202
  sel_capture_session "$pick"
2203
+ ar_trust "$pick"
2204
+ ar_spawn_watcher claude "$pick" || true
1796
2205
  exec "$REAL" "$@"
1797
2206
  fi
1798
2207