claude-multiacc 2.0.58 → 2.0.60

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 (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -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 +81 -9
  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 +146 -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/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -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 +14 -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_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
package/bin/claude CHANGED
@@ -123,20 +123,115 @@ 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
129
167
  # account against a snapshot taken just before). The real CLI's own parser and writer do
130
168
  # the work; the shim only copies the outcome. Exits with the real binary's status; a
131
169
  # mirror failure is one hint line. Returns 1 when this run is not such a command.
170
+ # The index of the first positional argument as the real CLI's parser (commander) finds
171
+ # it: global options may come first, in any number. The operator's `alias claude='claude
172
+ # --dangerously-skip-permissions'` puts one in front of every `claude mcp add` typed at a
173
+ # prompt, and the scoped-limit fallback prepends `--model X`; the mirror once looked at $1
174
+ # alone, so on 2026-10-22 motionsites (and playwright, inspo) sat in the one account a
175
+ # typed add ran under. A value option skips its value; an optional-value one the next
176
+ # word unless it is an option; a variadic one every word up to the next option. `--`, or
177
+ # a positional after `--` counts (commander's own rule). No positional: status 1. Options
178
+ # of claude 2.1.308 (`claude --help`; hidden ones left out on purpose): an unknown one
179
+ # counts as a flag — at worst its value is taken for the positional, which is then no
180
+ # `mcp`, the run is not mirrored, and the registry's adoption picks the server up instead.
181
+ CLI_VALUE_OPTS=" --agent --agents --append-system-prompt --append-system-prompt-file \
182
+ --autocompact --debug-file --effort --environment --fallback-model --input-format \
183
+ --json-schema --max-budget-usd --model -n --name --output-format --permission-mode \
184
+ --permission-prompts --permission-prompt-tool --plugin-dir --plugin-url \
185
+ --remote-control-session-name-prefix --session-id --setting-sources --settings \
186
+ --system-prompt --system-prompt-file --system-prompt-snapshot "
187
+ CLI_OPTIONAL_OPTS=" -d --debug --cloud --from-pr --prompt-suggestions --remote-control --rc \
188
+ --remote -r --resume --teleport -w --worktree "
189
+ CLI_VARIADIC_OPTS=" --add-dir --allowedTools --allowed-tools --betas --disallowedTools \
190
+ --disallowed-tools --file --mcp-config --tools "
191
+ cli_positional_at() { # argv -> prints the index of its first positional argument
192
+ local i=1 a nx
193
+ while [ "$i" -le "$#" ]; do
194
+ a="${!i}"
195
+ case "$a" in
196
+ --) i=$((i + 1)); [ "$i" -le "$#" ] || return 1; printf '%s\n' "$i"; return 0 ;;
197
+ --*=*) i=$((i + 1)); continue ;;
198
+ -?*) ;;
199
+ *) printf '%s\n' "$i"; return 0 ;;
200
+ esac
201
+ i=$((i + 1))
202
+ case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
203
+ case "$CLI_OPTIONAL_OPTS" in
204
+ *" $a "*) nx="${!i:-}"; case "$nx" in ''|-*) ;; *) i=$((i + 1)) ;; esac; continue ;;
205
+ esac
206
+ case "$CLI_VARIADIC_OPTS" in
207
+ *" $a "*)
208
+ while [ "$i" -le "$#" ]; do
209
+ nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
210
+ done ;;
211
+ esac
212
+ done
213
+ return 1
214
+ }
215
+
216
+ # Whether argv is `claude [global options] mcp add|add-json|add-from-claude-desktop|remove …`.
217
+ # The exact word -p/--print (or --bg/--background) anywhere before a `--` puts the client in
218
+ # print/background mode, which never runs the subcommand.
219
+ mcp_mutation_argv() {
220
+ local i j a
221
+ for a in "$@"; do
222
+ case "$a" in --) break ;; -p|--print|--bg|--background) return 1 ;; esac
223
+ done
224
+ i="$(cli_positional_at "$@")" || return 1
225
+ [ "${!i}" = "mcp" ] || return 1
226
+ j=$((i + 1))
227
+ case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) return 0 ;; esac
228
+ return 1
229
+ }
230
+
132
231
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
133
- local d="$1" snap="" rc verb="${2:-}" sub="${3:-}"
232
+ local d="$1" snap="" rc
134
233
  shift
