claude-multiacc 2.0.57 → 2.0.59

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 (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_packaged_commands.py +41 -0
package/bin/claude CHANGED
@@ -123,6 +123,44 @@ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
123
123
  return 0
124
124
  }
125
125
 
126
+ # ---- MCP OAuth logins for every account (lib/mcp_oauth.py) ------------------------
127
+ # The client keeps an MCP server's OAuth login in the config dir's credential store
128
+ # (`mcpOAuth` in .credentials.json, or in the account's Keychain item) — so a server
129
+ # authenticated under one pooled account said "needs authentication" under every other
130
+ # (2026-10-19: five separate higgsfield grants in five accounts, stubs everywhere else).
131
+ # The pool keeps the newest login per server in <pool>/mcp-oauth.json (machine-local,
132
+ # never synced) and merges it into the picked account's stores right before exec — a
133
+ # setup-token account with no .credentials.json gets one holding MCP state alone, which
134
+ # file_login reads as no login, so its token is still exported — and only behind a
135
+ # recent refreshing limits pass: one shared login, one refresher. No network here:
136
+ # refreshing is that pass's job. Bash only on the hot path —
137
+ # python runs when an account's file changed since the last harvest (the mtime of
138
+ # <pool>/.mcp-oauth-seen) and either a store exists or that file carries MCP OAuth state,
139
+ # or when the store changed since this account last got it (its <dir>/.mcp-oauth-applied
140
+ # stamp).
141
+ # Fail-open: python bounds its own run and never changes the exit status.
142
+ MCP_OAUTH_PY="$SELF_DIR/../lib/mcp_oauth.py"
143
+ mcp_oauth_ensure() { # $1 acct dir — silent, never changes the exit status
144
+ [ "${CLAUDE_MULTIACC_MCP_OAUTH:-1}" = "0" ] && return 0
145
+ [ -f "$MCP_OAUTH_PY" ] || return 0
146
+ local store="$ACC_ROOT/mcp-oauth.json" f changed=0 run=0
147
+ local -a creds
148
+ creds=("$ACC_ROOT"/acct-*/.credentials.json)
149
+ # -nt is also true when the right-hand file is missing: a first look always runs.
150
+ for f in "${creds[@]}"; do
151
+ if [ "$f" -nt "$ACC_ROOT/.mcp-oauth-seen" ]; then changed=1; break; fi
152
+ done
153
+ if [ -f "$store" ]; then
154
+ if [ "$changed" = 1 ] || [ "$store" -nt "$1/.mcp-oauth-applied" ]; then run=1; fi
155
+ elif [ "$changed" = 1 ] && LC_ALL=C grep -qs '"mcpOAuth"' "${creds[@]}" 2>/dev/null; then
156
+ run=1 # no store yet: a grep, and only after a change
157
+ fi
158
+ [ "$run" = 1 ] || return 0
159
+ "$MCP_PYBIN" "$MCP_OAUTH_PY" ensure --root "$ACC_ROOT" --account-dir "$1" --quiet \
160
+ >/dev/null 2>&1 </dev/null || true
161
+ return 0
162
+ }
163
+
126
164
  # `claude mcp add|add-json|remove` writes ONE account's .claude.json — the one it ran
127
165
  # under — and nobody else's. So the shim runs it as a child instead of exec-ing it, then
128
166
  # mirrors what it wrote into the registry and every other account (`learn` diffs the
@@ -243,18 +281,38 @@ kc_service() { # $1 = acct dir -> the keychain service name the client uses for
243
281
  elif command -v sha256sum >/dev/null 2>&1; then h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"; fi
244
282
  printf 'Claude Code-credentials-%s' "$(printf '%s' "$h" | cut -c1-8)"
245
283
  }
284
+ # A credentials document — the Keychain item or <dir>/.credentials.json — is a Claude
285
+ # LOGIN only when it carries a `claudeAiOauth` object. The client keeps its MCP servers'
286
+ # OAuth state in the same document (`mcpOAuth`, every entry with an expiresAt and a
287
+ # refreshToken of its own), and from a session that cannot open the Keychain (ssh/tmux)
288
+ # Claude Code 2.1.303 writes a .credentials.json holding ONLY that as soon as an OAuth MCP
289
+ # server is discovered. Counted as the login, such a file got its account picked with no
290
+ # token exported — "Not logged in · Run /login" on every run — while the greedy expiry
291
+ # scrape read the MCP entry's future expiresAt as the login's (2026-10-19). So every check
292
+ # reads the login object alone. It is flat: this isolates it up to its closing brace, or to
293
+ # the end of a truncated document (as lenient as the scrape always was). The C locale
294
+ # keeps one invalid byte elsewhere in the document from hiding it; no process is started.
295
+ CRED_LOGIN_RE='"claudeAiOauth"[[:space:]]*:[[:space:]]*[{][^}]*'
296
+ CRED_LOGIN=""
297
+ cred_login_of() { # $1 = document text; sets CRED_LOGIN to its login object, rc 1 when none
298
+ local LC_ALL=C
299
+ CRED_LOGIN=""
300
+ [[ $1 =~ $CRED_LOGIN_RE ]] || return 1
301
+ CRED_LOGIN="${BASH_REMATCH[0]}"
302
+ }
246
303
  # One lookup per dir per run: has_auth, auth_dead and acct_token all ask about the
247
304
  # same dir in a row, and every `security` call is a process. Memoised in variables
248
305
  # (not a $(...) result) so the memo survives — callers read KC_JSON after kc_lookup.
249
306
  KC_MEMO_DIR=""; KC_MEMO_RC=1; KC_JSON=""
250
- kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but locked here
307
+ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable login, 1 none (an item holding
308
+ # MCP state alone included), 2 exists but locked here
251
309
  [ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
252
310
  if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
253
311
  local out rc
254
312
  out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
255
313
  KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
256
314
  case "$rc" in
257
- 0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
315
+ 0) if cred_login_of "$out"; then KC_JSON="$out"; KC_MEMO_RC=0; fi ;;
258
316
  36) KC_MEMO_RC=2 ;;
