claude-multiacc 2.0.56 → 2.0.58

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 (106) hide show
  1. package/README.md +7 -2
  2. package/bin/claude +71 -8
  3. package/bin/codex +67 -14
  4. package/docs/AUTORESUME-HANDOFF.md +84 -0
  5. package/docs/AUTORESUME.md +16 -5
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  35. package/lib/autoresume.py +58 -14
  36. package/lib/autoresume_cross.py +143 -0
  37. package/lib/autoresume_cross_dispatch.py +114 -0
  38. package/lib/autoresume_cross_receipt.py +138 -0
  39. package/lib/autoresume_handoff.py +191 -0
  40. package/lib/autoresume_probe.py +36 -0
  41. package/package.json +1 -1
  42. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  89. package/tests/autoresume_cross_receipt_support.py +58 -0
  90. package/tests/autoresume_cross_tmux_environment.py +59 -0
  91. package/tests/autoresume_cross_tmux_support.py +112 -0
  92. package/tests/autoresume_cross_unit_support.py +92 -0
  93. package/tests/fixtures/cross_provider_cli.py +122 -0
  94. package/tests/test_autoresume.py +3 -1
  95. package/tests/test_autoresume_cross_claude_holders.py +81 -0
  96. package/tests/test_autoresume_cross_guards.py +133 -0
  97. package/tests/test_autoresume_cross_holders.py +89 -0
  98. package/tests/test_autoresume_cross_origin.py +70 -0
  99. package/tests/test_autoresume_cross_policy.py +123 -0
  100. package/tests/test_autoresume_cross_receipt.py +141 -0
  101. package/tests/test_autoresume_cross_tmux.py +140 -0
  102. package/tests/test_autoresume_cross_tmux_races.py +45 -0
  103. package/tests/test_autoresume_cross_tokens.py +102 -0
  104. package/tests/test_autoresume_handoff_argv.py +127 -0
  105. package/tests/test_autoresume_handoff_history.py +115 -0
  106. package/tests/test_autoresume_handoff_origins.py +53 -0
package/README.md CHANGED
@@ -442,7 +442,9 @@ Interactive sessions are not retried this way. They get auto-resume, described n
442
442
 
443
443
  An interactive `claude` or `codex` session in **tmux** can stop on a usage limit, a failed
444
444
  login, an API error or (claude) a crash. It is then **resumed automatically, same session
445
- id**, on another pooled account with headroom. There is no `/exit`, no `--resume` and no
445
+ id**, on another pooled account with headroom. If a usage limit exhausts that provider's
446
+ usable accounts, it can continue in the other provider with instructions to read the
447
+ original conversation's history. There is no `/exit`, no `--resume` and no
446
448
  "continue" to type. It covers the everyday launches:
447
449
 
448
450
  - `claude --dangerously-skip-permissions`, `--resume <id>` and `--model …`;
@@ -458,7 +460,8 @@ Every other launch, including `-p` and `codex exec`, runs exactly as before.
458
460
  it, so the client owns the task and rollout through account changes.
459
461
  - **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
460
462
  another account has headroom, including the 90–99% fallback for quota errors. If none
461
- does, it **holds** and Claude's own "continuing automatically at …" carries on.
463
+ does, a quota recovery checks the other provider's pool. If neither can serve, it
464
+ **holds** and Claude's own "continuing automatically at …" carries on.
462
465
  - **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
463
466
  prompt. It then types a one-line relaunch of the shim into that **shell**. It never
464
467
  sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