135
- # The scoped-limit fallback may have prepended `--model X` (a global option the real
136
- # CLI accepts in front of any subcommand): look past it.
137
- if [ "$verb" = "--model" ]; then verb="${3:-}"; sub="${4:-}"; fi
138
- [ "$verb" = "mcp" ] || return 1
139
- case "$sub" in add|add-json|remove) ;; *) return 1 ;; esac
234
+ mcp_mutation_argv "$@" || return 1
140
235
  [ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
141
236
  mcp_ensure "$d"
142
237
  snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
@@ -169,9 +264,8 @@ mcp_nested_mirror() {
169
264
  [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] && return 1
170
265
  [ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 1
171
266
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] || return 1
172
- local verb="${1:-}" cfg="${CLAUDE_CONFIG_DIR%/}" d=""
173
- if [ "$verb" = "--model" ]; then verb="${3:-}"; fi
174
- [ "$verb" = "mcp" ] || return 1
267
+ local cfg="${CLAUDE_CONFIG_DIR%/}" d=""
268
+ mcp_mutation_argv "$@" || return 1
175
269
  # A DIRECT child of the pool root, by its given spelling or its physical one (an
176
270
  # adopted account is a symlink whose target is the operator's ~/.claude).
177
271
  case "$cfg" in
@@ -243,18 +337,38 @@ kc_service() { # $1 = acct dir -> the keychain service name the client uses for
243
337
  elif command -v sha256sum >/dev/null 2>&1; then h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"; fi
244
338
  printf 'Claude Code-credentials-%s' "$(printf '%s' "$h" | cut -c1-8)"
245
339
  }
340
+ # A credentials document — the Keychain item or <dir>/.credentials.json — is a Claude
341
+ # LOGIN only when it carries a `claudeAiOauth` object. The client keeps its MCP servers'
342
+ # OAuth state in the same document (`mcpOAuth`, every entry with an expiresAt and a
343
+ # refreshToken of its own), and from a session that cannot open the Keychain (ssh/tmux)
344
+ # Claude Code 2.1.303 writes a .credentials.json holding ONLY that as soon as an OAuth MCP
345
+ # server is discovered. Counted as the login, such a file got its account picked with no
346
+ # token exported — "Not logged in · Run /login" on every run — while the greedy expiry
347
+ # scrape read the MCP entry's future expiresAt as the login's (2026-10-19). So every check
348
+ # reads the login object alone. It is flat: this isolates it up to its closing brace, or to
349
+ # the end of a truncated document (as lenient as the scrape always was). The C locale
350
+ # keeps one invalid byte elsewhere in the document from hiding it; no process is started.
351
+ CRED_LOGIN_RE='"claudeAiOauth"[[:space:]]*:[[:space:]]*[{][^}]*'
352
+ CRED_LOGIN=""
353
+ cred_login_of() { # $1 = document text; sets CRED_LOGIN to its login object, rc 1 when none
354
+ local LC_ALL=C
355
+ CRED_LOGIN=""
356
+ [[ $1 =~ $CRED_LOGIN_RE ]] || return 1
357
+ CRED_LOGIN="${BASH_REMATCH[0]}"
358
+ }
246
359
  # One lookup per dir per run: has_auth, auth_dead and acct_token all ask about the
247
360
  # same dir in a row, and every `security` call is a process. Memoised in variables
248
361
  # (not a $(...) result) so the memo survives — callers read KC_JSON after kc_lookup.
249
362
  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
363
+ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable login, 1 none (an item holding
364
+ # MCP state alone included), 2 exists but locked here
251
365
  [ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
252
366
  if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
253
367
  local out rc
254
368
  out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
255
369
  KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
256
370
  case "$rc" in
257
- 0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
371
+ 0) if cred_login_of "$out"; then KC_JSON="$out"; KC_MEMO_RC=0; fi ;;
258
372
  36) KC_MEMO_RC=2 ;;
259
373
  esac
260
374
  return "$KC_MEMO_RC"
@@ -288,7 +402,8 @@ sel_log() {
288
402
 
289
403
  # ---- auto-resume ----------------------------------------------------------------
290
404
  # 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
405
+ # crash, or whose client shows its critical-memory banner, is resumed — same session id, no
406
+ # keystroke — on another pooled account (crash/memory: any one, the same included). The TUI
292
407
  # at a limit does not exit; it idles on "continuing automatically at <reset>", which can
293
408
  # be days away. The shim's share of the job is small on purpose and never blocks:
294
409
  # - the plain interactive exec stays byte-identical; just before it, a detached watcher
@@ -321,6 +436,8 @@ AR_LOADED=0
321
436
  AR_ARGV=()
322
437
  AR_ORIG_ARGV=()
323
438
  AR_R_CLASS="" AR_R_ACCT="" AR_R_RESET="" AR_R_RTYPE="" AR_R_MARKED="" AR_R_SID=""
439
+ AR_R_MODEL="" # the model the relaunched session last ran on (its --resume restores it)
440
+ AR_R_VIA="" # token|login: the credential the left run carried (its watcher's state)
324
441
  AR_TRUST=0 # this run continues a relaunch: its cwd is trusted in the picked account
325
442
  AR_PY=""
326
443
 
@@ -343,7 +460,7 @@ ar_field_ok() { # $1 value
343
460
  # later, by ar_relaunch_apply, still ahead of any selection.
344
461
  ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields set
345
462
  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
463
+ local cls="" acct="" reset="" rtype="" marked="" depth=0 avoid="" hist="" chain="" cross_bad=0 model="" via=""
347
464
  # Alphanumerics only: the token becomes part of a path, so no '/' and no '..'.
348
465
  case "$tok" in *[!A-Za-z0-9]*) return 1 ;; esac
349
466
  [ "${#tok}" -ge 8 ] && [ "${#tok}" -le 64 ] || return 1
@@ -379,6 +496,8 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
379
496
  if [ "$k" = cross ]; then AR_CROSS="$v"; else AR_HANDOFF_TOKEN="$v"; fi ;;
380
497
  hist) ar_field_ok "$v" && hist="$v" ;;
381
498
  chain) ar_field_ok "$v" && [ "${#v}" -le 64 ] && chain="$v" ;;
499
+ model) case "$v" in ''|[!A-Za-z0-9]*|*[!]A-Za-z0-9._:/@+[-]*) ;; *) [ "${#v}" -le 200 ] && model="$v" ;; esac ;;
500
+ via) case "$v" in token|login) via="$v" ;; esac ;;
382
501
  esac
383
502
  done < "$rf"
384
503
  if [ "$ver" = 1 ] && [ "$prov" = claude ] && [ -f "$af" ] \
@@ -404,7 +523,7 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
404
523
  fi
405
524
  AR_LOADED=1
406
525
  AR_R_CLASS="$cls"; AR_R_ACCT="$acct"; AR_R_RESET="$reset"; AR_R_RTYPE="$rtype"
407
- AR_R_MARKED="$marked"
526
+ AR_R_MARKED="$marked"; AR_R_MODEL="$model"; AR_R_SID="$sid"; AR_R_VIA="$via"
408
527
  AR_DEPTH="$depth"; AR_HIST="$hist"; AR_CHAIN="$chain"; AR_AVOID="$avoid"
409
528
  # The pane's shell sits wherever the session was launched, but the session's own cwd is