259
317
  esac
260
318
  return "$KC_MEMO_RC"
@@ -288,7 +346,8 @@ sel_log() {
288
346
 
289
347
  # ---- auto-resume ----------------------------------------------------------------
290
348
  # An interactive session that stops on a usage limit, a failed login, an API error or a
291
- # crash is resumed — same session id, no keystroke — on another pooled account. The TUI
349
+ # crash, or whose client shows its critical-memory banner, is resumed — same session id, no
350
+ # keystroke — on another pooled account (crash/memory: any one, the same included). The TUI
292
351
  # at a limit does not exit; it idles on "continuing automatically at <reset>", which can
293
352
  # be days away. The shim's share of the job is small on purpose and never blocks:
294
353
  # - the plain interactive exec stays byte-identical; just before it, a detached watcher
@@ -321,6 +380,8 @@ AR_LOADED=0
321
380
  AR_ARGV=()
322
381
  AR_ORIG_ARGV=()
323
382
  AR_R_CLASS="" AR_R_ACCT="" AR_R_RESET="" AR_R_RTYPE="" AR_R_MARKED="" AR_R_SID=""
383
+ AR_R_MODEL="" # the model the relaunched session last ran on (its --resume restores it)
384
+ AR_R_VIA="" # token|login: the credential the left run carried (its watcher's state)
324
385
  AR_TRUST=0 # this run continues a relaunch: its cwd is trusted in the picked account
325
386
  AR_PY=""
326
387
 
@@ -343,7 +404,7 @@ ar_field_ok() { # $1 value
343
404
  # later, by ar_relaunch_apply, still ahead of any selection.
344
405
  ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields set
345
406
  local tok="$1" rf af line k v n=0 a="" ok=0 ver="" prov="" sid="" cwd=""
346
- local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain="" cross_bad=0
407
+ local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain="" cross_bad=0 model="" via=""
347
408
  # Alphanumerics only: the token becomes part of a path, so no '/' and no '..'.
348
409
  case "$tok" in *[!A-Za-z0-9]*) return 1 ;; esac
349
410
  [ "${#tok}" -ge 8 ] && [ "${#tok}" -le 64 ] || return 1
@@ -379,6 +440,8 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
379
440
  if [ "$k" = cross ]; then AR_CROSS="$v"; else AR_HANDOFF_TOKEN="$v"; fi ;;
380
441
  hist) ar_field_ok "$v" && hist="$v" ;;
381
442
  chain) ar_field_ok "$v" && [ "${#v}" -le 64 ] && chain="$v" ;;
443
+ model) case "$v" in ''|[!A-Za-z0-9]*|*[!]A-Za-z0-9._:/@+[-]*) ;; *) [ "${#v}" -le 200 ] && model="$v" ;; esac ;;
444
+ via) case "$v" in token|login) via="$v" ;; esac ;;
382
445
  esac
383
446
  done < "$rf"
384
447
  if [ "$ver" = 1 ] && [ "$prov" = claude ] && [ -f "$af" ] \
@@ -404,7 +467,7 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
404
467
  fi
405
468
  AR_LOADED=1
406
469
  AR_R_CLASS="$cls"; AR_R_ACCT="$acct"; AR_R_RESET="$reset"; AR_R_RTYPE="$rtype"
407
- AR_R_MARKED="$marked"
470
+ AR_R_MARKED="$marked"; AR_R_MODEL="$model"; AR_R_SID="$sid"; AR_R_VIA="$via"
408
471
  AR_DEPTH="$depth"; AR_HIST="$hist"; AR_CHAIN="$chain"; AR_AVOID="$avoid"
409
472
  # The pane's shell sits wherever the session was launched, but the session's own cwd is
410
473
  # what --resume looks the transcript up by. Everything already resolved against the old
@@ -439,18 +502,30 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
439
502
  # The verdict the watcher saw, written through the SAME writers the transcript scans use
440
503
  # (never shortening a longer park), so the old account is out of the pool before this
441
504
  # very selection runs — and for every other launch too. Other classes (model-scoped,
442
- # transient, billing/hold, crash) leave no marker: the watcher's AVOID list covers them.
505
+ # transient, billing/hold) leave no marker: the watcher's AVOID list covers them. crash and
506
+ # memory (the client at critical memory) are about the PROCESS: nothing is marked, the chain
507
+ # avoids nothing more, and selection is the ordinary one, the account just left included.
443
508
  ar_relaunch_apply() {
444
509
  local d=""
445
510
  [ "$AR_LOADED" = 1 ] || return 0
446
511
  [ -n "$AR_R_ACCT" ] && [ -d "$ACC_ROOT/$AR_R_ACCT" ] && d="$ACC_ROOT/$AR_R_ACCT"
447
512
  if [ -n "$d" ]; then
448
513
  case "$AR_R_CLASS" in
514
+ crash|memory) ;;
449
515
  quota)
450
516
  if num_ok "$AR_R_RESET" && [ "$AR_R_RESET" -gt "$now" ] && [ -n "$AR_R_RTYPE" ]; then
451
517
  mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
452
518
  fi ;;
453
- auth) mark_client_auth_dead "$d" ;;
519
+ auth)
520
+ # The watcher knew what its run carried (the state the launching shim wrote); only
521
+ # a relaunch file without that (an older watcher) falls back to the sid's index line,
522
+ # which names the session the run was launched on, not one it switched to since.
523
+ if [ -n "$AR_R_VIA" ]; then
524
+ SESS_VIA=""; [ "$AR_R_VIA" = token ] && SESS_VIA=token
525
+ else
526
+ sess_via "$d" "$AR_R_SID"
527
+ fi
528
+ mark_client_auth_dead "$d" "$SESS_VIA" ;;
454
529
  blocked)
455
530
  if [ "$AR_R_RTYPE" = "oauth_org_not_allowed" ]; then
456
531
  mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
@@ -563,8 +638,10 @@ ar_python() {
563
638
  AR_PY="$py"
564
639
  }
