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
@@ -678,6 +678,8 @@ cmd_add() {
678
678
  mutate_unlock
679
679
  RESERVED_DIR="" # committed — the trap must not delete it now
680
680
  trap - EXIT INT TERM
681
+ # Registered under the identity it read back: leave one Keychain item for the dir.
682
+ [ "$token" = "1" ] || keychain_prune "$d"
681
683
  # Only the full-login path actually READ the identity back. Saying "verified" for a
682
684
  # setup token would relaunder the very assumption this flow just warned about.
683
685
  local verdict="sign-in verified"
@@ -784,9 +786,10 @@ def has_auth(aid):
784
786
  if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
785
787
  return True
786
788
  # A login the client moved into the macOS Keychain still counts — deduping must
787
- # not throw away the one duplicate that actually holds the grant.
789
+ # not throw away the one duplicate that actually holds the grant, even one filed
790
+ # under a name its client no longer reads (the limits pass moves those back).
788
791
  try:
789
- return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
792
+ return keychain.probe(d, legacy=True)['state'] in ('present', 'locked', 'corrupt')
790
793
  except Exception:
791
794
  return False
792
795
  by_email = {}
@@ -1323,6 +1326,9 @@ cmd_login() {
1323
1326
  if [ -z "$got" ] && [ "$force" != "1" ]; then
1324
1327
  die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
1325
1328
  fi
1329
+ # Only now, with the identity confirmed, may the stale Keychain items go: a sign-in
1330
+ # as the wrong account (died above) must leave every item as it was.
1331
+ keychain_prune "$d"
1326
1332
  clear_auth_markers "$d" keep-token-park
1327
1333
  if [ -s "$d/.credentials.json" ]; then
1328
1334
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
@@ -1859,36 +1865,49 @@ class FileStore:
1859
1865
 
1860
1866
  class KeychainStore:
1861
1867
  """The same credential, held in the macOS Keychain by the client itself (see
1862
- lib/keychain.py). Read and written in place — NEVER copied out to a file: a file
1863
- beside a Keychain item is a second copy of a ROTATING refresh grant, and the
1864
- client reads the Keychain first, so the two would drift apart and strand one."""
1868
+ lib/keychain.py). Read from the item the client reads (the canonical name; this
1869
+ pass alone also accepts the legacy first match, which oauth_store moves) and written
1870
+ back under the canonical name, which also drops the siblings holding the grant the
1871
+ refresh started from — NEVER copied out to a file: a file beside a Keychain item is
1872
+ a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
1873
+ so the two would drift apart and strand one."""
1865
1874
  kind = 'keychain'
1866
1875
 
1867
1876
  def __init__(self, d, probe):
1868
1877
  self.d = d
1869
- self.account = probe.get('account')
1878
+ self.grant = None
1879
+ self.moved = False
1870
1880
 
1871
1881
  def read(self):
1872
- p = keychain.probe(self.d)
1882
+ p = keychain.probe(self.d, legacy=True)
1873
1883
  if p['state'] != 'present':
1874
1884
  raise ValueError(f'keychain credential {p["state"]}')
1885
+ grant = p['doc']['claudeAiOauth'].get('refreshToken')
1886
+ self.grant = grant if isinstance(grant, str) else None
1875
1887
  return p['doc']
1876
1888
 
1877
1889
  def write(self, doc):
1878
- if not keychain.write(self.d, doc, account=self.account):
1890
+ if not keychain.write(self.d, doc, supersedes=self.grant):
1879
1891
  raise OSError('keychain write refused')
1880
1892
 
1881
1893
 
1882
1894
  def oauth_store(d):
1883
1895
  """Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
1884
1896
  the string 'locked' (Keychain item exists but this session cannot open it), or
1885
- None. The file wins when both exist — same rule as lib/audit.oauth_login."""
1897
+ None. The file wins when both exist — same rule as lib/audit.oauth_login.
1898
+
1899
+ A Keychain login found only under a name its client no longer reads (a USER-less
1900
+ sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
1901
+ until then the client, the shim and the audit all see no login."""
1886
1902
  cpath = os.path.join(d, '.credentials.json')
1887
1903
  if os.path.isfile(cpath):
1888
1904
  return FileStore(cpath)
1889
- p = keychain.probe(d)
1905
+ p = keychain.probe(d, legacy=True)
1890
1906
  if p['state'] == 'present':
1891
- return KeychainStore(d, p)
1907
+ store = KeychainStore(d, p)
1908
+ if p['account'] != p['canonical']:
1909
+ store.moved = keychain.write(d, p['doc'])
1910
+ return store
1892
1911
  if p['state'] == 'locked':
1893
1912
  return 'locked'
1894
1913
  return None
@@ -2096,6 +2115,9 @@ for acct in manifest.get('accounts', []):
2096
2115
  except Exception as e:
2097
2116
  say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
2098
2117
  store = None
2118
+ if getattr(store, 'moved', False):
2119
+ say(f'{aid}: Keychain login moved under {keychain.canonical_account()}, '
2120
+ f'the name its client reads')
2099
2121
  locked = store == 'locked'
2100
2122
  if locked:
2101
2123
  store = None
@@ -2784,6 +2806,9 @@ for acct in manifest.get('accounts', []):
2784
2806
  # The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
2785
2807
  # session cannot open is not a failure of the ACCOUNT: the real call below
2786
2808
  # would fail for the session, not the grant, so it is a skip with a reason.
2809
+ # Only the canonical item counts — the one the client below reads; testing an
2810
+ # item under another name would run the client on a login it cannot see and
2811
+ # park an account its token serves.
2787
2812
  kc = keychain.probe(d)
2788
2813
  if kc['state'] == 'present':
2789
2814
  has_creds, cred_doc = True, kc['doc']
package/bin/codex CHANGED
@@ -178,6 +178,9 @@ mcp_nested_mirror() {
178
178
  if [ -n "${CODEX_HOME:-}" ] \
179
179
  || [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CODEX_SHIM_ACTIVE:-}" ] \
180
180
  || [ ! -f "$MANIFEST" ]; then
181
+ # An auto-resume probe (see "auto-resume" below) asks which account selection would
182
+ # pick; with no selection to run, the answer is none — it must never start a session.
183
+ if [ "${CODEX_MULTIACC_AR_PROBE:-0}" = "1" ]; then printf 'pick= tier=none\n'; exit 3; fi
181
184
  mcp_nested_mirror "$@" || true
182
185
  exec "$REAL" "$@"
183
186
  fi
@@ -729,10 +732,388 @@ expired_marked() { # $1 = acct dir
729
732
  # refresher / verify / the retry path below — all of which write `.expired`.
730
733
  auth_dead() { expired_marked "$1"; }
731
734
 
735
+ # ---- auto-resume -------------------------------------------------------------------
736
+ # An interactive codex that runs into a usage limit or a dead login does NOT exit: the
737
+ # TUI stays open on "You've hit your usage limit ... try again at <date>" and waits for a
738
+ # human. Auto-resume moves that session — same session id — onto another pooled account
739
+ # without a keystroke, and it does so without touching the exec below: the real codex
740
+ # still replaces this shim (same pid, same terminal), while a detached watcher
741
+ # (lib/autoresume.py) tails the session's rollout beside it. On a limit/auth verdict the
742
+ # watcher asks THIS shim which account a relaunch would get (probe mode), stops codex, and
743
+ # types ` CODEX_MULTIACC_AR=<token>:<sid> [CODEX_ACCOUNTS_ROOT=<root>] <this shim's path>`
744
+ # into the tmux pane's SHELL — never into the TUI (the pool root only when it is not the
745
+ # default one). The relaunch comes back through here: the token names a single-use relaunch
746
+ # file carrying the verdict and the argv (`resume <kept opts> <sid> <prompt>`); the old
747
+ # account is parked the way the exec retry path below parks one, and ordinary selection
748
+ # picks the next account. bin/claude carries the same hooks; docs/AUTORESUME.md is the
749
+ # reference. Fail open everywhere: nothing here may block before exec — no network, no
750
+ # waiting, no python in the foreground — and every path that cannot finish falls through
751
+ # to exactly today's exec. The one exception is a typed relaunch whose token is gone: it
752
+ # names the session to resume and exits 2, because its own (empty) argv would start a
753
+ # fresh session in the stopped one's place.
754
+ AR_DIR="$ACC_ROOT/tmp/autoresume"
755
+ AR_DEPTH=0 # how many relaunches this session's chain has already taken
756
+ AR_AVOID="" # acct-NN:until,... — accounts the chain must not land on again
757
+ AR_HIST="" # class:epoch,... — the chain's verdicts, for the watcher's budgets
758
+ AR_CHAIN="" # the chain's id, as the watcher named it
759
+ AR_ARGV=()
760
+ AR_ORIG_ARGV=()
761
+ AR_TIER="" # which cut produced the pick: eligible | soft | hard (probe answer)
762
+ AR_R_SID="" # the session a relaunch file named, for the dead-token hint
763
+ AR_PROBE=0
764
+ [ "${CODEX_MULTIACC_AR_PROBE:-0}" = "1" ] && AR_PROBE=1
765
+ # Spelled out, never ranges: under a UTF-8 locale bash matches `[a-z]` by collation, so
766
+ # `*[!a-z]*` lets "BAD" and "é" through. These validate what the relaunch file hands us.
767
+ AR_LOWER=abcdefghijklmnopqrstuvwxyz
768
+ AR_ALNUM="ABCDEFGHIJKLMNOPQRSTUVWXYZ${AR_LOWER}0123456789"
769
+
770
+ # Byte-identical with bin/claude's: a session id is hex and dashes, never an option.
771
+ sess_id_ok() { case "$1" in ''|-*|*[!0-9a-fA-F-]*) return 1 ;; *) return 0 ;; esac; }
772
+
773
+ ar_uuid_ok() { # $1 — a session id a relaunch may name: a bounded, dashed sess_id_ok
774
+ sess_id_ok "$1" || return 1
775
+ [ "${#1}" -le 64 ] || return 1
776
+ case "$1" in *-*) return 0 ;; esac
777
+ return 1
778
+ }
779
+
780
+ ar_word_ok() { # $1 value, $2 min length — [A-Za-z0-9]{min,64}: relaunch tokens, chain ids
781
+ case "$1" in ''|*[!$AR_ALNUM]*) return 1 ;; esac
782
+ [ "${#1}" -ge "$2" ] && [ "${#1}" -le 64 ]
783
+ }
784
+
785
+ ar_avoid_add() { # $1 comma list acct-NN:until -> appended to AR_AVOID, in-force entries only
786
+ # Every entry is re-validated here because the list arrives from a file or the probe's
787
+ # environment, and ar_avoided below does arithmetic on the until half.
788
+ local rest="$1" e id u n=0
789
+ while [ -n "$rest" ] && [ "$n" -lt 64 ]; do
790
+ e="${rest%%,*}"
791
+ case "$rest" in *,*) rest="${rest#*,}" ;; *) rest="" ;; esac
792
+ n=$((n + 1))
793
+ id="${e%%:*}"; u="${e#*:}"
794
+ case "$id" in acct-*[!0-9]*|acct-) continue ;; acct-*) ;; *) continue ;; esac
795
+ num_ok "$u" && [ "$u" -gt "$now" ] || continue
796
+ AR_AVOID="${AR_AVOID:+$AR_AVOID,}$id:$u"
797
+ done
798
+ return 0
799
+ }
800
+
801
+ ar_avoided() { # $1 acct dir — true while the relaunch chain asked to skip this account
802
+ # Fork-free: this runs once per candidate on every invocation, and AR_AVOID is empty
803
+ # for every launch that is not a relaunch or a probe.
804
+ local id="${1##*/}" rest="$AR_AVOID" e u
805
+ while [ -n "$rest" ]; do
806
+ e="${rest%%,*}"
807
+ case "$rest" in *,*) rest="${rest#*,}" ;; *) rest="" ;; esac
808
+ u="${e#*:}"
809
+ [ "${e%%:*}" = "$id" ] && num_ok "$u" && [ "$u" -gt "$now" ] && return 0
810
+ done
811
+ return 1
812
+ }
813
+
814
+ ar_set_hist() { # $1 comma list class:epoch -> AR_HIST: well-formed entries, newest 32
815
+ local rest="$1" e c t n=0 i keep=()
816
+ AR_HIST=""
817
+ while [ -n "$rest" ] && [ "$n" -lt 256 ]; do
818
+ e="${rest%%,*}"
819
+ case "$rest" in *,*) rest="${rest#*,}" ;; *) rest="" ;; esac
820
+ n=$((n + 1))
821
+ c="${e%%:*}"; t="${e#*:}"
822
+ case "$c" in ''|*[!$AR_LOWER]*) continue ;; esac
823
+ [ "${#c}" -le 16 ] && num_ok "$t" || continue
824
+ keep+=("$c:$t")
825
+ done
826
+ n=${#keep[@]}
827
+ i=$((n > 32 ? n - 32 : 0))
828
+ while [ "$i" -lt "$n" ]; do
829
+ AR_HIST="${AR_HIST:+$AR_HIST,}${keep[$i]}"
830
+ i=$((i + 1))
831
+ done
832
+ return 0
833
+ }
834
+
835
+ # The watcher's verdict, written in the exec retry path's own marker vocabulary (its park
836
+ # writers at the bottom of this file) and never weakening a park that already reaches
837
+ # further. A QUOTA verdict is only ever a
838
+ # 10-minute error-cooldown, never a client:7d marker: a weekly park is sticky until its
839
+ # reset (codex-accounts keeps it through every telemetry pass), and a watcher that read one
840
+ # rollout line must not pin an account out for days — a peer may have redeemed its reset
841
+ # in the meantime. The scheduled 5-minute limits pass marks the real window properly.
842
+ ar_mark_cooldown() { # $1 acct dir
843
+ local m="$1/.limited" cur="" until=$((now + 600))
844
+ if [ -f "$m" ]; then
845
+ IFS= read -r cur < "$m" 2>/dev/null || cur=""
846
+ num_ok "$cur" || cur=0
847
+ [ "$cur" -ge "$until" ] && return 0
848
+ fi
849
+ {
850
+ echo "$until"
851
+ echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
852
+ } 2>/dev/null > "$1/.limited.$$" \
853
+ && mv -f "$1/.limited.$$" "$m" 2>/dev/null \
854
+ || rm -f "$1/.limited.$$" 2>/dev/null || true
855
+ return 0
856
+ }
857
+
858
+ ar_mark_auth() { # $1 acct dir — the retry path's soft auth park (1h, self-healing)
859
+ local m="$1/.expired" soft=$((now + 3600)) cur
860
+ # A park still in force that is proven (no soft stamp: codex-accounts' verdict) or
861
+ # longer (org-blocked's 6h) says more than one failed session does: keep it.
862
+ if expired_marked "$1"; then
863
+ cur="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
864
+ num_ok "$cur" || return 0
865
+ [ "$cur" -ge "$soft" ] && return 0
866
+ fi
867
+ {
868
+ echo "$now"
869
+ echo "reason=auth-error soft_until=$soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=interactive session failed to authenticate"
870
+ } 2>/dev/null > "$1/.expired.$$" \
871
+ && mv -f "$1/.expired.$$" "$m" 2>/dev/null \
872
+ || rm -f "$1/.expired.$$" 2>/dev/null || true
873
+ sel_log "$(basename "$1") parked (auth-error until $soft) — see: codex-accounts expired"
874
+ return 0
875
+ }
876
+
877
+ # A typed relaunch whose token cannot be honoured (missing, expired, malformed). The
878
+ # watcher has already stopped the session and the typed line carries no argv of its own,
879
+ # so running on would open a FRESH session in its place: say how to get it back, and stop.
880
+ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
881
+ local sid="$1"
882
+ ar_uuid_ok "$sid" || sid=""
883
+ sel_log "autoresume expired sid=${sid:--}"
884
+ if [ -n "$sid" ]; then
885
+ printf 'codex-multiacc: auto-resume could not continue automatically (the resume token expired). Resume with: codex resume %s\n' "$sid" >&2
886
+ else
887
+ printf 'codex-multiacc: auto-resume could not continue automatically (the resume token expired).\n' >&2
888
+ fi
889
+ exit 2
890
+ }
891
+
892
+ # Relaunch entry: load the single-use relaunch file a watcher left for this token. Sets
893
+ # AR_ARGV (the argv to run) and the AR_* chain state; returns 1 when there is nothing
894
+ # usable (AR_R_SID then holds the file's session id, if it named one).
895
+ ar_relaunch() { # $1 token
896
+ local tok="$1" base f line k v n=0 a age
897
+ local ver="" prov="" cls="" acct="" sid="" cwd="" depth="" avoid="" hist="" chain=""
898
+ ar_word_ok "$tok" 8 || return 1
899
+ base="$AR_DIR/r-$tok"
900
+ f="$base.relaunch"
901
+ if [ ! -f "$f" ]; then
902
+ rm -f "$base.argv" 2>/dev/null
903
+ return 1
904
+ fi
905
+ while IFS= read -r line || [ -n "$line" ]; do
906
+ n=$((n + 1)); [ "$n" -le 64 ] || break
907
+ k="${line%%=*}"; v="${line#*=}"
908
+ [ "$k" != "$line" ] || continue
909
+ # Known keys only, each validated before anything can act on it.
910
+ case "$k" in
911
+ v) num_ok "$v" && ver="$v" ;;
912
+ provider) prov="$v" ;;
913
+ class) case "$v" in ''|*[!$AR_LOWER]*) ;; *) [ "${#v}" -le 16 ] && cls="$v" ;; esac ;;
914
+ acct) case "$v" in acct-*[!0-9]*|acct-) ;; acct-*) acct="$v" ;; esac ;;
915
+ sid) ar_uuid_ok "$v" && sid="$v" ;;
916
+ cwd) case "$v" in /*) cwd="$v" ;; esac ;;
917
+ depth) num_ok "$v" && depth="$v" ;;
918
+ avoid) avoid="$v" ;;
919
+ hist) hist="$v" ;;
920
+ chain) ar_word_ok "$v" 1 && chain="$v" ;;
921
+ esac
922
+ done < "$f"
923
+ age=$((now - $(file_mtime "$f")))
924
+ if [ "$ver" != "1" ] || [ "$prov" != "codex" ] || [ "$age" -gt 600 ] || [ ! -f "$base.argv" ]; then
925
+ rm -f "$f" "$base.argv" 2>/dev/null
926
+ AR_R_SID="$sid"
927
+ return 1
928
+ fi
929
+ n=0
930
+ while IFS= read -r -d '' a; do
931
+ n=$((n + 1)); [ "$n" -le 256 ] || break
932
+ AR_ARGV+=("$a")
933
+ done < "$base.argv"
934
+ rm -f "$f" "$base.argv" 2>/dev/null
935
+ if [ "${#AR_ARGV[@]}" -eq 0 ]; then
936
+ AR_R_SID="$sid"
937
+ return 1
938
+ fi
939
+ if [ -n "$acct" ] && [ -d "$ACC_ROOT/$acct" ]; then
940
+ case "$cls" in
941
+ quota) ar_mark_cooldown "$ACC_ROOT/$acct" ;;
942
+ auth) ar_mark_auth "$ACC_ROOT/$acct" ;;
943
+ esac
944
+ fi
945
+ AR_DEPTH="${depth:-0}"
946
+ ar_avoid_add "$avoid"
947
+ ar_set_hist "$hist"
948
+ AR_CHAIN="$chain"
949
+ if [ -n "$cwd" ] && [ -d "$cwd" ] && [ "$cwd" != "$PWD" ]; then
950
+ # Everything this shim resolved against the old directory has to survive the cd.
951
+ case "$ACC_ROOT" in /*) ;; *) ACC_ROOT="$PWD/$ACC_ROOT"; MANIFEST="$ACC_ROOT/accounts.json"; AR_DIR="$ACC_ROOT/tmp/autoresume" ;; esac
952
+ case "$REAL" in /*) ;; *) REAL="$PWD/$REAL" ;; esac
953
+ cd "$cwd" 2>/dev/null || true
954
+ fi
955
+ # Field 2 is the word `autoresume`, never an account id: lib/report.py and the
956
+ # *-accounts tools read an acct-NN in field 2 as a pick.
957
+ sel_log "autoresume relaunch chain=${AR_CHAIN:--} from=${acct:--} class=${cls:--} depth=$AR_DEPTH"
958
+ return 0
959
+ }
960
+
961
+ # The argv shapes a relaunch can rebuild as `resume <kept opts> <sid> <prompt>` — anything
962
+ # else (exec/e, -p profile, -c overrides, any subcommand, an unknown flag) gets no watcher.
963
+ # A positional must contain a space to count as a prompt: a single word may be a
964
+ # subcommand.
965
+ ar_argv_ok() { # the argv codex is about to get
966
+ local a resume=0 sid=0 prompt=0 want=0
967
+ for a in "$@"; do
968
+ if [ "$want" = 1 ]; then
969
+ case "$a" in ''|-*) return 1 ;; esac
970
+ want=0
971
+ continue
972
+ fi
973
+ case "$a" in
974
+ --dangerously-bypass-approvals-and-sandbox|--yolo) ;;
975
+ -m|--model) want=1 ;;
976
+ --model=?*) ;;
977
+ --last)
978
+ [ "$resume" = 1 ] && [ "$sid" = 0 ] || return 1
979
+ sid=1 ;;
980
+ resume)
981
+ [ "$resume" = 0 ] && [ "$prompt" = 0 ] || return 1
982
+ resume=1 ;;
983
+ -*) return 1 ;;
984
+ *)
985
+ if [ "$resume" = 1 ] && [ "$sid" = 0 ]; then
986
+ ar_uuid_ok "$a" || return 1 # codex reads it as SESSION_ID
987
+ sid=1
988
+ else
989
+ [ "$prompt" = 0 ] || return 1
990
+ case "$a" in *" "*) prompt=1 ;; *) return 1 ;; esac
991
+ fi ;;
992
+ esac
993
+ done
994
+ [ "$want" = 0 ] || return 1
995
+ # A bare `resume` opens the session picker: there is no session to follow yet.
996
+ [ "$resume" = 0 ] || [ "$sid" = 1 ]
997
+ }
998
+
999
+ # The relaunch line is typed into the pane's shell unquoted — this shim's own path, and the
1000
+ # pool root when it is not the default — so only paths that never need quoting qualify.
1001
+ ar_path_ok() { # $1 absolute path
1002
+ case "$1" in /*) ;; *) return 1 ;; esac
1003
+ case "$1" in *[!$AR_ALNUM/._+-]*) return 1 ;; esac
1004
+ return 0
1005
+ }
1006
+
1007
+ ar_find_python() { # -> AR_PY, a python3 the watcher can run on. Never RUNS a candidate.
1008
+ AR_PY=""
1009
+ if [ -n "${CODEX_MULTIACC_PYTHON:-}" ] && [ -f "$CODEX_MULTIACC_PYTHON" ] \
1010
+ && [ -x "$CODEX_MULTIACC_PYTHON" ]; then
1011
+ AR_PY="$CODEX_MULTIACC_PYTHON"
1012
+ return 0
1013
+ fi
1014
+ AR_PY="$(command -v python3 2>/dev/null)" || AR_PY=""
1015
+ [ -n "$AR_PY" ] || return 1
1016
+ # macOS ships /usr/bin/python3 as a stub that pops the Command Line Tools installer
1017
+ # when nothing backs it — a GUI dialog out of a background watcher. Only use it when
1018
+ # the CLT python or the developer dir `xcode-select -p` names is actually present, and
1019
+ # find that out with file tests (the xcode_select_link it reads, and Xcode's default
1020
+ # location it falls back to) rather than by running anything.
1021
+ case "${OSTYPE:-}" in darwin*)
1022
+ if [ "$AR_PY" = "/usr/bin/python3" ] \
1023
+ && [ ! -x /Library/Developer/CommandLineTools/usr/bin/python3 ] \
1024
+ && [ ! -d /var/db/xcode_select_link ] \
1025
+ && [ ! -d /Applications/Xcode.app/Contents/Developer ]; then
1026
+ AR_PY=""
1027
+ return 1
1028
+ fi ;;
1029
+ esac
1030
+ return 0
1031
+ }
1032
+
1033
+ # Gate + spawn, called ONLY from the plain interactive exec at the bottom, immediately
1034
+ # before it. Returns without doing anything unless every precondition holds, and never
1035
+ # waits on what it starts: the watcher is detached and the exec happens right after.
1036
+ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is about to get
1037
+ local prov="$1" pick="$2" lib st root nl='
1038
+ '
1039
+ shift 2
1040
+ case "${CODEX_MULTIACC_AUTORESUME:-1}" in 0|false|no|off) return 1 ;; esac
1041
+ [ -e "$ACC_ROOT/autoresume.off" ] && return 1
1042
+ # A pin is the caller overriding selection. A valid one execs long before this; an
1043
+ # invalid one fell through to a random pick, and moving that session is not ours to do.
1044
+ [ -z "${CODEX_ACCOUNT:-}" ] || return 1
1045
+ if [ "${CODEX_MULTIACC_AR_TEST_TTY:-0}" != "1" ]; then
1046
+ [ -t 0 ] && [ -t 1 ] || return 1
1047
+ fi
1048
+ [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ] || return 1
1049
+ ar_argv_ok "$@" || return 1
1050
+ ar_find_python || return 1
1051
+ lib="$SELF_DIR/../lib/autoresume.py"
1052
+ [ -f "$lib" ] || return 1
1053
+ # The state file is line-oriented key=value: a value carrying a newline cannot be
1054
+ # written faithfully, so such a launch simply goes unsupervised.
1055
+ case "$PWD$TMUX$TMUX_PANE$SELF$ACC_ROOT$pick" in *"$nl"*) return 1 ;; esac
1056
+ root="$ACC_ROOT"
1057
+ case "$root" in /*) ;; *) root="$PWD/$root" ;; esac
1058
+ ar_path_ok "$SELF" && ar_path_ok "$root" || return 1
1059
+ [ -d "$AR_DIR" ] || mkdir -p "$AR_DIR" 2>/dev/null || return 1
1060
+ st="$AR_DIR/$$.state"
1061
+ # The ORIGINAL argv (A2), NUL-separated: the watcher rebuilds the relaunch from it.
1062
+ if [ "${#AR_ORIG_ARGV[@]}" -gt 0 ]; then
1063
+ printf '%s\0' "${AR_ORIG_ARGV[@]}" 2>/dev/null > "$AR_DIR/$$.argv.tmp"
1064
+ else
1065
+ : 2>/dev/null > "$AR_DIR/$$.argv.tmp"
1066
+ fi && mv -f "$AR_DIR/$$.argv.tmp" "$AR_DIR/$$.argv" 2>/dev/null || {
1067
+ rm -f "$AR_DIR/$$.argv.tmp" 2>/dev/null
1068
+ return 1
1069
+ }
1070
+ {
1071
+ printf 'v=1\nprovider=%s\npid=%s\nppid=%s\n' "$prov" "$$" "$PPID"
1072
+ printf 'acct=%s\nacct_dir=%s\ncwd=%s\nlaunched=%s\n' "${pick##*/}" "$pick" "$PWD" "$(date +%s)"
1073
+ printf 'tmux=%s\npane=%s\nself=%s\nacc_root=%s\n' "$TMUX" "$TMUX_PANE" "$SELF" "$root"
1074
+ printf 'depth=%s\navoid=%s\nhist=%s\nchain=%s\n' "$AR_DEPTH" "$AR_AVOID" "$AR_HIST" "$AR_CHAIN"
1075
+ } 2>/dev/null > "$st.tmp" && mv -f "$st.tmp" "$st" 2>/dev/null || {
1076
+ rm -f "$st.tmp" "$AR_DIR/$$.argv" 2>/dev/null
1077
+ return 1
1078
+ }
1079
+ # Detached and never waited on: the subshell returns as soon as the watcher is forked,
1080
+ # and the watcher ignores the terminal's signals until its own setsid().
1081
+ ( trap '' INT QUIT HUP TSTP
1082
+ exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 & ) >/dev/null 2>&1
1083
+ return 0
1084
+ }
1085
+
1086
+ ar_scrub_env() { # the TUI (and everything it spawns) never sees an auto-resume variable
1087
+ local v
1088
+ for v in ${!CODEX_MULTIACC_AR@}; do
1089
+ case "$v" in CODEX_MULTIACC_AR|CODEX_MULTIACC_AR_*) unset "$v" ;; esac
1090
+ done
1091
+ return 0
1092
+ }
1093
+
1094
+ # A1 — the relaunch entry, before any selection (the pin included): a token is consumed
1095
+ # exactly once, and a probe never consumes one. A2 — the argv selection starts from is
1096
+ # what a later relaunch rebuilds; codex never rewrites "$@" after this point.
1097
+ # The typed variable is <token>[:<session id>]: the id rides along so a token that cannot
1098
+ # be honoured still names the session to resume.
1099
+ if [ -n "${CODEX_MULTIACC_AR+x}" ]; then
1100
+ ar_tok="$CODEX_MULTIACC_AR"
1101
+ unset CODEX_MULTIACC_AR
1102
+ if [ "$AR_PROBE" != "1" ] && [ -n "$ar_tok" ]; then
1103
+ ar_sid=""
1104
+ case "$ar_tok" in *:*) ar_sid="${ar_tok#*:}"; ar_tok="${ar_tok%%:*}" ;; esac
1105
+ ar_relaunch "$ar_tok" || ar_token_dead "${ar_sid:-$AR_R_SID}"
1106
+ set -- ${AR_ARGV[@]+"${AR_ARGV[@]}"}
1107
+ fi
1108
+ fi
1109
+ [ -n "${CODEX_MULTIACC_AR_AVOID:-}" ] && ar_avoid_add "$CODEX_MULTIACC_AR_AVOID"
1110
+ AR_ORIG_ARGV=("$@")
1111
+
732
1112
  # Explicit pin wins over everything — markers, and even missing auth: the
