claude-multiacc 2.0.46 → 2.0.48

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 (41) hide show
  1. package/README.md +29 -23
  2. package/bin/claude +48 -31
  3. package/bin/claude-accounts +30 -16
  4. package/docs/ACCOUNT_OPERATIONS.md +20 -10
  5. package/docs/AUTORESUME.md +8 -1
  6. package/docs/VERIFICATION.md +5 -2
  7. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/audit.py +8 -26
  20. package/lib/autoresume.py +7 -5
  21. package/lib/claude_client_error.py +99 -0
  22. package/lib/common.sh +11 -0
  23. package/lib/report.py +4 -0
  24. package/package.json +1 -1
  25. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  37. package/tests/run-tests.sh +65 -38
  38. package/tests/test_autoresume.py +15 -2
  39. package/tests/test_claude_client_error.py +147 -0
  40. package/tests/test_claude_org_block.py +277 -0
  41. package/tests/test_claude_org_report.py +68 -0
package/README.md CHANGED
@@ -141,17 +141,15 @@ this one is *down*, and handing work to it guarantees a hard failure. Reported s
141
141
  Replace it with `claude-accounts login acct-NN --token` and sync. See
142
142
  [token identity checks and recovery](docs/claude-token-identity.md).
143
143
  - **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
144
- *"Your organization has disabled Claude subscription access for Claude Code"*. Handled
145
- like any other dead login (`claude-accounts relogin` re-issues the grant and normally
146
- clears it); if it comes back BLOCKED after a re-login, an admin has to enable Claude
147
- Code for that account. Telemetry cannot see this — those accounts' usage endpoint
148
- answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
149
- park deliberately survives credential refreshes (a new access token says nothing about
150
- an org policy). Every org park carries a 6-hour `soft_until` (a re-observed block
151
- renews it), and a `verify` that the **usage limiter** refuses ("You've hit your weekly
152
- limit") lifts it at once: the provider checks the org's Claude Code gate before the
153
- limiter, so a limit answer proves the org lets the account through (acct-15,
154
- 2026-09-27: a lapsed subscription paid again stayed BLOCKED until this rule existed).
144
+ *"Your organization has disabled Claude subscription access for Claude Code"*. The
145
+ shim also detects `oauth_org_not_allowed` in that account's interactive transcript.
146
+ An admin must enable Claude Code access, or restore the subscription, then run
147
+ `claude-accounts verify`. Usage telemetry cannot see this policy, so the account stays
148
+ excluded until a real verification call proves access. A PASS lifts the park, and so
149
+ does a `verify` that the **usage limiter** refuses ("You've hit your weekly limit"):
150
+ the provider checks the org's Claude Code gate before the limiter, so a limit answer
151
+ proves the org lets the account through (acct-15, 2026-09-27: a lapsed subscription
152
+ paid again stayed BLOCKED until this rule existed).
155
153
  The line still reads `FAIL` — the account cannot work until the limit resets — with a
156
154
  `usage-limited` note saying which park it lifted.
157
155
 
@@ -161,13 +159,14 @@ Exclusion self-heals, but only against the evidence it was based on: a **credent
161
159
  park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
162
160
  by another process — or when a usage fetch authenticates; a **policy** park (org block)
163
161
  ignores both, because a fresh access token says nothing about whether the org re-enabled
164
- Claude Code: only a real call that got past the org gate (a passing `verify`, or one
165
- refused only by a usage limit), a re-login, or its own 6-hour `soft_until` lifts it.
166
- Parks the **shim** writes from a single failed run additionally carry a `soft_until`
167
- stamp and expire on their own — the shim's guess never outlives the evidence for it —
168
- while parks `claude-accounts` writes from proof of a dead credential (an `invalid_grant`
169
- refresh response, a real call that failed to authenticate) stay until the account works
170
- again. If *every* account is unusable the shim still
162
+ Claude Code. Re-login, token replacement and elapsed time also leave a Claude org block
163
+ intact, including old markers whose `soft_until` has passed. Only full
164
+ `claude-accounts verify` proving access, normally or with a usage-limit refusal, lifts it.
165
+ Recovery writes `.client-org-cleared` so old transcript errors cannot recreate the park.
166
+ OAuth-login parks the **shim** guesses from a single failed run carry a `soft_until` stamp and
167
+ expire on their own, while parks `claude-accounts` writes from proof of a dead credential
168
+ (an `invalid_grant` refresh response, a real call that failed to authenticate) stay until
169
+ the account works again. If *every* account is unusable the shim still
171
170
  fails open into stock `claude`, and logs why to `selection.log`.
172
171
 
173
172
  The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
@@ -345,7 +344,13 @@ wrongly parked account is worse than a missed limit:
345
344
  hostile or unreadable index entry is skipped; `CLAUDE_MULTIACC_CLIENT_LIMITS=0` turns
346
345
  the whole scan off.
347
346
 
348
- The resulting marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
347
+ The same ownership and handover checks detect Claude's interactive
348
+ `oauth_org_not_allowed` refusal and persist an org-blocked `.expired` marker before the
349
+ next selection. It has no automatic expiry; full `claude-accounts verify` must prove
350
+ access after the organization enables it. `.client-org-cleared` suppresses transcript
351
+ errors recorded before that verification.
352
+
353
+ The resulting usage marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
349
354
  pass while its window is still open — first-hand evidence beats a usage payload that
350
355
  disagrees. A **weekly** rejection is absolute: nothing — neither a shim invocation nor the
351
356
  scheduled `limits` pass — retires it before its reset epoch, since a weekly bucket cannot
@@ -415,10 +420,11 @@ handing the link back would hand the damage back.
415
420
  auth- or rate-limit-looking failure the shim retries once on a different account and
416
421
  marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
417
422
  or an **`.expired`** park for an authentication failure / org block (they do not). The
418
- park patterns are deliberately narrow and carry a `soft_until` stamp, because this grep
419
- also sees the model's own answer: a `-p` run that merely *mentions* a 403 must never cost
420
- an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
421
- never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
423
+ park patterns are deliberately narrow, because this grep also sees the model's own
424
+ answer: a `-p` run that merely *mentions* a 403 must never cost
425
+ an account. Authentication parks carry a `soft_until`; Claude org blocks persist until
426
+ full verification proves access. Output is buffered so a retried call never double-emits.
427
+ Only engages when stdin is finite (tty / regular file / `/dev/null`)
422
428
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
423
429
  Interactive sessions are not retried this way. They get auto-resume, described next.
424
430
 
package/bin/claude CHANGED
@@ -356,7 +356,7 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
356
356
  class) case "$v" in ''|*[!a-z_]*) ;; *) cls="$v" ;; esac ;;