410
529
  # what --resume looks the transcript up by. Everything already resolved against the old
@@ -439,18 +558,30 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
439
558
  # The verdict the watcher saw, written through the SAME writers the transcript scans use
440
559
  # (never shortening a longer park), so the old account is out of the pool before this
441
560
  # 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.
561
+ # transient, billing/hold) leave no marker: the watcher's AVOID list covers them. crash and
562
+ # memory (the client at critical memory) are about the PROCESS: nothing is marked, the chain
563
+ # avoids nothing more, and selection is the ordinary one, the account just left included.
443
564
  ar_relaunch_apply() {
444
565
  local d=""
445
566
  [ "$AR_LOADED" = 1 ] || return 0
446
567
  [ -n "$AR_R_ACCT" ] && [ -d "$ACC_ROOT/$AR_R_ACCT" ] && d="$ACC_ROOT/$AR_R_ACCT"
447
568
  if [ -n "$d" ]; then
448
569
  case "$AR_R_CLASS" in
570
+ crash|memory) ;;
449
571
  quota)
450
572
  if num_ok "$AR_R_RESET" && [ "$AR_R_RESET" -gt "$now" ] && [ -n "$AR_R_RTYPE" ]; then
451
573
  mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
452
574
  fi ;;
453
- auth) mark_client_auth_dead "$d" ;;
575
+ auth)
576
+ # The watcher knew what its run carried (the state the launching shim wrote); only
577
+ # a relaunch file without that (an older watcher) falls back to the sid's index line,
578
+ # which names the session the run was launched on, not one it switched to since.
579
+ if [ -n "$AR_R_VIA" ]; then
580
+ SESS_VIA=""; [ "$AR_R_VIA" = token ] && SESS_VIA=token
581
+ else
582
+ sess_via "$d" "$AR_R_SID"
583
+ fi
584
+ mark_client_auth_dead "$d" "$SESS_VIA" ;;
454
585
  blocked)
455
586
  if [ "$AR_R_RTYPE" = "oauth_org_not_allowed" ]; then
456
587
  mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
@@ -563,8 +694,10 @@ ar_python() {
563
694
  AR_PY="$py"
564
695
  }
565
696
 