733
1113
  # add/login ceremony pins to a dir that has no credentials yet, and the login
734
1114
  # must land exactly there, never in a randomly selected account's dir.
735
- if [ -n "${CODEX_ACCOUNT:-}" ]; then
1115
+ # A probe asks what SELECTION would pick, so it never takes (and never execs) a pin.
1116
+ if [ -n "${CODEX_ACCOUNT:-}" ] && [ "$AR_PROBE" != "1" ]; then
736
1117
  d="$ACC_ROOT/$CODEX_ACCOUNT"
737
1118
  if [ -d "$d" ]; then
738
1119
  sel_log "$CODEX_ACCOUNT pinned pwd=$PWD"
@@ -774,6 +1155,9 @@ for d in "$ACC_ROOT"/acct-*; do
774
1155
  continue
775
1156
  fi
776
1157
  over_threshold "$d" && continue
1158
+ # An auto-resume relaunch (or its probe) skips the account the session just left until
1159
+ # the reset it was handed. `eligible` only: the all-limited fallback still sees it.
1160
+ ar_avoided "$d" && continue
777
1161
  eligible+=("$d")
778
1162
  done
779
1163
 
@@ -798,6 +1182,8 @@ fi
798
1182
  # No usable accounts => stock behavior (fail open, never block work), but say WHY when