565
640
 
566
- ar_start_watcher() { # $1 provider, $2 picked acct dir
567
- local prov="$1" dir="$2" lib st tmp launched root nl=$'\n'
641
+ ar_start_watcher() { # $1 provider, $2 picked acct dir, $3 token when the run carries the
642
+ # portable token (the relaunch names the credential it left: via=)
643
+ local prov="$1" dir="$2" lib st tmp launched root nl=$'\n' via=login
644
+ [ "${3:-}" = token ] && via=token
568
645
  # Kill switches: the env for one shell, the file for the whole pool (the watcher
569
646
  # re-reads the file every tick, so it also stops sessions already running).
570
647
  case "${CLAUDE_MULTIACC_AUTORESUME:-1}" in 0|false|no|off) return 1 ;; esac
@@ -606,7 +683,7 @@ ar_start_watcher() { # $1 provider, $2 picked acct dir
606
683
  "self=$SELF" "acc_root=$root" "depth=$AR_DEPTH" "avoid=$AR_AVOID" \
607
684
  "hist=$AR_HIST" "chain=$AR_CHAIN" "avoid_claude=$AR_AVOID_CLAUDE" \
608
685
  "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" \
686
+ "handoff_token=$AR_HANDOFF_TOKEN" "via=$via" 2>/dev/null > "$tmp" \
610
687
  && mv -f "$tmp" "$st" 2>/dev/null \
611
688
  || { rm -f "$tmp" 2>/dev/null; return 1; }
612
689
  if [ -n "$AR_HANDOFF_TOKEN" ]; then
@@ -637,7 +714,7 @@ ar_trust() { # $1 picked acct dir
637
714
 
638
715
  # Called right before the plain interactive exec. Whatever the gate decided, the TUI
639
716
  # never sees an auto-resume variable (a nested `claude` must not inherit a probe flag).
640
- ar_spawn_watcher() { # $1 provider, $2 picked acct dir
717
+ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, $3 token|"" (ar_start_watcher)
641
718
  local rc=0 v
642
719
  ar_start_watcher "$@" || rc=$?
643
720
  for v in ${!CLAUDE_MULTIACC_AR_*}; do unset "$v"; done
@@ -1271,20 +1348,44 @@ iso_of_epoch() { # $1 seconds -> UTC ISO8601 ('' when neither date(1) dialect wo
1271
1348
  # Locale-proof (plain integers), and short enough that num_ok always passes.
1272
1349
  iso_key() { local t="${1%%.*}"; t="$(printf '%s' "$t" | LC_ALL=C tr -cd '0-9')"; printf '%s\n' "${t}"; }
1273
1350
 
1274
- # Index line: "<session id> <ISO claim>". The claim is the session's OWN start time, and
1275
- # an id belongs to exactly ONE account.
1351
+ # Index line: "<session id> <ISO claim>[ token]". The claim is the session's OWN start
1352
+ # time, and an id belongs to exactly ONE account.
1276
1353
  # Why both: `claude --continue` resumes the SAME session id under whichever account the
1277
1354
  # pool hands out next (the client only mints a new id with --fork-session), and the
1278
1355
  # transcript is shared — so without a single owner one rejection would mark every account
1279
1356
  # that ever touched that session, and without the claim time the new owner would inherit
1280
1357
  # a rejection the PREVIOUS owner earned. Each rule only ever removes attribution: the
1281
1358
  # failure mode is a missed limit, never an invented one.
1282
- sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional)
1283
- local idx="$1/.sessions-index" tmp oidx claim=""
1359
+ # `token` says the shim launched the run on the account's portable token itself (its
1360
+ # capture, $4): only such a run's authentication failure is evidence against that token.
1361
+ # A session merely sighted carries none, nor one launched on the login; the launching run
1362
+ # sets it every time, since a session resumed in the same account may change credential.
1363
+ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional), $4 token|login:
1364
+ # the credential THIS shim launched the run on (sel_capture_session)
1365
+ local idx="$1/.sessions-index" tmp oidx claim="" via="" ln out="" hit=0
1284
1366
  sess_id_ok "$2" || return 0
1367
+ [ "${4:-}" = token ] && via=" token"
1285
1368
  # `( |$)` also matches a claim-less line from an older build, so such an entry is still
1286
1369
  # deduped and can still be released when another account takes the session over.
1287
- [ -f "$idx" ] && LC_ALL=C grep -qE -- "^$2( |$)" "$idx" 2>/dev/null && return 0
1370
+ if [ -f "$idx" ] && LC_ALL=C grep -qE -- "^$2( |$)" "$idx" 2>/dev/null; then
1371
+ [ -n "${4:-}" ] || return 0
1372
+ # The launching run's credential, onto the line the session already has (claim kept;
1373
+ # a claim-less line cannot carry it). Builtins only, and no write when it is right.
1374
+ while IFS= read -r ln || [ -n "$ln" ]; do
1375
+ case "$ln" in
1376
+ "$2 "*)
1377
+ claim="${ln#* }"; claim="${claim%% *}"
1378
+ [ "$ln" = "$2 $claim$via" ] || { ln="$2 $claim$via"; hit=1; } ;;
1379
+ esac
1380
+ out="$out$ln
1381
+ "
1382
+ ln=""
1383
+ done < "$idx"
1384
+ [ "$hit" = 1 ] || return 0
1385
+ printf '%s' "$out" 2>/dev/null > "$idx.$$" && mv -f "$idx.$$" "$idx" 2>/dev/null \
1386
+ || rm -f "$idx.$$" 2>/dev/null
1387
+ return 0
1388
+ fi
1288
1389
  num_ok "${3:-}" && claim="$(iso_of_epoch "$3")"
1289
1390
  [ -n "$claim" ] || claim="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1290
1391
  for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
@@ -1300,35 +1401,90 @@ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional)
1300
1401
  fi
1301
1402
  done
1302
1403
  tmp="$idx.$$"
1303
- { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s\n' "$2" "$claim"; } \
1404
+ { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s%s\n' "$2" "$claim" "$via"; } \
1304
1405
  | tail -n "$SESS_INDEX_MAX" 2>/dev/null > "$tmp" \
1305
1406
  && mv -f "$tmp" "$idx" 2>/dev/null || rm -f "$tmp" 2>/dev/null
1306
1407
  return 0
1307
1408
  }
1308
1409
 
1309
- sess_index_refresh() { # $1 acct dir — record every run currently live in this account
1310
- local f id started known="" ln
1410
+ # The credential the shim launched <session> on, from <dir>'s index: SESS_VIA=token, or
1411
+ # empty (the login, or not launched by the shim at all). Builtins only.
1412
+ SESS_VIA=""
1413
+ sess_via() { # $1 acct dir, $2 session id; sets SESS_VIA
1414
+ local ln
1415
+ SESS_VIA=""
1416
+ sess_id_ok "${2:-}" || return 0
1417
+ [ -f "$1/.sessions-index" ] || return 0
1418
+ while IFS= read -r ln; do
1419
+ case "$ln" in "$2 "*" token") SESS_VIA=token ;; "$2"|"$2 "*) SESS_VIA="" ;; esac
1420
+ done < "$1/.sessions-index"
1421
+ }
1422
+
1423
+ sess_index_refresh() { # $1 acct dir — record every run currently live in this account;
1424
+ # $2 the pid this shim execs into and $3 the credential that run
1425
+ # carries (token|login): sel_capture_session only
1426
+ local f id started known="" tagged=" " ln via pid rst rvia
1311
1427
  sessions_owned "$1" || return 0
1312
1428
  # Read the index ONCE with the builtin, so the steady state (every live session already
1313
1429
  # claimed) costs one sed per session and not a grep and a second sed on top.
1314
1430
  if [ -f "$1/.sessions-index" ]; then
1315
- while IFS= read -r ln; do known="$known ${ln%% *}"; done < "$1/.sessions-index"
1431
+ while IFS= read -r ln; do
1432
+ known="$known ${ln%% *}"
1433
+ case "$ln" in *" token") tagged="$tagged${ln%% *} " ;; esac
1434
+ done < "$1/.sessions-index"
1316
1435
  fi
1317
1436
  for f in "$1"/sessions/*.json; do
1318
1437
  [ -f "$f" ] || continue
1319
1438
  # A symlinked registry entry would let another pool's session id in under this
1320
1439
  # account's name (second-pass codex-review finding, nested-symlink variant).
1321
1440
  [ -L "$f" ] && continue
1441
+ pid="${f##*/}"; pid="${pid%.json}"
1442
+ # The run this shim execs into is the one session whose credential it knows: its line
1443
+ # gets it even when another run sighted the session first. Any other live run the shim
1444
+ # launched has its credential on record by PROCESS (sess_via_record), for whichever
1445
+ # session the client has switched it to since (/clear, /resume inside the TUI).
1446
+ via=""; rvia=""
1447
+ if [ -n "${3:-}" ] && [ "$pid" = "${2:-}" ]; then
1448
+ via="$3"
1449
+ else
1450
+ case "$pid" in
1451
+ ''|*[!0-9]*) ;;
1452
+ *) if [ -f "$1/.sessions-via/$pid" ] && [ ! -L "$1/.sessions-via/$pid" ]; then
1453
+ rst=""
1454
+ { IFS=' ' read -r rst rvia < "$1/.sessions-via/$pid"; } 2>/dev/null
1455
+ case "$rvia" in token|login) ;; *) rvia="" ;; esac
1456
+ fi ;;
1457
+ esac
1458
+ fi
1322
1459
  id="$(LC_ALL=C sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -1)"
