claude-multiacc 2.0.41 → 2.0.42

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
@@ -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
@@ -394,6 +396,45 @@ also sees the model's own answer: a `-p` run that merely *mentions* a 403 must n
394
396
  an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
395
397
  never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
396
398
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
399
+ Interactive sessions are not retried this way. They get auto-resume, described next.
400
+
401
+ ## Auto-resume
402
+
403
+ An interactive `claude` or `codex` session in **tmux** can stop on a usage limit, a failed
404
+ login, an API error or (claude) a crash. It is then **resumed automatically, same session
405
+ id**, on another pooled account with headroom. There is no `/exit`, no `--resume` and no
406
+ "continue" to type. It covers the everyday launches:
407
+
408
+ - `claude --dangerously-skip-permissions`, `--resume <id>` and `--model …`;
409
+ - `codex --dangerously-bypass-approvals-and-sandbox` and `codex resume …`.
410
+
411
+ Every other launch, including `-p` and `codex exec`, runs exactly as before.
412
+
413
+ **How it works.**
414
+
415
+ - **Launch.** The shim's `exec` is unchanged. Just before it, a detached watcher starts
416
+ (`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
417
+ rollout).
418
+ - **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
419
+ another account has headroom. If none does, it **holds** and touches nothing, and
420
+ Claude's own "continuing automatically at …" carries on.
421
+ - **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
422
+ prompt. It then types a one-line relaunch of the shim into that **shell**. It never
423
+ sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
424
+ `/limit-reset`.
425
+ - **Resume.** The relaunched shim parks the old account with the usual markers, then runs
426
+ normal selection minus the account it just left. The session resumes with a short
427
+ prompt telling the model it was restarted and should continue.
428
+
429
+ Every step appends an `autoresume <event>` line to `selection.log`.
430
+
431
+ ```bash
432
+ touch ~/.claude-accounts/autoresume.off # claude pool off, already-running sessions included
433
+ touch ~/.codex-accounts/autoresume.off # codex pool off
434
+ CLAUDE_MULTIACC_AUTORESUME=0 claude # one launch (CODEX_MULTIACC_AUTORESUME=0 codex)
435
+ ```
436
+
437
+ See [auto-resume: error classes, gates, budgets, logs and limitations](docs/AUTORESUME.md).
397
438
 
398
439
  ## MCP servers for every account
399
440
 
@@ -404,7 +445,7 @@ account, and a stock `claude mcp add` lands in **one random account**. That is h
404
445
  now owns a **registry**, `<pool>/mcp-servers.json`, that is reconciled into every account:
405
446
 
406
447
  ```bash
407
- claude-accounts mcp add appinspire-mcp -- npx -y appinspire-mcp@latest serve # both pools
448
+ claude-accounts mcp add docs-search -- node /path/docs-search.mjs serve # both pools
408
449
  claude-accounts mcp add --provider claude local-dev -e KEY=v -- node /path/server.mjs serve
409
450
  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
451
  claude-accounts mcp remove adspower-local-api # retire EVERYWHERE: every account, every project entry
@@ -441,6 +482,12 @@ claude-accounts mcp apply # re-apply now (repair drift)
441
482
  from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
442
483
  an agent): the session's config dir is a pool account, so the same mirror applies. A
443
484
  config dir outside the pool is plain passthrough.
485
+ - **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
486
+ each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
487
+ registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
488
+ the synced entry wins and every Mac launches one machine's Node and package paths. Never
489
+ `mcp remove` it either: the tombstone deletes the managed entry on every Mac. Drop a stray
490
+ registry entry without a tombstone, then `mcp apply` and `sync`.
444
491
  - **Replicas**: on a pool whose `sync-role` file says `replica`, `add`/`remove` and the
445
492
  shim's mirror land in the machine-local overlay (owner `local`) instead of the synced
446
493
  registry, because the source's next push would overwrite them. Make registry changes on