799
1183
  # the pool is merely un-authenticated: this is the one case the user can actually fix.
800
1184
  if [ "${#valid[@]}" -eq 0 ]; then
1185
+ # A probe has nothing to offer and must never exec (nor log a fallback it never took).
1186
+ if [ "$AR_PROBE" = "1" ]; then printf 'pick= tier=none\n'; exit 3; fi
801
1187
  if [ "${#expired[@]}" -gt 0 ]; then
802
1188
  sel_log "all-expired: falling back to the default login (see: codex-accounts expired)"
803
1189
  # Terminal only: a service-spawned `codex exec` must keep its stderr byte-clean,
@@ -911,6 +1297,7 @@ pick_best() { # args: candidate dirs
911
1297
  }
912
1298
 
913
1299
  if [ "${#eligible[@]}" -gt 0 ]; then
1300
+ AR_TIER=eligible
914
1301
  if [ "${CODEX_SHIM_SELECT:-headroom}" = "random" ]; then
915
1302
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
916
1303
  PICK_BAND_COUNT=${#eligible[@]}
@@ -932,12 +1319,16 @@ else
932
1319
  for d in "${valid[@]}"; do
933
1320
  if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
934
1321
  done
1322
+ # A probe only ASKS: the fallback lines below would claim a pick nobody launched.
935
1323
  if [ "${#soft[@]}" -gt 0 ]; then
1324
+ AR_TIER=soft
936
1325
  PICK_STRICT=1
937
1326
  pick_best "${soft[@]}"
938
1327
  PICK_STRICT=0
939
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
1328
+ [ "$AR_PROBE" = "1" ] \
1329
+ || sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
940
1330
  else
1331
+ AR_TIER=hard
941
1332
  # Every account is exhausted RIGHT NOW: nothing serves, so hand out the one that
942
1333
  # unblocks first — its rejection window is the shortest.
943
1334
  PICK_DIR=""
@@ -951,11 +1342,12 @@ else
951
1342
  PICK_DIR="$d"; best_reset="$r"
952
1343
  fi
953
1344
  done
954
- sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1345
+ [ "$AR_PROBE" = "1" ] \
1346
+ || sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
955
1347
  # Every candidate rejects right now, so this pick WILL fail: say so on a terminal
956
1348
  # instead of letting the operator read the client's bare limit error as a bad
957
1349
  # choice by the pool. Throttled, and never on a service's stderr.
958
- if [ -t 2 ]; then
1350
+ if [ -t 2 ] && [ "$AR_PROBE" != "1" ]; then
959
1351
  exn="$ACC_ROOT/.exhausted-notice"
960
1352
  exlast=0
961
1353
  [ -f "$exn" ] && exlast="$(file_mtime "$exn")"
@@ -968,6 +1360,12 @@ else
968
1360
  fi
969
1361
  fi
970
1362
  pick="$PICK_DIR"
1363
+ # A probe answers here, before anything that would count as a launch: no rotation
1364
+ # memory, no limits kick, no pick line in selection.log — and never an exec.
1365
+ if [ "$AR_PROBE" = "1" ]; then
1366
+ printf 'pick=%s tier=%s\n' "${pick##*/}" "$AR_TIER"
1367
+ exit 0
1368
+ fi
971
1369
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
972
1370
  # CODEX_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
973
1371
  # not a turn in the rotation.
@@ -1038,6 +1436,10 @@ fi
1038
1436
  share_state_index "$pick"
1039
1437
  if [ "$wants_retry" = "0" ]; then
1040
1438
  export CODEX_HOME="$pick"
1439
+ # Auto-resume: a gated, detached watcher beside this exec — the exec itself (same pid,
1440
+ # same argv, same environment minus the auto-resume variables) is unchanged.
1441
+ ar_spawn_watcher codex "$pick" "$@" || true
1442
+ ar_scrub_env
1041
1443
  exec "$REAL" "$@"
1042
1444
  fi
1043
1445
 
@@ -73,6 +73,11 @@ auto-refreshing login — `.credentials.json`, or on macOS the login Keychain wh
73
73
  the session can open it (Claude Code migrates the file into the Keychain on the first
74
74
  refresh from a keychain-capable session; ssh sessions then see the account as
75
75
  `KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
76
+ The Keychain item is named after `$USER`; the CLI fills it from `id -un` when the caller's
77
+ env has none (a USER-less client files its login under `unknown`, where the next client
78
+ never reads it). The pool judges only the item under that name, as the client does; a
79
+ login found only under another name is moved there by the next limits pass, and a
80
+ sign-in whose identity matched leaves exactly one item, under that name.
76
81
  That credential is **machine-local** (never synced),
77
82
  which is what keeps two machines from invalidating each other's refresh token — so an
78
83
  account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
@@ -402,13 +407,14 @@ derived from the pool root. Uninstalling an instance removes only that instance'
402
407
  | `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | clean client-limit scan cache; default 20s |
403
408
  | `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` | five-hour recovery grace; default 300s (weekly never clears early) |
404
409
  | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
410
+ | `CLAUDE_MULTIACC_AUTORESUME=0` | no [auto-resume](AUTORESUME.md) watcher for launches from this environment. `touch <pool>/autoresume.off` also stops running watchers |
405
411
  | `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool; legacy spelling is `CLAUDE_ACCOUNTS_DIR` |
406
412
  | `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
407
413
  | `CLAUDE_MULTIACC_SYNC_ROOT=... / _SYNC_REPO=...` | remote pool root / addon repo that goes with it |
408
414
 
409
415
  The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
410
416
  `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
411
- `CODEX_SHIM_SELECT`, `CODEX_MULTIACC_HEADROOM_BAND`, `CODEX_MULTIACC_SESSION_GATE`,
417
+ `CODEX_MULTIACC_AUTORESUME`, `CODEX_SHIM_SELECT`, `CODEX_MULTIACC_HEADROOM_BAND`, `CODEX_MULTIACC_SESSION_GATE`,
412
418
  `CODEX_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY` (5h client-marker recovery grace, default
413
419
  300s — weekly markers never clear early), `CODEX_ACCOUNTS_ROOT` (legacy
414
420
  `CODEX_ACCOUNTS_DIR`), `CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.