357
357
  acct) case "$v" in acct-*[!0-9]*|acct-) ;; acct-*) acct="$v" ;; esac ;;
358
358
  reset) num_ok "$v" && reset="$v" ;;
359
- rtype) case "$v" in five_hour|seven_day) rtype="$v" ;; esac ;;
359
+ rtype) case "$v" in five_hour|seven_day|oauth_org_not_allowed) rtype="$v" ;; esac ;;
360
360
  marked) case "$v" in ''|*[!0-9TZ:.+-]*) ;; *) marked="$v" ;; esac ;;
361
361
  sid) case "$v" in ''|-*|*[!0-9a-fA-F-]*) ;; *) sid="$v" ;; esac ;;
362
362
  cwd) case "$v" in /*) cwd="$v" ;; esac ;;
@@ -422,7 +422,7 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
422
422
  # The verdict the watcher saw, written through the SAME writers the transcript scans use
423
423
  # (never shortening a longer park), so the old account is out of the pool before this
424
424
  # very selection runs — and for every other launch too. Other classes (model-scoped,
425
- # transient, blocked, crash) leave no marker: the watcher's AVOID list covers them.
425
+ # transient, billing/hold, crash) leave no marker: the watcher's AVOID list covers them.
426
426
  ar_relaunch_apply() {
427
427
  local d=""
428
428
  [ "$AR_LOADED" = 1 ] || return 0
@@ -434,6 +434,10 @@ ar_relaunch_apply() {
434
434
  mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
435
435
  fi ;;
436
436
  auth) mark_client_auth_dead "$d" ;;
437
+ blocked)
438
+ if [ "$AR_R_RTYPE" = "oauth_org_not_allowed" ]; then
439
+ mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
440
+ fi ;;
437
441
  esac
438
442
  fi
439
443
  # Field 2 is the word `autoresume`, never an account id: lib/report.py and the
@@ -1428,13 +1432,21 @@ client_limit_scan() { # $1 acct dir
1428
1432
  return 1
1429
1433
  }
1430
1434
 
1431
- # A TUI auth failure cannot use the -p retry path because the client owns the terminal.
1432
- # Harvest the same account-owned transcripts used for quota detection so the next launch
1433
- # parks a rejected setup-token instead of selecting it again.
1434
- client_auth_scan() { # $1 acct dir
1435
- local idx="$1/.sessions-index" ln id claim p line ts ck ak read_n=0 i
1435
+ # A TUI rejection cannot use the -p retry path because the client owns the terminal.
1436
+ # Read only structured API errors belonging to this account's session claim. The JSON
1437
+ # helper runs only when the bounded grep finds an auth or organization error code.
1438
+ CLIENT_ERROR_REASON=""
1439
+ client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
1440
+ local idx="$1/.sessions-index" ln id claim p line read_n=0 i cleared=0 parser auth=""
1436
1441
  local ids=() claims=()
1442
+ CLIENT_ERROR_REASON=""
1443
+ { [ -L "$1" ] || [ -L "$1/sessions" ]; } && return 1
1444
+ parser="$SELF_DIR/../lib/claude_client_error.py"
1445
+ [ -f "$parser" ] || return 1
1446
+ [ -L "$idx" ] && return 1
1437
1447
  [ -f "$idx" ] || return 1
1448
+ [ -f "$1/.client-org-cleared" ] \
1449
+ && { IFS= read -r cleared < "$1/.client-org-cleared" 2>/dev/null || cleared=0; }
1438
1450
  while IFS= read -r ln; do
1439
1451
  id="${ln%% *}"; claim=""
1440
1452
  case "$ln" in *' '*) claim="${ln#* }" ;; esac
@@ -1446,24 +1458,31 @@ client_auth_scan() { # $1 acct dir
1446
1458
  sess_transcript "$1" "$id" || continue
1447
1459
  p="$SESS_TRANSCRIPT"
1448
1460
  [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
1461
+ [ -n "$claim" ] || continue
1449
1462
  line="$(tail -c "$QUOTA_SCAN_BYTES" "$p" 2>/dev/null \
1450
- | LC_ALL=C grep -a '"error"[[:space:]]*:[[:space:]]*"authentication_failed"' | tail -1)"
1463
+ | LC_ALL=C grep -aE '"error"[[:space:]]*:[[:space:]]*"(authentication_failed|oauth_org_not_allowed)"')"
1451
1464
  [ -n "$line" ] || continue
1452
- [ -n "$claim" ] || continue
1453
- ts="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1454
- ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
1455
- num_ok "$ck" || continue; num_ok "$ak" || continue
1456
- [ "$ck" -lt "$ak" ] && continue
1457
1465
  claim_conflicted "$1" "$id" "$claim" && continue
1458
- return 0
1466
+ line="$(printf '%s\n' "$line" | "${CLAUDE_MULTIACC_PYTHON:-python3}" -I "$parser" \
1467
+ --claim "$claim" --org-cleared "$cleared" 2>/dev/null)" || continue
1468
+ case "$line" in
1469
+ org-blocked\ *) CLIENT_ERROR_REASON="org-blocked"; return 0 ;;
1470
+ auth\ *) auth=1 ;;
1471
+ esac
1459
1472
  done
1473
+ [ -n "$auth" ] && { CLIENT_ERROR_REASON="auth"; return 0; }
1460
1474
  return 1
1461
1475
  }
1462
1476
 
1463
1477
  mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reason slug
1464
- local marked slug="${3:-auth-error}"
1478
+ local marked slug="${3:-auth-error}" soft="${4:-0}" stamp=""
1479
+ if [ "$slug" != "org-blocked" ] \
1480
+ && grep -q 'reason=org-blocked' "$1/.expired" 2>/dev/null; then
1481
+ return 0
1482
+ fi
1483
+ num_ok "$soft" && [ "$soft" -gt 0 ] && stamp="soft_until=$soft "
1465
1484
  marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1466
- { echo "$now"; echo "reason=$slug marked_at=$marked detail=$2"; } \
1485
+ { echo "$now"; echo "reason=$slug ${stamp}marked_at=$marked detail=$2"; } \
1467
1486
  2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
1468
1487
  || rm -f "$1/.expired.$$" 2>/dev/null || true
1469
1488
  }
@@ -1583,12 +1602,13 @@ creds_dead() { # $1 = acct dir
1583
1602
  # a misread never costs an account permanently. Markers written by claude-accounts —
1584
1603
  # a refresh grant that answered "revoked", a real verify call that failed to
1585
1604
  # authenticate — carry no soft_until and stay until the account provably works again.
1586
- # An org block is the exception: verify's carries the shim's 6 h soft_until too, and a
1587
- # verify the usage limiter refused lifts it (the limiter answers only past the org gate).
1605
+ # An org block stays until verify proves access, including a usage-limiter refusal.
1606
+ # Time and credential refreshes cannot prove the organization's policy changed.
1588
1607
  expired_marked() { # $1 = acct dir
1589
1608
  local m="$1/.expired" mt f soft reason
1590
1609
  [ -f "$m" ] || return 1
1591
1610
  reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
1611
+ [ "$reason" = "org-blocked" ] && return 0
1592
1612
  if [ "$reason" = "setup-token-invalid" ]; then
1593
1613
  # A TOKEN-scoped park: it says the portable token is dead and nothing about the
1594
1614
  # login beside it. Only a NEW token heals it — a login refresh rewrites
@@ -1781,8 +1801,13 @@ for d in "$ACC_ROOT"/acct-*; do
1781
1801
  fi
1782
1802
  valid+=("$d")
1783
1803
  sess_index_refresh "$d"
1784
- if client_auth_scan "$d"; then
1785
- mark_client_auth_dead "$d"
1804
+ if client_error_scan "$d"; then
1805
+ if [ "$CLIENT_ERROR_REASON" = "org-blocked" ]; then
1806
+ mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
1807
+ sel_log "${d##*/} parked (org-blocked) — client-reported"
1808
+ else
1809
+ mark_client_auth_dead "$d"
1810
+ fi
1786
1811
  valid=("${valid[@]:0:${#valid[@]}-1}")