1323
1460
  sess_id_ok "$id" || continue
1324
- case "$known" in *" $id "*|*" $id") continue ;; esac
1461
+ case "$known" in
1462
+ *" $id "*|*" $id")
1463
+ if [ -z "$via" ]; then
1464
+ # Builtins only while the line already says what the record says.
1465
+ [ -n "$rvia" ] || continue
1466
+ case "$tagged" in
1467
+ *" $id "*) [ "$rvia" = token ] && continue ;;
1468
+ *) [ "$rvia" = login ] && continue ;;
1469
+ esac
1470
+ fi ;;
1471
+ esac
1325
1472
  # startedAt is epoch MILLIseconds; using the session's real start (not "now") is what
1326
1473
  # lets a session that has been running since before this shim was installed still be
1327
- # attributed correctly.
1474
+ # attributed correctly. It also names the process: the client rewrites only the
1475
+ # sessionId when it switches sessions, so a record whose startedAt differs was written
1476
+ # for an earlier process under a reused pid, and says nothing about this one.
1328
1477
  started="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
1478
+ if [ -n "$rvia" ] && num_ok "$started" && [ "$started" = "$rst" ]; then
1479
+ via="$rvia"
1480
+ fi
1481
+ if [ -z "$via" ]; then
1482
+ case "$known" in *" $id "*|*" $id") continue ;; esac
1483
+ fi
1329
1484
  num_ok "$started" && started=$((started / 1000)) || started=""
1330
- sess_index_add "$1" "$id" "$started"
1485
+ sess_index_add "$1" "$id" "$started" "$via"
1331
1486
  known="$known $id"
1487
+ case "$via" in token) tagged="$tagged$id " ;; login) tagged="${tagged/ $id / }" ;; esac
1332
1488
  done
1333
1489
  return 0
1334
1490
  }
@@ -1350,7 +1506,10 @@ claim_conflicted() { # $1 acct dir, $2 session id, $3 our ISO claim
1350
1506
  # older claim followed by a newer one), and it is the newest rival that decides.
1351
1507
  while IFS= read -r other; do
1352
1508
  [ -n "$other" ] || continue
1353
- case "$other" in *' '*) ok="$(iso_key "${other#* }")" ;; *) return 0 ;; esac
1509
+ case "$other" in
1510
+ *' '*) other="${other#* }"; ok="$(iso_key "${other%% *}")" ;;
1511
+ *) return 0 ;;
1512
+ esac
1354
1513
  num_ok "$ok" || return 0
