claude-multiacc 2.0.40 → 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.
Files changed (30) hide show
  1. package/README.md +49 -2
  2. package/bin/claude +394 -2
  3. package/bin/codex +406 -4
  4. package/docs/ACCOUNT_OPERATIONS.md +2 -1
  5. package/docs/AUTORESUME.md +321 -0
  6. package/docs/CODEX.md +12 -1
  7. package/docs/VERIFICATION.md +9 -1
  8. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  18. package/lib/autoresume.py +2271 -0
  19. package/lib/report.py +16 -1
  20. package/package.json +1 -1
  21. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  22. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  23. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  28. package/tests/run-tests.sh +935 -3
  29. package/tests/test_autoresume.py +2565 -0
  30. package/tests/test_claude_reset.py +30 -0
@@ -5,7 +5,8 @@
5
5
  set -u
6
6
 
7
7
  REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
8
- WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")"
8
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")" || exit 1
9
+ [ -n "$WORK" ] && [ -d "$WORK" ] || exit 1
9
10
  # Loopback stub servers register their pid here. Deleting $WORK does not kill a running
10
11
  # python process, so an interrupted run (CI cancel, Ctrl-C) would otherwise leave one
11
12
  # listening until the machine goes away.
@@ -16,7 +17,13 @@ cleanup() {
16
17
  kill "$p" 2>/dev/null
17
18
  wait "$p" 2>/dev/null || true
18
19
  done
19
- rm -rf "$WORK"
20
+ # The auto-resume sections reap their own processes; this is the backstop for an
21
+ # interrupted run. Every fake client and watcher they start carries $WORK in its argv.
22
+ # Never with an empty WORK: `pkill -f /` would match nearly every process the user owns.
23
+ if [ -n "${WORK:-}" ] && [ -d "$WORK" ]; then
24
+ pkill -KILL -f "$WORK/" 2>/dev/null || true
25
+ rm -rf "$WORK"
26
+ fi
20
27
  }
21
28
  trap cleanup EXIT
22
29
 
@@ -169,6 +176,56 @@ if [ "${1:-}" = "setup-token" ]; then
169
176
  echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
170
177
  exit 0
171
178
  fi
179
+ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
180
+ # The auto-resume sections' INTERACTIVE client: it behaves the way the real TUI does at
181
+ # an API error — registers sessions/<pid>.json in the account dir, writes the error
182
+ # record into the (shared) transcript and then IDLES; SIGTERM deletes the registry and
183
+ # exits 143, and a crash (SIGKILL) leaves the registry behind. The scripted error only
184
+ # fires on a fresh launch; a `--resume <sid>` launch appends what the real client does
185
+ # after the auto-resume prompt — a normal reply — unless FAKE_TUI_QUIET is set.
186
+ acct="$(basename "$CLAUDE_CONFIG_DIR")"
187
+ sid=""; prev=""
188
+ for a in "$@"; do
189
+ case "$prev" in -r|--resume) sid="$a" ;; esac
190
+ case "$a" in --resume=*) sid="${a#--resume=}" ;; esac
191
+ prev="$a"
192
+ done
193
+ resumed=0; [ -n "$sid" ] && resumed=1
194
+ [ -n "$sid" ] || sid="${FAKE_TUI_SID:-5f0c1d2e-3a4b-4c5d-8e9f-a0b1c2d3e4f5}"
195
+ [ -n "${FAKE_TUI_PIDS:-}" ] && echo "$$" >> "$FAKE_TUI_PIDS"
196
+ echo "CFG=$acct ARGS=$*" >> "${FAKE_TUI_LOG:-/dev/null}"
197
+ proj="$CLAUDE_CONFIG_DIR/projects/-fake-tui"
198
+ mkdir -p "$CLAUDE_CONFIG_DIR/sessions" "$proj"
199
+ tr="$proj/$sid.jsonl"
200
+ ts() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
201
+ apierr() { # $1 error code, $2 extra JSON members (with a leading comma) or empty
202
+ printf '{"type":"assistant","isApiErrorMessage":true,"error":"%s"%s,"timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"content":[{"type":"text","text":"API Error"}]}}\n' \
203
+ "$1" "${2:-}" "$(ts)" "$sid" >> "$tr"
204
+ }
205
+ if [ "$resumed" = 1 ] && [ -z "${FAKE_TUI_QUIET:-}" ]; then
206
+ printf '{"type":"assistant","timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"content":[{"type":"text","text":"Resumed."}]}}\n' \
207
+ "$(ts)" "$sid" >> "$tr"
208
+ fi
209
+ reg="$CLAUDE_CONFIG_DIR/sessions/$$.json"
210
+ printf '{"pid":%s,"sessionId":"%s","cwd":"%s","kind":"interactive","status":"busy","startedAt":%s000}\n' \
211
+ "$$" "$sid" "$PWD" "$(date +%s)" > "$reg"
212
+ trap 'rm -f "$reg"; exit 143' TERM
213
+ trap 'rm -f "$reg"; exit 129' HUP
214
+ if [ "$resumed" = 0 ]; then
215
+ sleep "${FAKE_TUI_DELAY:-0.3}"
216
+ case "$FAKE_TUI" in
217
+ quota) apierr rate_limit ",\"quotaLimits\":{\"status\":\"rejected\",\"resetsAt\":$(( $(date +%s) + 7200 )),\"rateLimitType\":\"five_hour\"}" ;;
218
+ auth) apierr authentication_failed ;;
219
+ invalid) apierr invalid_request ;;
220
+ crash) sleep 0.3; kill -9 $$ ;;
221
+ esac
222
+ fi
223
+ # Bounded, in short sleeps so a TERM is handled within a tenth of a second.
224
+ i=0
225
+ while [ "$i" -lt "${FAKE_TUI_LIFE:-400}" ]; do sleep 0.1; i=$((i + 1)); done
226
+ rm -f "$reg"
227
+ exit 0
228
+ fi
172
229
  if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
173
230
  # simulate an interactive session in which the user completed /login
174
231
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-new","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$CLAUDE_CONFIG_DIR/.credentials.json"
@@ -260,6 +317,8 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
260
317
  sleep "${FAKE_SESSION_HOLD:-4}"
261
318
  rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
262
319
  fi
320
+ # what an interactive client would inherit from the shim (auto-resume must leak nothing)
321
+ [ -n "${FAKE_PRINT_ENV:-}" ] && echo "AR_ENV=$(env | grep -c '^CLAUDE_MULTIACC_AR')"
263
322
  echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
264
323
  EOF
265
324
  chmod +x "$FAKEBIN/claude"
@@ -352,6 +411,12 @@ export CLAUDE_MULTIACC_KEYCHAIN=0
352
411
  # runs under the operator's real HOME, whose rc files are not under test — section 18
353
412
  # turns the probe on against its own fake HOME.
354
413
  export CLAUDE_MULTIACC_PATH_PROBE=0
414
+ # Auto-resume spawns a detached watcher for an interactive launch inside tmux. The suite
415
+ # may itself run inside tmux, and no legacy section is about auto-resume, so it is off
416
+ # (and the pane invisible) everywhere except the sections that turn it on against a fake
417
+ # tmux — a watcher here would otherwise type into the developer's REAL pane.
418
+ export CLAUDE_MULTIACC_AUTORESUME=0 CODEX_MULTIACC_AUTORESUME=0
419
+ unset TMUX TMUX_PANE 2>/dev/null || true
355
420
 
356
421
  now="$(date +%s)"
357
422
 
@@ -4852,6 +4917,61 @@ with open(path + ".tmp", "w") as f:
4852
4917
  os.replace(path + ".tmp", path)
4853
4918
  EOF
4854
4919
 