@@ -477,6 +480,8 @@ CLAUDE_MULTIACC_AUTORESUME=0 claude # one launch (CODEX_MULTIACC_AUTORESUM
477
480
  ```
478
481
 
479
482
  See [auto-resume: error classes, gates, budgets, logs and limitations](docs/AUTORESUME.md).
483
+ See [continuing between Codex and Claude](docs/AUTORESUME-HANDOFF.md) for history,
484
+ permission handling and the cross-provider opt-out.
480
485
 
481
486
  ## MCP servers for every account
482
487
 
package/bin/claude CHANGED
@@ -196,6 +196,10 @@ if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
196
196
  # An auto-resume probe asks who WOULD be picked and must never start a client; a
197
197
  # passthrough has no pool pick to report (see ---- auto-resume ----).
198
198
  [ "${CLAUDE_MULTIACC_AR_PROBE:-}" = "1" ] && { printf 'pick= tier=none\n'; exit 3; }
199
+ if [ -n "${CLAUDE_MULTIACC_AR:-}${CLAUDE_MULTIACC_AR_CROSS_CARRY:-}" ]; then
200
+ printf 'claude-multiacc: auto-resume cannot enter account selection.\n' >&2
201
+ exit 2
202
+ fi
199
203
  mcp_nested_mirror "$@" || true
200
204
  exec "$REAL" "$@"
201
205
  fi
@@ -300,9 +304,9 @@ sel_log() {
300
304
  # picked now?" — the real candidate loop, then `pick=<acct> tier=<t>` and exit, so the
301
305
  # watcher only kills a session there is somewhere to move to.
302
306
  # 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.
307
+ # not recognise all mean "exec exactly as before, unsupervised". A typed relaunch whose
308
+ # token is gone exits 2 instead of starting a fresh session. A cross-provider handoff
309
+ # also requires remaining quota, a startup receipt and successful watcher setup.
306
310
  # Tokens and relaunch/state files live in $ACC_ROOT/tmp/autoresume; the watcher prunes.
307
311
  AR_DIR="$ACC_ROOT/tmp/autoresume"
308
312
  AR_PROBE=0
@@ -311,6 +315,8 @@ AR_DEPTH=0 # relaunches so far in this chain (the watcher caps it)
311
315
  AR_AVOID="" # acct-NN:<until epoch>,... — accounts this chain just left
312
316
  AR_HIST="" # class:epoch,... — the watcher's hourly budgets
313
317
  AR_CHAIN="" # opaque chain id, minted and read by the watcher
318
+ AR_CROSS="" AR_HANDOFF_TOKEN=""
319
+ AR_AVOID_CLAUDE="" AR_AVOID_CODEX="" AR_POOL_CLAUDE="" AR_POOL_CODEX=""
314
320
  AR_LOADED=0
315
321
  AR_ARGV=()
316
322
  AR_ORIG_ARGV=()
@@ -337,7 +343,7 @@ ar_field_ok() { # $1 value
337
343
  # later, by ar_relaunch_apply, still ahead of any selection.
338
344
  ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields set
339
345
  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=""
346
+ local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain="" cross_bad=0
341
347
  # Alphanumerics only: the token becomes part of a path, so no '/' and no '..'.
342
348
  case "$tok" in *[!A-Za-z0-9]*) return 1 ;; esac
343
349
  [ "${#tok}" -ge 8 ] && [ "${#tok}" -le 64 ] || return 1
@@ -362,6 +368,15 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
362
368
  cwd) case "$v" in /*) cwd="$v" ;; esac ;;
363
369
  depth) num_ok "$v" && depth="$v" ;;
364
370
  avoid) ar_field_ok "$v" && avoid="$v" ;;
371
+ avoid_claude) ar_field_ok "$v" && AR_AVOID_CLAUDE="$v" || cross_bad=1 ;;
372
+ avoid_codex) ar_field_ok "$v" && AR_AVOID_CODEX="$v" || cross_bad=1 ;;
373
+ pool_claude) [ -z "$v" ] || { ar_path_ok "$v" && AR_POOL_CLAUDE="$v"; } || cross_bad=1 ;;
374
+ pool_codex) [ -z "$v" ] || { ar_path_ok "$v" && AR_POOL_CODEX="$v"; } || cross_bad=1 ;;
375
+ cross|handoff_token)
376
+ [ -n "$v" ] || continue
377
+ case "$v" in *[!A-Za-z0-9]*) cross_bad=1; continue ;; esac
378
+ [ "${#v}" -ge 8 ] && [ "${#v}" -le 64 ] || { cross_bad=1; continue; }
379
+ if [ "$k" = cross ]; then AR_CROSS="$v"; else AR_HANDOFF_TOKEN="$v"; fi ;;
365
380
  hist) ar_field_ok "$v" && hist="$v" ;;
366
381
  chain) ar_field_ok "$v" && [ "${#v}" -le 64 ] && chain="$v" ;;
367
382
  esac
@@ -380,6 +395,8 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
380
395
  fi
381
396
  # Single use, whatever the outcome: a token is never honoured twice.
382
397
  rm -f "$rf" "$af" 2>/dev/null
398
+ [ "$cross_bad" = 0 ] || ok=0
399
+ [ -z "$AR_HANDOFF_TOKEN" ] || { [ -z "$acct" ] && [ -z "$AR_CROSS" ]; } || ok=0
383
400
  if [ "$ok" != 1 ]; then
384
401
  AR_ARGV=()
385
402
  AR_R_SID="$sid"
@@ -587,14 +604,20 @@ ar_start_watcher() { # $1 provider, $2 picked acct dir
587
604
  printf '%s\n' "v=1" "provider=$prov" "pid=$$" "ppid=$PPID" "acct=${dir##*/}" \
588
605
  "acct_dir=$dir" "cwd=$PWD" "launched=$launched" "tmux=$TMUX" "pane=$TMUX_PANE" \
589
606
  "self=$SELF" "acc_root=$root" "depth=$AR_DEPTH" "avoid=$AR_AVOID" \
590
- "hist=$AR_HIST" "chain=$AR_CHAIN" 2>/dev/null > "$tmp" \
607
+ "hist=$AR_HIST" "chain=$AR_CHAIN" "avoid_claude=$AR_AVOID_CLAUDE" \
608
+ "avoid_codex=$AR_AVOID_CODEX" "pool_claude=$AR_POOL_CLAUDE" "pool_codex=$AR_POOL_CODEX" \
609
+ "handoff_token=$AR_HANDOFF_TOKEN" 2>/dev/null > "$tmp" \
591
610
  && mv -f "$tmp" "$st" 2>/dev/null \
592
611
  || { rm -f "$tmp" 2>/dev/null; return 1; }
612
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
613
+ "$AR_PY" -I "$SELF_DIR/../lib/autoresume_cross_dispatch.py" startup \
614
+ --state "$st" --token "$AR_HANDOFF_TOKEN" </dev/null || return 1
615
+ fi
593
616
  # Detached and never waited for; the watcher setsid()s itself at once. It inherits
594
617
  # this environment on purpose (the probe strips the account-specific part).
595
618
  ( trap '' INT QUIT HUP TSTP
596
619
  exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 &
597
- ) >/dev/null 2>&1
620
+ ) >/dev/null 2>&1 || return 1
598
621
  return 0
599
622
  }
600
623
 
@@ -642,7 +665,8 @@ fi
642
665
  # `exec "$SELF"`. A relaunch has spent its token by then, so it hands its chain (depth,
643
666
  # the accounts it left, its budgets) over in the environment — without it the re-entered
644
667
  # selection could land right back on the account the session just left. Only such a
645
- # re-entry (PREFLIGHT_DEPTH set) may use it, and never a probe or a fresh token.
668
+ # re-entry (PREFLIGHT_DEPTH set) may use it, and never a probe or a fresh token. The
669
+ # separate CROSS_CARRY keeps both pools and their avoid lists for later reverse hops.
646
670
  if [ -n "${CLAUDE_MULTIACC_AR_CARRY+x}" ]; then
647
671
  ar_c="$CLAUDE_MULTIACC_AR_CARRY"
648
672
  unset CLAUDE_MULTIACC_AR_CARRY
@@ -657,6 +681,22 @@ if [ -n "${CLAUDE_MULTIACC_AR_CARRY+x}" ]; then
657
681
  fi
658
682
  unset ar_c ar_d ar_a ar_h
659
683
  fi
684
+ if [ -n "${CLAUDE_MULTIACC_AR_CROSS_CARRY+x}" ]; then
685
+ if [ "$AR_PROBE" != 1 ] && [ "$AR_LOADED" != 1 ] && [ -n "${CLAUDE_MULTIACC_PREFLIGHT_DEPTH:-}" ]; then
686
+ IFS='|' read -r ar_t ar_c ar_x ar_pc ar_px ar_extra <<< "$CLAUDE_MULTIACC_AR_CROSS_CARRY"
687
+ if ar_field_ok "$ar_c" && ar_field_ok "$ar_x" && [ -z "$ar_extra" ] \
688
+ && { [ -z "$ar_pc" ] || ar_path_ok "$ar_pc"; } && { [ -z "$ar_px" ] || ar_path_ok "$ar_px"; }; then
689
+ case "$ar_t" in *[!A-Za-z0-9]*) exit 2 ;; esac
690
+ [ -z "$ar_t" ] || { [ "${#ar_t}" -ge 8 ] && [ "${#ar_t}" -le 64 ]; } || exit 2
691
+ AR_HANDOFF_TOKEN="$ar_t"; AR_AVOID_CLAUDE="$ar_c"; AR_AVOID_CODEX="$ar_x"
692
+ AR_POOL_CLAUDE="$ar_pc"; AR_POOL_CODEX="$ar_px"
693
+ else
694
+ printf 'claude-multiacc: auto-resume preflight state is invalid.\n' >&2
695
+ exit 2
696
+ fi
697
+ fi
698
+ unset CLAUDE_MULTIACC_AR_CROSS_CARRY ar_t ar_c ar_x ar_pc ar_px ar_extra
699
+ fi
660
700
  AR_ORIG_ARGV=(${1+"$@"})
661
701
 
662
702
  # ---- model-scoped limits ------------------------------------------------------
@@ -1752,6 +1792,14 @@ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven unusable
1752
1792
 
1753
1793
  # A relaunch marks the account it left before anything reads the pool (auto-resume).
1754
1794
  ar_relaunch_apply
1795
+ if [ -n "$AR_CROSS" ]; then
1796
+ [ -z "$AR_HANDOFF_TOKEN" ] && ar_python || exit 2
1797
+ exec "$AR_PY" -I "$SELF_DIR/../lib/autoresume_cross_dispatch.py" dispatch \
1798
+ --root "$ACC_ROOT" --token "$AR_CROSS"
1799
+ fi
1800
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1801
+ [ -z "${CLAUDE_ACCOUNT:-}" ] && ar_argv_ok "$@" || exit 2
1802
+ fi
1755
1803
 
1756
1804
  # Explicit pin wins over everything — markers, and even missing auth: the
1757
1805
  # add/login ceremony pins to a dir that has no credentials yet, and the login
@@ -1854,6 +1902,10 @@ fi
1854
1902
  # the pool is merely un-authenticated: this is the one case the user can actually fix.
1855
1903
  if [ "${#valid[@]}" -eq 0 ]; then
1856
1904
  [ "$AR_PROBE" = 1 ] && ar_probe_exit none
1905
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1906
+ printf 'claude-multiacc: auto-resume handoff has no usable account.\n' >&2
1907
+ exit 2
1908
+ fi
1857
1909
  if [ "${#expired[@]}" -gt 0 ]; then
1858
1910
  sel_log "all-expired: falling back to the default login (see: claude-accounts expired)"
1859
1911
  # Terminal only: a service-spawned `claude -p` must keep its stderr byte-clean, and
@@ -2099,6 +2151,10 @@ pick="$PICK_DIR"
2099
2151
  # An auto-resume probe ends here: before the token preflight (a real inference), the
2100
2152
  # model pin, remember_pick, the limits kick and the pick line — it chose nothing.
2101
2153
  [ "$AR_PROBE" = 1 ] && ar_probe_exit
2154
+ if [ -n "$AR_HANDOFF_TOKEN" ] && [ "${#eligible[@]}" -eq 0 ] && [ "${#soft[@]}" -eq 0 ]; then
2155
+ printf 'claude-multiacc: auto-resume handoff has no remaining quota.\n' >&2
2156
+ exit 2
2157
+ fi
2102
2158
 
2103
2159
  # The preflight can prove the selected token dead without exposing its 401 to the
2104
2160
  # caller. Re-enter selection so the new .expired marker is applied and another
@@ -2117,6 +2173,10 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
2117
2173
  # An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
2118
2174
  # outside every chain field's charset, so it separates them unambiguously.
2119
2175
  [ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
2176
+ if [ -n "$AR_HANDOFF_TOKEN$AR_AVOID_CLAUDE$AR_AVOID_CODEX$AR_POOL_CLAUDE$AR_POOL_CODEX" ]; then
2177
+ ar_cross_carry="$AR_HANDOFF_TOKEN|$AR_AVOID_CLAUDE|$AR_AVOID_CODEX"
2178
+ export CLAUDE_MULTIACC_AR_CROSS_CARRY="$ar_cross_carry|$AR_POOL_CLAUDE|$AR_POOL_CODEX"
2179
+ fi
2120
2180
  exec "$SELF" "$@"
2121
2181
  fi
2122
2182
  printf 'claude-multiacc: portable token preflight retry limit reached\n' >&2
@@ -2262,7 +2322,10 @@ if [ "$wants_retry" = "0" ]; then
2262
2322
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2263
2323
  sel_capture_session "$pick"
2264
2324
  ar_trust "$pick"
2265
- ar_spawn_watcher claude "$pick" || true
2325
+ if ! ar_spawn_watcher claude "$pick" && [ -n "$AR_HANDOFF_TOKEN" ]; then
2326
+ printf 'claude-multiacc: auto-resume handoff could not start its watcher.\n' >&2
2327
+ exit 2
2328
+ fi
2266
2329
  exec "$REAL" "$@"
2267
2330
  fi
2268
2331
 
package/bin/codex CHANGED
@@ -181,6 +181,10 @@ if [ -n "${CODEX_HOME:-}" ] \
181
181
  # An auto-resume probe (see "auto-resume" below) asks which account selection would
182
182
  # pick; with no selection to run, the answer is none — it must never start a session.
183
183
  if [ "${CODEX_MULTIACC_AR_PROBE:-0}" = "1" ]; then printf 'pick= tier=none\n'; exit 3; fi
184
+ if [ -n "${CODEX_MULTIACC_AR:-}" ]; then
185
+ printf 'codex-multiacc: auto-resume cannot enter account selection.\n' >&2
186
+ exit 2
187
+ fi
184
188
  mcp_nested_mirror "$@" || true
185
189
  exec "$REAL" "$@"
186
190
  fi
@@ -749,14 +753,16 @@ auth_dead() { expired_marked "$1"; }
749
753
  # picks the next account. bin/claude carries the same hooks; docs/AUTORESUME.md is the
750
754
  # reference. Fail open everywhere: no network; the local CLI mode probe is bounded to
751
755
  # two seconds, and every path that cannot finish falls through
752
- # to exactly today's exec. The one exception is a typed relaunch whose token is gone: it
753
- # names the session to resume and exits 2, because its own (empty) argv would start a
754
- # fresh session in the stopped one's place.
756
+ # to exactly today's exec. A typed relaunch whose token is gone exits 2 instead of
757
+ # starting a fresh session. A cross-provider handoff also requires remaining quota,
758
+ # a startup receipt and successful watcher setup.
755
759
  AR_DIR="$ACC_ROOT/tmp/autoresume"
756
760
  AR_DEPTH=0 # how many relaunches this session's chain has already taken
757
761
  AR_AVOID="" # acct-NN:until,... — accounts the chain must not land on again
758
762
  AR_HIST="" # class:epoch,... — the chain's verdicts, for the watcher's budgets
759
763
  AR_CHAIN="" # the chain's id, as the watcher named it
764
+ AR_CROSS="" AR_HANDOFF_TOKEN=""
765
+ AR_AVOID_CLAUDE="" AR_AVOID_CODEX="" AR_POOL_CLAUDE="" AR_POOL_CODEX=""
760
766
  AR_ARGV=()
761
767
  AR_ORIG_ARGV=()
762
768
  AR_NO_DAEMON=0
@@ -898,7 +904,7 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
898
904
  ar_relaunch() { # $1 token
899
905
  local tok="$1" base f line k v n=0 a age
900
906
  local ver="" prov="" cls="" acct="" sid="" cwd="" depth="" avoid="" hist="" chain=""
901
- local funding="" funding_pid=""
907
+ local funding="" funding_pid="" cross_bad=0
902
908
  ar_word_ok "$tok" 8 || return 1
903
909
  base="$AR_DIR/r-$tok"
904
910
  f="$base.relaunch"
@@ -920,6 +926,14 @@ ar_relaunch() { # $1 token
920
926
  cwd) case "$v" in /*) cwd="$v" ;; esac ;;
921
927
  depth) num_ok "$v" && depth="$v" ;;
922
928
  avoid) avoid="$v" ;;
929
+ avoid_claude|avoid_codex)
930
+ case "$v" in *[!A-Za-z0-9:,._-]*) cross_bad=1; continue ;; esac
931
+ [ "${#v}" -le 2048 ] || { cross_bad=1; continue; }
932
+ if [ "$k" = avoid_claude ]; then AR_AVOID_CLAUDE="$v"; else AR_AVOID_CODEX="$v"; fi ;;
933
+ pool_claude) [ -z "$v" ] || { ar_path_ok "$v" && AR_POOL_CLAUDE="$v"; } || cross_bad=1 ;;
934
+ pool_codex) [ -z "$v" ] || { ar_path_ok "$v" && AR_POOL_CODEX="$v"; } || cross_bad=1 ;;
935
+ cross) [ -z "$v" ] || { ar_word_ok "$v" 8 && AR_CROSS="$v"; } || cross_bad=1 ;;
936
+ handoff_token) [ -z "$v" ] || { ar_word_ok "$v" 8 && AR_HANDOFF_TOKEN="$v"; } || cross_bad=1 ;;
923
937
  hist) hist="$v" ;;
924
938
  chain) ar_word_ok "$v" 1 && chain="$v" ;;
925
939
  funding_tier) [ "$v" = credits ] && funding=credits ;;
@@ -927,8 +941,10 @@ ar_relaunch() { # $1 token
927
941
  rollout_cursor) case "$v" in *[!0-9:]*|'') ;; *) AR_ROLLOUT_CURSOR="$v" ;; esac ;;
928
942
  esac
929
943
  done < "$f"
944
+ [ -z "$AR_HANDOFF_TOKEN" ] || { [ -z "$acct" ] && [ -z "$AR_CROSS" ]; } || cross_bad=1
930
945
  age=$((now - $(file_mtime "$f")))
931
- if [ "$ver" != "1" ] || [ "$prov" != "codex" ] || [ "$age" -gt 600 ] || [ ! -f "$base.argv" ]; then
946
+ if [ "$cross_bad" = 1 ] || [ "$ver" != "1" ] || [ "$prov" != "codex" ] \
947
+ || [ "$age" -gt 600 ] || [ ! -f "$base.argv" ]; then
932
948
  rm -f "$f" "$base.argv" 2>/dev/null
933
949
  AR_R_SID="$sid"
934
950
  return 1
@@ -979,16 +995,24 @@ ar_relaunch() { # $1 token
979
995
  # A positional must contain a space to count as a prompt: a single word may be a
980
996
  # subcommand.
981
997
  ar_argv_ok() { # the argv codex is about to get
982
- local a resume=0 sid=0 prompt=0 want=0
998
+ local a resume=0 sid=0 prompt=0 want="" sandbox=0 approval=0 bypass=0
983
999
  for a in "$@"; do
984
- if [ "$want" = 1 ]; then
985
- case "$a" in ''|-*) return 1 ;; esac
986
- want=0
1000
+ if [ -n "$want" ]; then
1001
+ case "$want:$a" in
1002
+ sandbox:read-only) sandbox=1 ;;
1003
+ approval:on-request) approval=1 ;;
1004
+ model:*) case "$a" in ''|-*) return 1 ;; esac ;;
1005
+ *) return 1 ;;
1006
+ esac
1007
+ want=""
987
1008
  continue
988
1009
  fi
989
1010
  case "$a" in
990
- --dangerously-bypass-approvals-and-sandbox|--yolo|--no-daemon) ;;
991
- -m|--model) want=1 ;;
1011
+ --dangerously-bypass-approvals-and-sandbox|--yolo) bypass=1 ;;
1012
+ --no-daemon) ;;
1013
+ --sandbox) [ "$sandbox" = 0 ] || return 1; want=sandbox ;;
1014
+ --ask-for-approval) [ "$approval" = 0 ] || return 1; want=approval ;;
1015
+ -m|--model) want=model ;;
992
1016
  --model=?*) ;;
993
1017
  --last)
994
1018
  [ "$resume" = 1 ] && [ "$sid" = 0 ] || return 1
@@ -1007,7 +1031,8 @@ ar_argv_ok() { # the argv codex is about to get
1007
1031
  fi ;;
1008
1032
  esac
1009
1033
  done
1010
- [ "$want" = 0 ] || return 1
1034
+ [ -z "$want" ] && [ "$sandbox" = "$approval" ] || return 1
1035
+ [ "$sandbox" = 0 ] || [ "$bypass" = 0 ] || return 1
1011
1036
  # A bare `resume` opens the session picker: there is no session to follow yet.
1012
1037
  [ "$resume" = 0 ] || [ "$sid" = 1 ]
1013
1038
  }
@@ -1102,16 +1127,23 @@ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is a
1102
1127
  printf 'acct=%s\nacct_dir=%s\ncwd=%s\nlaunched=%s\n' "${pick##*/}" "$pick" "$PWD" "$(date +%s)"
1103
1128
  printf 'tmux=%s\npane=%s\nself=%s\nacc_root=%s\n' "$TMUX" "$TMUX_PANE" "$SELF" "$root"
1104
1129
  printf 'depth=%s\navoid=%s\nhist=%s\nchain=%s\n' "$AR_DEPTH" "$AR_AVOID" "$AR_HIST" "$AR_CHAIN"
1130
+ printf 'avoid_claude=%s\navoid_codex=%s\n' "$AR_AVOID_CLAUDE" "$AR_AVOID_CODEX"
1131
+ printf 'pool_claude=%s\npool_codex=%s\n' "$AR_POOL_CLAUDE" "$AR_POOL_CODEX"
1132
+ printf 'handoff_token=%s\n' "$AR_HANDOFF_TOKEN"
1105
1133
  printf 'funding_tier=%s\n' "$AR_TIER"
1106
1134
  printf 'rollout_cursor=%s\n' "$AR_ROLLOUT_CURSOR"
1107
1135
  } 2>/dev/null > "$st.tmp" && mv -f "$st.tmp" "$st" 2>/dev/null || {
1108
1136
  rm -f "$st.tmp" "$AR_DIR/$$.argv" 2>/dev/null
1109
1137
  return 1
1110
1138
  }
1139
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1140
+ "$AR_PY" -I "$SELF_DIR/../lib/autoresume_cross_dispatch.py" startup \
1141
+ --state "$st" --token "$AR_HANDOFF_TOKEN" </dev/null || return 1
1142
+ fi
1111
1143
  # Detached and never waited on: the subshell returns as soon as the watcher is forked,
1112
1144
  # and the watcher ignores the terminal's signals until its own setsid().
1113
1145
  ( trap '' INT QUIT HUP TSTP
1114
- exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 & ) >/dev/null 2>&1
1146
+ exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 & ) >/dev/null 2>&1 || return 1
1115
1147
  [ "$mode" != --no-daemon ] || AR_NO_DAEMON=1
1116
1148
  return 0
1117
1149
  }
@@ -1141,6 +1173,14 @@ if [ -n "${CODEX_MULTIACC_AR+x}" ]; then
1141
1173
  fi
1142
1174
  [ -n "${CODEX_MULTIACC_AR_AVOID:-}" ] && ar_avoid_add "$CODEX_MULTIACC_AR_AVOID"
1143
1175
  AR_ORIG_ARGV=("$@")
1176
+ if [ -n "$AR_CROSS" ]; then
1177
+ [ -z "$AR_HANDOFF_TOKEN" ] && ar_find_python || exit 2
1178
+ exec "$AR_PY" -I "$SELF_DIR/../lib/autoresume_cross_dispatch.py" dispatch \
1179
+ --root "$ACC_ROOT" --token "$AR_CROSS"
1180
+ fi
1181
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1182
+ [ -z "${CODEX_ACCOUNT:-}" ] && ar_argv_ok "$@" || exit 2
1183
+ fi
1144
1184
 
1145
1185
  # Explicit pin wins over everything — markers, and even missing auth: the
1146
1186
  # add/login ceremony pins to a dir that has no credentials yet, and the login
@@ -1217,6 +1257,10 @@ fi
1217
1257
  if [ "${#valid[@]}" -eq 0 ]; then
1218
1258
  # A probe has nothing to offer and must never exec (nor log a fallback it never took).
1219
1259
  if [ "$AR_PROBE" = "1" ]; then printf 'pick= tier=none\n'; exit 3; fi
1260
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1261
+ printf 'codex-multiacc: auto-resume handoff has no usable account.\n' >&2
1262
+ exit 2
1263
+ fi
1220
1264
  if [ "${#expired[@]}" -gt 0 ]; then
1221
1265
  sel_log "all-expired: falling back to the default login (see: codex-accounts expired)"
1222
1266
  # Terminal only: a service-spawned `codex exec` must keep its stderr byte-clean,
@@ -1430,6 +1474,12 @@ fi
1430
1474
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
1431
1475
  # CODEX_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
1432
1476
  # not a turn in the rotation.
1477
+ if [ -n "$AR_HANDOFF_TOKEN" ]; then
1478
+ case "$AR_TIER" in
1479
+ eligible|soft|credits) ;;
1480
+ *) printf 'codex-multiacc: auto-resume handoff has no remaining quota.\n' >&2; exit 2 ;;
1481
+ esac
1482
+ fi
1433
1483
  remember_pick "$pick"
1434
1484
  if [ "$AR_TIER" = "credits" ]; then
1435
1485
  "$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" record "$pick" "$$" 2>/dev/null || true
@@ -1507,7 +1557,10 @@ if [ "$wants_retry" = "0" ]; then
1507
1557
  # Auto-resume: a gated, detached watcher beside this exec — the exec itself (same pid,
1508
1558
  # same environment minus the auto-resume variables). Supported clients own their
1509
1559
  # task through --no-daemon, including the continuation passed on resume.
1510
- ar_spawn_watcher codex "$pick" "$@" || true
1560
+ if ! ar_spawn_watcher codex "$pick" "$@" && [ -n "$AR_HANDOFF_TOKEN" ]; then
1561
+ printf 'codex-multiacc: auto-resume handoff could not start its watcher.\n' >&2
1562
+ exit 2
1563
+ fi
1511
1564
  ar_scrub_env
1512
1565
  [ "$AR_NO_DAEMON" != 1 ] || set -- --no-daemon "$@"
1513
1566
  exec "$REAL" "$@"
@@ -0,0 +1,84 @@
1
+ # Continuing between Codex and Claude
2
+
3
+ When a supervised tmux session hits a usage limit, auto-resume first looks for another
4
+ usable account from the same provider. If that pool is exhausted, it checks the other
5
+ provider: Codex can continue in Claude, and Claude can continue in Codex.
6
+
7
+ The destination uses its normal account-selection policy. Eligible accounts, soft
8
+ fallback with remaining quota, and configured credits keep their existing meaning.
9
+ An exhausted destination account is not usable. If neither provider can serve, the
10
+ source client stays running and the watcher checks again later.
11
+
12
+ This fallback applies to quota errors. Login failures, model-specific limits, transient
13
+ errors and crashes retain their existing same-provider recovery behavior.
14
+
15
+ ## Conversation continuity
16
+
17
+ A provider change starts a **new destination conversation**. Its first message names:
18
+
19
+ - the source provider and exact conversation ID;
20
+ - the validated absolute path to that conversation's local history;
21
+ - the original working directory and instructions to read the history before continuing.
22
+
23
+ The destination agent must recover the user's latest objective, completed work and
24
+ remaining work from that history. It also checks whether background processes or
25
+ subagents remain active before restarting or duplicating them. The source history stays
26
+ available; a Codex ID is never passed as a Claude native `--resume` ID, or vice versa.
27
+
28
+ The watcher uses the history already bound to the source client. It does not guess from
29
+ the newest transcript or directory name. Invalid, ambiguous or unavailable source
30
+ history prevents the handoff before the source client is stopped.
31
+ Codex main conversations marked `source=cli` or `source=vscode` are supported when
32
+ resumed in a supervised TUI; their exact conversation and file identity still must match.
33
+
34
+ If another live client holds the same source conversation, the watcher holds to avoid
35
+ creating duplicate destination conversations. Stale process records do not count.
36
+
37
+ Success requires the destination to persist the exact handoff message as user input.
38
+ Starting the shell command or consuming a relaunch token is insufficient. The destination
39
+ then gets its own watcher, with the existing rotation chain and budgets preserved.
40
+ Account avoidance is separate for each provider, so Claude's `acct-01` and Codex's
41
+ `acct-01` cannot be confused.
42
+
43
+ ## Permissions and arguments
44
+
45
+ The working directory is preserved. Source model names, effort settings, session
46
+ selectors and the original launch prompt are not replayed as destination CLI arguments.
47
+ The destination uses its own configured model.
48
+
49
+ An explicit permission bypass maps to the equivalent destination bypass. Merely allowing
50
+ a bypass flag is not treated as enabling it. Ordinary launches use explicit conservative
51
+ destination permissions so a pool default cannot silently grant broader access.
52
+ Codex receives `--sandbox read-only --ask-for-approval on-request`; Claude receives
53
+ `--permission-mode default`. Existing permission prompts may therefore still need you.
54
+ Restrictive modes without a safe equivalent keep the source session running instead of
55
+ relaxing its permissions.
56
+
57
+ ## Controls
58
+
59
+ Cross-provider quota fallback is enabled by default. Disable it for launches from an
60
+ environment while retaining same-provider auto-resume:
61
+
62
+ ```bash
63
+ CODEX_MULTIACC_CROSS_PROVIDER=0 codex
64
+ CLAUDE_MULTIACC_CROSS_PROVIDER=0 claude
65
+ ```
66
+
67
+ Both providers' normal disable settings and auto-resume kill switches apply. For example,
68
+ `~/.claude-accounts/autoresume.off` prevents a Codex quota recovery from starting Claude.
69
+ Custom pools use their configured roots. The destination CLI must be installed and its
70
+ pool must have an account that can serve.
71
+
72
+ The process safeguards are the same as [ordinary auto-resume](AUTORESUME.md): validate
73
+ the client and pane, prepare the relaunch, stop only that client's job, then type into
74
+ the launching shell. No recovery keys are sent to a running TUI.
75
+
76
+ ## Limits
77
+
78
+ Conversation history provides context; provider-specific in-memory state, running
79
+ subagents and native goal databases are not migrated. The continuation instructions
80
+ require the destination to inspect existing work before proceeding.
81
+
82
+ If capacity disappears between the probe and launch, the destination refuses to start
83
+ on an exhausted account. Missing confirmation of the handoff is reported as a failure,
84
+ not as a successful continuation.
@@ -2,8 +2,10 @@
2
2
 
3
3
  An interactive `claude` or `codex` session that stops on a usage limit, a failed login, an
4
4
  API error or (claude only) a crash is restarted automatically on another pooled account
5
- with headroom. It keeps the **same session id, directory and flags**, and you press no
6
- keys. This covers sessions launched from a shell inside **tmux**. Every other launch runs
5
+ with headroom. Within the same provider it keeps the **same session id, directory and
6
+ flags**, and you press no keys. When a quota recovery has no usable account left in that
7
+ provider, it can continue in the other provider using the source conversation's history.
8
+ This covers sessions launched from a shell inside **tmux**. Every other launch runs
7
9
  exactly as it did before.
8
10
 
9
11
  It is on by default. The kill switches:
@@ -98,6 +100,11 @@ shell: runs the shim again → token → marker on the old account → normal
98
100
  serve; an exhausted account or one with an active client rejection cannot.
99
101
  - **Login, refusal and model-limit errors** still require a different account below
100
102
  the exclusion threshold.
103
+ - **Quota recovery can cross providers.** Only after the source pool has no usable
104
+ account, the watcher checks the other provider's pool. It starts a fresh target
105
+ conversation with the exact source provider, session ID and validated history path.
106
+ Same-provider flags and session IDs are not blindly passed to the other CLI.
107
+ See [cross-provider continuation](AUTORESUME-HANDOFF.md).
101
108
  - **Otherwise the watcher holds.** Nothing is stopped, and Claude's own "continuing
102
109
  automatically" keeps running. The probe repeats every 60 s for as long as the error
103
110
  stands.
@@ -258,8 +265,10 @@ launch.
258
265
  `-c` / `--continue`, `-r` / `--resume <uuid>` (or `--resume=<uuid>`), `--model <m>`,
259
266
  `--effort <e>`, `--permission-mode <m>`, and at most one prompt argument that
260
267
  contains a space.
261
- - **codex:** `--dangerously-bypass-approvals-and-sandbox`, `--yolo`, `-m` / `--model <m>`,
262
- `resume <uuid>|--last`, and at most one prompt that contains a space.
268
+ - **codex:** `--dangerously-bypass-approvals-and-sandbox`, `--yolo`, `--no-daemon`,
269
+ `-m` / `--model <m>`, `resume <uuid>|--last`, and at most one prompt that contains a
270
+ space. The conservative pair `--sandbox read-only --ask-for-approval on-request`
271
+ is also supported; partial, conflicting or other sandbox/approval combinations are not.
263
272
  - **Anything else means no watcher.** That includes `-p` (for codex, `-p` is
264
273
  `--profile`), a subcommand, `--fork-session` and any single-word argument, which
265
274
  could be a subcommand.
@@ -292,6 +301,9 @@ tokens.
292
301
  | `giveup` | watcher | could not relaunch safely (`reason=` pane, shell, pgrp, holder, tree, relaunch, …); with `stopped=1` the session was stopped and `resume=` names the command to run |
293
302
  | `relaunch` | the relaunched shim | consumed the token (`chain=`, `from=`, `class=`, `depth=`) |
294
303
 
304
+ Cross-provider `switch` records also include `to_provider` and the newly confirmed
305
+ destination conversation's `to_sid`; `sid` continues to identify the source conversation.
306
+
295
307
  Around these lines:
296
308
 
297
309
  - The new pick logs its usual selection line.
@@ -347,7 +359,6 @@ CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_AR_AVOID=acct-07:1790000000 claude
347
359
  (`goals_1.sqlite`), so set the goal again after a codex rotation. Claude restores a goal
348
360
  from the transcript on resume.
349
361
  - **Out of scope for this version:**
350
- - cross-provider continuation;
351
362
  - proactive switching before a limit is hit;
352
363
  - an agent that simply ends its turn early, which is not an error; use `/goal` for
353
364
  that.