1355
1514
  [ "$ok" -ge "$mk" ] && return 0
1356
1515
  done <<EOF
@@ -1401,7 +1560,7 @@ client_limit_scan() { # $1 acct dir
1401
1560
  while IFS= read -r ln; do
1402
1561
  id="${ln%% *}"
1403
1562
  claim=""
1404
- case "$ln" in *' '*) claim="${ln#* }" ;; esac
1563
+ case "$ln" in *' '*) claim="${ln#* }"; claim="${claim%% *}" ;; esac
1405
1564
  sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
1406
1565
  done < "$idx"
1407
1566
  # The budget bounds ENTRIES EXAMINED, not just transcripts read: an entry that fails the
@@ -1475,11 +1634,14 @@ client_limit_scan() { # $1 acct dir
1475
1634
  # A TUI rejection cannot use the -p retry path because the client owns the terminal.
1476
1635
  # Read only structured API errors belonging to this account's session claim. The JSON
1477
1636
  # helper runs only when the bounded grep finds an auth or organization error code.
1478
- CLIENT_ERROR_REASON=""
1479
- client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1480
- local idx="$1/.sessions-index" ln id claim p line read_n=0 i cleared=0 parser auth=""
1481
- local ids=() claims=()
1482
- CLIENT_ERROR_REASON=""
1637
+ # A `claude-accounts verify` that reached the account (.client-org-cleared) supersedes
1638
+ # every error recorded before it, an authentication failure as much as an org refusal.
1639
+ CLIENT_ERROR_REASON=""; CLIENT_ERROR_VIA=""
1640
+ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON, and CLIENT_ERROR_VIA=token
1641
+ # when an auth failure came from a run the shim launched on the token
1642
+ local idx="$1/.sessions-index" ln id claim via p line read_n=0 i cleared=0 parser auth=""
1643
+ local ids=() claims=() vias=()
1644
+ CLIENT_ERROR_REASON=""; CLIENT_ERROR_VIA=""
1483
1645
  { [ -L "$1" ] || [ -L "$1/sessions" ]; } && return 1
1484
1646
  parser="$SELF_DIR/../lib/claude_client_error.py"
1485
1647
  [ -f "$parser" ] || return 1
@@ -1488,13 +1650,15 @@ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1488
1650
  [ -f "$1/.client-org-cleared" ] \
1489
1651
  && { IFS= read -r cleared < "$1/.client-org-cleared" 2>/dev/null || cleared=0; }
1490
1652
  while IFS= read -r ln; do
1491
- id="${ln%% *}"; claim=""
1653
+ id="${ln%% *}"; claim=""; via=""
1492
1654
  case "$ln" in *' '*) claim="${ln#* }" ;; esac
1493
- sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
1655
+ case "$claim" in *' '*) via="${claim#* }"; claim="${claim%% *}" ;; esac
1656
+ sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); vias+=("$via"); }
1494
1657
  done < "$idx"
1495
1658
  i=$(( ${#ids[@]} - 1 ))
1496
1659
  while [ "$i" -ge 0 ] && [ "$read_n" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
1497
- id="${ids[$i]}"; claim="${claims[$i]}"; i=$((i - 1)); read_n=$((read_n + 1))
1660
+ id="${ids[$i]}"; claim="${claims[$i]}"; via="${vias[$i]}"
1661
+ i=$((i - 1)); read_n=$((read_n + 1))
1498
1662
  sess_transcript "$1" "$id" || continue
1499
1663
  p="$SESS_TRANSCRIPT"
1500
1664
  [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
@@ -1507,7 +1671,7 @@ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1507
1671
  --claim "$claim" --org-cleared "$cleared" 2>/dev/null)" || continue
1508
1672
  case "$line" in
1509
1673
  org-blocked\ *) CLIENT_ERROR_REASON="org-blocked"; return 0 ;;
1510
- auth\ *) auth=1 ;;
1674
+ auth\ *) auth=1; [ "$via" = token ] && CLIENT_ERROR_VIA=token ;;
1511
1675
  esac
1512
1676
  done
1513
1677
  [ -n "$auth" ] && { CLIENT_ERROR_REASON="auth"; return 0; }
@@ -1527,10 +1691,14 @@ mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reaso
1527
1691
  || rm -f "$1/.expired.$$" 2>/dev/null || true
1528
1692
  }
1529
1693
 