566
- ar_start_watcher() { # $1 provider, $2 picked acct dir
567
- local prov="$1" dir="$2" lib st tmp launched root nl=$'\n'
697
+ ar_start_watcher() { # $1 provider, $2 picked acct dir, $3 token when the run carries the
698
+ # portable token (the relaunch names the credential it left: via=)
699
+ local prov="$1" dir="$2" lib st tmp launched root nl=$'\n' via=login
700
+ [ "${3:-}" = token ] && via=token
568
701
  # Kill switches: the env for one shell, the file for the whole pool (the watcher
569
702
  # re-reads the file every tick, so it also stops sessions already running).
570
703
  case "${CLAUDE_MULTIACC_AUTORESUME:-1}" in 0|false|no|off) return 1 ;; esac
@@ -606,7 +739,7 @@ ar_start_watcher() { # $1 provider, $2 picked acct dir
606
739
  "self=$SELF" "acc_root=$root" "depth=$AR_DEPTH" "avoid=$AR_AVOID" \
607
740
  "hist=$AR_HIST" "chain=$AR_CHAIN" "avoid_claude=$AR_AVOID_CLAUDE" \
608
741
  "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" \
742
+ "handoff_token=$AR_HANDOFF_TOKEN" "via=$via" 2>/dev/null > "$tmp" \
610
743
  && mv -f "$tmp" "$st" 2>/dev/null \
611
744
  || { rm -f "$tmp" 2>/dev/null; return 1; }
612
745
  if [ -n "$AR_HANDOFF_TOKEN" ]; then
@@ -637,7 +770,7 @@ ar_trust() { # $1 picked acct dir
637
770
 
638
771
  # Called right before the plain interactive exec. Whatever the gate decided, the TUI
639
772
  # 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
773
+ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, $3 token|"" (ar_start_watcher)
641
774
  local rc=0 v
642
775
  ar_start_watcher "$@" || rc=$?
643
776
  for v in ${!CLAUDE_MULTIACC_AR_*}; do unset "$v"; done
@@ -1271,20 +1404,44 @@ iso_of_epoch() { # $1 seconds -> UTC ISO8601 ('' when neither date(1) dialect wo
1271
1404
  # Locale-proof (plain integers), and short enough that num_ok always passes.
1272
1405
  iso_key() { local t="${1%%.*}"; t="$(printf '%s' "$t" | LC_ALL=C tr -cd '0-9')"; printf '%s\n' "${t}"; }
1273
1406
 
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.
1407
+ # Index line: "<session id> <ISO claim>[ token]". The claim is the session's OWN start
1408
+ # time, and an id belongs to exactly ONE account.
1276
1409
  # Why both: `claude --continue` resumes the SAME session id under whichever account the
1277
1410
  # pool hands out next (the client only mints a new id with --fork-session), and the
1278
1411
  # transcript is shared — so without a single owner one rejection would mark every account
1279
1412
  # that ever touched that session, and without the claim time the new owner would inherit
1280
1413
  # a rejection the PREVIOUS owner earned. Each rule only ever removes attribution: the
1281
1414
  # 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=""
1415
+ # `token` says the shim launched the run on the account's portable token itself (its
1416
+ # capture, $4): only such a run's authentication failure is evidence against that token.
1417
+ # A session merely sighted carries none, nor one launched on the login; the launching run
1418
+ # sets it every time, since a session resumed in the same account may change credential.
1419
+ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional), $4 token|login:
1420
+ # the credential THIS shim launched the run on (sel_capture_session)
1421
+ local idx="$1/.sessions-index" tmp oidx claim="" via="" ln out="" hit=0
1284
1422
  sess_id_ok "$2" || return 0
1423
+ [ "${4:-}" = token ] && via=" token"
1285
1424
  # `( |$)` also matches a claim-less line from an older build, so such an entry is still
1286
1425
  # 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
1426
+ if [ -f "$idx" ] && LC_ALL=C grep -qE -- "^$2( |$)" "$idx" 2>/dev/null; then
1427
+ [ -n "${4:-}" ] || return 0
1428
+ # The launching run's credential, onto the line the session already has (claim kept;
1429
+ # a claim-less line cannot carry it). Builtins only, and no write when it is right.
1430
+ while IFS= read -r ln || [ -n "$ln" ]; do
1431
+ case "$ln" in
1432
+ "$2 "*)
1433
+ claim="${ln#* }"; claim="${claim%% *}"
1434
+ [ "$ln" = "$2 $claim$via" ] || { ln="$2 $claim$via"; hit=1; } ;;
1435
+ esac
1436
+ out="$out$ln
1437
+ "
1438
+ ln=""
1439
+ done < "$idx"
1440
+ [ "$hit" = 1 ] || return 0
1441
+ printf '%s' "$out" 2>/dev/null > "$idx.$$" && mv -f "$idx.$$" "$idx" 2>/dev/null \
1442
+ || rm -f "$idx.$$" 2>/dev/null
1443
+ return 0
1444
+ fi
1288
1445
  num_ok "${3:-}" && claim="$(iso_of_epoch "$3")"
1289
1446
  [ -n "$claim" ] || claim="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1290
1447
  for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
@@ -1300,35 +1457,90 @@ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional)
1300
1457
  fi
1301
1458
  done
1302
1459
  tmp="$idx.$$"
1303
- { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s\n' "$2" "$claim"; } \
1460
+ { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s%s\n' "$2" "$claim" "$via"; } \
1304
1461
  | tail -n "$SESS_INDEX_MAX" 2>/dev/null > "$tmp" \
1305
1462
  && mv -f "$tmp" "$idx" 2>/dev/null || rm -f "$tmp" 2>/dev/null
1306
1463
  return 0
1307
1464
  }
1308
1465
 
1309
- sess_index_refresh() { # $1 acct dir — record every run currently live in this account
1310
- local f id started known="" ln
1466
+ # The credential the shim launched <session> on, from <dir>'s index: SESS_VIA=token, or
1467
+ # empty (the login, or not launched by the shim at all). Builtins only.
1468
+ SESS_VIA=""
1469
+ sess_via() { # $1 acct dir, $2 session id; sets SESS_VIA
1470
+ local ln
1471
+ SESS_VIA=""
1472
+ sess_id_ok "${2:-}" || return 0
1473
+ [ -f "$1/.sessions-index" ] || return 0
1474
+ while IFS= read -r ln; do
1475
+ case "$ln" in "$2 "*" token") SESS_VIA=token ;; "$2"|"$2 "*) SESS_VIA="" ;; esac
1476
+ done < "$1/.sessions-index"
1477
+ }
1478
+
1479
+ sess_index_refresh() { # $1 acct dir — record every run currently live in this account;
1480
+ # $2 the pid this shim execs into and $3 the credential that run
1481
+ # carries (token|login): sel_capture_session only
1482
+ local f id started known="" tagged=" " ln via pid rst rvia
1311
1483
  sessions_owned "$1" || return 0
1312
1484
  # Read the index ONCE with the builtin, so the steady state (every live session already
1313
1485
  # claimed) costs one sed per session and not a grep and a second sed on top.
1314
1486
  if [ -f "$1/.sessions-index" ]; then
1315
- while IFS= read -r ln; do known="$known ${ln%% *}"; done < "$1/.sessions-index"
1487
+ while IFS= read -r ln; do
1488
+ known="$known ${ln%% *}"
1489
+ case "$ln" in *" token") tagged="$tagged${ln%% *} " ;; esac
1490
+ done < "$1/.sessions-index"
1316
1491
  fi
1317
1492
  for f in "$1"/sessions/*.json; do
1318
1493
  [ -f "$f" ] || continue
1319
1494
  # A symlinked registry entry would let another pool's session id in under this
1320
1495
  # account's name (second-pass codex-review finding, nested-symlink variant).
1321
1496
  [ -L "$f" ] && continue
1497
+ pid="${f##*/}"; pid="${pid%.json}"
1498
+ # The run this shim execs into is the one session whose credential it knows: its line
1499
+ # gets it even when another run sighted the session first. Any other live run the shim
1500
+ # launched has its credential on record by PROCESS (sess_via_record), for whichever
1501
+ # session the client has switched it to since (/clear, /resume inside the TUI).
1502
+ via=""; rvia=""
1503
+ if [ -n "${3:-}" ] && [ "$pid" = "${2:-}" ]; then
1504
+ via="$3"
1505
+ else
1506
+ case "$pid" in
1507
+ ''|*[!0-9]*) ;;
1508
+ *) if [ -f "$1/.sessions-via/$pid" ] && [ ! -L "$1/.sessions-via/$pid" ]; then
1509
+ rst=""
1510
+ { IFS=' ' read -r rst rvia < "$1/.sessions-via/$pid"; } 2>/dev/null
1511
+ case "$rvia" in token|login) ;; *) rvia="" ;; esac
1512
+ fi ;;
1513
+ esac
1514
+ fi
1322
1515
  id="$(LC_ALL=C sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -1)"
1323
1516
  sess_id_ok "$id" || continue
1324
- case "$known" in *" $id "*|*" $id") continue ;; esac
1517
+ case "$known" in
1518
+ *" $id "*|*" $id")
1519
+ if [ -z "$via" ]; then
1520
+ # Builtins only while the line already says what the record says.
1521
+ [ -n "$rvia" ] || continue
1522
+ case "$tagged" in
1523
+ *" $id "*) [ "$rvia" = token ] && continue ;;
1524
+ *) [ "$rvia" = login ] && continue ;;
1525
+ esac
1526
+ fi ;;
1527
+ esac
1325
1528
  # startedAt is epoch MILLIseconds; using the session's real start (not "now") is what