package/bin/claude CHANGED
@@ -181,6 +181,9 @@ mcp_nested_mirror() {
181
181
  if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
182
182
  || [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
183
183
  || [ ! -f "$MANIFEST" ]; then
184
+ # An auto-resume probe asks who WOULD be picked and must never start a client; a
185
+ # passthrough has no pool pick to report (see ---- auto-resume ----).
186
+ [ "${CLAUDE_MULTIACC_AR_PROBE:-}" = "1" ] && { printf 'pick= tier=none\n'; exit 3; }
184
187
  mcp_nested_mirror "$@" || true
185
188
  exec "$REAL" "$@"
186
189
  fi
@@ -262,6 +265,379 @@ sel_log() {
262
265
  printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
263
266
  }
264
267
 
268
+ # ---- auto-resume ----------------------------------------------------------------
269
+ # An interactive session that stops on a usage limit, a failed login, an API error or a
270
+ # crash is resumed — same session id, no keystroke — on another pooled account. The TUI
271
+ # at a limit does not exit; it idles on "continuing automatically at <reset>", which can
272
+ # be days away. The shim's share of the job is small on purpose and never blocks:
273
+ # - the plain interactive exec stays byte-identical; just before it, a detached watcher
274
+ # (lib/autoresume.py) is spawned for the pid the client is about to become;
275
+ # - when the watcher moves a session it stops the client, writes a single-use relaunch
276
+ # file and types ` CLAUDE_MULTIACC_AR=<token>:<sid> [CLAUDE_ACCOUNTS_ROOT=<root>] <this
277
+ # shim's path>` into the pane's SHELL — never into the TUI, whose limit menu can "Add
278
+ # funds" or spend the one-shot /limit-reset — and that command lands HERE: the token
279
+ # names the file, which holds the verdict to mark on the old account BEFORE selection,
280
+ # the argv to resume with, and the chain state (depth/avoid/hist) the next watcher
281
+ # carries on; the pool root is typed only when it is not the default one;
282
+ # - a probe run (CLAUDE_MULTIACC_AR_PROBE=1) is how the watcher asks "who would be
283
+ # picked now?" — the real candidate loop, then `pick=<acct> tier=<t>` and exit, so the
284
+ # watcher only kills a session there is somewhere to move to.
285
+ # Everything fails OPEN: a missing python, a non-tmux terminal or an argv this code does
286
+ # not recognise all mean "exec exactly as before, unsupervised". The one exception is a
287
+ # typed relaunch whose token is gone: it names the session to resume and exits 2, because
288
+ # its own (empty) argv would start a fresh session in the stopped one's place.
289
+ # Tokens and relaunch/state files live in $ACC_ROOT/tmp/autoresume; the watcher prunes.
290
+ AR_DIR="$ACC_ROOT/tmp/autoresume"
291
+ AR_PROBE=0
292
+ [ "${CLAUDE_MULTIACC_AR_PROBE:-}" = "1" ] && AR_PROBE=1
293
+ AR_DEPTH=0 # relaunches so far in this chain (the watcher caps it)
294
+ AR_AVOID="" # acct-NN:<until epoch>,... — accounts this chain just left
295
+ AR_HIST="" # class:epoch,... — the watcher's hourly budgets
296
+ AR_CHAIN="" # opaque chain id, minted and read by the watcher
297
+ AR_LOADED=0
298
+ AR_ARGV=()
299
+ AR_ORIG_ARGV=()
300
+ AR_R_CLASS="" AR_R_ACCT="" AR_R_RESET="" AR_R_RTYPE="" AR_R_MARKED="" AR_R_SID=""
301
+ AR_TRUST=0 # this run continues a relaunch: its cwd is trusted in the picked account
302
+ AR_PY=""
303
+
304
+ # A chain field copied from a file (or the probe's env) into the next state file: one
305
+ # line, a narrow charset, bounded. Anything else is dropped — the chain restarts, and
306
+ # nothing but the watcher's budgets depends on it.
307
+ ar_field_ok() { # $1 value
308
+ [ "${#1}" -le 2048 ] || return 1
309
+ case "$1" in *[!A-Za-z0-9:,._-]*) return 1 ;; esac
310
+ return 0
311
+ }
312
+ # The probe gets the watcher's AVOID list from its env; a normal run only ever takes it
313
+ # from a relaunch file.
314
+ [ "$AR_PROBE" = 1 ] && ar_field_ok "${CLAUDE_MULTIACC_AR_AVOID:-}" \
315
+ && AR_AVOID="${CLAUDE_MULTIACC_AR_AVOID:-}"
316
+
317
+ # Load a relaunch token. Runs BEFORE RUN_MODEL reads argv below (the relaunch argv may
318
+ # carry --model) and before anything else consults $PWD. sess_id_ok and the markers are
319
+ # defined further down, so the id class is spelled inline and the verdict is applied
320
+ # later, by ar_relaunch_apply, still ahead of any selection.
321
+ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields set
322
+ local tok="$1" rf af line k v n=0 a="" ok=0 ver="" prov="" sid="" cwd=""
323
+ local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain=""
324
+ # Alphanumerics only: the token becomes part of a path, so no '/' and no '..'.
325
+ case "$tok" in *[!A-Za-z0-9]*) return 1 ;; esac
326
+ [ "${#tok}" -ge 8 ] && [ "${#tok}" -le 64 ] || return 1
327
+ rf="$AR_DIR/r-$tok.relaunch"
328
+ af="$AR_DIR/r-$tok.argv"
329
+ # -f before every read: a FIFO planted under that name would block the read forever,
330
+ # a hang before exec. Unknown keys are ignored; known ones are validated one by one.
331
+ if [ -f "$rf" ]; then
332
+ while IFS= read -r line || [ -n "$line" ]; do
333
+ n=$((n + 1)); [ "$n" -le 64 ] || break
334
+ case "$line" in *=*) ;; *) line=""; continue ;; esac
335
+ k="${line%%=*}"; v="${line#*=}"; line=""
336
+ case "$k" in
337
+ v) ver="$v" ;;
338
+ provider) prov="$v" ;;
339
+ class) case "$v" in ''|*[!a-z_]*) ;; *) cls="$v" ;; esac ;;
340
+ acct) case "$v" in acct-*[!0-9]*|acct-) ;; acct-*) acct="$v" ;; esac ;;
341
+ reset) num_ok "$v" && reset="$v" ;;
342
+ rtype) case "$v" in five_hour|seven_day) rtype="$v" ;; esac ;;
343
+ marked) case "$v" in ''|*[!0-9TZ:.+-]*) ;; *) marked="$v" ;; esac ;;
344
+ sid) case "$v" in ''|-*|*[!0-9a-fA-F-]*) ;; *) sid="$v" ;; esac ;;
345
+ cwd) case "$v" in /*) cwd="$v" ;; esac ;;
346
+ depth) num_ok "$v" && depth="$v" ;;
347
+ avoid) ar_field_ok "$v" && avoid="$v" ;;
348
+ hist) ar_field_ok "$v" && hist="$v" ;;
349
+ chain) ar_field_ok "$v" && [ "${#v}" -le 64 ] && chain="$v" ;;
350
+ esac
351
+ done < "$rf"
352
+ if [ "$ver" = 1 ] && [ "$prov" = claude ] && [ -f "$af" ] \
353
+ && [ $((now - $(file_mtime "$rf"))) -le 600 ]; then
354
+ AR_ARGV=()
355
+ while IFS= read -r -d '' a; do
356
+ AR_ARGV+=("$a"); a=""
357
+ [ "${#AR_ARGV[@]}" -lt 512 ] || break
358
+ done < "$af"
359
+ # A writer that omits the final NUL still means its last element.
360
+ [ -n "$a" ] && AR_ARGV+=("$a")
361
+ [ "${#AR_ARGV[@]}" -gt 0 ] && ok=1
362
+ fi
363
+ fi
364
+ # Single use, whatever the outcome: a token is never honoured twice.
365
+ rm -f "$rf" "$af" 2>/dev/null
366
+ if [ "$ok" != 1 ]; then
367
+ AR_ARGV=()
368
+ AR_R_SID="$sid"
369
+ return 1
370
+ fi
371
+ AR_LOADED=1
372
+ AR_R_CLASS="$cls"; AR_R_ACCT="$acct"; AR_R_RESET="$reset"; AR_R_RTYPE="$rtype"
373
+ AR_R_MARKED="$marked"
374
+ AR_DEPTH="$depth"; AR_HIST="$hist"; AR_CHAIN="$chain"; AR_AVOID="$avoid"
375
+ # The pane's shell sits wherever the session was launched, but the session's own cwd is
376
+ # what --resume looks the transcript up by. Everything already resolved against the old
377
+ # directory (a relative pool root or PATH entry) has to survive the cd.
378
+ if [ -n "$cwd" ] && [ -d "$cwd" ] && [ "$cwd" != "$PWD" ]; then
379
+ case "$ACC_ROOT" in /*) ;; *) ACC_ROOT="$PWD/$ACC_ROOT"; MANIFEST="$ACC_ROOT/accounts.json"; AR_DIR="$ACC_ROOT/tmp/autoresume" ;; esac
380
+ case "$REAL" in /*) ;; *) REAL="$PWD/$REAL" ;; esac
381
+ cd -- "$cwd" 2>/dev/null || true
382
+ fi
383
+ return 0
384
+ }
385
+
386
+ # A typed relaunch whose token cannot be honoured (missing, expired, malformed). The
387
+ # watcher has already stopped the session and the typed line carries no argv of its own,
388
+ # so running on would open a FRESH session in its place: say how to get it back, and stop.
389
+ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
390
+ local sid="$1"
391
+ case "$sid" in
392
+ ''|-*|*[!0123456789abcdefABCDEF-]*) sid="" ;;
393
+ *-*) [ "${#sid}" -le 64 ] || sid="" ;;
394
+ *) sid="" ;;
395
+ esac
396
+ sel_log "autoresume expired sid=${sid:--}"
397
+ if [ -n "$sid" ]; then
398
+ printf 'claude-multiacc: auto-resume could not continue automatically (the resume token expired). Resume with: claude --resume %s\n' "$sid" >&2
399
+ else
400
+ printf 'claude-multiacc: auto-resume could not continue automatically (the resume token expired).\n' >&2
401
+ fi
402
+ exit 2
403
+ }
404
+
405
+ # The verdict the watcher saw, written through the SAME writers the transcript scans use
406
+ # (never shortening a longer park), so the old account is out of the pool before this
407
+ # very selection runs — and for every other launch too. Other classes (model-scoped,
408
+ # transient, blocked, crash) leave no marker: the watcher's AVOID list covers them.
409
+ ar_relaunch_apply() {
410
+ local d=""
411
+ [ "$AR_LOADED" = 1 ] || return 0
412
+ [ -n "$AR_R_ACCT" ] && [ -d "$ACC_ROOT/$AR_R_ACCT" ] && d="$ACC_ROOT/$AR_R_ACCT"
413
+ if [ -n "$d" ]; then
414
+ case "$AR_R_CLASS" in
415
+ quota)
416
+ if num_ok "$AR_R_RESET" && [ "$AR_R_RESET" -gt "$now" ] && [ -n "$AR_R_RTYPE" ]; then
417
+ mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
418
+ fi ;;
419
+ auth) mark_client_auth_dead "$d" ;;
420
+ esac
421
+ fi
422
+ # Field 2 is the word `autoresume`, never an account id: lib/report.py and the
423
+ # *-accounts tools read an acct-NN in field 2 as a pick.
424
+ sel_log "autoresume relaunch chain=${AR_CHAIN:--} from=${AR_R_ACCT:--}" \
425
+ "class=${AR_R_CLASS:--} depth=$AR_DEPTH"
426
+ return 0
427
+ }
428
+
429
+ # AVOID: accounts this chain just left, each until its own epoch. Filters `eligible` only
430
+ # — never `valid` — so a pool with nowhere else to go still reaches the all-limited
431
+ # fallback exactly as before. Builtins only: this runs once per candidate.
432
+ ar_avoided() { # $1 acct dir
433
+ local rest e id until
434
+ [ -n "$AR_AVOID" ] || return 1
435
+ rest="$AR_AVOID,"
436
+ while [ -n "$rest" ]; do
437
+ e="${rest%%,*}"; rest="${rest#*,}"
438
+ id="${e%%:*}"; until="${e#*:}"
439
+ [ "$id" = "${1##*/}" ] || continue
440
+ num_ok "$until" && [ "$until" -gt "$now" ] && return 0
441
+ done
442
+ return 1
443
+ }
444
+
445
+ # Probe answer, and the end of a probe run. `none` (exit 3) means nothing would be picked
446
+ # at all; otherwise the tier says which cut the pick came from.
447
+ ar_probe_exit() { # $1 = none, or empty to derive the tier from the selection just made
448
+ local t="${1:-}"
449
+ if [ -z "$t" ]; then
450
+ if [ "${#eligible[@]}" -gt 0 ]; then t=eligible
451
+ elif [ "${#soft[@]}" -gt 0 ]; then t=soft
452
+ else t=hard; fi
453
+ fi
454
+ if [ "$t" = none ] || [ -z "${pick:-}" ]; then
455
+ printf 'pick= tier=none\n'
456
+ exit 3
457
+ fi
458
+ printf 'pick=%s tier=%s\n' "${pick##*/}" "$t"
459
+ exit 0
460
+ }
461
+
462
+ # The watcher only understands sessions it can resume faithfully: an interactive TUI with
463
+ # these flags and at most one prompt. A single word may be a subcommand (`claude doctor`,
464
+ # `claude mcp …`), so a positional counts as a prompt only when it contains a space.
465
+ # Anything else — -p, --fork-session, a bare --resume picker — runs unsupervised.
466
+ ar_argv_ok() { # args: the session's argv
467
+ local a want="" pos=0
468
+ for a in "$@"; do
469
+ if [ -n "$want" ]; then
470
+ case "$want" in
471
+ uuid) sess_id_ok "$a" || return 1
472
+ case "$a" in *-*) ;; *) return 1 ;; esac ;;
473
+ *) case "$a" in ''|-*) return 1 ;; esac ;;
474
+ esac
475
+ want=""
476
+ continue
477
+ fi
478
+ case "$a" in
479
+ --dangerously-skip-permissions|--allow-dangerously-skip-permissions|-c|--continue) ;;
480
+ -r|--resume) want=uuid ;;
481
+ --resume=*) a="${a#--resume=}"
482
+ sess_id_ok "$a" || return 1
483
+ case "$a" in *-*) ;; *) return 1 ;; esac ;;
484
+ --model|--effort|--permission-mode) want=value ;;
485
+ --model=*) [ -n "${a#--model=}" ] || return 1 ;;
486
+ -*) return 1 ;;
487
+ *' '*) pos=$((pos + 1)); [ "$pos" -le 1 ] || return 1 ;;
488
+ *) return 1 ;;
489
+ esac
490
+ done
491
+ [ -z "$want" ]
492
+ }
493
+
494
+ # The relaunch line is typed into the pane's shell unquoted — this shim's own path, and the
495
+ # pool root when it is not the default — so only paths that never need quoting qualify.
496
+ # Spelled out, never ranges: under a UTF-8 locale bash matches `[a-z]` by collation.
497
+ ar_path_ok() { # $1 absolute path
498
+ case "$1" in
499
+ /*) ;;
500
+ *) return 1 ;;
501
+ esac
502
+ case "$1" in
503
+ *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/._+-]*) return 1 ;;
504
+ esac
505
+ return 0
506
+ }
507
+
508
+ # A python the watcher can run on. /usr/bin/python3 on a Mac without developer tools is a
509
+ # stub that pops an "install the command line tools" dialog and fails — only trusted when
510
+ # what it forwards to is there. File tests only: xcode-select itself would be a fork.
511
+ ar_python() {
512
+ local py="${CLAUDE_MULTIACC_PYTHON:-}"
513
+ if [ -n "$py" ] && [ -f "$py" ] && [ -x "$py" ]; then AR_PY="$py"; return 0; fi
514
+ py="$(command -v python3 2>/dev/null)" || return 1
515
+ case "$py" in /*) ;; *) return 1 ;; esac
516
+ if [ "$py" = /usr/bin/python3 ]; then
517
+ case "${OSTYPE:-}" in
518
+ darwin*)
519
+ [ -x /Library/Developer/CommandLineTools/usr/bin/python3 ] \
520
+ || [ -x /var/db/xcode_select_link/usr/bin/python3 ] \
521
+ || [ -x "${DEVELOPER_DIR:-/nonexistent}/usr/bin/python3" ] \
522
+ || return 1 ;;
523
+ esac
524
+ fi
525
+ AR_PY="$py"
526
+ }
527
+
528
+ ar_start_watcher() { # $1 provider, $2 picked acct dir
529
+ local prov="$1" dir="$2" lib st tmp launched root nl=$'\n'
530
+ # Kill switches: the env for one shell, the file for the whole pool (the watcher
531
+ # re-reads the file every tick, so it also stops sessions already running).
532
+ case "${CLAUDE_MULTIACC_AUTORESUME:-1}" in 0|false|no|off) return 1 ;; esac
533
+ [ -e "$ACC_ROOT/autoresume.off" ] && return 1
534
+ # A pin is the caller overriding selection. A valid one execs long before this; an
535
+ # invalid one fell through to a random pick, and moving that session is not ours to do.
536
+ [ -z "${CLAUDE_ACCOUNT:-}" ] || return 1
537
+ if [ "${CLAUDE_MULTIACC_AR_TEST_TTY:-}" != 1 ]; then
538
+ [ -t 0 ] && [ -t 1 ] || return 1
539
+ fi
540
+ # The relaunch is typed into the pane's shell: no tmux, nothing to type into.
541
+ [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ] || return 1
542
+ ar_argv_ok ${AR_ORIG_ARGV[@]+"${AR_ORIG_ARGV[@]}"} || return 1
543
+ ar_python || return 1
544
+ lib="$SELF_DIR/../lib/autoresume.py"
545
+ [ -f "$lib" ] || return 1
546
+ # The state file is key=value lines; a value with a newline cannot be carried.
547
+ case "$PWD$dir$ACC_ROOT$SELF$TMUX$TMUX_PANE" in *"$nl"*) return 1 ;; esac
548
+ # The watcher and the relaunch run elsewhere: hand them absolute pool paths.
549
+ root="$ACC_ROOT"
550
+ case "$root" in /*) ;; *) root="$PWD/$root"; dir="$PWD/$dir" ;; esac
551
+ ar_path_ok "$SELF" && ar_path_ok "$root" || return 1
552
+ [ -d "$AR_DIR" ] || mkdir -p "$AR_DIR" 2>/dev/null || return 1
553
+ # The ORIGINAL argv (before the fallback-model pin): the watcher rebuilds the relaunch
554
+ # from it, and the relaunch re-runs selection, which pins again for the NEW pick.
555
+ if [ "${#AR_ORIG_ARGV[@]}" -gt 0 ]; then
556
+ printf '%s\0' "${AR_ORIG_ARGV[@]}" 2>/dev/null > "$AR_DIR/$$.argv" || return 1
557
+ else
558
+ : 2>/dev/null > "$AR_DIR/$$.argv" || return 1
559
+ fi
560
+ launched="$(date +%s)"
561
+ num_ok "$launched" || launched="$now"
562
+ # $$ is the pid the client keeps across exec — the name of its sessions/<pid>.json.
563
+ # Written atomically: the watcher must never parse half a state file.
564
+ st="$AR_DIR/$$.state"
565
+ tmp="$AR_DIR/.$$.state.tmp"
566
+ printf '%s\n' "v=1" "provider=$prov" "pid=$$" "ppid=$PPID" "acct=${dir##*/}" \
567
+ "acct_dir=$dir" "cwd=$PWD" "launched=$launched" "tmux=$TMUX" "pane=$TMUX_PANE" \
568
+ "self=$SELF" "acc_root=$root" "depth=$AR_DEPTH" "avoid=$AR_AVOID" \
569
+ "hist=$AR_HIST" "chain=$AR_CHAIN" 2>/dev/null > "$tmp" \
570
+ && mv -f "$tmp" "$st" 2>/dev/null \
571
+ || { rm -f "$tmp" 2>/dev/null; return 1; }
572
+ # Detached and never waited for; the watcher setsid()s itself at once. It inherits
573
+ # this environment on purpose (the probe strips the account-specific part).
574
+ ( trap '' INT QUIT HUP TSTP
575
+ exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 &
576
+ ) >/dev/null 2>&1
577
+ return 0
578
+ }
579
+
580
+ # A relaunch may land on an account that never opened this directory, and Claude asks "Is
581
+ # this a project you trust?" there even under --dangerously-skip-permissions: a dialog
582
+ # nobody is watching would stall the resumed session. So the relaunch (and only it) marks
583
+ # its cwd trusted in the picked account's .claude.json. Bounded file work, every failure
584
+ # ignored; a probe never gets here.
585
+ ar_trust() { # $1 picked acct dir
586
+ [ "$AR_TRUST" = 1 ] || return 0
587
+ [ -f "$1/.claude.json" ] || return 0
588
+ ar_python || return 0
589
+ "$AR_PY" -I "$SELF_DIR/../lib/autoresume.py" trust --acct-dir "$1" --cwd "$PWD" \
590
+ </dev/null >/dev/null 2>&1 || true
591
+ return 0
592
+ }
593
+
594
+ # Called right before the plain interactive exec. Whatever the gate decided, the TUI
595
+ # never sees an auto-resume variable (a nested `claude` must not inherit a probe flag).
596
+ ar_spawn_watcher() { # $1 provider, $2 picked acct dir
597
+ local rc=0 v
598
+ ar_start_watcher "$@" || rc=$?
599
+ for v in ${!CLAUDE_MULTIACC_AR_*}; do unset "$v"; done
600
+ unset CLAUDE_MULTIACC_AR
601
+ return "$rc"
602
+ }
603
+
604
+ # The relaunch entry itself: CLAUDE_MULTIACC_AR=<token>[:<session id>]. The id rides along
605
+ # so a token that cannot be honoured can still name the session. A probe never consumes a
606
+ # token: that belongs to the relaunch.
607
+ if [ -n "${CLAUDE_MULTIACC_AR+x}" ]; then
608
+ ar_tok="$CLAUDE_MULTIACC_AR"
609
+ unset CLAUDE_MULTIACC_AR
610
+ if [ "$AR_PROBE" != 1 ] && [ -n "$ar_tok" ]; then
611
+ ar_sid=""
612
+ case "$ar_tok" in *:*) ar_sid="${ar_tok#*:}"; ar_tok="${ar_tok%%:*}" ;; esac
613
+ ar_relaunch_load "$ar_tok" || ar_token_dead "${ar_sid:-$AR_R_SID}"
614
+ set -- "${AR_ARGV[@]}"
615
+ AR_TRUST=1
616
+ unset ar_sid
617
+ fi
618
+ unset ar_tok
619
+ fi
620
+ # The token preflight below can prove the pick's token dead and re-enter this shim with
621
+ # `exec "$SELF"`. A relaunch has spent its token by then, so it hands its chain (depth,
622
+ # the accounts it left, its budgets) over in the environment — without it the re-entered
623
+ # selection could land right back on the account the session just left. Only such a
624
+ # re-entry (PREFLIGHT_DEPTH set) may use it, and never a probe or a fresh token.
625
+ if [ -n "${CLAUDE_MULTIACC_AR_CARRY+x}" ]; then
626
+ ar_c="$CLAUDE_MULTIACC_AR_CARRY"
627
+ unset CLAUDE_MULTIACC_AR_CARRY
628
+ if [ "$AR_PROBE" != 1 ] && [ "$AR_LOADED" != 1 ] && [ -n "${CLAUDE_MULTIACC_PREFLIGHT_DEPTH:-}" ]; then
629
+ ar_d="${ar_c%%|*}"; ar_c="${ar_c#*|}"
630
+ ar_a="${ar_c%%|*}"; ar_c="${ar_c#*|}"
631
+ ar_h="${ar_c%%|*}"; ar_c="${ar_c#*|}"
632
+ if num_ok "$ar_d" && ar_field_ok "$ar_a" && ar_field_ok "$ar_h" && ar_field_ok "$ar_c" \
633
+ && [ "${#ar_c}" -le 64 ]; then
634
+ AR_DEPTH="$ar_d"; AR_AVOID="$ar_a"; AR_HIST="$ar_h"; AR_CHAIN="$ar_c"; AR_TRUST=1
635
+ fi
636
+ fi
637
+ unset ar_c ar_d ar_a ar_h
638
+ fi
639
+ AR_ORIG_ARGV=(${1+"$@"})
640
+
265
641
  # ---- model-scoped limits ------------------------------------------------------