1530
- mark_client_auth_dead() { # $1 acct dir
1531
- local tok
1532
- tok="$(acct_token "$1")"
1533
- if [ -n "$tok" ]; then
1694
+ # The park names the credential the FAILED run carried, never the one this run would pick
1695
+ # (they differ whenever the account's credentials changed in between): a run the shim
1696
+ # launched on the portable token (its index line, sess_index_add) retires that token, and
1697
+ # anything else is the login's. Asking acct_token here charged the token for runs that
1698
+ # never carried one — the "Not logged in" runs of 2026-10-19, once an MCP-only file stopped
1699
+ # counting as their login — and parked a working token until a new one was minted.
1700
+ mark_client_auth_dead() { # $1 acct dir, $2 token when the failed run carried the token
1701
+ if [ "${2:-}" = token ]; then
1534
1702
  rm -f "$1/.server-token-verified" 2>/dev/null || true
1535
1703
  mark_proven_auth_dead "$1" "client rejected the portable OAuth token" \
1536
1704
  "setup-token-invalid"
@@ -1569,47 +1737,97 @@ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type, $4 reje
1569
1737
  # it (and every other live run of the account) in the index, because by the time the
1570
1738
  # user quits a limit-hit session and starts a new one, the client has already deleted
1571
1739
  # its registry file and nothing else can name the transcript that holds the evidence.
1572
- sel_capture_session() { # $1 acct dir
1573
- local d="$1" pid=$$
1740
+ # It also records what the run carries — the portable token, or the login — which only
1741
+ # this shim knows (mark_client_auth_dead).
1742
+ sel_capture_session() { # $1 acct dir, $2 token when the run carries the portable token
1743
+ local d="$1" pid=$$ via=login
1744
+ [ "${2:-}" = token ] && via=token
1574
1745
  [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 0
1575
1746
  (
1576
1747
  # Ctrl-C / a closed terminal must not kill the capture: it is bounded (60s) and
1577
1748
  # stops the moment the run it is watching is gone.
1578
1749
  trap '' INT HUP TERM QUIT
1579
- i=0
1750
+ i=0 tries=0
1580
1751
  while [ "$i" -lt 600 ]; do
1581
1752
  kill -0 "$pid" 2>/dev/null || break
1582
- sess_index_refresh "$d"
1583
- [ -f "$d/sessions/$pid.json" ] && break
1753
+ sess_index_refresh "$d" "$pid" "$via"
1754
+ if [ -f "$d/sessions/$pid.json" ]; then
1755
+ # Registered: the run's credential goes on record by process. A registry file caught
1756
+ # mid-write (no startedAt yet) gets a few more looks.
1757
+ sess_via_record "$d" "$pid" "$via" && break
1758
+ tries=$((tries + 1)); [ "$tries" -lt 10 ] || break
1759
+ fi
1584
1760
  sleep 0.1
1585
1761
  i=$((i + 1))
1586
1762
  done
1587
- sess_index_refresh "$d"
1763
+ sess_index_refresh "$d" "$pid" "$via"
1588
1764
  ) >/dev/null 2>&1 </dev/null &
1589
1765
  return 0
1590
1766
  }
1591
1767
 
1768
+ # The credential the shim launched a PROCESS on, for that process's whole life:
1769
+ # <acct>/.sessions-via/<pid> holds "<its registry startedAt, ms> token|login". The client
1770
+ # keeps one registry file per process and, on /clear or a /resume inside the TUI, rewrites
1771
+ # only its sessionId (2.1.304's session-switch listener merges {sessionId, updatedAt} into
1772
+ # it), so the capture tags only the run's FIRST session; sess_index_refresh tags every later
1773
+ # one from this record. startedAt survives those rewrites and names the process, so a reused
1774
+ # pid inherits nothing. Written once the registry file is there (the capture, in the
1775
+ # background); a record whose registry file is gone is dropped then too.
1776
+ sess_via_record() { # $1 acct dir, $2 pid, $3 token|login; rc 1: the registry file is there
1777
+ # but names no startedAt (yet)
1778
+ local dir="$1/.sessions-via" f st
1779
+ sessions_owned "$1" || return 0
1780
+ [ -f "$1/sessions/$2.json" ] && [ ! -L "$1/sessions/$2.json" ] || return 0
1781
+ st="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \
1782
+ "$1/sessions/$2.json" 2>/dev/null | head -1)"
1783
+ num_ok "$st" || return 1
1784
+ [ -L "$dir" ] && return 0
1785
+ [ -d "$dir" ] || mkdir "$dir" 2>/dev/null || [ -d "$dir" ] || return 0
1786
+ for f in "$dir"/*; do
1787
+ [ -f "$f" ] || continue
1788
+ [ -e "$1/sessions/${f##*/}.json" ] || rm -f "$f" 2>/dev/null
1789
+ done
1790
+ printf '%s %s\n' "$st" "$3" 2>/dev/null > "$dir/.$2.$$" \
1791
+ && mv -f "$dir/.$2.$$" "$dir/$2" 2>/dev/null || rm -f "$dir/.$2.$$" 2>/dev/null
1792
+ return 0
1793
+ }
1794
+
1795
+ # The login a dir's .credentials.json carries (cred_login_of) — a file holding only MCP
1796
+ # state, or an empty one, carries none. Memoised for the dir like kc_lookup: has_auth,
1797
+ # auth_dead and acct_token all read it in a row, and the read is a fork.
1798
+ FILE_MEMO_DIR=""; FILE_MEMO_RC=1; FILE_LOGIN=""
1799
+ file_login() { # $1 = acct dir; sets CRED_LOGIN; rc 0 when the file holds a login
1800
+ if [ "$FILE_MEMO_DIR" != "$1" ]; then
1801
+ local doc=""
1802
+ FILE_MEMO_DIR="$1"; FILE_MEMO_RC=1; FILE_LOGIN=""
1803
+ if [ -s "$1/.credentials.json" ]; then
1804
+ { doc="$(< "$1/.credentials.json")"; } 2>/dev/null
1805
+ cred_login_of "$doc" && { FILE_LOGIN="$CRED_LOGIN"; FILE_MEMO_RC=0; }
1806
+ fi
1807
+ fi
1808
+ CRED_LOGIN="$FILE_LOGIN"
1809
+ return "$FILE_MEMO_RC"
1810
+ }
1811
+
1592
1812
  # An empty credentials file is NOT auth (an interrupted write must not make a
1593
- # dead account selectable and turn a working stock run into an auth failure).
1813
+ # dead account selectable and turn a working stock run into an auth failure), and
1814
+ # neither is one holding only MCP state: it leaves the decision to the token/Keychain.
1594
1815
  # A Keychain-held login counts only when THIS session can read it (kc_lookup rc 0).
1595
- has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ] || kc_lookup "$1"; }
1816
+ has_auth() { file_login "$1" || [ -s "$1/server.token" ] || kc_lookup "$1"; }
1596
1817
  # The OAuth login itself, wherever the client keeps it (file or readable Keychain item).
1597
- has_oauth() { [ -f "$1/.credentials.json" ] || kc_lookup "$1"; }
1818
+ has_oauth() { file_login "$1" || kc_lookup "$1"; }
1598
1819
 
1599
1820
  cred_num() { # cred_num <json text> <json key> -> integer (epoch ms) or empty
1600
1821
  # The leading quote makes "expiresAt" unambiguous: "refreshTokenExpiresAt" cannot match it.
1601
1822
  printf '%s' "$1" | LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" 2>/dev/null | head -1
1602
1823
  }