1326
1529
  # lets a session that has been running since before this shim was installed still be
1327
- # attributed correctly.
1530
+ # attributed correctly. It also names the process: the client rewrites only the
1531
+ # sessionId when it switches sessions, so a record whose startedAt differs was written
1532
+ # for an earlier process under a reused pid, and says nothing about this one.
1328
1533
  started="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
1534
+ if [ -n "$rvia" ] && num_ok "$started" && [ "$started" = "$rst" ]; then
1535
+ via="$rvia"
1536
+ fi
1537
+ if [ -z "$via" ]; then
1538
+ case "$known" in *" $id "*|*" $id") continue ;; esac
1539
+ fi
1329
1540
  num_ok "$started" && started=$((started / 1000)) || started=""
1330
- sess_index_add "$1" "$id" "$started"
1541
+ sess_index_add "$1" "$id" "$started" "$via"
1331
1542
  known="$known $id"
1543
+ case "$via" in token) tagged="$tagged$id " ;; login) tagged="${tagged/ $id / }" ;; esac
1332
1544
  done
1333
1545
  return 0
1334
1546
  }
@@ -1350,7 +1562,10 @@ claim_conflicted() { # $1 acct dir, $2 session id, $3 our ISO claim
1350
1562
  # older claim followed by a newer one), and it is the newest rival that decides.
1351
1563
  while IFS= read -r other; do
1352
1564
  [ -n "$other" ] || continue
1353
- case "$other" in *' '*) ok="$(iso_key "${other#* }")" ;; *) return 0 ;; esac
1565
+ case "$other" in
1566
+ *' '*) other="${other#* }"; ok="$(iso_key "${other%% *}")" ;;
1567
+ *) return 0 ;;
1568
+ esac
1354
1569
  num_ok "$ok" || return 0
1355
1570
  [ "$ok" -ge "$mk" ] && return 0
1356
1571
  done <<EOF
@@ -1401,7 +1616,7 @@ client_limit_scan() { # $1 acct dir
1401
1616
  while IFS= read -r ln; do
1402
1617
  id="${ln%% *}"
1403
1618
  claim=""
1404
- case "$ln" in *' '*) claim="${ln#* }" ;; esac
1619
+ case "$ln" in *' '*) claim="${ln#* }"; claim="${claim%% *}" ;; esac
1405
1620
  sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
1406
1621
  done < "$idx"
1407
1622
  # The budget bounds ENTRIES EXAMINED, not just transcripts read: an entry that fails the
@@ -1475,11 +1690,14 @@ client_limit_scan() { # $1 acct dir
1475
1690
  # A TUI rejection cannot use the -p retry path because the client owns the terminal.
1476
1691
  # Read only structured API errors belonging to this account's session claim. The JSON
1477
1692
  # 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=""
1693
+ # A `claude-accounts verify` that reached the account (.client-org-cleared) supersedes
1694
+ # every error recorded before it, an authentication failure as much as an org refusal.
1695
+ CLIENT_ERROR_REASON=""; CLIENT_ERROR_VIA=""
1696
+ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON, and CLIENT_ERROR_VIA=token
1697
+ # when an auth failure came from a run the shim launched on the token
1698
+ local idx="$1/.sessions-index" ln id claim via p line read_n=0 i cleared=0 parser auth=""
1699
+ local ids=() claims=() vias=()
1700
+ CLIENT_ERROR_REASON=""; CLIENT_ERROR_VIA=""
1483
1701
  { [ -L "$1" ] || [ -L "$1/sessions" ]; } && return 1
1484
1702
  parser="$SELF_DIR/../lib/claude_client_error.py"
1485
1703
  [ -f "$parser" ] || return 1
@@ -1488,13 +1706,15 @@ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1488
1706
  [ -f "$1/.client-org-cleared" ] \
1489
1707
  && { IFS= read -r cleared < "$1/.client-org-cleared" 2>/dev/null || cleared=0; }
1490
1708
  while IFS= read -r ln; do
1491
- id="${ln%% *}"; claim=""
1709
+ id="${ln%% *}"; claim=""; via=""
1492
1710
  case "$ln" in *' '*) claim="${ln#* }" ;; esac
1493
- sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
1711
+ case "$claim" in *' '*) via="${claim#* }"; claim="${claim%% *}" ;; esac
1712
+ sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); vias+=("$via"); }
1494
1713
  done < "$idx"
1495
1714
  i=$(( ${#ids[@]} - 1 ))
1496
1715
  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))
1716
+ id="${ids[$i]}"; claim="${claims[$i]}"; via="${vias[$i]}"
1717
+ i=$((i - 1)); read_n=$((read_n + 1))
1498
1718
  sess_transcript "$1" "$id" || continue
1499
1719
  p="$SESS_TRANSCRIPT"
1500
1720
  [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
@@ -1507,7 +1727,7 @@ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1507
1727
  --claim "$claim" --org-cleared "$cleared" 2>/dev/null)" || continue
1508
1728
  case "$line" in
1509
1729
  org-blocked\ *) CLIENT_ERROR_REASON="org-blocked"; return 0 ;;
1510
- auth\ *) auth=1 ;;
1730
+ auth\ *) auth=1; [ "$via" = token ] && CLIENT_ERROR_VIA=token ;;
1511
1731
  esac
1512
1732
  done
1513
1733
  [ -n "$auth" ] && { CLIENT_ERROR_REASON="auth"; return 0; }
@@ -1527,10 +1747,14 @@ mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reaso
1527
1747
  || rm -f "$1/.expired.$$" 2>/dev/null || true
1528
1748
  }
1529
1749
 