266
642
  # A usage bucket can be scoped to ONE MODEL ("weekly_scoped:Fable"), and such a bucket
267
643
  # says nothing about the account's ability to serve a DIFFERENT model. The limits
@@ -1310,12 +1686,16 @@ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid
1310
1686
  return 0
1311
1687
  }
1312
1688
 
1689
+ # A relaunch marks the account it left before anything reads the pool (auto-resume).
1690
+ ar_relaunch_apply
1691
+
1313
1692
  # Explicit pin wins over everything — markers, and even missing auth: the
1314
1693
  # add/login ceremony pins to a dir that has no credentials yet, and the login
1315
1694
  # must land exactly there, never in a randomly selected account's dir.
1316
1695
  if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1317
1696
  d="$ACC_ROOT/$CLAUDE_ACCOUNT"
1318
1697
  if [ -d "$d" ]; then
1698
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit none
1319
1699
  sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
1320
1700
  export CLAUDE_CONFIG_DIR="$d"
1321
1701
  export CLAUDE_SHIM_ACTIVE=1
@@ -1379,6 +1759,7 @@ for d in "$ACC_ROOT"/acct-*; do
1379
1759
  continue
1380
1760
  fi
1381
1761
  over_threshold "$d" && continue
1762
+ ar_avoided "$d" && continue
1382
1763
  eligible+=("$d")