4920
+ # The auto-resume sections' INTERACTIVE codex (sourced by the fake below when FAKE_TUI is
4921
+ # set): it writes a rollout the way the real TUI does — line 1 session_meta (id ==
4922
+ # session_id, cwd, originator codex-tui), held open like the native binary holds it — then
4923
+ # the scripted token_count + task_complete error, and IDLES; SIGTERM exits 0 (what the node
4924
+ # wrapper reports). A `resume <sid>` launch appends task_started (the resumed turn) to the
4925
+ # SAME rollout instead of an error.
4926
+ cat > "$WORK/fake-tui-codex.sh" <<'EOF'
4927
+ acct="$(basename "$CODEX_HOME")"
4928
+ sid=""; resumed=0
4929
+ if [ "${1:-}" = resume ]; then
4930
+ resumed=1
4931
+ for a in "$@"; do
4932
+ case "$a" in
4933
+ *' '*) ;;
4934
+ ????????-????-????-????-????????????) [ -n "$sid" ] || sid="$a" ;;
4935
+ esac
4936
+ done
4937
+ fi
4938
+ [ -n "${FAKE_TUI_PIDS:-}" ] && echo "$$" >> "$FAKE_TUI_PIDS"
4939
+ echo "CFG=$acct ARGS=$*" >> "${FAKE_TUI_LOG:-/dev/null}"
4940
+ ts() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
4941
+ ro=""
4942
+ if [ -n "$sid" ]; then
4943
+ for f in "$CODEX_HOME"/sessions/*/*/*/rollout-*-"$sid".jsonl; do [ -f "$f" ] && ro="$f"; done
4944
+ fi
4945
+ if [ -z "$ro" ]; then
4946
+ [ -n "$sid" ] || sid="${FAKE_TUI_SID:-6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d}"
4947
+ day="$CODEX_HOME/sessions/$(date +%Y/%m/%d)"
4948
+ mkdir -p "$day"
4949
+ ro="$day/rollout-$(date +%Y-%m-%dT%H-%M-%S)-$sid.jsonl"
4950
+ printf '{"timestamp":"%s","type":"session_meta","payload":{"id":"%s","session_id":"%s","timestamp":"%s","cwd":"%s","originator":"codex-tui","cli_version":"0.0.0","source":"cli"}}\n' \
4951
+ "$(ts)" "$sid" "$sid" "$(ts)" "$PWD" > "$ro"
4952
+ fi
4953
+ exec 9>>"$ro"
4954
+ trap 'exit 0' TERM
4955
+ trap 'exit 0' HUP
4956
+ if [ "$resumed" = 1 ]; then
4957
+ [ -n "${FAKE_TUI_QUIET:-}" ] \
4958
+ || printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_started","turn_id":"2"}}\n' "$(ts)" >&9
4959
+ else
4960
+ sleep "${FAKE_TUI_DELAY:-0.3}"
4961
+ case "$FAKE_TUI" in
4962
+ quota)
4963
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"premium","primary":null,"secondary":null}}}\n' "$(ts)" >&9
4964
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"codex","primary":{"used_percent":100.0,"window_minutes":300,"resets_at":%s},"secondary":{"used_percent":40.0,"window_minutes":10080,"resets_at":%s}}}}\n' \
4965
+ "$(ts)" "$(( $(date +%s) + 5400 ))" "$(( $(date +%s) + 500000 ))" >&9
4966
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_complete","turn_id":"1","last_agent_message":null,"error":{"message":"You'"'"'ve hit your usage limit. Upgrade to Pro, or try again at Sep 26th, 2026 11:22 AM.","codex_error_info":"usage_limit_exceeded"},"completed_at":%s}}\n' \
4967
+ "$(ts)" "$(date +%s)" >&9 ;;
4968
+ esac
4969
+ fi
4970
+ i=0
4971
+ while [ "$i" -lt "${FAKE_TUI_LIFE:-400}" ]; do sleep 0.1; i=$((i + 1)); done
4972
+ exit 0
4973
+ EOF
4974
+
4855
4975
  # Fake "real" codex: prints which CODEX_HOME it ran under; scriptable login +
4856
4976
  # failures via a control file. (Note: must not contain the hyphenated shim marker.)
4857
4977
  cat > "$FAKEBIN/codex" <<EOF
@@ -4864,6 +4984,7 @@ if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
4864
4984
  [ "\$rc" = 0 ] && [ -n "\${FAKE_MCP_WRITE_THEN_FAIL:-}" ] && exit 1
4865
4985
  exit \$rc
4866
4986
  fi
4987
+ if [ -n "\${FAKE_TUI:-}" ] && [ -n "\${CODEX_HOME:-}" ]; then . "$WORK/fake-tui-codex.sh"; fi
4867
4988
  if [ "\${1:-}" = "login" ]; then
4868
4989
  shift
4869
4990
  # record the flags the CLI chose (device-auth default vs --browser opt-out)
@@ -4899,6 +5020,8 @@ for a in "\$@"; do
4899
5020
  prev="\$a"
4900
5021
  done
4901
5022
  [ -n "\$outfile" ] && printf 'OK' > "\$outfile"
5023
+ # what an interactive client would inherit from the shim (auto-resume must leak nothing)
5024
+ [ -n "\${FAKE_PRINT_ENV:-}" ] && echo "AR_ENV=\$(env | grep -c '^CODEX_MULTIACC_AR')"
4902
5025
  echo "CFG=\$acct"
4903
5026
  EOF
4904
5027
  chmod +x "$FAKEBIN/codex"
@@ -6117,7 +6240,7 @@ printf '%s' "$out" | grep -q "acct-01" \
6117
6240
  && t_fail "codex fetch throttle" "fresh account was re-fetched: $out" \
6118
6241
  || t_ok "codex: fresh account skipped by the fetch throttle"
6119
6242
  # 429 backoff honored
6120
- python3 - "$CX/acct-02/limits.json" "$now" <<'EOF'
6243
+ python3 - "$CX/acct-02/limits.json" "$(date +%s)" <<'EOF'
6121
6244
  import json, sys
6122
6245
  json.dump({'fetched_at': 0, 'retry_after': int(sys.argv[2]) + 300, 'backoff': 120},
6123
6246
  open(sys.argv[1], 'w'))
@@ -7885,6 +8008,808 @@ fi
7885
8008
  out="$(HOME="$RCH" claude-accounts status 2>&1)"
7886
8009
  check "status honours CLAUDE_MULTIACC_PATH_PROBE=0" "probe disabled" "$out"
7887
8010
 