1603
1824
 
1604
- # The credential document for <dir>: .credentials.json when present (even unreadable
1605
- # or truncated — the scrape below is deliberately lenient), else the Keychain item
1606
- # this session can read. Empty when there is neither.
1607
- cred_text() { # $1 = acct dir
1608
- if [ -s "$1/.credentials.json" ]; then
1609
- cat "$1/.credentials.json" 2>/dev/null
1610
- elif kc_lookup "$1"; then
1611
- printf '%s' "$KC_JSON"
1612
- fi
1825
+ # The login object for <dir>: from .credentials.json when that file carries one (even
1826
+ # truncated — the scrape below is deliberately lenient), else from the Keychain item
1827
+ # this session can read. rc 1, CRED_LOGIN empty, when neither holds a login.
1828
+ cred_login() { # $1 = acct dir; sets CRED_LOGIN
1829
+ file_login "$1" && return 0
1830
+ kc_lookup "$1" && cred_login_of "$KC_JSON"
1613
1831
  }
1614
1832
 
1615
1833
  # TRUE when the dir's OAuth login exists but can no longer authenticate: the access
@@ -1617,17 +1835,17 @@ cred_text() { # $1 = acct dir
1617
1835
  # itself expired). Such an account fails EVERY run with "OAuth session expired and
1618
1836
  # could not be refreshed", so selecting it is strictly worse than not having it.
1619
1837
  # Anything unparseable fails OPEN (treated as alive) — telemetry-free guesswork must
1620
- # never take a working account out of the pool.
1838
+ # never take a working account out of the pool. Read from the login object only: the
1839
+ # MCP entries beside it carry an expiresAt and a refreshToken that say nothing about it.
1621
1840
  creds_dead() { # $1 = acct dir
1622
- local src exp rexp
1623
- has_oauth "$1" || return 1
1624
- src="$(cred_text "$1")"
1625
- exp="$(cred_num "$src" expiresAt)"
1841
+ local exp rexp
1842
+ cred_login "$1" || return 1
1843
+ exp="$(cred_num "$CRED_LOGIN" expiresAt)"
1626
1844
  case "$exp" in ''|*[!0-9]*) exp=0 ;; esac
1627
1845
  [ $((exp / 1000)) -gt "$now" ] && return 1 # access token still valid
1628
1846
  # Access token expired: only a live refresh token can save it.
1629
- printf '%s' "$src" | grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' 2>/dev/null || return 0
1630
- rexp="$(cred_num "$src" refreshTokenExpiresAt)"
1847
+ printf '%s' "$CRED_LOGIN" | grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' 2>/dev/null || return 0
1848
+ rexp="$(cred_num "$CRED_LOGIN" refreshTokenExpiresAt)"
1631
1849
  case "$rexp" in ''|*[!0-9]*) return 1 ;; esac # unknown expiry => fail open
1632
1850
  [ $((rexp / 1000)) -le "$now" ]
1633
1851
  }