1383
1764
  done
1384
1765
 
@@ -1403,6 +1784,7 @@ fi
1403
1784
  # No usable accounts => stock behavior (fail open, never block work), but say WHY when
1404
1785
  # the pool is merely un-authenticated: this is the one case the user can actually fix.
1405
1786
  if [ "${#valid[@]}" -eq 0 ]; then
1787
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit none
1406
1788
  if [ "${#expired[@]}" -gt 0 ]; then
1407
1789
  sel_log "all-expired: falling back to the default login (see: claude-accounts expired)"
1408
1790
  # Terminal only: a service-spawned `claude -p` must keep its stderr byte-clean, and
@@ -1611,7 +1993,8 @@ else
1611
1993
  PICK_DIR="$d"; best_reset="$r"
1612
1994
  fi
1613
1995
  done
1614
- sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1996
+ [ "$AR_PROBE" = 1 ] \
1997
+ || sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1615
1998
  # Every candidate rejects right now, so this pick WILL fail: say so on a terminal
1616
1999
  # instead of letting the operator read the client's bare limit error as a bad
1617
2000
  # choice by the pool (2026-08-29). Throttled, and never on a service's stderr.
@@ -1632,7 +2015,8 @@ else
1632
2015
  # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
1633
2016
  # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
1634
2017
  # reading only this event concluded the choice had no usage input at all.