1787
1812
  expired+=("$d")
1788
1813
  continue
@@ -2281,8 +2306,7 @@ fi
2281
2306
  # fixes that. "ask your admin to enable ACCESS", never the bare prefix:
2282
2307
  # the same client ends a 404 model_not_found with "…or ask your admin to
2283
2308
  # enable this model", which says nothing about the organization.
2284
- # Both shim-written parks carry a soft_until stamp, so even a false positive returns to
2285
- # the pool on its own — the shim's guess must never outlive the evidence for it.
2309
+ # Credential guesses carry a soft expiry; an organization refusal needs verified recovery.
2286
2310
  PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
2287
2311
  PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable access|not authorized to use claude code'
2288
2312
  # A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
@@ -2330,7 +2354,6 @@ argv_set_model() { # $1 = model id
2330
2354
  }
2331
2355
 
2332
2356
  PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
2333
- PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
2334
2357
 
2335
2358
  # True when the tail of stdout carries a stream-json result marked is_error. Bounded
2336
2359
  # to the tail because that is where the final object lands, and because a task log
@@ -2387,7 +2410,6 @@ while :; do
2387
2410
  elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
2388
2411
  park_reason="org-blocked"
2389
2412
  park_detail="the account's organization has disabled Claude Code subscription access"
2390
- park_soft=$((now + PARK_SOFT_ORG))
2391
2413
  elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