@@ -1635,8 +1853,9 @@ creds_dead() { # $1 = acct dir
1635
1853
  # `.expired` is the persistent "this account needs a re-login" marker: written by
1636
1854
  # claude-accounts (refresh grant expired/revoked) and by the retry path below when a
1637
1855
  # real call fails with an auth error. It SELF-HEALS: any credential written after the
1638
- # marker (successful re-login, or a refresh by another process) clears it, and a
1639
- # successful `claude-accounts limits` fetch removes it outright.
1856
+ # marker (successful re-login, or a refresh by another process — a login or a token, never
1857
+ # MCP state alone) clears it, and a successful `claude-accounts limits` fetch removes it
1858
+ # outright.
1640
1859
  # A marker written by the SHIM (a guess from one failed run) also carries
1641
1860
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
1642
1861
  # a misread never costs an account permanently. Markers written by claude-accounts —
@@ -1645,7 +1864,7 @@ creds_dead() { # $1 = acct dir
1645
1864
  # An org block stays until verify proves access, including a usage-limiter refusal.
1646
1865
  # Time and credential refreshes cannot prove the organization's policy changed.
1647
1866
  expired_marked() { # $1 = acct dir
1648
- local m="$1/.expired" mt f soft reason
1867
+ local m="$1/.expired" mt soft reason kc
1649
1868
  [ -f "$m" ] || return 1
1650
1869
  reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
1651
1870
  [ "$reason" = "org-blocked" ] && return 0
@@ -1681,17 +1900,27 @@ expired_marked() { # $1 = acct dir
1681
1900
  # "Your organization has disabled Claude subscription access".
1682
1901
  if [ "$reason" != "org-blocked" ]; then
1683
1902
  mt="$(file_mtime "$m")"
1684
- for f in "$1/.credentials.json" "$1/server.token"; do
1685
- if [ -f "$f" ] && [ "$(file_mtime "$f")" -gt "$mt" ]; then
1903
+ if [ -f "$1/server.token" ] && [ "$(file_mtime "$1/server.token")" -gt "$mt" ]; then
1904
+ rm -f "$m" 2>/dev/null
1905
+ return 1
1906
+ fi
1907
+ # A newer LOGIN is the evidence. The client rewrites .credentials.json with MCP state
1908
+ # alone on every MCP token refresh, which says nothing about the login, so only a
1909
+ # file that carries one can heal the park; any other leaves it to the Keychain.
1910
+ if file_login "$1"; then
1911
+ if [ "$(file_mtime "$1/.credentials.json")" -gt "$mt" ]; then
1686
1912
  rm -f "$m" 2>/dev/null
1687
1913
  return 1
1688
1914
  fi
1689
- done
1690
1915
  # A login the client keeps in the Keychain is "written after the marker" in the
1691
- # same sense; its stamp is readable even from a session that cannot open it.
1692
- if [ ! -f "$1/.credentials.json" ] && [ "$(kc_mtime "$1")" -gt "$mt" ]; then
1693
- rm -f "$m" 2>/dev/null
1694
- return 1
1916
+ # same sense; its stamp is readable even from a session that cannot open it. An item
1917
+ # this session CAN read is judged by what it holds: MCP state alone heals nothing.
1918
+ elif [ "$(kc_mtime "$1")" -gt "$mt" ]; then
1919
+ kc=0; kc_lookup "$1" || kc=$?
1920
+ if [ "$kc" -ne 1 ]; then
1921
+ rm -f "$m" 2>/dev/null
1922
+ return 1
1923
+ fi
1695
1924
  fi
1696
1925
  fi
1697
1926
  soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
@@ -1824,11 +2053,13 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1824
2053
  fi
1825
2054
  export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1826
2055
  fi
1827
- # A pinned account carries every registered MCP server too, and a pinned
1828
- # `claude mcp add|remove` is mirrored like any other (exits inside).
2056
+ # A pinned account carries every registered MCP server (and the pool's MCP OAuth
2057
+ # logins) too, and a pinned `claude mcp add|remove` is mirrored like any other
2058
+ # (exits inside).
1829
2059
  mcp_ensure "$d"
2060
+ mcp_oauth_ensure "$d"
1830
2061
  mcp_mirror_run "$d" "$@" || true
1831
- sel_capture_session "$d"
2062
+ sel_capture_session "$d" "${tok:+token}"
1832
2063
  exec "$REAL" "$@"
1833
2064
  fi
1834
2065
  sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
@@ -1854,7 +2085,7 @@ for d in "$ACC_ROOT"/acct-*; do
1854
2085
  mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
1855
2086
  sel_log "${d##*/} parked (org-blocked) — client-reported"
1856
2087
  else
1857
- mark_client_auth_dead "$d"
2088
+ mark_client_auth_dead "$d" "$CLIENT_ERROR_VIA"
1858
2089
  fi
1859
2090
  valid=("${valid[@]:0:${#valid[@]}-1}")
1860
2091
  expired+=("$d")
@@ -2192,20 +2423,27 @@ PICK_TOKEN="$tok"
2192
2423
  # session on "You've reached your Fable 5 limit" — the exact failure this selection
2193
2424
  # path exists to avoid (operator, tmux 136) — so the fallback model is pinned once,
2194
2425
  # before exec. An explicit --model is never touched: such a run does not get here.
2426
+ # A relaunch names the model its session last ran on, which --resume restores: only a
2427
+ # session on the limited model itself is pinned off it; any other keeps its own model.
2195
2428
  if [ -z "$RUN_MODEL" ]; then
2196
2429
  sel_tok="$(scoped_tok_of_marker "$pick")"
2197
2430
  [ -n "$sel_tok" ] || sel_tok="$(scoped_over_tok "$pick")"
2198
2431
  if [ -n "$sel_tok" ] && ! scoped_blocks_run "$sel_tok"; then
2199
- set -- --model "$FALLBACK_MODEL" "$@"
2200
- sel_log "$(basename "$pick") -> --model $FALLBACK_MODEL (its $sel_tok bucket is full)"
2432
+ case "$(sel_lc "${AR_R_MODEL:-$sel_tok}")" in
2433
+ *"$(sel_lc "$sel_tok")"*)
2434
+ set -- --model "$FALLBACK_MODEL" "$@"
2435
+ sel_log "$(basename "$pick") -> --model $FALLBACK_MODEL (its $sel_tok bucket is full)" ;;
2436
+ esac
2201
2437
  fi
2202
2438
  fi
2203
2439
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
2204
2440
  # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
2205
2441
  # not a turn in the rotation.
2206
2442
  remember_pick "$pick"
2207
- # The picked account carries every registered MCP server before the client starts.
2443
+ # The picked account carries every registered MCP server, and the MCP OAuth logins any
2444
+ # account on this machine holds, before the client starts.
2208
2445
  mcp_ensure "$pick"
2446
+ mcp_oauth_ensure "$pick"
2209
2447
 
2210
2448
  # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
2211
2449
  # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
@@ -2320,9 +2558,9 @@ if [ "$wants_retry" = "0" ]; then
2320
2558
  export CLAUDE_CONFIG_DIR="$pick"
2321
2559
  tok="$PICK_TOKEN"
2322
2560
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2323
- sel_capture_session "$pick"
2561
+ sel_capture_session "$pick" "${tok:+token}"
2324
2562
  ar_trust "$pick"
2325
- if ! ar_spawn_watcher claude "$pick" && [ -n "$AR_HANDOFF_TOKEN" ]; then
2563
+ if ! ar_spawn_watcher claude "$pick" "${tok:+token}" && [ -n "$AR_HANDOFF_TOKEN" ]; then
2326
2564
  printf 'claude-multiacc: auto-resume handoff could not start its watcher.\n' >&2
2327
2565
  exit 2
2328
2566
  fi
@@ -2335,7 +2573,7 @@ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
2335
2573
  export CLAUDE_CONFIG_DIR="$pick"
2336
2574
  tok="$PICK_TOKEN"
2337
2575
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2338
- sel_capture_session "$pick"
2576
+ sel_capture_session "$pick" "${tok:+token}"
2339
2577
  exec "$REAL" "$@"
2340
2578
  }
2341
2579
  trap 'rm -rf "$tmpd"' EXIT
@@ -2347,7 +2585,7 @@ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
2347
2585
  export CLAUDE_CONFIG_DIR="$pick"
2348
2586
  tok="$PICK_TOKEN"
2349
2587
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2350
- sel_capture_session "$pick"
2588
+ sel_capture_session "$pick" "${tok:+token}"
2351
2589
  exec "$REAL" "$@"
2352
2590
  fi
2353
2591
 
@@ -2530,6 +2768,7 @@ while :; do
2530
2768
  # away from — not the one that bounced.
2531
2769
  remember_pick "$cur"
2532
2770
  mcp_ensure "$cur"
2771
+ mcp_oauth_ensure "$cur"
2533
2772
  rotated=1
2534
2773
  attempt=2
2535
2774
  continue