1530
- mark_client_auth_dead() { # $1 acct dir
1531
- local tok
1532
- tok="$(acct_token "$1")"
1533
- if [ -n "$tok" ]; then
1750
+ # The park names the credential the FAILED run carried, never the one this run would pick
1751
+ # (they differ whenever the account's credentials changed in between): a run the shim
1752
+ # launched on the portable token (its index line, sess_index_add) retires that token, and
1753
+ # anything else is the login's. Asking acct_token here charged the token for runs that
1754
+ # never carried one — the "Not logged in" runs of 2026-10-19, once an MCP-only file stopped
1755
+ # counting as their login — and parked a working token until a new one was minted.
1756
+ mark_client_auth_dead() { # $1 acct dir, $2 token when the failed run carried the token
1757
+ if [ "${2:-}" = token ]; then
1534
1758
  rm -f "$1/.server-token-verified" 2>/dev/null || true
1535
1759
  mark_proven_auth_dead "$1" "client rejected the portable OAuth token" \
1536
1760
  "setup-token-invalid"
@@ -1569,47 +1793,97 @@ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type, $4 reje
1569
1793
  # it (and every other live run of the account) in the index, because by the time the
1570
1794
  # user quits a limit-hit session and starts a new one, the client has already deleted
1571
1795
  # 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=$$
1796
+ # It also records what the run carries — the portable token, or the login — which only
1797
+ # this shim knows (mark_client_auth_dead).
1798
+ sel_capture_session() { # $1 acct dir, $2 token when the run carries the portable token
1799
+ local d="$1" pid=$$ via=login
1800
+ [ "${2:-}" = token ] && via=token
1574
1801
  [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 0
1575
1802
  (
1576
1803
  # Ctrl-C / a closed terminal must not kill the capture: it is bounded (60s) and
1577
1804
  # stops the moment the run it is watching is gone.
1578
1805
  trap '' INT HUP TERM QUIT
1579
- i=0
1806
+ i=0 tries=0
1580
1807
  while [ "$i" -lt 600 ]; do
1581
1808
  kill -0 "$pid" 2>/dev/null || break
1582
- sess_index_refresh "$d"
1583
- [ -f "$d/sessions/$pid.json" ] && break
1809
+ sess_index_refresh "$d" "$pid" "$via"
1810
+ if [ -f "$d/sessions/$pid.json" ]; then
1811
+ # Registered: the run's credential goes on record by process. A registry file caught
1812
+ # mid-write (no startedAt yet) gets a few more looks.
1813
+ sess_via_record "$d" "$pid" "$via" && break
1814
+ tries=$((tries + 1)); [ "$tries" -lt 10 ] || break
1815
+ fi
1584
1816
  sleep 0.1
1585
1817
  i=$((i + 1))
1586
1818
  done
1587
- sess_index_refresh "$d"
1819
+ sess_index_refresh "$d" "$pid" "$via"
1588
1820
  ) >/dev/null 2>&1 </dev/null &
1589
1821
  return 0
1590
1822
  }
1591
1823
 
1824
+ # The credential the shim launched a PROCESS on, for that process's whole life:
1825
+ # <acct>/.sessions-via/<pid> holds "<its registry startedAt, ms> token|login". The client
1826
+ # keeps one registry file per process and, on /clear or a /resume inside the TUI, rewrites
1827
+ # only its sessionId (2.1.304's session-switch listener merges {sessionId, updatedAt} into
1828
+ # it), so the capture tags only the run's FIRST session; sess_index_refresh tags every later
1829
+ # one from this record. startedAt survives those rewrites and names the process, so a reused
1830
+ # pid inherits nothing. Written once the registry file is there (the capture, in the
1831
+ # background); a record whose registry file is gone is dropped then too.
1832
+ sess_via_record() { # $1 acct dir, $2 pid, $3 token|login; rc 1: the registry file is there
1833
+ # but names no startedAt (yet)
1834
+ local dir="$1/.sessions-via" f st
1835
+ sessions_owned "$1" || return 0
1836
+ [ -f "$1/sessions/$2.json" ] && [ ! -L "$1/sessions/$2.json" ] || return 0
1837
+ st="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \
1838
+ "$1/sessions/$2.json" 2>/dev/null | head -1)"
1839
+ num_ok "$st" || return 1
1840
+ [ -L "$dir" ] && return 0
1841
+ [ -d "$dir" ] || mkdir "$dir" 2>/dev/null || [ -d "$dir" ] || return 0
1842
+ for f in "$dir"/*; do
1843
+ [ -f "$f" ] || continue
1844
+ [ -e "$1/sessions/${f##*/}.json" ] || rm -f "$f" 2>/dev/null
1845
+ done
1846
+ printf '%s %s\n' "$st" "$3" 2>/dev/null > "$dir/.$2.$$" \
1847
+ && mv -f "$dir/.$2.$$" "$dir/$2" 2>/dev/null || rm -f "$dir/.$2.$$" 2>/dev/null
1848
+ return 0
1849
+ }
1850
+
1851
+ # The login a dir's .credentials.json carries (cred_login_of) — a file holding only MCP
1852
+ # state, or an empty one, carries none. Memoised for the dir like kc_lookup: has_auth,
1853
+ # auth_dead and acct_token all read it in a row, and the read is a fork.
1854
+ FILE_MEMO_DIR=""; FILE_MEMO_RC=1; FILE_LOGIN=""
1855
+ file_login() { # $1 = acct dir; sets CRED_LOGIN; rc 0 when the file holds a login
1856
+ if [ "$FILE_MEMO_DIR" != "$1" ]; then
1857
+ local doc=""
1858
+ FILE_MEMO_DIR="$1"; FILE_MEMO_RC=1; FILE_LOGIN=""
1859
+ if [ -s "$1/.credentials.json" ]; then
1860
+ { doc="$(< "$1/.credentials.json")"; } 2>/dev/null
1861
+ cred_login_of "$doc" && { FILE_LOGIN="$CRED_LOGIN"; FILE_MEMO_RC=0; }
1862
+ fi
1863
+ fi
1864
+ CRED_LOGIN="$FILE_LOGIN"
1865
+ return "$FILE_MEMO_RC"
1866
+ }
1867
+
1592
1868
  # 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).
1869
+ # dead account selectable and turn a working stock run into an auth failure), and
1870
+ # neither is one holding only MCP state: it leaves the decision to the token/Keychain.
1594
1871
  # 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"; }
1872
+ has_auth() { file_login "$1" || [ -s "$1/server.token" ] || kc_lookup "$1"; }
1596
1873
  # The OAuth login itself, wherever the client keeps it (file or readable Keychain item).
1597
- has_oauth() { [ -f "$1/.credentials.json" ] || kc_lookup "$1"; }
1874
+ has_oauth() { file_login "$1" || kc_lookup "$1"; }
1598
1875
 
1599
1876
  cred_num() { # cred_num <json text> <json key> -> integer (epoch ms) or empty
1600
1877
  # The leading quote makes "expiresAt" unambiguous: "refreshTokenExpiresAt" cannot match it.
1601
1878
  printf '%s' "$1" | LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" 2>/dev/null | head -1
1602
1879
  }
1603
1880
 
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
1881
+ # The login object for <dir>: from .credentials.json when that file carries one (even
1882
+ # truncated — the scrape below is deliberately lenient), else from the Keychain item
1883
+ # this session can read. rc 1, CRED_LOGIN empty, when neither holds a login.
1884
+ cred_login() { # $1 = acct dir; sets CRED_LOGIN
1885
+ file_login "$1" && return 0
1886
+ kc_lookup "$1" && cred_login_of "$KC_JSON"
1613
1887
  }
1614
1888
 
1615
1889
  # TRUE when the dir's OAuth login exists but can no longer authenticate: the access
@@ -1617,17 +1891,17 @@ cred_text() { # $1 = acct dir
1617
1891
  # itself expired). Such an account fails EVERY run with "OAuth session expired and
1618
1892
  # could not be refreshed", so selecting it is strictly worse than not having it.
1619
1893
  # Anything unparseable fails OPEN (treated as alive) — telemetry-free guesswork must
1620
- # never take a working account out of the pool.
1894
+ # never take a working account out of the pool. Read from the login object only: the
1895
+ # MCP entries beside it carry an expiresAt and a refreshToken that say nothing about it.
1621
1896
  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)"
1897
+ local exp rexp
1898
+ cred_login "$1" || return 1
1899
+ exp="$(cred_num "$CRED_LOGIN" expiresAt)"
1626
1900
  case "$exp" in ''|*[!0-9]*) exp=0 ;; esac