2392
2414
  if [ -n "$tok" ]; then
2393
2415
  park_reason="setup-token-invalid"
@@ -2406,14 +2428,9 @@ while :; do
2406
2428
  if [ -n "$park_reason" ]; then
2407
2429
  if [ "$park_reason" = "setup-token-invalid" ]; then
2408
2430
  rm -f "$cur/.server-token-verified" 2>/dev/null || true
2409
- park_line="reason=$park_reason marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
2410
- else
2411
- park_line="reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
2412
2431
  fi
2413
- { echo "$now"; echo "$park_line"; } 2>/dev/null > "$cur/.expired.$$" \
2414
- && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
2415
- || rm -f "$cur/.expired.$$" 2>/dev/null || true
2416
- if [ "$park_reason" = "setup-token-invalid" ]; then
2432
+ mark_proven_auth_dead "$cur" "$park_detail" "$park_reason" "$park_soft"
2433
+ if [ "$park_reason" = "setup-token-invalid" ] || [ "$park_reason" = "org-blocked" ]; then
2417
2434
  sel_log "$(basename "$cur") parked ($park_reason, durable) — see: claude-accounts expired"
2418
2435
  else
2419
2436
  sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
@@ -1401,7 +1401,7 @@ cmd_expired() {
1401
1401
  return 1
1402
1402
  fi
1403
1403
  printf '%s\n' "$rows" | awk -F'\t' '
1404
- BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0 }
1404
+ BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0; blocked = 0 }
1405
1405
  NF < 4 || $1 == "" { next } # never invent an account from a blank line
1406
1406
  $4 == "ok" { ok++; next }
1407
1407
  $4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
@@ -1419,6 +1419,13 @@ cmd_expired() {
1419
1419
  printf " %-9s %-28s %-13s fix: %s\n", "", "", "", $7
1420
1420
  next
1421
1421
  }