1635
- if [ "${#soft[@]}" -gt 0 ]; then
2018
+ # (A probe picks nothing: its fallback lines would read as picks that never ran.)
2019
+ if [ "${#soft[@]}" -gt 0 ] && [ "$AR_PROBE" != 1 ]; then
1636
2020
  if [ "$degraded" = 1 ]; then
1637
2021
  sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
1638
2022
  elif [ "$blind" = 1 ]; then
@@ -1643,6 +2027,9 @@ else
1643
2027
  fi
1644
2028
  fi
1645
2029
  pick="$PICK_DIR"
2030
+ # An auto-resume probe ends here: before the token preflight (a real inference), the
2031
+ # model pin, remember_pick, the limits kick and the pick line — it chose nothing.
2032
+ [ "$AR_PROBE" = 1 ] && ar_probe_exit
1646
2033
 
1647
2034
  # The preflight can prove the selected token dead without exposing its 401 to the
1648
2035
  # caller. Re-enter selection so the new .expired marker is applied and another
@@ -1654,6 +2041,9 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
1654
2041
  num_ok "$preflight_depth" || preflight_depth=0
1655
2042
  if [ "$preflight_depth" -lt 64 ]; then
1656
2043
  export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
2044
+ # An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
2045
+ # outside every chain field's charset, so it separates them unambiguously.
2046
+ [ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
1657
2047
  exec "$SELF" "$@"
1658
2048
  fi
1659
2049
  fi
@@ -1793,6 +2183,8 @@ if [ "$wants_retry" = "0" ]; then
1793
2183
  tok="$(acct_token "$pick")"
1794
2184
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1795
2185
  sel_capture_session "$pick"
2186
+ ar_trust "$pick"
2187
+ ar_spawn_watcher claude "$pick" || true
1796
2188
  exec "$REAL" "$@"
1797
2189
  fi
1798
2190