1627
1901
  [ $((exp / 1000)) -gt "$now" ] && return 1 # access token still valid
1628
1902
  # 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)"
1903
+ printf '%s' "$CRED_LOGIN" | grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' 2>/dev/null || return 0
1904
+ rexp="$(cred_num "$CRED_LOGIN" refreshTokenExpiresAt)"
1631
1905
  case "$rexp" in ''|*[!0-9]*) return 1 ;; esac # unknown expiry => fail open
1632
1906
  [ $((rexp / 1000)) -le "$now" ]
1633
1907
  }
@@ -1635,8 +1909,9 @@ creds_dead() { # $1 = acct dir
1635
1909
  # `.expired` is the persistent "this account needs a re-login" marker: written by
1636
1910
  # claude-accounts (refresh grant expired/revoked) and by the retry path below when a
1637
1911
  # 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.
1912
+ # marker (successful re-login, or a refresh by another process — a login or a token, never
1913
+ # MCP state alone) clears it, and a successful `claude-accounts limits` fetch removes it
1914
+ # outright.
1640
1915
  # A marker written by the SHIM (a guess from one failed run) also carries
1641
1916
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
1642
1917
  # a misread never costs an account permanently. Markers written by claude-accounts —
@@ -1645,7 +1920,7 @@ creds_dead() { # $1 = acct dir
1645
1920
  # An org block stays until verify proves access, including a usage-limiter refusal.
1646
1921
  # Time and credential refreshes cannot prove the organization's policy changed.
1647
1922
  expired_marked() { # $1 = acct dir
1648
- local m="$1/.expired" mt f soft reason
1923
+ local m="$1/.expired" mt soft reason kc
1649
1924
  [ -f "$m" ] || return 1
1650
1925
  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
1926
  [ "$reason" = "org-blocked" ] && return 0
@@ -1681,17 +1956,27 @@ expired_marked() { # $1 = acct dir
1681
1956
  # "Your organization has disabled Claude subscription access".
1682
1957
  if [ "$reason" != "org-blocked" ]; then
1683
1958
  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
1959
+ if [ -f "$1/server.token" ] && [ "$(file_mtime "$1/server.token")" -gt "$mt" ]; then
1960
+ rm -f "$m" 2>/dev/null
1961
+ return 1
1962
+ fi
1963
+ # A newer LOGIN is the evidence. The client rewrites .credentials.json with MCP state
1964
+ # alone on every MCP token refresh, which says nothing about the login, so only a
1965
+ # file that carries one can heal the park; any other leaves it to the Keychain.
1966
+ if file_login "$1"; then
1967
+ if [ "$(file_mtime "$1/.credentials.json")" -gt "$mt" ]; then
1686
1968
  rm -f "$m" 2>/dev/null
1687
1969
  return 1
1688
1970
  fi
1689
- done
1690
1971
  # 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
1972
+ # same sense; its stamp is readable even from a session that cannot open it. An item
1973
+ # this session CAN read is judged by what it holds: MCP state alone heals nothing.
1974
+ elif [ "$(kc_mtime "$1")" -gt "$mt" ]; then
1975
+ kc=0; kc_lookup "$1" || kc=$?
1976
+ if [ "$kc" -ne 1 ]; then
1977
+ rm -f "$m" 2>/dev/null
1978
+ return 1
1979
+ fi
1695
1980
  fi
1696
1981
  fi
1697
1982
  soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
@@ -1824,11 +2109,13 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1824
2109
  fi
1825
2110
  export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1826
2111
  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).
2112
+ # A pinned account carries every registered MCP server (and the pool's MCP OAuth
2113
+ # logins) too, and a pinned `claude mcp add|remove` is mirrored like any other
2114
+ # (exits inside).
1829
2115
  mcp_ensure "$d"
2116
+ mcp_oauth_ensure "$d"
1830
2117
  mcp_mirror_run "$d" "$@" || true
1831
- sel_capture_session "$d"
2118
+ sel_capture_session "$d" "${tok:+token}"
1832
2119
  exec "$REAL" "$@"
1833
2120
  fi
1834
2121
  sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
@@ -1854,7 +2141,7 @@ for d in "$ACC_ROOT"/acct-*; do
1854
2141
  mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
1855
2142
  sel_log "${d##*/} parked (org-blocked) — client-reported"
1856
2143
  else
1857
- mark_client_auth_dead "$d"
2144
+ mark_client_auth_dead "$d" "$CLIENT_ERROR_VIA"
1858
2145
  fi
1859
2146
  valid=("${valid[@]:0:${#valid[@]}-1}")
1860
2147
  expired+=("$d")
@@ -2192,20 +2479,27 @@ PICK_TOKEN="$tok"
2192
2479
  # session on "You've reached your Fable 5 limit" — the exact failure this selection
2193
2480
  # path exists to avoid (operator, tmux 136) — so the fallback model is pinned once,
2194
2481
  # before exec. An explicit --model is never touched: such a run does not get here.
2482
+ # A relaunch names the model its session last ran on, which --resume restores: only a
2483
+ # session on the limited model itself is pinned off it; any other keeps its own model.
2195
2484
  if [ -z "$RUN_MODEL" ]; then
2196
2485
  sel_tok="$(scoped_tok_of_marker "$pick")"
2197
2486
  [ -n "$sel_tok" ] || sel_tok="$(scoped_over_tok "$pick")"
2198
2487
  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)"
2488
+ case "$(sel_lc "${AR_R_MODEL:-$sel_tok}")" in
2489
+ *"$(sel_lc "$sel_tok")"*)
2490
+ set -- --model "$FALLBACK_MODEL" "$@"
2491
+ sel_log "$(basename "$pick") -> --model $FALLBACK_MODEL (its $sel_tok bucket is full)" ;;
2492
+ esac
2201
2493
  fi
2202
2494
  fi
2203
2495
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
2204
2496
  # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
2205
2497
  # not a turn in the rotation.
2206
2498
  remember_pick "$pick"
2207
- # The picked account carries every registered MCP server before the client starts.
2499
+ # The picked account carries every registered MCP server, and the MCP OAuth logins any
2500
+ # account on this machine holds, before the client starts.
2208
2501
  mcp_ensure "$pick"
2502
+ mcp_oauth_ensure "$pick"
2209
2503
 
2210
2504
  # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
2211
2505
  # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
@@ -2284,7 +2578,7 @@ export CLAUDE_SHIM_ACTIVE=1
2284
2578
 
2285
2579
  # A `claude mcp add|add-json|remove` is run as a child under the picked account and
2286
2580
  # mirrored to every other one (exits inside); anything else falls through to exec.
2287
- if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
2581
+ if mcp_mutation_argv "$@"; then
2288
2582
  export CLAUDE_CONFIG_DIR="$pick"
2289
2583
  tok="$PICK_TOKEN"
2290
2584
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
@@ -2320,9 +2614,9 @@ if [ "$wants_retry" = "0" ]; then
2320
2614
  export CLAUDE_CONFIG_DIR="$pick"
2321
2615
  tok="$PICK_TOKEN"
2322
2616
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2323
- sel_capture_session "$pick"
2617
+ sel_capture_session "$pick" "${tok:+token}"
2324
2618
  ar_trust "$pick"
2325
- if ! ar_spawn_watcher claude "$pick" && [ -n "$AR_HANDOFF_TOKEN" ]; then
2619
+ if ! ar_spawn_watcher claude "$pick" "${tok:+token}" && [ -n "$AR_HANDOFF_TOKEN" ]; then
2326
2620
  printf 'claude-multiacc: auto-resume handoff could not start its watcher.\n' >&2
2327
2621
  exit 2
2328
2622
  fi
@@ -2335,7 +2629,7 @@ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
2335
2629
  export CLAUDE_CONFIG_DIR="$pick"
2336
2630
  tok="$PICK_TOKEN"
2337
2631
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2338
- sel_capture_session "$pick"
2632
+ sel_capture_session "$pick" "${tok:+token}"
2339
2633
  exec "$REAL" "$@"
2340
2634
  }
2341
2635
  trap 'rm -rf "$tmpd"' EXIT
@@ -2347,7 +2641,7 @@ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
2347
2641
  export CLAUDE_CONFIG_DIR="$pick"
2348
2642
  tok="$PICK_TOKEN"
2349
2643
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2350
- sel_capture_session "$pick"
2644
+ sel_capture_session "$pick" "${tok:+token}"
2351
2645
  exec "$REAL" "$@"
2352
2646
  fi
2353
2647
 
@@ -2530,6 +2824,7 @@ while :; do
2530
2824
  # away from — not the one that bounced.
2531
2825
  remember_pick "$cur"
2532
2826
  mcp_ensure "$cur"
2827
+ mcp_oauth_ensure "$cur"
2533
2828
  rotated=1
2534
2829
  attempt=2
2535
2830
  continue