1422
+ $4 == "blocked" {
1423
+ bad++
1424
+ blocked++
1425
+ printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
1426
+ printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
1427
+ next
1428
+ }
1422
1429
  {
1423
1430
  bad++
1424
1431
  relogin++
@@ -1440,6 +1447,10 @@ cmd_expired() {
1440
1447
  printf "\nReplace invalid portable setup-tokens:\n"
1441
1448
  printf " claude-accounts login acct-NN --token\n"
1442
1449
  }
1450
+ if (blocked > 0) {
1451
+ printf "\nEnable Claude Code subscription access through the organization admin, then verify:\n"
1452
+ printf " claude-accounts verify\n"
1453
+ }
1443
1454
  if (relogin > 0) {
1444
1455
  printf "\nRe-authenticate them:\n"
1445
1456
  printf " claude-accounts relogin # every account that needs it\n"
@@ -1814,8 +1825,8 @@ def mark_expired(d, slug, detail=''):
1814
1825
  # An org-blocked marker is the strongest statement there is about an account and
1815
1826
  # telemetry can never lift it (see clear_expired). Overwriting its REASON with a
1816
1827
  # weaker one is how it gets lifted by accident: the next successful fetch sees a
1817
- # reason clear_expired is willing to drop, and the block vanishes. One whose
1818
- # soft_until has run out binds nothing any more and outranks nothing either.
1828
+ # reason clear_expired is willing to drop, and the block vanishes. Legacy soft
1829
+ # expiry stamps cannot prove organization access was enabled again either.
1819
1830
  try:
1820
1831
  if org_block_in_force(open(mpath, errors='replace').read(), now):
1821
1832
  return
@@ -1841,9 +1852,8 @@ def clear_expired(d):
1841
1852
  un-parked every dead-token account minutes after the shim parked it, and every
1842
1853
  fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
1843
1854
  token (the shim's newer-file rule) or a real call that used the token lifts it.
1844
- Only a real call lifts the excepted ones — a passing `verify`, or one the usage
1845
- limiter refused (the limiter answers only past the credential and the org gate) —
1846
- or a re-login; an org block also lapses at its soft_until (6 h)."""
1855
+ Only inference verification lifts an org block: a passing `verify`, or one the
1856
+ usage limiter refused (the limiter answers only past the credential and org gate)."""
1847
1857
  mpath = os.path.join(d, '.expired')
1848
1858
  try:
1849
1859
  body = open(mpath, errors='replace').read()
@@ -2836,9 +2846,6 @@ LIMIT_ERR = re.compile(
2836
2846
  r"|limit (reached|exceeded)|rate[ _-]?limit|switch to another model", re.I)
2837
2847
  NOT_A_LIMIT = re.compile(
2838
2848
  r'not your usage limit|temporarily limiting|overloaded|\b529\b|high load', re.I)
2839
- # bin/claude PARK_SOFT_ORG: an org policy will not change in minutes, but a verdict that
2840
- # never expires kept a paid-again account out of the pool until a person noticed.
2841
- PARK_SOFT_ORG = 21600
2842
2849
  # EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
2843
2850
  # it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
2844
2851
  # machine fault whose output happens to contain those three digits parks a live
@@ -2862,8 +2869,7 @@ def mark_expired(d, slug, detail=''):
2862
2869
  t = time.time()
2863
2870
  # Same rule as the limits path: an org block outranks every other reason while it
2864
2871
  # binds. Rewriting its reason with a weaker one is how it gets lifted by accident
2865
- # later. Only the org refusal itself may rewrite it — and does: a re-observed block
2866
- # is a fresh park with a fresh soft_until, not a stale one left to run out.
2872
+ # later. Only another org refusal may refresh it; access must be verified to lift it.
2867
2873
  if slug != 'org-blocked':
2868
2874
  try:
2869
2875
  if org_block_in_force(open(mpath, errors='replace').read(), t):
@@ -2871,11 +2877,10 @@ def mark_expired(d, slug, detail=''):
2871
2877
  except OSError:
2872
2878
  pass
2873
2879
  # The shim's field order (bin/claude park_line), so every reader parses one shape.
2874
- soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
2875
2880
  try:
2876
2881
  with open(mpath + '.tmp', 'w') as f:
2877
2882
  f.write(f'{int(t)}\n')
2878
- f.write(f"reason={slug} {soft}marked_at="
2883
+ f.write(f"reason={slug} marked_at="
2879
2884
  f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
2880
2885
  os.replace(mpath + '.tmp', mpath)
2881
2886
  except Exception:
@@ -2893,6 +2898,16 @@ def lift_park(d, uses_token):
2893
2898
  marker = open(mpath, errors='replace').read()
2894
2899
  except OSError:
2895
2900
  marker = ''
2901
+ # Record recovery even before a transcript rejection was harvested. Persist the
2902
+ # watermark before removing a park so a failed write cannot cause a re-park loop.
2903
+ try:
2904
+ cleared = os.path.join(d, '.client-org-cleared')
2905
+ temp = cleared + f'.tmp.{os.getpid()}'
2906
+ with open(temp, 'w') as f:
2907
+ f.write(f'{time.time()}\n')
2908
+ os.replace(temp, cleared)
2909
+ except OSError:
2910
+ return ''
2896
2911
  if not uses_token and 'reason=setup-token-invalid' in marker:
2897
2912
  return ''
2898
2913
  try:
@@ -3039,12 +3054,11 @@ for acct in manifest.get('accounts', []):
3039
3054
  elif ORG_ERR.search(out) or ORG_ERR.search(err):
3040
3055
  # Not an auth problem: the account authenticates fine, its organization
3041
3056
  # has simply turned Claude Code subscription access off. Park it — a
3042
- # re-login changes nothing — and say what actually helps. The park carries
3043
- # the shim's 6 h soft_until, and a re-observed block refreshes it.
3057
+ # re-login changes nothing. Keep it parked until access is verified again.
3044
3058
  mark_expired(d, 'org-blocked',
3045
3059
  "the account's organization has disabled Claude Code access")
3046
3060
  hint = (f' — ORG BLOCKED, excluded from the pool; '
3047
- f'try: claude-accounts relogin {aid}')
3061
+ 'ask an admin to enable Claude Code subscription access, then run: claude-accounts verify')
3048
3062
  elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
3049
3063
  try:
3050
3064
  os.remove(os.path.join(d, '.server-token-verified'))
@@ -5,8 +5,8 @@
5
5
  ```bash
6
6
  claude-accounts list # pool at a glance
7
7
  claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
8
- claude-accounts expired # ONLY the accounts that cannot authenticate, and why (exit 1 if any)
9
- claude-accounts relogin # sign those back in, one at a time (ids or --all to pick your own)
8
+ claude-accounts expired # accounts with broken credentials or blocked access, and why (exit 1 if any)
9
+ claude-accounts relogin # repair expired logins, one at a time (ids or --all to pick your own)
10
10
  ```
11
11
 
12
12
  Every account in the pool is authenticated — `add` cannot register one otherwise.
@@ -24,20 +24,24 @@ claude-accounts expired
24
24
  # fix: claude-accounts relogin acct-01
25
25
  # acct-08 dev@example.com BLOCKED this account's organization has disabled
26
26
  # Claude Code subscription access
27
- # fix: claude-accounts relogin acct-08
27
+ # fix: enable Claude Code access, then claude-accounts verify
28
28
 
29
29
  claude-accounts relogin # re-authenticate everything a sign-in can fix
30
30
  claude-accounts relogin acct-01 # or just one
31
31
  claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
32
32
  ```
33
33
 
34
- `relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
35
- clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
36
- one at any time; a PASS clears the park automatically, and so does a call the usage
37
- limiter refuses (`FAIL … — usage-limited, not a login or org problem; lifted its
34
+ For `BLOCKED`, ask the admin to enable Claude Code access or restore the subscription,
35
+ then run `claude-accounts verify` (a real `-p` call per account). A PASS clears the park
36
+ automatically, and so does a call the usage limiter refuses
37
+ (`FAIL … — usage-limited, not a login or org problem; lifted its
38
38
  org-blocked park`): the org's Claude Code gate answers before the limiter, so a limit
39
- answer proves the org lets the account through. A `BLOCKED` park also lapses on its own
40
- after 6 hours (`soft_until`), and the pool tries the account again.
39
+ answer proves the org lets the account through. Claude org blocks stay excluded until
40
+ that proof, including old markers with elapsed `soft_until`. Re-login, credential refresh,
41
+ token replacement and clean usage telemetry cannot lift them. Interactive transcript
42
+ refusals are attributed to the account that ran the session; successful verification
43
+ writes `.client-org-cleared` so old errors cannot park it again. `verify --quick` checks
44
+ credentials only and cannot establish organization recovery.
41
45
 
42
46
  `relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
43
47
  the wrong email), one account at a time, and syncs to the server once at the end.
@@ -275,7 +279,7 @@ providers (`lib/report.py`), so a consumer writes one parser:
275
279
  "portable": true,
276
280
  "credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
277
281
  "oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
278
- "token_age_days": 39},
282
+ "token_age_days": 39, "org_access_verified_at": 1790765000.125},
279
283
  "limited": false, "limit_reset_at": null, "limit_detail": null,
280
284
  "usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
281
285
  // a null percent = that signal had no informative bucket; all three
@@ -302,6 +306,12 @@ A consumer that only distinguishes *usable / parked / needs-a-human* can read
302
306
  without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
303
307
  whose reset time has passed does not count, an unreadable one does.
304
308
 
309
+ Claude's optional `credentials.org_access_verified_at` is a Unix timestamp from the
310
+ last real verification that passed the organisation access gate (a successful inference
311
+ or a genuine usage-limit refusal). Missing or invalid proof is omitted. Consumers can
312
+ use newer proof to lift an older organisation hold; a later refusal still keeps the
313
+ account blocked. Usage telemetry, login and token verification alone are not recovery proof.
314
+
305
315
  A percent field is **null** when that signal had no informative bucket to aggregate. The
306
316
  writers keep the signals separate: `weekly_percent` comes from informative weekly buckets,
307
317
  `session_percent` from informative session ones, and each is omitted on its own when its
@@ -180,11 +180,18 @@ The prompt is submitted automatically:
180
180
  | quota | `rate_limit`, `quotaLimits.status: rejected`, `rateLimitType` `five_hour` / `seven_day` | `.limited` `client:<type>` until `resetsAt`, the marker the transcript scan writes | until that reset |
181
181
  | model | `rate_limit` for any other limit type (a per-model weekly), "reached your … limit" / "switch to another model", `model_requires_usage_credits` | nothing: the limit covers one model | 5 h |
182
182
  | auth | `authentication_failed` | `.expired`, the shim's client-reported auth park | 1 h |
183
- | blocked | `oauth_org_not_allowed`, `account_on_hold`, `billing_error`, `verification_required` | nothing: one transcript record only makes this chain avoid it | 6 h |
183
+ | blocked | `oauth_org_not_allowed` | persistent `.expired` `org-blocked` park | 6 h in this chain |
184
+ | blocked | `account_on_hold`, `billing_error`, `verification_required` | nothing: only this chain avoids it | 6 h |
184
185
  | transient | `overloaded`, `server_error`, `unknown`, any other `rate_limit` | nothing | none, so the same account may continue |
185
186
  | crash | the process ended on its own after ≥ 60 s, leaving its session registry behind | nothing | none, so normal selection applies |
186
187
  | never | anything else (`invalid_request`, `max_output_tokens`, `model_not_found`, …) | nothing | not restarted; logged once |
187
188
 
189
+ An organization refusal carries `rtype=oauth_org_not_allowed` into the relaunch file,
190
+ so the shim parks the old account even if its earlier session-index capture missed it.
191
+ An admin must enable access, then full `claude-accounts verify` must prove it; a PASS
192
+ or a usage-limit refusal lifts the park and records `.client-org-cleared`. Billing,
193
+ hold and verification errors retain their existing chain avoidance without an org park.
194
+
188
195
  **Codex**, from `task_complete` events' `error.codex_error_info`:
189
196
 
190
197
  | Class | `codex_error_info` | Old account | Avoided for |
@@ -55,8 +55,11 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
55
55
  access for Claude Code"), e.g. while a lapsed subscription was unpaid. Run
56
56
  `claude-accounts verify`: a PASS lifts it, and so does a refusal from the usage limiter
57
57
  (`FAIL … — usage-limited, not a login or org problem; lifted its org-blocked park`),
58
- because the provider answers the org gate before the limiter. Without either, the park
59
- lapses after its 6-hour `soft_until` and the pool tries the account again. The 404
58
+ because the provider answers the org gate before the limiter. Until full verification
59
+ proves access, the account stays excluded even after re-login, token replacement,
60
+ clean usage telemetry or an old marker's `soft_until`. `verify --quick` cannot lift it.
61
+ Interactive `oauth_org_not_allowed` transcript errors also create this park;
62
+ verification records `.client-org-cleared` to prevent old errors from restoring it. The 404
60
63
  "…or ask your admin to enable this model" is about a model, never an org block.
61
64
  - **A session restarted itself, or did not** — that is [auto-resume](AUTORESUME.md).
62
65
  `grep autoresume ~/.claude-accounts/selection.log` shows each step: `hold` means no
package/lib/audit.py CHANGED
@@ -8,8 +8,7 @@ States:
8
8
  token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
9
9
  blocked authenticates, but its organization has disabled Claude Code subscription
10
10
  access — a re-login cannot fix it, so it needs an admin (or removal); a
11
- park with a soft_until (every one written now: 6 h) lapses on its own
12
- and the pool re-tries the account
11
+ park stays until a real verification call proves access was enabled again
13
12
  locked the OAuth login is in the macOS Keychain and THIS session cannot open it
14
13
  (ssh/tmux/background) — it works from the Mac's own GUI session, so it is
15
14
  neither dead nor missing; just not usable from here
@@ -82,30 +81,24 @@ def marker_soft_until(detail):
82
81
 
83
82
 
84
83
  def org_block_in_force(body, now=None):
85
- """True when a marker is an org-blocked park that still binds — no soft_until, or
86
- one still ahead (expired_marked below and bin/claude's reading of the same line).
87
- The writers use it to keep a weaker reason from overwriting a live org block; a
88
- block whose soft window has run out no longer outranks anything."""
89
- now = time.time() if now is None else now
90
- if marker_slug(body) != 'org-blocked':
91
- return False
92
- soft = marker_soft_until(body)
93
- return soft is None or soft > now
84
+ """Organization policy outlives credentials and legacy soft expiry stamps."""
85
+ return marker_slug(body) == 'org-blocked'
94
86
 
95
87
 
96
88
  def expired_marked(d, now=None):
97
89
  """True when <d>/.expired is still in force. Mirrors the shim exactly:
98
90
  a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
99
91
  refresh by another process); a POLICY park (org-blocked) does not, because a token
100
- refresh says nothing about whether the org re-enabled Claude Code. Either kind also
101
- expires at its own soft_until when it carries one — every shim park, and every
102
- org-blocked park (the shim's and verify's alike carry six hours)."""
92
+ refresh says nothing about whether the org re-enabled Claude Code. Only credential
93
+ parks expire at their soft_until; legacy org expiry stamps are ignored."""
103
94
  now = time.time() if now is None else now
104
95
  mpath = os.path.join(d, '.expired')
105
96
  if not os.path.isfile(mpath):
106
97
  return False
107
98
  detail = marker_reason(mpath)
108
99
  slug = marker_slug(detail)
100
+ if slug == 'org-blocked':
101
+ return True
109
102
  if slug == 'setup-token-invalid':
110
103
  # A TOKEN park heals only when a NEW token lands (bin/claude expired_marked):
111
104
  # a login refresh rewriting .credentials.json or the Keychain item says
@@ -252,11 +245,7 @@ def _fix_for(state, aid):
252
245
  if state == 'token-invalid':
253
246
  return f'claude-accounts login {aid} --token'
254
247
  if state == 'blocked':
255
- # A fresh sign-in re-issues the grant and in practice clears this, so it is
256
- # handled like any other dead login. If it comes back BLOCKED after a re-login,
257
- # the org really has Claude Code switched off and an admin has to enable it.
258
- return (f'claude-accounts relogin {aid} '
259
- f'(if it stays BLOCKED, an admin must enable Claude Code for it)')
248
+ return 'ask an admin to enable Claude Code subscription access, then run: claude-accounts verify'
260
249
  if state in ('expired', 'missing'):
261
250
  return f'claude-accounts relogin {aid}'
262
251
  if state == 'locked':
@@ -309,13 +298,6 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
309
298
  row['state'] = 'blocked'
310
299
  row['reason'] = ('this account\'s organization has disabled Claude Code '
311
300
  'subscription access')
312
- soft = marker_soft_until(detail)
313
- if soft is not None:
314
- # When the pool gives it another real call on its own (the park's
315
- # soft_until). A passing verify, one refused only by a usage limit, or
316
- # a re-login lifts it sooner.
317
- row['reason'] += time.strftime(
318
- '; re-tried by the pool after %Y-%m-%d %H:%M UTC', time.gmtime(soft))
319
301
  elif slug == 'setup-token-invalid':
320
302
  login = oauth_login(d, now)
321
303
  if login['state'] == 'ok':
package/lib/autoresume.py CHANGED
@@ -440,7 +440,8 @@ def classify_claude(rec, prefixes=(PROMPT_PREFIX,)):
440
440
  if code == "authentication_failed":
441
441
  return ("error", _verdict("auth", code, at))
442
442
  if code in CLAUDE_BLOCKED:
443
- return ("error", _verdict("blocked", code, at))
443
+ rtype = code if code == "oauth_org_not_allowed" else ""
444
+ return ("error", _verdict("blocked", code, at, rtype=rtype))
444
445
  # invalid_request, max_output_tokens, model_not_found, ...: another account would
445
446
  # fail the same way, so it is logged once and left alone.
446
447
  return ("never", code or "?")
@@ -873,15 +874,16 @@ def remove_quietly(*paths):
873
874
 
874
875
 
875
876
  def relaunch_fields(provider, verdict, acct, sid, cwd, depth, avoid, hist, chain):
876
- """The relaunch file's fields for one verdict. reset/rtype/marked only mean something
877
- to the shim for a quota verdict (claude marks ``client:<rtype>`` until ``reset``;
878
- codex writes its fixed cooldown)."""
877
+ """The relaunch fields for a verdict. Claude quota carries its bucket and reset;
878
+ its organization refusal carries only the known error code in rtype. Codex quota
879
+ uses its fixed cooldown."""
879
880
  cls = verdict["class"]
880
881
  quota = cls == "quota"
882
+ org_block = cls == "blocked" and verdict.get("rtype") == "oauth_org_not_allowed"
881
883
  return {
882
884
  "v": "1", "provider": provider, "class": cls, "acct": acct,
883
885
  "reset": str(int(verdict["reset"])) if quota and verdict.get("reset") else "",
884
- "rtype": verdict.get("rtype", "") if quota and provider == "claude" else "",
886
+ "rtype": verdict.get("rtype", "") if (quota or org_block) and provider == "claude" else "",
885
887
  "marked": verdict.get("marked") or "",
886
888
  "sid": sid, "cwd": cwd, "depth": str(int(depth) + 1), "avoid": avoid,
887
889
  "hist": hist, "chain": chain,