8011
+ # ============================ AUTO-RESUME (end to end) ============================
8012
+ # An interactive session that stops on a usage limit, a dead login or a crash is resumed
8013
+ # on another account: the shim spawns lib/autoresume.py beside its UNCHANGED exec, the
8014
+ # watcher tails the session's transcript (codex: rollout), stops the TUI and types the
8015
+ # relaunch into the pane's SHELL, and the relaunched shim marks the old account before it
8016
+ # selects. Here the TUI is the fakes' FAKE_TUI mode and tmux is a fake that never talks to
8017
+ # a server: display-message answers from the environment, and Enter runs the typed line
8018
+ # with `bash -c` in the background — the pane's shell, with the pane's environment (none
8019
+ # of the client's CLAUDE_CONFIG_DIR/CODEX_HOME exports). Every case gets its own pool, cut-
8020
+ # down timing knobs and hard timeouts, and ends in ar_reap, which kills what it started.
8021
+ ARB="$WORK/ar"
8022
+ ARH="$WORK/ar-home" # a sandbox HOME: nothing here may touch the operator's
8023
+ ARW="$WORK/ar-work dir" # the session's directory (with a space, on purpose)
8024
+ ARPY="$(command -v python3)"
8025
+ mkdir -p "$ARB" "$ARH" "$ARW"
8026
+ cat > "$FAKEBIN/tmux" <<'EOF'
8027
+ #!/usr/bin/env bash
8028
+ # fake tmux for the auto-resume sections (never a server; shadows any real tmux on PATH)
8029
+ st="${FAKE_TMUX_DIR:-}"
8030
+ [ -n "$st" ] && [ -d "$st" ] || exit 1
8031
+ printf '%s\n' "$*" >> "$st/calls"
8032
+ while [ $# -gt 0 ]; do
8033
+ case "$1" in -S|-L) shift; [ $# -gt 0 ] && shift ;; *) break ;; esac
8034
+ done
8035
+ verb="${1:-}"; [ $# -gt 0 ] && shift
8036
+ case "$verb" in
8037
+ display-message)
8038
+ # -p answers the format the watcher asked for; without -p it is a status-line notice.
8039
+ p=0; fmt=""
8040
+ while [ $# -gt 0 ]; do
8041
+ case "$1" in
8042
+ -p) p=1; shift ;;
8043
+ -t) shift; [ $# -gt 0 ] && shift ;;
8044
+ *) fmt="$1"; shift ;;
8045
+ esac
8046
+ done
8047
+ [ "$p" = 1 ] || exit 0
8048
+ printf '%s\n' "$fmt" | sed -e "s/#{pane_pid}/${FAKE_TMUX_PANE_PID:-1}/g" \
8049
+ -e "s/#{pane_current_command}/${FAKE_TMUX_PANE_CMD:-zsh}/g" \
8050
+ -e "s/#{pane_in_mode}/${FAKE_TMUX_PANE_MODE:-0}/g" \
8051
+ -e "s/#{synchronize-panes}/${FAKE_TMUX_PANE_SYNC:-0}/g" ;;
8052
+ send-keys)
8053
+ lit=0
8054
+ while [ $# -gt 0 ]; do
8055
+ case "$1" in
8056
+ -t) shift; [ $# -gt 0 ] && shift; continue ;;
8057
+ -R) shift; continue ;;
8058
+ -l) lit=1; shift; continue ;;
8059
+ esac
8060
+ if [ "$lit" = 1 ]; then
8061
+ printf '%s' "$1" >> "$st/line"
8062
+ else
8063
+ case "$1" in
8064
+ C-u) : > "$st/line" ;;
8065
+ Enter)
8066
+ line="$(cat "$st/line" 2>/dev/null)"; : > "$st/line"
8067
+ printf '%s\n' "$line" >> "$st/typed"
8068
+ # The trailing `:` keeps bash from exec'ing the command in its own place: like
8069
+ # the pane's real shell, it stays the relaunched shim's parent (its PPID).
8070
+ ( unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_SHIM_ACTIVE CODEX_HOME CODEX_SHIM_ACTIVE
8071
+ exec bash -c "$line; :" ) </dev/null >> "$st/shell.out" 2>&1 & ;;
8072
+ esac
8073
+ fi
8074
+ shift
8075
+ done ;;
8076
+ esac
8077
+ exit 0
8078
+ EOF
8079
+ chmod +x "$FAKEBIN/tmux"
8080
+ # Records a spawn instead of watching: the gate cases need a watcher that leaves its state
8081
+ # file in place, so "no state file" can only mean "no spawn".
8082
+ cat > "$ARB/stub-python" <<'EOF'
8083
+ #!/bin/sh
8084
+ echo "$*" >> "${FAKE_STUB_PY_LOG:-/dev/null}"
8085
+ EOF
8086
+ chmod +x "$ARB/stub-python"
8087
+
8088
+ ar_wait() { # ar_wait <seconds> <cmd...>: poll every 0.1 s until cmd succeeds; 1 on timeout
8089
+ local n=0 lim=$(($1 * 10))
8090
+ shift
8091
+ while ! "$@"; do
8092
+ n=$((n + 1)); [ "$n" -lt "$lim" ] || return 1
8093
+ sleep 0.1
8094
+ done
8095
+ return 0
8096
+ }
8097
+ ar_lines() { [ -f "$2" ] && [ "$(($(wc -l < "$2")))" -ge "$1" ]; } # <n> <file>
8098
+ ar_logged() { grep -Eq "$2" "$1/selection.log" 2>/dev/null; } # <pool> <ERE>
8099
+ ar_no_watcher() { ! pgrep -f "$1/tmp/autoresume/" >/dev/null 2>&1; } # <pool>
8100
+ ar_acct_of() { printf '%s' "$1" | sed -n 's/^CFG=\(acct-[0-9]*\) .*/\1/p'; }
8101
+ # A stopped client may linger as a zombie until its parent reaps it: that is gone, not alive.
8102
+ ar_running() { kill -0 "$1" 2>/dev/null && ! ps -o stat= -p "$1" 2>/dev/null | grep -q '^Z'; }
8103
+ ar_stopped() { ! ar_running "$1"; }
8104
+ ar_start() { # ar_start [VAR=val ...] -- <cmd> [args...]: the launch typed in the pane, backgrounded
8105
+ local pre=()
8106
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8107
+ [ $# -gt 0 ] && shift
8108
+ ( cd "$ARW" && exec env "${AREC[@]}" ${pre[@]+"${pre[@]}"} "$@" ) </dev/null >> "$ARB/tui.out" 2>&1 &
8109
+ AR_PID=$!
8110
+ # Not a job of this shell: the crash case SIGKILLs it, and bash would print a job notice.
8111
+ # bash still reaps it (waitpid(-1) on SIGCHLD); ar_reap kills it by pid.
8112
+ disown "$AR_PID" 2>/dev/null || true
8113
+ }
8114
+ ar_reap() { # ar_reap <pool>: stop every client and watcher the case started, then prove it
8115
+ local p n=0
8116
+ : > "$1/autoresume.off" 2>/dev/null || true # running watchers leave on their next tick
8117
+ if [ -f "$ARB/pids" ]; then
8118
+ while read -r p; do kill -TERM "$p" 2>/dev/null; done < "$ARB/pids"
8119
+ fi
8120
+ while ! ar_no_watcher "$1" && [ "$n" -lt 50 ]; do sleep 0.1; n=$((n + 1)); done
8121
+ pkill -KILL -f "$1/tmp/autoresume/" 2>/dev/null || true
8122
+ if [ -f "$ARB/pids" ]; then
8123
+ while read -r p; do kill -KILL "$p" 2>/dev/null; done < "$ARB/pids"
8124
+ fi
8125
+ [ -n "${AR_PID:-}" ] && kill -KILL "$AR_PID" 2>/dev/null
8126
+ AR_PID=""
8127
+ }
8128
+ AR_SID=5f0c1d2e-3a4b-4c5d-8e9f-a0b1c2d3e4f5 # the fake claude's default session id
8129
+ AR_CXSID=6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d # the fake codex's
8130
+ ar_typed_ok() { # ar_typed_ok <CLAUDE|CODEX> <sid> <ROOTVAR=root or ""> <shim>: exactly one line, exactly this
8131
+ local line tok
8132
+ [ -f "$ARB/tmux/typed" ] && [ "$(($(wc -l < "$ARB/tmux/typed")))" = 1 ] || return 1
8133
+ line="$(cat "$ARB/tmux/typed")"
8134
+ tok="${line#" $1_MULTIACC_AR="}"; tok="${tok%%:*}"
8135
+ printf '%s\n' "$tok" | grep -Eqx '[A-Za-z0-9]{16,64}' || return 1
8136
+ [ "$line" = " $1_MULTIACC_AR=$tok:$2 ${3:+$3 }$4" ]
8137
+ }
8138
+ ar_pty() { # ar_pty <both|stdin> <cmd...>: stdin on a pty; stdout on that pty too, or on a pipe
8139
+ python3 -c '
8140
+ import pty, subprocess, sys
8141
+ m, s = pty.openpty()
8142
+ r = subprocess.run(sys.argv[2:], stdin=s, stdout=s if sys.argv[1] == "both" else subprocess.PIPE)
8143
+ if r.stdout:
8144
+ sys.stdout.buffer.write(r.stdout)
8145
+ sys.exit(r.returncode)' "$@"
8146
+ }
8147
+ ar_plant() { # ar_plant <pool> <token> <provider> <class> <left acct> -- <argv...>: a watcher's relaunch files
8148
+ local pool="$1" tok="$2" prov="$3" cls="$4" acct="$5" sid="$AR_SID"
8149
+ shift 5; [ "${1:-}" = "--" ] && shift
8150
+ [ "$prov" = codex ] && sid="$AR_CXSID"
8151
+ mkdir -p "$pool/tmp/autoresume"
8152
+ printf '%s\n' v=1 "provider=$prov" "class=$cls" "acct=$acct" "sid=$sid" "cwd=$ARW" depth=1 chain=PlantTst \
8153
+ > "$pool/tmp/autoresume/r-$tok.relaunch"
8154
+ printf '%s\0' "$@" > "$pool/tmp/autoresume/r-$tok.argv"
8155
+ }
8156
+ # A typed relaunch whose token cannot be honoured names the session and exits 2: the line
8157
+ # has no argv of its own, so running on would open a FRESH session in the stopped one's place.
8158
+ ar_dead() { # ar_dead <claude|codex> <name> <expected output> [VAR=val ...]
8159
+ local prov="$1" name="$2" want="$3" out rc
8160
+ shift 3
8161
+ if [ "$prov" = claude ]; then
8162
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" "$@" claude </dev/null 2>&1)"; rc=$?
8163
+ else
8164
+ out="$(cd "$ARH" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" "$@" codex </dev/null 2>&1)"; rc=$?
8165
+ fi
8166
+ [ "$rc" = 2 ] && [ "$out" = "$want" ] \
8167
+ && t_ok "AR dead token ($prov): $name -> the resume hint, exit 2, nothing started" \
8168
+ || t_fail "AR dead token ($prov): $name" "rc=$rc out=$out"
8169
+ }
8170
+ AR_DEAD_MSG="auto-resume could not continue automatically (the resume token expired)."
8171
+
8172
+ # ---- AR1. claude: fixture pool, environment, and the gate --------------------------------
8173
+ ARC="$WORK/ar-claude"
8174
+ ar_claude_pool() { # a fresh two-account pool whose accounts share one projects tree
8175
+ local i
8176
+ rm -rf "$ARC" "$ARB/tmux" "$ARB/pids" "$ARB/tui.log" "$ARB/stub.log"
8177
+ mkdir -p "$ARC/tmp" "$ARC/shared-projects" "$ARB/tmux"
8178
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s]}\n' \
8179
+ '{"id": "acct-01", "email": "a@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8180
+ '{"id": "acct-02", "email": "b@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8181
+ > "$ARC/accounts.json"
8182
+ for i in 01 02; do
8183
+ mkdir -p "$ARC/acct-$i"
8184
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-ar%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' "$i" \
8185
+ > "$ARC/acct-$i/.credentials.json"
8186
+ printf '{"fetched_at":%s,"weekly_percent":20,"session_percent":10,"max_percent":20,"buckets":[]}' "$(date +%s)" \
8187
+ > "$ARC/acct-$i/limits.json"
8188
+ ln -s "$ARC/shared-projects" "$ARC/acct-$i/projects"
8189
+ done
8190
+ : > "$ARC/.limits-kick"
8191
+ }
8192
+ AREC=(CLAUDE_ACCOUNTS_DIR="$ARC" HOME="$ARH" TMUX="$ARB/tmux-sock,4242,0" TMUX_PANE=%1
8193
+ CLAUDE_MULTIACC_AUTORESUME=1 CLAUDE_MULTIACC_PYTHON="$ARPY" CLAUDE_MULTIACC_AR_TEST_TTY=1
8194
+ CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE=1 CLAUDE_MULTIACC_AR_POLL=0.1 CLAUDE_MULTIACC_AR_GRACE=0.5
8195
+ CLAUDE_MULTIACC_AR_TERM_GRACE=3 CLAUDE_MULTIACC_AR_PANE_WAIT=5 CLAUDE_MULTIACC_AR_PROBE_TIMEOUT=15
8196
+ CLAUDE_MULTIACC_AR_CRASH_MIN_RUNTIME=0 CLAUDE_MULTIACC_AUTORESUME_DEBUG=1
8197
+ FAKE_TMUX_DIR="$ARB/tmux" FAKE_TUI_LOG="$ARB/tui.log" FAKE_TUI_PIDS="$ARB/pids")
8198
+
8199
+ # The gate: only the plain interactive exec in tmux, with an argv the relaunch can rebuild,
8200
+ # gets a watcher; everything else is exactly today's exec (same argv, no state file).
8201
+ ar_claude_pool
8202
+ ar_gate() { # ar_gate <name> <watcher|none> [VAR=val ...] -- <claude args...>
8203
+ local name="$1" want="$2" pre=() out states
8204
+ shift 2
8205
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8206
+ [ $# -gt 0 ] && shift
8207
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8208
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8209
+ FAKE_STUB_PY_LOG="$ARB/stub.log" ${pre[@]+"${pre[@]}"} claude "$@" </dev/null 2>&1)"
8210
+ states="$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8211
+ case "$out" in
8212
+ *"ARGS=$*") ;;
8213
+ *) t_fail "auto-resume gate: $name execs the argv unchanged" "$out"; return ;;
8214
+ esac
8215
+ if [ "$want" = watcher ]; then
8216
+ if [ "$states" = 1 ] && ar_wait 5 grep -qs -- "-I .*lib/autoresume.py watch --state $ARC/tmp/autoresume/[0-9]*\.state" "$ARB/stub.log"; then
8217
+ t_ok "auto-resume gate: $name gets a watcher, exec unchanged"
8218
+ else
8219
+ t_fail "auto-resume gate: $name gets a watcher" "states=$states stub=$(cat "$ARB/stub.log" 2>/dev/null)"
8220
+ fi
8221
+ else
8222
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8223
+ && t_ok "auto-resume gate: $name gets no watcher, exec unchanged" \
8224
+ || t_fail "auto-resume gate: $name gets no watcher" "states=$states"
8225
+ fi
8226
+ }
8227
+ ar_gate "a prompt with the permission flag" watcher -- --dangerously-skip-permissions "fix the bug please"
8228
+ ar_gate "--resume <uuid> with a model" watcher -- --model opus --resume "$AR_SID"
8229
+ ar_gate "-p" none -- -p "hi there"
8230
+ ar_gate "a pinned run" none CLAUDE_ACCOUNT=acct-01 -- --dangerously-skip-permissions
8231
+ ar_gate "outside tmux" none TMUX= -- --dangerously-skip-permissions
8232
+ ar_gate "piped stdio" none CLAUDE_MULTIACC_AR_TEST_TTY= -- --dangerously-skip-permissions
8233
+ ar_gate "an unknown flag" none -- --fork-session --dangerously-skip-permissions
8234
+ ar_gate "a single-word positional (maybe a subcommand)" none -- doctor
8235
+ ar_gate "CLAUDE_MULTIACC_AUTORESUME=0" none CLAUDE_MULTIACC_AUTORESUME=0 -- --dangerously-skip-permissions
8236
+ : > "$ARC/autoresume.off"
8237
+ ar_gate "the pool's autoresume.off" none -- --dangerously-skip-permissions
8238
+ rm -f "$ARC/autoresume.off"
8239
+ # the watcher's argv is the ORIGINAL one, before the fallback-model pin adds --model
8240
+ ar_gate "no argv at all" watcher --
8241
+ af="$(ls "$ARC/tmp/autoresume/"*.argv 2>/dev/null | head -1)"
8242
+ [ -n "$af" ] && [ ! -s "$af" ] \
8243
+ && t_ok "auto-resume: a bare launch records an empty original argv" \
8244
+ || t_fail "auto-resume: a bare launch records an empty original argv" "$af"
8245
+ # ...and it stays empty when the pin DOES fire: every account's Fable bucket is spent, so
8246
+ # the exec carries --model, but the watcher must rebuild from what the user typed (the
8247
+ # relaunch re-runs selection and pins again for the NEW pick).
8248
+ for i in 01 02; do
8249
+ printf '%s\nbucket=weekly_scoped:Fable percent=100 marked_at=x reason=limits\n' "$(( $(date +%s) + 3600 ))" \
8250
+ > "$ARC/acct-$i/.limited"
8251
+ printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":20,"session_percent":10,"buckets":[{"name":"session","percent":10},{"name":"weekly_all","percent":20},{"name":"weekly_scoped:Fable","percent":100}]}' \
8252
+ "$(date +%s)" > "$ARC/acct-$i/limits.json"
8253
+ done
8254
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8255
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8256
+ claude </dev/null 2>&1)"
8257
+ af="$(ls "$ARC/tmp/autoresume/"*.argv 2>/dev/null | head -1)"
8258
+ case "$out" in
8259
+ *"ARGS=--model claude-opus-5")
8260
+ [ -n "$af" ] && [ -f "$af" ] && [ ! -s "$af" ] \
8261
+ && t_ok "auto-resume: the watcher gets the argv from BEFORE the fallback-model pin" \
8262
+ || t_fail "auto-resume: the .argv file carries the pin" "$(tr '\0' ' ' < "$af" 2>/dev/null)" ;;
8263
+ *) t_fail "auto-resume: the fallback-model pin fired" "$out" ;;
8264
+ esac
8265
+ ar_claude_pool
8266
+ ar_gate "an invalid pin (random fallback)" none CLAUDE_ACCOUNT=acct-99 -- --dangerously-skip-permissions
8267
+ # The relaunch line is typed into the shell unquoted: a pool root or a shim path that would
8268
+ # need quoting gets no watcher, and the launch itself is unchanged.
8269
+ ln -s "$ARC" "$WORK/ar claude root"
8270
+ ar_gate "a pool root that would need quoting" none CLAUDE_ACCOUNTS_DIR="$WORK/ar claude root" -- --dangerously-skip-permissions
8271
+ mkdir -p "$WORK/ar shim copy"
8272
+ cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$WORK/ar shim copy/"
8273
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8274
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8275
+ "$WORK/ar shim copy/bin/claude" --dangerously-skip-permissions </dev/null 2>&1)"
8276
+ case "$out" in
8277
+ *"ARGS=--dangerously-skip-permissions")
8278
+ [ -z "$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep '\.state$')" ] && [ ! -s "$ARB/stub.log" ] \
8279
+ && t_ok "auto-resume gate: a shim path that would need quoting gets no watcher, exec unchanged" \
8280
+ || t_fail "auto-resume gate: a shim path that would need quoting gets no watcher" "$(ls "$ARC/tmp/autoresume/" 2>/dev/null)" ;;
8281
+ *) t_fail "auto-resume gate: a shim path that would need quoting execs the argv unchanged" "$out" ;;
8282
+ esac
8283
+ # stdin on a terminal but stdout piped (`claude "x y" | tee log`): no watcher. The same
8284
+ # launch with stdout on the terminal too does get one, so the pipe is what decided it.
8285
+ for mode in stdin both; do
8286
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8287
+ out="$(cd "$ARW" && ar_pty "$mode" env "${AREC[@]}" CLAUDE_MULTIACC_AR_TEST_TTY= \
8288
+ CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8289
+ claude --dangerously-skip-permissions 2>&1)"
8290
+ states="$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8291
+ if [ "$mode" = stdin ]; then
8292
+ case "$out" in *"ARGS=--dangerously-skip-permissions") ;; *) states="exec:$out" ;; esac
8293
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8294
+ && t_ok "auto-resume gate: a terminal on stdin with stdout piped gets no watcher, exec unchanged" \
8295
+ || t_fail "auto-resume gate: stdout piped gets no watcher" "states=$states"
8296
+ else
8297
+ [ "$states" = 1 ] && t_ok "auto-resume gate: stdin and stdout on a terminal get a watcher (control)" \
8298
+ || t_fail "auto-resume gate: both on a terminal get a watcher" "states=$states"
8299
+ fi
8300
+ done
8301
+ # The client never sees an auto-resume variable (a nested claude must not inherit a probe).
8302
+ rm -rf "$ARC/tmp/autoresume"
8303
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8304
+ CLAUDE_MULTIACC_AR_AVOID=acct-01:9999999999 FAKE_PRINT_ENV=1 \
8305
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8306
+ check "auto-resume: the client never sees an auto-resume variable" "AR_ENV=0" "$out"
8307
+ st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
8308
+ grep -q "^pid=[0-9]" "$st" 2>/dev/null && grep -q "^tmux=$ARB/tmux-sock,4242,0\$" "$st" \
8309
+ && grep -q '^pane=%1$' "$st" && grep -q "^acc_root=$ARC\$" "$st" && grep -q '^depth=0$' "$st" \
8310
+ && t_ok "auto-resume: the state file names the pid, pane, pool and chain depth" \
8311
+ || t_fail "auto-resume state file" "$(cat "$st" 2>/dev/null)"
8312
+
8313
+ # ---- AR2. claude probe mode: the real candidate loop, never an exec ----------------------
8314
+ ar_claude_pool
8315
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 claude --dangerously-skip-permissions </dev/null 2>&1)"; rc=$?
8316
+ case "$rc:$out" in
8317
+ 0:pick=acct-0[12]" tier=eligible") t_ok "probe prints pick/tier and exits 0" ;;
8318
+ *) t_fail "probe prints pick/tier" "rc=$rc out=$out" ;;
8319
+ esac
8320
+ case "$out" in *CFG=*) t_fail "probe never execs the client" "$out" ;; *) t_ok "probe never execs the client" ;; esac
8321
+ all2=1
8322
+ for _ in 1 2 3 4 5; do
8323
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
8324
+ CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) - 5 ))" \
8325
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8326
+ [ "$out" = "pick=acct-02 tier=eligible" ] || all2=0
8327
+ done
8328
+ [ "$all2" = 1 ] && t_ok "probe honours AVOID (an expired entry is ignored)" \
8329
+ || t_fail "probe AVOID" "$out"
8330
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
8331
+ CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) + 3600 ))" \
8332
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8333
+ case "$out" in "pick=acct-0"[12]" tier=soft") t_ok "probe with every account avoided answers tier=soft" ;;
8334
+ *) t_fail "probe tier=soft" "$out" ;; esac
8335
+ ar_logged "$ARC" 'all-limited fallback' \
8336
+ && t_fail "a probe logs no fallback pick" "$(grep 'all-limited' "$ARC/selection.log")" \
8337
+ || t_ok "a probe logs no fallback pick"
8338
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_DISABLE=1 claude </dev/null 2>&1)"; rc=$?
8339
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "probe on a passthrough answers tier=none (exit 3)" \
8340
+ || t_fail "probe passthrough" "rc=$rc out=$out"
8341
+ [ ! -f "$ARC/.last-pick" ] && t_ok "a probe does not count as a pick (no rotation memory)" \
8342
+ || t_fail "a probe does not count as a pick" "$(cat "$ARC/.last-pick")"
8343
+ # No valid login anywhere: a launch would fall back to the stock client — a probe must not.
8344
+ rm -f "$ARC"/acct-0*/.credentials.json
8345
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 claude --dangerously-skip-permissions </dev/null 2>&1)"; rc=$?
8346
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "probe on a pool with no valid login answers tier=none (exit 3), never execs" \
8347
+ || t_fail "probe with no valid login" "rc=$rc out=$out"
8348
+
8349
+ # ---- AR3. claude quota: stop, relaunch on the other account, same session ----------------
8350
+ # No ANYPANE here: the pane's pid is this shell, which IS the launching shim's parent. The
8351
+ # transcript scan (12f) is off, so the old account's marker can only come from the relaunch.
8352
+ ar_claude_pool
8353
+ ar_start FAKE_TUI=quota CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE= FAKE_TMUX_PANE_PID=$$ \
8354
+ CLAUDE_MULTIACC_CLIENT_LIMITS=0 -- claude --dangerously-skip-permissions "fix the bug please"
8355
+ first=$AR_PID
8356
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8357
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8358
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8359
+ check "AR quota: the first launch ran the user's argv" \
8360
+ "ARGS=--dangerously-skip-permissions fix the bug please" "$l1"
8361
+ [ -n "$from" ] && [ -n "$to" ] && [ "$from" != "$to" ] \
8362
+ && t_ok "AR quota: relaunched on the OTHER account ($from -> $to)" \
8363
+ || t_fail "AR quota: relaunched on the other account" "$l1 | $l2"
8364
+ check "AR quota: the relaunch resumes the same session with the auto-resume prompt" \
8365
+ "ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account hit its usage limit." "$l2"
8366
+ case "$l2" in *"fix the bug please"*) t_fail "AR quota: the original prompt is not replayed" "$l2" ;;
8367
+ *) t_ok "AR quota: the original prompt is not replayed" ;; esac
8368
+ ar_wait 5 ar_stopped "$first" && t_ok "AR quota: the stuck client was stopped" \
8369
+ || t_fail "AR quota: the stuck client was stopped" "pid $first alive"
8370
+ grep -q 'bucket=client:five_hour percent=100' "$ARC/$from/.limited" 2>/dev/null \
8371
+ && t_ok "AR quota: the old account carries a client:five_hour marker" \
8372
+ || t_fail "AR quota: marker on the old account" "$(cat "$ARC/$from/.limited" 2>/dev/null)"
8373
+ ar_logged "$ARC" "autoresume switch provider=claude chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_SID depth=1 probe=$to" \
8374
+ && t_ok "AR quota: the watcher logged the switch (field 2 = autoresume)" \
8375
+ || t_fail "AR quota: switch log line" "$(grep autoresume "$ARC/selection.log")"
8376
+ chain="$(sed -n 's/.*autoresume watch provider=claude chain=\([A-Za-z0-9]*\) .*/\1/p' "$ARC/selection.log" | head -1)"
8377
+ ar_logged "$ARC" "autoresume relaunch chain=$chain from=$from class=quota depth=1\$" \
8378
+ && t_ok "AR quota: the relaunched shim consumed the token and kept the chain" \
8379
+ || t_fail "AR quota: relaunch log line" "$(grep autoresume "$ARC/selection.log")"
8380
+ ar_typed_ok CLAUDE "$AR_SID" "CLAUDE_ACCOUNTS_ROOT=$(cd "$ARC" && pwd)" "$REPO_DIR/bin/claude" \
8381
+ && t_ok "AR quota: exactly one token line was typed: token:sid, the pool root, the shim's path" \
8382
+ || t_fail "AR quota: typed line" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8383
+ seq_ok="$(grep -E 'display-message|send-keys' "$ARB/tmux/calls" | sed 's/ -l .*/ -l <line>/' | tr '\n' '|')"
8384
+ case "$seq_ok" in
8385
+ *"-S $ARB/tmux-sock display-message -p -t %1 #{pane_pid}"*"-S $ARB/tmux-sock send-keys -R -t %1|-S $ARB/tmux-sock send-keys -t %1 C-u|-S $ARB/tmux-sock send-keys -t %1 -l <line>|-S $ARB/tmux-sock send-keys -t %1 Enter|")
8386
+ t_ok "AR quota: pane check, then -R, C-u, the line, Enter — on the launching pane" ;;
8387
+ *) t_fail "AR quota: tmux sequence" "$seq_ok" ;;
8388
+ esac
8389
+ ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8390
+ && t_fail "AR quota: the relaunch token is single use" "$(ls "$ARC/tmp/autoresume/")" \
8391
+ || t_ok "AR quota: the relaunch token is single use (files consumed)"
8392
+ ar_wait 10 ar_logged "$ARC" "autoresume watch provider=claude chain=$chain pid=[0-9]+ acct=$to depth=1\$" \
8393
+ && t_ok "AR quota: the relaunched session is supervised again (same chain, depth 1)" \
8394
+ || t_fail "AR quota: second watcher" "$(grep autoresume "$ARC/selection.log")"
8395
+ else
8396
+ t_fail "AR quota: relaunched within 30 s" "tui=$(cat "$ARB/tui.log" 2>/dev/null) log=$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8397
+ fi
8398
+ ar_reap "$ARC"
8399
+ ar_no_watcher "$ARC" && t_ok "AR quota: no watcher left running" || t_fail "AR quota: watcher left running"
8400
+
8401
+ # ---- AR4. claude: a pane that is not the launching shell is never typed into -----------
8402
+ ar_claude_pool
8403
+ ar_start FAKE_TUI=quota CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE= FAKE_TMUX_PANE_PID=1 \
8404
+ -- claude --dangerously-skip-permissions "fix the bug please"
8405
+ if ar_wait 20 ar_logged "$ARC" 'autoresume giveup provider=claude .*reason=pane'; then
8406
+ sleep 0.3
8407
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8408
+ && ! ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8409
+ && t_ok "AR pane: another pane's process -> give up before stopping anything" \
8410
+ || t_fail "AR pane: nothing stopped, nothing typed" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8411
+ else
8412
+ t_fail "AR pane: giveup logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8413
+ fi
8414
+ ar_reap "$ARC"
8415
+
8416
+ # ---- AR5. claude auth: the old login is parked, the session moves ------------------------
8417
+ ar_claude_pool
8418
+ ar_start FAKE_TUI=auth -- claude --dangerously-skip-permissions "fix the bug please"
8419
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8420
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8421
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8422
+ [ -n "$from" ] && [ "$from" != "$to" ] && [ -f "$ARC/$from/.expired" ] \
8423
+ && t_ok "AR auth: the old account is parked (.expired) and the session moved ($from -> $to)" \
8424
+ || t_fail "AR auth: park + rotate" "$l1 | $l2 | $(ls -a "$ARC/$from" 2>/dev/null | tr '\n' ' ')"
8425
+ check "AR auth: resumed with the login reason" \
8426
+ "--resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account's login failed." "$l2"
8427
+ else
8428
+ t_fail "AR auth: relaunched within 30 s" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8429
+ fi
8430
+ ar_reap "$ARC"
8431
+
8432
+ # ---- AR6. claude crash: the registry left behind means a crash; relaunch in place -------
8433
+ ar_claude_pool
8434
+ ar_start FAKE_TUI=crash -- claude --dangerously-skip-permissions "fix the bug please"
8435
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8436
+ l2="$(sed -n 2p "$ARB/tui.log")"
8437
+ check "AR crash: resumed with the crash reason, no 'another account'" \
8438
+ "--resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically because the previous process exited unexpectedly." "$l2"
8439
+ ar_logged "$ARC" 'autoresume crash provider=claude .* runtime=[0-9]+' \
8440
+ && ar_logged "$ARC" 'autoresume relaunch chain=[A-Za-z0-9]+ from=acct-0[12] class=crash depth=1$' \
8441
+ && t_ok "AR crash: logged as a crash and relaunched (no marker class)" \
8442
+ || t_fail "AR crash: log lines" "$(grep autoresume "$ARC/selection.log")"
8443
+ ls "$ARC"/acct-0*/.limited >/dev/null 2>&1 \
8444
+ && t_fail "AR crash: no account is marked" "$(ls "$ARC"/acct-0*/.limited)" \
8445
+ || t_ok "AR crash: no account is marked"
8446
+ else
8447
+ t_fail "AR crash: relaunched within 30 s" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8448
+ fi
8449
+ ar_reap "$ARC"
8450
+
8451
+ # ---- AR7. claude: errors auto-resume must NOT act on ---------------------------------------
8452
+ # invalid_request: another account would fail the same way — logged once, left alone.
8453
+ ar_claude_pool
8454
+ ar_start FAKE_TUI=invalid -- claude --dangerously-skip-permissions "fix the bug please"
8455
+ if ar_wait 15 ar_logged "$ARC" 'autoresume never provider=claude .*code=invalid_request'; then
8456
+ sleep 1.5
8457
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8458
+ && t_ok "AR never: invalid_request is logged and the session left alone" \
8459
+ || t_fail "AR never: the session was stopped"
8460
+ else
8461
+ t_fail "AR never: logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8462
+ fi
8463
+ ar_reap "$ARC"
8464
+ # An OLD rejection in a resumed transcript never fires: only records from this launch count.
8465
+ ar_claude_pool
8466
+ mkdir -p "$ARC/shared-projects/-fake-tui"
8467
+ printf '{"type":"assistant","isApiErrorMessage":true,"error":"rate_limit","quotaLimits":{"status":"rejected","resetsAt":%s,"rateLimitType":"five_hour"},"timestamp":"2026-01-01T00:00:00.000Z","sessionId":"%s","message":{"content":[{"type":"text","text":"limit"}]}}\n' \
8468
+ "$(( $(date +%s) + 3600 ))" "$AR_SID" > "$ARC/shared-projects/-fake-tui/$AR_SID.jsonl"
8469
+ ar_start FAKE_TUI=idle FAKE_TUI_QUIET=1 -- claude --dangerously-skip-permissions --resume "$AR_SID" "carry on please"
8470
+ if ar_wait 15 grep -qs "transcript .*$AR_SID.jsonl" "$ARC/tmp/autoresume/$AR_PID.log"; then
8471
+ sleep 1.5
8472
+ ar_logged "$ARC" 'autoresume (detect|switch)' || ! ar_running "$AR_PID" \
8473
+ && t_fail "AR resumed transcript: an old rejection fired" "$(grep autoresume "$ARC/selection.log")" \
8474
+ || t_ok "AR resumed transcript: an old rejection does not fire"
8475
+ else
8476
+ t_fail "AR resumed transcript: watcher followed the transcript" "$(cat "$ARC/tmp/autoresume/$AR_PID.log" 2>/dev/null)"
8477
+ fi
8478
+ ar_reap "$ARC"
8479
+
8480
+ # ---- AR8. claude: no account to move to -> HOLD, nothing is stopped ----------------------
8481
+ ar_claude_pool
8482
+ # acct-02 is out for the week (a weekly client marker outlives every telemetry pass).
8483
+ printf '%s\nbucket=client:seven_day percent=100 marked_at=%s reason=client-rate-limit\n' \
8484
+ "$(( $(date +%s) + 86400 ))" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$ARC/acct-02/.limited"
8485
+ ar_start FAKE_TUI=quota -- claude --dangerously-skip-permissions "fix the bug please"
8486
+ if ar_wait 20 ar_logged "$ARC" 'autoresume hold provider=claude .*class=quota .*reason=no-room'; then
8487
+ sleep 1
8488
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8489
+ && ! ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8490
+ && t_ok "AR hold: no eligible account -> hold, no kill, nothing typed" \
8491
+ || t_fail "AR hold: the session was stopped"
8492
+ grep -q 'CFG=acct-01' "$ARB/tui.log" && t_ok "AR hold: the first launch took the only eligible account" \
8493
+ || t_fail "AR hold: first pick" "$(cat "$ARB/tui.log")"
8494
+ else
8495
+ t_fail "AR hold: logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8496
+ fi
8497
+ ar_reap "$ARC"
8498
+
8499
+ # ---- AR9. claude: autoresume.off stops a RUNNING watcher ---------------------------------
8500
+ ar_claude_pool
8501
+ ar_start FAKE_TUI=quota FAKE_TUI_DELAY=2.5 -- claude --dangerously-skip-permissions "fix the bug please"
8502
+ if ar_wait 15 ar_logged "$ARC" 'autoresume watch provider=claude'; then
8503
+ : > "$ARC/autoresume.off"
8504
+ ar_wait 5 ar_no_watcher "$ARC" && t_ok "AR off: the running watcher exits on autoresume.off" \
8505
+ || t_fail "AR off: the running watcher exits on autoresume.off"
8506
+ ar_wait 10 grep -qs '"rate_limit"' "$ARC/shared-projects/-fake-tui/$AR_SID.jsonl"
8507
+ sleep 1.2
8508
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] && ! ar_logged "$ARC" 'autoresume (detect|switch)' \
8509
+ && t_ok "AR off: the limit after the kill switch stops nothing" \
8510
+ || t_fail "AR off: the session was stopped"
8511
+ else
8512
+ t_fail "AR off: watcher started" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8513
+ fi
8514
+ ar_reap "$ARC"
8515
+
8516
+ # ---- AR11. a relaunch re-entering through the token preflight keeps its chain -----------
8517
+ # The relaunch left acct-01 on a MODEL limit (no marker: the chain's AVOID list is all that
8518
+ # keeps the session off it). acct-02 leads on headroom but its portable token is revoked,
8519
+ # so the preflight parks it and re-enters the shim with `exec "$SELF"` — the token is spent
8520
+ # by then, so the chain rides along in the environment. Without it the re-entered selection
8521
+ # takes acct-01 (the best weekly left) and the next watcher restarts at depth 0.
8522
+ ar_claude_pool
8523
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s, %s]}\n' \
8524
+ '{"id": "acct-01", "email": "a@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8525
+ '{"id": "acct-02", "email": "b@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8526
+ '{"id": "acct-03", "email": "c@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8527
+ > "$ARC/accounts.json"
8528
+ mkdir -p "$ARC/acct-03"
8529
+ cp "$ARC/acct-01/.credentials.json" "$ARC/acct-03/.credentials.json"
8530
+ ln -s "$ARC/shared-projects" "$ARC/acct-03/projects"
8531
+ rm -f "$ARC/acct-02/.credentials.json"
8532
+ printf 'sk-ant-oat01-REVOKED-carry\n' > "$ARC/acct-02/server.token"
8533
+ nowc="$(date +%s)"
8534
+ for i in 01:5 02:10 03:20; do
8535
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8536
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8537
+ done
8538
+ mkdir -p "$ARC/tmp/autoresume"
8539
+ tok=CarryTok0123456789
8540
+ printf '%s\n' v=1 provider=claude class=model acct=acct-01 reset= rtype= marked= "sid=$AR_SID" \
8541
+ "cwd=$ARW" depth=2 "avoid=acct-01:$((nowc + 18000))" "hist=model:$((nowc - 100))" chain=CarryTst \
8542
+ > "$ARC/tmp/autoresume/r-$tok.relaunch"
8543
+ printf '%s\0' --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please" \
8544
+ > "$ARC/tmp/autoresume/r-$tok.argv"
8545
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8546
+ CLAUDE_MULTIACC_HEADROOM_BAND=0 CLAUDE_MULTIACC_AR="$tok" claude </dev/null 2>&1)"
8547
+ check "AR carry: the re-entered selection still skips the account the chain left" \
8548
+ "CFG=acct-03 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
8549
+ ar_logged "$ARC" 'acct-02 parked \(setup-token-invalid\)' \
8550
+ && t_ok "AR carry: the revoked token was parked by the preflight (the re-entry happened)" \
8551
+ || t_fail "AR carry: preflight park" "$(cat "$ARC/selection.log")"
8552
+ st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
8553
+ grep -q '^depth=2$' "$st" 2>/dev/null && grep -q '^chain=CarryTst$' "$st" \
8554
+ && grep -q "^avoid=acct-01:$((nowc + 18000))\$" "$st" && grep -q '^cwd=/.*/ar-work dir$' "$st" \
8555
+ && t_ok "AR carry: the next watcher inherits depth, chain, AVOID and the session's cwd" \
8556
+ || t_fail "AR carry: state file" "$(cat "$st" 2>/dev/null)"
8557
+ [ "$(grep -c 'autoresume relaunch chain=CarryTst from=acct-01 class=model depth=2$' "$ARC/selection.log")" = 1 ] \
8558
+ && t_ok "AR carry: the relaunch is logged once, not again by the re-entry" \
8559
+ || t_fail "AR carry: relaunch log" "$(grep autoresume "$ARC/selection.log")"
8560
+ ar_reap "$ARC"
8561
+
8562
+ # ---- AR12. claude relaunch tokens: the auth park, dead tokens, the trust mirror ------------
8563
+ # Planted directly (no watcher, no TUI), so each assertion can only come from the relaunch
8564
+ # entry itself. acct-01 leads on headroom and HEADROOM_BAND=0 makes the lead decisive.
8565
+ ar_claude_pool
8566
+ nowc="$(date +%s)"
8567
+ for i in 01:5 02:20; do
8568
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8569
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8570
+ done
8571
+ # class=auth: no auth record in any transcript, so only the relaunch can park acct-01.
8572
+ ar_plant "$ARC" AuthTok0123456789 claude auth acct-01 -- \
8573
+ --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
8574
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
8575
+ CLAUDE_MULTIACC_AR="AuthTok0123456789:$AR_SID" claude </dev/null 2>&1)"
8576
+ [ -f "$ARC/acct-01/.expired" ] && ar_logged "$ARC" 'acct-01 parked \(' \
8577
+ && t_ok "AR auth token: the relaunch parks the account it left (.expired)" \
8578
+ || t_fail "AR auth token: park" "$(ls -a "$ARC/acct-01" | tr '\n' ' ')"
8579
+ check "AR auth token: ...and the run lands on the other account, resuming the session" \
8580
+ "CFG=acct-02 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
8581
+ # Dead tokens: missing, expired (older than 10 min), malformed, a FIFO in its place.
8582
+ ar_dead claude "a missing relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8583
+ CLAUDE_MULTIACC_AR="MissingTok0123456:$AR_SID"
8584
+ ar_plant "$ARC" ExpiredTok0123456 claude quota acct-01 -- --resume "$AR_SID" "(claude-multiacc auto-resume) x y"
8585
+ touch -t 202601010000 "$ARC/tmp/autoresume/r-ExpiredTok0123456.relaunch"
8586
+ ar_dead claude "an expired relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8587
+ CLAUDE_MULTIACC_AR="ExpiredTok0123456:$AR_SID"
8588
+ ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-ExpiredTok' \
8589
+ && t_fail "AR dead token (claude): an expired token is consumed" "$(ls "$ARC/tmp/autoresume/")" \
8590
+ || t_ok "AR dead token (claude): an expired token is consumed"
8591
+ ar_dead claude "a malformed token" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8592
+ CLAUDE_MULTIACC_AR="../bad/tok:$AR_SID"
8593
+ ar_dead claude "an unusable session id (no hint)" "claude-multiacc: $AR_DEAD_MSG" \
8594
+ CLAUDE_MULTIACC_AR='MissingTok0123456:$(id)'
8595
+ mkfifo "$ARC/tmp/autoresume/r-FifoTok0123456789.relaunch"
8596
+ ( cd "$ARH" && exec env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8597
+ CLAUDE_MULTIACC_AR="FifoTok0123456789:$AR_SID" claude ) </dev/null > "$ARB/fifo.out" 2>&1 &
8598
+ fp=$!
8599
+ if ar_wait 10 ar_stopped "$fp"; then
8600
+ wait "$fp"; rc=$?
8601
+ [ "$rc" = 2 ] && ! grep -q 'CFG=' "$ARB/fifo.out" \
8602
+ && t_ok "AR dead token (claude): a FIFO named like a relaunch file never blocks the launch" \
8603
+ || t_fail "AR dead token (claude): FIFO" "rc=$rc $(cat "$ARB/fifo.out")"
8604
+ else
8605
+ kill -KILL "$fp" 2>/dev/null; wait "$fp" 2>/dev/null
8606
+ t_fail "AR dead token (claude): a FIFO named like a relaunch file blocked the launch"
8607
+ fi
8608
+ rm -f "$ARC/tmp/autoresume/r-FifoTok0123456789.relaunch"
8609
+ # Trust: the relaunch marks its cwd trusted in the PICKED account's .claude.json (Claude
8610
+ # shows a blocking trust dialog in a new directory even with --dangerously-skip-permissions),
8611
+ # keeping every other key. The real python runs it; TMUX= keeps the watcher out of the way.
8612
+ # The key is the directory as the relaunch's own `cd` spells it, plus its physical path
8613
+ # when that differs (Claude may key projects by either; macOS TMPDIR is a symlink).
8614
+ ar_claude_pool
8615
+ nowc="$(date +%s)"
8616
+ arw_key="$(cd "$ARW" && pwd)"
8617
+ for i in 01:20 02:5; do
8618
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8619
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8620
+ done
8621
+ printf '{"numStartups": 7, "projects": {"%s": {"allowedTools": ["Bash"]}, "/elsewhere": {"hasTrustDialogAccepted": false}}}\n' \
8622
+ "$arw_key" > "$ARC/acct-02/.claude.json"
8623
+ chmod 600 "$ARC/acct-02/.claude.json"
8624
+ printf '{"numStartups": 1}\n' > "$ARC/acct-01/.claude.json"
8625
+ cp "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json"
8626
+ ar_plant "$ARC" TrustTok0123456789 claude crash acct-01 -- \
8627
+ --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
8628
+ out="$(cd "$ARH" && env "${AREC[@]}" TMUX= CLAUDE_MULTIACC_HEADROOM_BAND=0 \
8629
+ CLAUDE_MULTIACC_AR="TrustTok0123456789:$AR_SID" claude </dev/null 2>&1)"
8630
+ check "AR trust: the relaunch ran on the picked account" "CFG=acct-02 " "$out"
8631
+ if python3 - "$ARC/acct-02/.claude.json" "$arw_key" <<'PYEOF'
8632
+ import json, os, stat, sys
8633
+ path, cwd = sys.argv[1], sys.argv[2]
8634
+ d = json.load(open(path))
8635
+ p = d["projects"]
8636
+ real = os.path.realpath(cwd)
8637
+ ok = (p[cwd] == {"allowedTools": ["Bash"], "hasTrustDialogAccepted": True}
8638
+ and (real == cwd or p.get(real) == {"hasTrustDialogAccepted": True})
8639
+ and len(p) == (2 if real == cwd else 3) and d.get("numStartups") == 7
8640
+ and p["/elsewhere"] == {"hasTrustDialogAccepted": False}
8641
+ and stat.S_IMODE(os.stat(path).st_mode) == 0o600)
8642
+ sys.exit(0 if ok else 1)
8643
+ PYEOF
8644
+ then
8645
+ t_ok "AR trust: projects[cwd].hasTrustDialogAccepted=true, every other key and the file mode kept"
8646
+ else
8647
+ t_fail "AR trust: .claude.json after the relaunch" "$(cat "$ARC/acct-02/.claude.json")"
8648
+ fi
8649
+ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
8650
+ && t_ok "AR trust: the account the session left is not touched" \
8651
+ || t_fail "AR trust: the left account's .claude.json changed" "$(cat "$ARC/acct-01/.claude.json")"
8652
+ # A plain launch (no token) never edits trust.
8653
+ printf '{"numStartups": 7}\n' > "$ARC/acct-02/.claude.json"
8654
+ cp "$ARC/acct-02/.claude.json" "$ARB/acct-02.claude.json"
8655
+ (cd "$ARW" && env "${AREC[@]}" TMUX= claude --dangerously-skip-permissions </dev/null >/dev/null 2>&1)
8656
+ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
8657
+ && cmp -s "$ARC/acct-02/.claude.json" "$ARB/acct-02.claude.json" \
8658
+ && t_ok "AR trust: a launch that is not a relaunch never edits .claude.json" \
8659
+ || t_fail "AR trust: a plain launch edited .claude.json" "$(cat "$ARC"/acct-0*/.claude.json)"
8660
+ ar_reap "$ARC"
8661
+
8662
+ # ---- AR10. codex: the same machinery through bin/codex ------------------------------------
8663
+ ARX="$WORK/ar-codex"
8664
+ ar_codex_pool() { # a fresh two-account codex pool sharing one sessions tree (the installed layout)
8665
+ local i
8666
+ rm -rf "$ARX" "$ARB/tmux" "$ARB/pids" "$ARB/tui.log" "$ARB/stub.log"
8667
+ mkdir -p "$ARX/tmp" "$ARX/shared-sessions" "$ARB/tmux"
8668
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s]}\n' \
8669
+ '{"id": "acct-01", "email": "a@arx", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8670
+ '{"id": "acct-02", "email": "b@arx", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8671
+ > "$ARX/accounts.json"
8672
+ for i in 01 02; do
8673
+ mkdir -p "$ARX/acct-$i"
8674
+ mk_cx_auth "$ARX/acct-$i/auth.json" "$i@arx" "$FUTURE_EXP"
8675
+ printf '{"fetched_at":%s,"weekly_percent":20,"session_percent":10,"max_percent":20,"buckets":[]}' "$(date +%s)" \
8676
+ > "$ARX/acct-$i/limits.json"
8677
+ ln -s "$ARX/shared-sessions" "$ARX/acct-$i/sessions"
8678
+ done
8679
+ : > "$ARX/.limits-kick"
8680
+ }
8681
+ AREX=(CODEX_ACCOUNTS_DIR="$ARX" HOME="$ARH" TMUX="$ARB/tmux-sock,4243,0" TMUX_PANE=%2
8682
+ CODEX_MULTIACC_AUTORESUME=1 CODEX_MULTIACC_PYTHON="$ARPY" CODEX_MULTIACC_AR_TEST_TTY=1
8683
+ CODEX_MULTIACC_AR_TEST_TMUX_ANYPANE=1 CODEX_MULTIACC_AR_POLL=0.1 CODEX_MULTIACC_AR_GRACE=0.5
8684
+ CODEX_MULTIACC_AR_TERM_GRACE=3 CODEX_MULTIACC_AR_PANE_WAIT=5 CODEX_MULTIACC_AR_PROBE_TIMEOUT=15
8685
+ CODEX_MULTIACC_AR_DISCOVER_TIMEOUT=20 CODEX_MULTIACC_AUTORESUME_DEBUG=1
8686
+ FAKE_TMUX_DIR="$ARB/tmux" FAKE_TUI_LOG="$ARB/tui.log" FAKE_TUI_PIDS="$ARB/pids")
8687
+ ar_codex_pool
8688
+ cx_gate() { # cx_gate <name> <watcher|none> [VAR=val ...] -- <codex args...>
8689
+ local name="$1" want="$2" pre=() out states
8690
+ shift 2
8691
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8692
+ [ $# -gt 0 ] && shift
8693
+ rm -rf "$ARX/tmp/autoresume" "$ARB/stub.log"
8694
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" \
8695
+ FAKE_STUB_PY_LOG="$ARB/stub.log" ${pre[@]+"${pre[@]}"} codex "$@" </dev/null 2>&1)"
8696
+ states="$(ls "$ARX/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8697
+ case "$out" in *CFG=acct-0*) ;; *) t_fail "codex auto-resume gate: $name execs" "$out"; return ;; esac
8698
+ if [ "$want" = watcher ]; then
8699
+ [ "$states" = 1 ] && ar_wait 5 grep -qs -- "-I .*lib/autoresume.py watch --state $ARX/tmp/autoresume/[0-9]*\.state" "$ARB/stub.log" \
8700
+ && t_ok "codex auto-resume gate: $name gets a watcher" \
8701
+ || t_fail "codex auto-resume gate: $name gets a watcher" "states=$states"
8702
+ else
8703
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8704
+ && t_ok "codex auto-resume gate: $name gets no watcher" \
8705
+ || t_fail "codex auto-resume gate: $name gets no watcher" "states=$states"
8706
+ fi
8707
+ }
8708
+ cx_gate "a prompt with the bypass flag" watcher -- --dangerously-bypass-approvals-and-sandbox "fix the bug please"
8709
+ cx_gate "resume <uuid>" watcher -- resume "$AR_CXSID"
8710
+ cx_gate "exec" none -- exec "do it now"
8711
+ cx_gate "-p (profile)" none -- -p work
8712
+ cx_gate "-c overrides" none -- -c model=o3
8713
+ cx_gate "a pinned run" none CODEX_ACCOUNT=acct-01 --
8714
+ cx_gate "an invalid pin (random fallback)" none CODEX_ACCOUNT=acct-99 --
8715
+ cx_gate "outside tmux" none TMUX_PANE= --
8716
+ cx_gate "CODEX_MULTIACC_AUTORESUME=off" none CODEX_MULTIACC_AUTORESUME=off --
8717
+ ln -s "$ARX" "$WORK/ar codex root"
8718
+ cx_gate "a pool root that would need quoting" none CODEX_ACCOUNTS_DIR="$WORK/ar codex root" -- --yolo
8719
+ # The client never sees an auto-resume variable (AREX itself exports several).
8720
+ rm -rf "$ARX/tmp/autoresume"
8721
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" FAKE_PRINT_ENV=1 \
8722
+ codex --yolo </dev/null 2>&1)"
8723
+ case "$out" in
8724
+ *"AR_ENV=0"*) [ -n "$(ls "$ARX/tmp/autoresume/" 2>/dev/null | grep '\.state$')" ] \
8725
+ && t_ok "codex auto-resume: the client never sees an auto-resume variable (watcher spawned)" \
8726
+ || t_fail "codex auto-resume: env scrub case spawned no watcher" "$out" ;;
8727
+ *) t_fail "codex auto-resume: the client never sees an auto-resume variable" "$out" ;;
8728
+ esac
8729
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 codex --yolo </dev/null 2>&1)"; rc=$?
8730
+ case "$rc:$out" in 0:pick=acct-0[12]" tier=eligible") t_ok "codex probe prints pick/tier, never execs" ;;
8731
+ *) t_fail "codex probe" "rc=$rc out=$out" ;; esac
8732
+ all1=1
8733
+ for _ in 1 2 3 4 5; do
8734
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 \
8735
+ CODEX_MULTIACC_AR_AVOID="acct-02:$(( $(date +%s) + 3600 ))" codex --yolo </dev/null 2>&1)"
8736
+ [ "$out" = "pick=acct-01 tier=eligible" ] || all1=0
8737
+ done
8738
+ [ "$all1" = 1 ] && t_ok "codex probe honours AVOID (5 of 5)" || t_fail "codex probe AVOID" "$out"
8739
+ # codex relaunch tokens, planted directly: acct-01 leads, so only the relaunch's own park
8740
+ # keeps the run off it (the shared sessions tree turns the rollout scan off).
8741
+ nowx="$(date +%s)"
8742
+ for i in 01:5 02:20; do
8743
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8744
+ "$nowx" "${i#*:}" "${i#*:}" > "$ARX/acct-${i%%:*}/limits.json"
8745
+ done
8746
+ rm -f "$ARX/.last-pick"
8747
+ ar_plant "$ARX" CxAuthTok01234567 codex auth acct-01 -- \
8748
+ resume --yolo "$AR_CXSID" "(claude-multiacc auto-resume) carry on please"
8749
+ out="$(cd "$ARH" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" CODEX_MULTIACC_HEADROOM_BAND=0 \
8750
+ CODEX_MULTIACC_AR="CxAuthTok01234567:$AR_CXSID" codex </dev/null 2>&1)"
8751
+ grep -q 'reason=auth-error soft_until=' "$ARX/acct-01/.expired" 2>/dev/null \
8752
+ && t_ok "AR codex auth token: the relaunch parks the account it left (auth-error, soft)" \
8753
+ || t_fail "AR codex auth token: park" "$(cat "$ARX/acct-01/.expired" 2>/dev/null)"
8754
+ check "AR codex auth token: ...and the run lands on the other account" "CFG=acct-02" "$out"
8755
+ ar_dead codex "a missing relaunch file" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8756
+ CODEX_MULTIACC_AR="MissingTok0123456:$AR_CXSID"
8757
+ ar_plant "$ARX" ExpiredTok0123456 codex quota acct-01 -- resume "$AR_CXSID" "(claude-multiacc auto-resume) x y"
8758
+ touch -t 202601010000 "$ARX/tmp/autoresume/r-ExpiredTok0123456.relaunch"
8759
+ ar_dead codex "an expired relaunch file" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8760
+ CODEX_MULTIACC_AR="ExpiredTok0123456:$AR_CXSID"
8761
+ ls "$ARX/tmp/autoresume/" 2>/dev/null | grep -q '^r-ExpiredTok' \
8762
+ && t_fail "AR dead token (codex): an expired token is consumed" "$(ls "$ARX/tmp/autoresume/")" \
8763
+ || t_ok "AR dead token (codex): an expired token is consumed"
8764
+ ar_dead codex "a malformed token" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8765
+ CODEX_MULTIACC_AR="../bad/tok:$AR_CXSID"
8766
+ ar_dead codex "an unusable session id (no hint)" "codex-multiacc: $AR_DEAD_MSG" \
8767
+ CODEX_MULTIACC_AR='MissingTok0123456:$(id)'
8768
+ # No valid login anywhere: a launch would fall back to the stock client — a probe must not.
8769
+ ar_codex_pool
8770
+ rm -f "$ARX"/acct-0*/auth.json
8771
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 codex --yolo </dev/null 2>&1)"; rc=$?
8772
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "codex probe on a pool with no valid login answers tier=none (exit 3), never execs" \
8773
+ || t_fail "codex probe with no valid login" "rc=$rc out=$out"
8774
+
8775
+ ar_codex_pool
8776
+ AREC=("${AREX[@]}") # ar_start launches with AREC
8777
+ ar_start FAKE_TUI=quota -- codex --dangerously-bypass-approvals-and-sandbox "fix the bug please"
8778
+ first=$AR_PID
8779
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8780
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8781
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8782
+ [ -n "$from" ] && [ -n "$to" ] && [ "$from" != "$to" ] \
8783
+ && t_ok "AR codex quota: relaunched on the OTHER account ($from -> $to)" \
8784
+ || t_fail "AR codex quota: relaunched on the other account" "$l1 | $l2"
8785
+ check "AR codex quota: \`resume <opts> <sid> <prompt>\`" \
8786
+ "ARGS=resume --dangerously-bypass-approvals-and-sandbox $AR_CXSID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account hit its usage limit." "$l2"
8787
+ ar_wait 5 ar_stopped "$first" && t_ok "AR codex quota: the stuck client was stopped" \
8788
+ || t_fail "AR codex quota: the stuck client was stopped" "pid $first alive"
8789
+ m1="$(sed -n 1p "$ARX/$from/.limited" 2>/dev/null)"; m2="$(sed -n 2p "$ARX/$from/.limited" 2>/dev/null)"
8790
+ nowx="$(date +%s)"
8791
+ case "$m1" in ''|*[!0-9]*) m1=0 ;; esac
8792
+ [ "$m1" -gt "$((nowx + 500))" ] && [ "$m1" -le "$((nowx + 600))" ] \
8793
+ && case "$m2" in "bucket=error-cooldown percent=? marked_at="*" reason=error-cooldown") true ;; *) false ;; esac \
8794
+ && t_ok "AR codex quota: the old account gets the 10-minute error-cooldown, never client:7d" \
8795
+ || t_fail "AR codex quota: cooldown marker" "$m1 / $m2"
8796
+ ar_logged "$ARX" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=quota depth=1\$" \
8797
+ && ar_logged "$ARX" "autoresume switch provider=codex chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_CXSID depth=1 probe=$to" \
8798
+ && t_ok "AR codex quota: switch + relaunch logged" \
8799
+ || t_fail "AR codex quota: log lines" "$(grep autoresume "$ARX/selection.log")"
8800
+ ar_typed_ok CODEX "$AR_CXSID" "CODEX_ACCOUNTS_ROOT=$(cd "$ARX" && pwd)" "$REPO_DIR/bin/codex" \
8801
+ && grep -q -- "send-keys -R -t %2" "$ARB/tmux/calls" \
8802
+ && t_ok "AR codex quota: terminal reset, then the token line typed" \
8803
+ || t_fail "AR codex quota: typed line" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8804
+ ar_wait 10 ar_logged "$ARX" "autoresume watch provider=codex chain=[A-Za-z0-9]+ pid=[0-9]+ acct=$to depth=1\$" \
8805
+ && t_ok "AR codex quota: the resumed session is supervised again" \
8806
+ || t_fail "AR codex quota: second watcher" "$(grep autoresume "$ARX/selection.log")"
8807
+ else
8808
+ t_fail "AR codex quota: relaunched within 30 s" "tui=$(cat "$ARB/tui.log" 2>/dev/null) log=$(grep autoresume "$ARX/selection.log" 2>/dev/null)"
8809
+ fi
8810
+ ar_reap "$ARX"
8811
+ ar_no_watcher "$ARX" && t_ok "AR codex: no watcher left running" || t_fail "AR codex: watcher left running"
8812
+
7888
8813
  # The reset-credit contract is easier to prove against a stateful local HTTP server
7889
8814
  # than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
7890
8815
  if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
@@ -7909,6 +8834,13 @@ if CLAUDE_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests
7909
8834
  else
7910
8835
  t_fail "claude automatic reset suite" "see unittest output above"
7911
8836
  fi
8837
+ # ...and the auto-resume watcher, unit by unit: classifiers, argv sanitizer, relaunch
8838
+ # files, budgets, tmux commands and a fake-runner walk through every stop/relaunch branch.
8839
+ if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume.py'; then
8840
+ t_ok "auto-resume watcher unit suite"
8841
+ else
8842
+ t_fail "auto-resume watcher unit suite" "see unittest output above"
8843
+ fi
7912
8844
 
7913
8845
  # ---- summary ---------------------------------------------------------------------
7914
8846
  echo