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.
- package/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- 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
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1283
|
-
|
|
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
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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" ]
|
|
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() {
|
|
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() {
|
|
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
|
|
1605
|
-
#
|
|
1606
|
-
# this session can read.
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
|
1623
|
-
|
|
1624
|
-
|
|
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' "$
|
|
1630
|
-
rexp="$(cred_num "$
|
|
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
|
|
1639
|
-
# successful `claude-accounts limits` fetch removes it
|
|
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
|
|
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
|
-
|
|
1685
|
-
|
|
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
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
|
1828
|
-
# `claude mcp add|remove` is mirrored like any other
|
|
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
|
-
|
|
2200
|
-
|
|
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
|
|
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
|