claude-multiacc 2.0.14 → 2.0.16

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/bin/claude CHANGED
@@ -160,8 +160,119 @@ sel_log() {
160
160
  printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
161
161
  }
162
162
 
163
+ # ---- model-scoped limits ------------------------------------------------------
164
+ # A usage bucket can be scoped to ONE MODEL ("weekly_scoped:Fable"), and such a bucket
165
+ # says nothing about the account's ability to serve a DIFFERENT model. The limits
166
+ # refresher parks the whole account for it all the same, and the >=90% cutoff reads the
167
+ # peak across every bucket — so on 2026-08-29 acct-02/06/07 sat at Fable 100% with their
168
+ # session buckets at 51/23/25%, were excluded from every selection, and a pool of eleven
169
+ # accounts offered nothing usable: the all-limited fallback handed out a
170
+ # session-exhausted account that rejected the operator's very first request. The retry
171
+ # layer already knew a scoped limit means "switch model, not account" (MODEL_LIMITPAT
172
+ # below); selection has to know it too.
173
+ FALLBACK_MODEL="${CLAUDE_MULTIACC_FALLBACK_MODEL:-claude-opus-5}"
174
+
175
+ # The model THIS invocation asks for ("" when it pins none), read from the caller's own
176
+ # argv: an explicit --model is never second-guessed, so an account whose Fable bucket is
177
+ # full stays genuinely unusable for `--model claude-fable-5`.
178
+ RUN_MODEL=""
179
+ _sel_next_is_model=0
180
+ for _sel_arg in "$@"; do
181
+ if [ "$_sel_next_is_model" = 1 ]; then
182
+ RUN_MODEL="$_sel_arg"; _sel_next_is_model=0; continue
183
+ fi
184
+ case "$_sel_arg" in
185
+ --model) _sel_next_is_model=1 ;;
186
+ --model=*) RUN_MODEL="${_sel_arg#--model=}" ;;
187
+ esac
188
+ done
189
+ unset _sel_arg _sel_next_is_model
190
+
191
+ sel_lc() { printf '%s' "${1:-}" | LC_ALL=C tr 'A-Z' 'a-z'; }
192
+
193
+ # The model token a `.limited` marker is scoped to ("Fable"); empty when the marker is
194
+ # not model-scoped (session, weekly_all, client:…).
195
+ scoped_tok_of_marker() { # $1 = acct dir
196
+ local line=""
197
+ [ -f "$1/.limited" ] && line="$(sed -n 2p "$1/.limited" 2>/dev/null)"
198
+ case "$line" in
199
+ *bucket=weekly_scoped:*) line="${line#*bucket=weekly_scoped:}"; printf '%s' "${line%% *}" ;;
200
+ esac
201
+ }
202
+
203
+ # Does a bucket scoped to model token $1 constrain THIS run?
204
+ # pinned to that model -> yes; the caller asked for exactly it
205
+ # pinned to another model -> no
206
+ # unpinned -> no, PROVIDED the fallback model escapes the bucket. The
207
+ # pick path then pins that fallback before exec, so an
208
+ # unpinned run never walks into the exhausted model.
209
+ scoped_blocks_run() { # $1 = model token
210
+ local tok run fb
211
+ tok="$(sel_lc "$1")"
212
+ [ -n "$tok" ] || return 1
213
+ run="$(sel_lc "${RUN_MODEL:-}")"
214
+ if [ -n "$run" ]; then
215
+ case "$run" in *"$tok"*) return 0 ;; *) return 1 ;; esac
216
+ fi
217
+ fb="$(sel_lc "$FALLBACK_MODEL")"
218
+ case "$fb" in *"$tok"*) return 0 ;; esac # nothing left to switch to
219
+ return 1
220
+ }
221
+
222
+ # Peak percent over the buckets that constrain THIS run — scoped buckets belonging to
223
+ # other models are skipped. Prints nothing when the reading predates per-bucket
224
+ # telemetry, so the caller falls back to the flat max_percent it used to trust.
225
+ applicable_peak() { # $1 = acct dir
226
+ local f="$1/limits.json"
227
+ [ -f "$f" ] || return 1
228
+ LC_ALL=C tr '{},' '\n\n\n' < "$f" 2>/dev/null \
229
+ | LC_ALL=C awk -v run="$(sel_lc "${RUN_MODEL:-}")" -v fb="$(sel_lc "$FALLBACK_MODEL")" '
230
+ function applies(name, tok) {
231
+ if (index(name, "weekly_scoped:") != 1) return 1
232
+ tok = substr(name, 15)
233
+ if (run != "") return index(run, tok) > 0
234
+ return index(fb, tok) > 0
235
+ }
236
+ /"name"[[:space:]]*:/ {
237
+ n = $0
238
+ sub(/.*"name"[[:space:]]*:[[:space:]]*"/, "", n); sub(/".*/, "", n)
239
+ name = tolower(n); next
240
+ }
241
+ /"percent"[[:space:]]*:/ {
242
+ p = $0
243
+ sub(/.*"percent"[[:space:]]*:[[:space:]]*/, "", p); sub(/[^0-9].*/, "", p)
244
+ if (p != "") { seen = 1; if (applies(name) && p + 0 > best) best = p + 0 }
245
+ next
246
+ }
247
+ END { if (seen) print best + 0 }
248
+ '
249
+ }
250
+
251
+ # The model token of a scoped bucket that is itself at/over the threshold ("" when
252
+ # none) — what the pick path needs to know to pin the fallback for an unpinned run.
253
+ scoped_over_tok() { # $1 = acct dir
254
+ local f="$1/limits.json"
255
+ [ -f "$f" ] || return 0
256
+ LC_ALL=C tr '{},' '\n\n\n' < "$f" 2>/dev/null \
257
+ | LC_ALL=C awk -v thr="${CLAUDE_MULTIACC_THRESHOLD:-90}" '
258
+ /"name"[[:space:]]*:/ {
259
+ n = $0
260
+ sub(/.*"name"[[:space:]]*:[[:space:]]*"/, "", n); sub(/".*/, "", n)
261
+ name = n; next
262
+ }
263
+ /"percent"[[:space:]]*:/ {
264
+ p = $0
265
+ sub(/.*"percent"[[:space:]]*:[[:space:]]*/, "", p); sub(/[^0-9].*/, "", p)
266
+ if (p != "" && p + 0 >= thr + 0 && index(tolower(name), "weekly_scoped:") == 1) {
267
+ print substr(name, 15); exit
268
+ }
269
+ next
270
+ }
271
+ '
272
+ }
273
+
163
274
  marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
164
- local m="$1/.limited" reset=""
275
+ local m="$1/.limited" reset="" tok
165
276
  [ -f "$m" ] || return 1
166
277
  IFS= read -r reset < "$m" 2>/dev/null || reset=""
167
278
  if ! num_ok "$reset"; then
@@ -174,6 +285,11 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
174
285
  rm -f "$m" 2>/dev/null
175
286
  return 1
176
287
  fi
288
+ # A park scoped to ONE model does not stop a run that will use another one.
289
+ tok="$(scoped_tok_of_marker "$1")"
290
+ if [ -n "$tok" ] && ! scoped_blocks_run "$tok"; then
291
+ return 1
292
+ fi
177
293
  return 0
178
294
  }
179
295
 
@@ -305,10 +421,15 @@ limited_percent_of() { # $1 acct dir -> the marked bucket's percent, or -1 (unkn
305
421
  # fallback lives on, because "degraded service beats a hard failure" only holds
306
422
  # for an account that can actually serve.
307
423
  limited_hard_blocked() { # $1 acct dir
308
- local m="$1/.limited" line="" p
424
+ local m="$1/.limited" line="" p tok
309
425
  if [ -f "$m" ]; then
310
426
  line="$(sed -n 2p "$m" 2>/dev/null)"
311
427
  case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
428
+ # Exhausted for ONE model is not exhausted for the model this run will use.
429
+ tok="$(scoped_tok_of_marker "$1")"
430
+ if [ -n "$tok" ] && ! scoped_blocks_run "$tok"; then
431
+ return 1
432
+ fi
312
433
  p="$(limited_percent_of "$1")"
313
434
  if [ "$p" -ge 0 ] 2>/dev/null; then
314
435
  [ "$p" -ge 100 ]
@@ -335,7 +456,12 @@ util_of() {
335
456
  # (fail open — telemetry must never invent exclusions).
336
457
  over_threshold() { # $1 = acct dir
337
458
  local v
338
- v="$(cutoff_field "$1" max_percent)" || return 1
459
+ within_window "$1" "$EXCLUDE_STALE_AFTER" || return 1
460
+ # Buckets that constrain THIS run; a reading with no bucket list falls back to the
461
+ # flat peak, which is what this check always used.
462
+ v="$(applicable_peak "$1")"
463
+ [ -n "$v" ] || v="$(cutoff_field "$1" max_percent)" || return 1
464
+ num_ok "$v" || return 1
339
465
  [ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
340
466
  }
341
467
 
@@ -1083,6 +1209,22 @@ else
1083
1209
  fi
1084
1210
  done
1085
1211
  sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1212
+ # Every candidate rejects right now, so this pick WILL fail: say so on a terminal
1213
+ # instead of letting the operator read the client's bare limit error as a bad
1214
+ # choice by the pool (2026-08-29). Throttled, and never on a service's stderr.
1215
+ if [ -t 2 ]; then
1216
+ exn="$ACC_ROOT/.exhausted-notice"
1217
+ exlast=0
1218
+ [ -f "$exn" ] && exlast="$(file_mtime "$exn")"
1219
+ if [ $((now - exlast)) -gt 600 ]; then
1220
+ : 2>/dev/null > "$exn" || true
1221
+ exextra=""
1222
+ [ "${#expired[@]}" -gt 0 ] \
1223
+ && exextra=" ${#expired[@]} further account(s) are unusable — see: claude-accounts expired."
1224
+ printf 'claude-multiacc: every usable account is at a limit right now; %s frees up in %ds and was chosen for that.%s\n' \
1225
+ "$(basename "$PICK_DIR")" "$((best_reset > now ? best_reset - now : 0))" "$exextra" >&2
1226
+ fi
1227
+ fi
1086
1228
  fi
1087
1229
  # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
1088
1230
  # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
@@ -1113,6 +1255,20 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
1113
1255
  fi
1114
1256
  fi
1115
1257
  unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
1258
+
1259
+ # The picked account may be eligible only because its exhausted bucket belongs to a
1260
+ # model this run did not ask for. Handing it the default model anyway would open the
1261
+ # session on "You've reached your Fable 5 limit" — the exact failure this selection
1262
+ # path exists to avoid (operator, tmux 136) — so the fallback model is pinned once,
1263
+ # before exec. An explicit --model is never touched: such a run does not get here.
1264
+ if [ -z "$RUN_MODEL" ]; then
1265
+ sel_tok="$(scoped_tok_of_marker "$pick")"
1266
+ [ -n "$sel_tok" ] || sel_tok="$(scoped_over_tok "$pick")"
1267
+ if [ -n "$sel_tok" ] && ! scoped_blocks_run "$sel_tok"; then
1268
+ set -- --model "$FALLBACK_MODEL" "$@"
1269
+ sel_log "$(basename "$pick") -> --model $FALLBACK_MODEL (its $sel_tok bucket is full)"
1270
+ fi
1271
+ fi
1116
1272
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
1117
1273
  # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
1118
1274
  # not a turn in the rotation.
@@ -946,11 +946,14 @@ ceremony_debrief() { # $1 = capture file, $2 = redacted transcript to write; pri
946
946
  "$PYBIN" - "$1" "$2" <<'PYEOF'
947
947
  import os, re, sys
948
948
  raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
949
- txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
950
- txt = re.sub(r'\x1b\[[0-9;?]*[A-Za-z]', '', txt) # CSI (colour, cursor)
951
- txt = re.sub(r'\x1b[()][A-Z0-9]', '', txt).replace('\r', '\n')
952
- txt = re.sub(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+', 'sk-ant-***', txt)
949
+ txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
950
+ txt = re.sub(r'\x1b\[[0-9;?<>=]*[A-Za-z]', '', txt) # CSI, private modes too ([>4m, [<u)
951
+ txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
952
+ txt = txt.replace('\r', '\n')
953
953
  lines = []
954
+ flat = re.sub(r'\s+', '', txt)
955
+ api_key = bool(re.search(r'sk-ant-api\d{2}-', flat)) and not re.search(r'sk-ant-oat\d{2}-', flat)
956
+ txt = re.sub(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+', 'sk-ant-***', txt)
954
957
  for ln in txt.splitlines():
955
958
  ln = ln.strip()
956
959
  if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
@@ -960,6 +963,12 @@ for ln in txt.splitlines():
960
963
  if lines and lines[-1] == ln:
961
964
  continue
962
965
  lines.append(ln)
966
+ if api_key:
967
+ # Said LAST, so it is what the operator reads: the client minted an API key, not a
968
+ # subscription token — the browser session was signed into a Console org.
969
+ lines.append('the client minted an API KEY (sk-ant-api…), not a subscription setup-token: '
970
+ 'the browser session belongs to a Console / API-billing organization — sign in '
971
+ 'as the subscription account and try again')
963
972
  try:
964
973
  fd = os.open(sys.argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
965
974
  with os.fdopen(fd, 'w') as f:
@@ -970,6 +979,35 @@ print(' | '.join(lines[-3:])[:400])
970
979
  PYEOF
971
980
  }
972
981
 
982
+ # The token, out of the transcript. The client's renderer places WORDS with cursor
983
+ # moves instead of spaces and can emit a long token as positioned, styled segments;
984
+ # a narrow terminal wraps it. A raw-bytes grep therefore missed a perfectly good token
985
+ # on 2026-08-29 ("no token captured" after "token created successfully"). Every
986
+ # terminal escape is stripped first, and the token block the client prints (between
987
+ # "Your OAuth token …:" and "Store this token securely") is joined across spaces and
988
+ # line breaks before matching, so neither placement nor wrapping can split it. The
989
+ # real inference in commit_ceremony_token then proves whatever came out.
990
+ ceremony_extract() { # $1 = capture file -> the setup-token, or nothing
991
+ "$PYBIN" - "$1" <<'PYEOF'
992
+ import re, sys
993
+ raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
994
+ txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
995
+ txt = re.sub(r'\x1b\[[0-9;?<>=]*[A-Za-z]', '', txt) # CSI, private modes too ([>4m, [<u)
996
+ txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
997
+ pat = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}')
998
+ cands = []
999
+ for m in re.finditer(r'Your\s*OAuth\s*token[^\n]{0,80}?:', txt):
1000
+ block = txt[m.end(): m.end() + 4000]
1001
+ stop = re.search(r'Store\s*this\s*token', block)
1002
+ if stop:
1003
+ block = block[: stop.start()]
1004
+ cands += pat.findall(re.sub(r'\s+', '', block))
1005
+ cands += pat.findall(txt)
1006
+ cands += pat.findall(re.sub(r'[ \t]+', '', txt))
1007
+ print(max(cands, key=len) if cands else '')
1008
+ PYEOF
1009
+ }
1010
+
973
1011
  CEREMONY_TOKEN=""
974
1012
  run_token_ceremony() { # $1 = config dir
975
1013
  CEREMONY_TOKEN=""
@@ -1009,17 +1047,22 @@ TIP
1009
1047
  # Headless (tests / piped code): capture into the 0600 file only. Never tee the
1010
1048
  # raw token to stdout — a redirected run would write the secret to a plain log.
1011
1049
  CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token > "$cap" 2>&1
1012
- sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
1050
+ # Every credential shape, not just setup-tokens: a Console session mints an API key.
1051
+ sed -E 's/sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]*/sk-ant-***-<redacted>/g' "$cap"
1013
1052
  fi
1014
1053
  umask "$old_umask"
1015
- # The LONGEST match, not the last: a TUI repaints its frame many times, and a
1016
- # frame rendered while the terminal was still narrow holds a wrapped fragment.
1017
- CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" \
1018
- | awk '{ if (length($0) > length(best)) best = $0 } END { if (best != "") print best }')"
1054
+ CEREMONY_TOKEN="$(ceremony_extract "$cap" 2>/dev/null)"
1019
1055
  if [ -z "$CEREMONY_TOKEN" ]; then
1020
- CEREMONY_TRANSCRIPT="$ACC_ROOT/tmp/mint-failed.$(date -u +%Y%m%dT%H%M%SZ).log"
1056
+ local stamp rawkeep
1057
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
1058
+ CEREMONY_TRANSCRIPT="$ACC_ROOT/tmp/mint-failed.$stamp.log"
1059
+ rawkeep="$ACC_ROOT/tmp/mint-failed.$stamp.raw"
1021
1060
  CEREMONY_LAST_WORDS="$(ceremony_debrief "$cap" "$CEREMONY_TRANSCRIPT" 2>/dev/null)"
1022
1061
  [ -s "$CEREMONY_TRANSCRIPT" ] || CEREMONY_TRANSCRIPT=""
1062
+ # The raw bytes are the only record of what the renderer actually emitted, and may
1063
+ # hold a token this extractor did not recognise: kept 0600 beside the pool, like
1064
+ # the tokens themselves, so the next unknown shape can be read instead of guessed.
1065
+ ( umask 077; cp "$cap" "$rawkeep" ) 2>/dev/null || true
1023
1066
  rm -f "$cap"
1024
1067
  [ -n "$CEREMONY_LAST_WORDS" ] && warn "the sign-in ended without a token — the client said: $CEREMONY_LAST_WORDS"
1025
1068
  [ -n "$CEREMONY_TRANSCRIPT" ] && warn "redacted transcript kept at $CEREMONY_TRANSCRIPT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,16 +75,29 @@ if [ "${1:-}" = "setup-token" ]; then
75
75
  echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
76
76
  [ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "${FAKE_TOKEN_FAIL_MSG:-sign-in aborted}" >&2; exit 1; }
77
77
  if [ -n "${FAKE_TOKEN_FULL:-}" ]; then
78
- # The real client's shape: a 108-character token that the TUI HARD-WRAPS at the
79
- # terminal's width an unsized pty (0 columns) renders as 80, and a 79-character
80
- # first line is exactly what every panel mint of 2026-08-28 saved.
78
+ # The real client's shape (2.1.251): a success line, the token on its own line
79
+ # between "Your OAuth token …:" and "Store this token securely", 108 characters.
80
+ # The renderer HARD-WRAPS it at the terminal's width (an unsized pty renders as 80
81
+ # — the 79-character first line every panel mint of 2026-08-28 saved), and even at
82
+ # 400 columns emits it as cursor-positioned, styled segments (my-mini, 2026-08-29).
81
83
  tok="sk-ant-oat01-$(printf '%66s' '' | tr ' ' W)$(printf '%23s' '' | tr ' ' T)TAILOK"
82
84
  cols="$(stty size 2>/dev/null | awk '{print $2}')"
83
- if [ -n "${FAKE_TOKEN_WRAP:-}" ] || { [ -n "$cols" ] && [ "$cols" -lt 108 ]; }; then
85
+ printf 'Long-lived authentication token created successfully!\n'
86
+ printf 'Your\033[5GOAuth\033[11Gtoken\033[17G(valid\033[24Gfor\033[28G1\033[30Gyear):\n'
87
+ if [ -n "${FAKE_TOKEN_SPLIT:-}" ]; then
88
+ printf '\033[2G%s\033[38;5;246m\033[15G%s\033[39m\033[60G%s\033[0m\r\n' "${tok:0:13}" "${tok:13:45}" "${tok:58}"
89
+ elif [ -n "${FAKE_TOKEN_WRAP:-}" ] || { [ -n "$cols" ] && [ "$cols" -lt 108 ]; }; then
84
90
  printf ' %s\n%s\n' "${tok:0:79}" "${tok:79}"
85
91
  else
86
92
  printf ' %s\n' "$tok"
87
93
  fi
94
+ printf 'Store\033[7Gthis\033[12Gtoken\033[18Gsecurely.\n'
95
+ exit 0
96
+ fi
97
+ if [ -n "${FAKE_TOKEN_APIKEY:-}" ]; then
98
+ # A browser session signed into a Console (API-billing) org: the client mints an
99
+ # API key, which is not a subscription token and must not be saved.
100
+ printf 'Your OAuth token (valid for 1 year):\nsk-ant-api03-%s\nStore this token securely.\n' "$(printf '%80s' '' | tr ' ' K)"
88
101
  exit 0
89
102
  fi
90
103
  echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
@@ -408,7 +421,8 @@ check "pinned dead-credential account still runs under its token" "TOK=sk-ant-oa
408
421
  rm -rf "$ACC/acct-07"
409
422
 
410
423
  # ---- 7. limited marker excludes account ------------------------------------
411
- printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
424
+ # (An account-wide bucket. A MODEL-SCOPED one deliberately does not exclude — 9a4.)
425
+ printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
412
426
  all2=1
413
427
  for _ in $(seq 1 15); do
414
428
  out="$(claude 2>&1)"
@@ -441,7 +455,7 @@ rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
441
455
  # acct-02: session at 100% — far better weekly (7%), but every request bounces until
442
456
  # the reset. Ranking on headroom alone handed out the guaranteed rejection
443
457
  # (operator report 2026-08-29: "claude keeps starting on an out-of-limits account").
444
- printf '%s\nbucket=weekly_scoped:Fable percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
458
+ printf '%s\nbucket=weekly_all percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
445
459
  printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
446
460
  printf '{"fetched_at":%s,"max_percent":99,"weekly_percent":99,"session_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
447
461
  printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":7,"session_percent":100,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
@@ -518,6 +532,58 @@ for f in server.token .credentials.json .expired .server-token-verified limits.j
518
532
  [ -e "$WORK/bak01/$f" ] && cp -p "$WORK/bak01/$f" "$ACC/acct-01/$f"
519
533
  done
520
534
 
535
+ # ---- 9a4. a MODEL-scoped limit does not park the whole account ----------------------
536
+ # "weekly_scoped:Fable at 100%" says the account cannot serve Fable — nothing more. It
537
+ # used to park the account outright (and max_percent re-excluded it anyway), so on
538
+ # 2026-08-29 three accounts sitting at Fable 100% with their session buckets at 51/23/25%
539
+ # were invisible to selection, the pool offered nothing, and the fallback handed out a
540
+ # session-exhausted account that rejected the operator's first request.
541
+ rm -rf "$WORK/bak94"; mkdir -p "$WORK/bak94"
542
+ for a in acct-01 acct-02; do
543
+ for f in .limited limits.json; do
544
+ [ -e "$ACC/$a/$f" ] && cp -p "$ACC/$a/$f" "$WORK/bak94/$a.$f"
545
+ done
546
+ done
547
+ # acct-01: only its Fable bucket is spent. acct-02: session spent — dead for every model.
548
+ printf '%s\nbucket=weekly_scoped:Fable percent=100 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
549
+ printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":100,"session_percent":12,"buckets":[{"name":"session","percent":12},{"name":"weekly_all","percent":40},{"name":"weekly_scoped:Fable","percent":100}]}' "$now" > "$ACC/acct-01/limits.json"
550
+ printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
551
+ printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":30,"session_percent":100,"buckets":[{"name":"session","percent":100},{"name":"weekly_all","percent":30}]}' "$now" > "$ACC/acct-02/limits.json"
552
+ out="$(claude 2>&1)"
553
+ check "a Fable-only park still serves an unpinned run" "CFG=acct-01" "$out"
554
+ check "...and that run is pinned to the fallback model up front" "ARGS=--model claude-opus-5" "$out"
555
+ grep -q 'acct-01 -> --model claude-opus-5 (its Fable bucket is full)' "$ACC/selection.log" \
556
+ && t_ok "the model pin is logged with its reason" || t_fail "model pin log" "no line in selection.log"
557
+ # A run that ASKS for the exhausted model must not be sent to that account.
558
+ out="$(claude --model claude-fable-5 2>&1)"
559
+ check "a run pinning the exhausted model does not get that account" "CFG=acct-02" "$out"
560
+ # A run that pins another model uses it as-is — never pinned twice.
561
+ out="$(claude --model claude-opus-5 2>&1)"
562
+ check "a run pinning another model gets the Fable-spent account" "CFG=acct-01" "$out"
563
+ case "$out" in
564
+ *"--model claude-opus-5 --model"*) t_fail "an explicit model is not pinned twice" "double --model" ;;
565
+ *) t_ok "an explicit model is not pinned twice" ;;
566
+ esac
567
+ # The >=90% cutoff reads the same way: no marker at all, Fable spent in telemetry only.
568
+ rm -f "$ACC/acct-01/.limited"
569
+ out="$(claude 2>&1)"
570
+ check "the cutoff ignores a scoped bucket the run will not use" "CFG=acct-01" "$out"
571
+ out="$(claude --model claude-fable-5 2>&1)"
572
+ check "the cutoff still excludes it for the model that IS spent" "CFG=acct-02" "$out"
573
+ # A reading with no bucket list keeps the old flat behaviour (fail closed at >=90%),
574
+ # with a plainly healthy neighbour so nothing but that exclusion decides the pick.
575
+ printf '{"fetched_at":%s,"max_percent":97,"weekly_percent":97,"session_percent":97}' "$now" > "$ACC/acct-01/limits.json"
576
+ rm -f "$ACC/acct-02/.limited"
577
+ printf '{"fetched_at":%s,"max_percent":20,"weekly_percent":20,"session_percent":20,"buckets":[{"name":"session","percent":20},{"name":"weekly_all","percent":20}]}' "$now" > "$ACC/acct-02/limits.json"
578
+ out="$(claude 2>&1)"
579
+ check "a bucket-less reading still excludes at the flat peak" "CFG=acct-02" "$out"
580
+ for a in acct-01 acct-02; do
581
+ rm -f "$ACC/$a/.limited" "$ACC/$a/limits.json"
582
+ for f in .limited limits.json; do
583
+ [ -e "$WORK/bak94/$a.$f" ] && cp -p "$WORK/bak94/$a.$f" "$ACC/$a/$f"
584
+ done
585
+ done
586
+
521
587
  # ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
522
588
  # empty .credentials.json must NOT count as auth (interrupted write)
523
589
  mkdir -p "$ACC/acct-06"
@@ -1383,25 +1449,54 @@ rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
1383
1449
  print(rows["acct-04"].get("token_verified"))')"
1384
1450
  [ "$out" = "True" ] && t_ok "list --json reports the token as verified here" \
1385
1451
  || t_fail "list --json token_verified" "expected True, got: $out"
1386
- # The 2026-08-28 shape: the client's TUI wrapped the 108-character token at 80 columns
1387
- # and only its 79-character first line was captured. Claude rejects the fragment; the
1388
- # mint must refuse to save it and say WHY (seven fleet-wide 401s were the old answer).
1452
+ # The 2026-08-28 shape: the client's TUI wrapped the 108-character token at 80 columns.
1453
+ # The token block is joined across the break, so the capture is WHOLE — and proven.
1389
1454
  orig="$(cat "$ACC/acct-04/server.token")"
1390
1455
  out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_WRAP=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1391
1456
  rc=$?
1392
- [ "$rc" != "0" ] && t_ok "a wrapped (79-char) token capture is refused" || t_fail "wrapped token rc" "rc=0"
1393
- check "the refusal names the truncation" "79 characters" "$out"
1394
- check "the refusal names the cause" "terminal wrapped it" "$out"
1395
- [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a refused capture leaves the old token in place" \
1396
- || t_fail "refused capture" "server.token was overwritten"
1397
- # An INCONCLUSIVE probe (the API is busy) must not let a wrapped capture through as
1398
- # merely "unverified": its length alone convicts it, and that is the exact token the
1399
- # fleet then rejects on every Mac.
1400
- out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_WRAP=1 FAKE_PROBE_FLAKY=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1457
+ [ "$rc" = "0" ] && t_ok "a wrapped token is reassembled from the transcript" || t_fail "wrapped token rc" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
1458
+ case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
1459
+ *TAILOK) [ "$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')" = "108" ] \
1460
+ && t_ok "the reassembled token is the complete one" || t_fail "reassembled token" "wrong length" ;;
1461
+ *) t_fail "reassembled token" "tail missing" ;;
1462
+ esac
1463
+ printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
1464
+ # The 2026-08-29 shape: at 400 columns the renderer emits the token as cursor-positioned,
1465
+ # styled segments a raw-bytes grep saw no token at all right after "token created
1466
+ # successfully". Stripped and joined, it is whole.
1467
+ out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_SPLIT=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1468
+ rc=$?
1469
+ [ "$rc" = "0" ] && t_ok "a token the renderer split into positioned segments is captured" \
1470
+ || t_fail "split token rc" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
1471
+ case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
1472
+ sk-ant-oat01-WWW*TAILOK) t_ok "the split token was joined correctly" ;;
1473
+ *) t_fail "split token" "unexpected content" ;;
1474
+ esac
1475
+ [ -f "$ACC/acct-04/.server-token-verified" ] && t_ok "the joined token was proven by a real call" \
1476
+ || t_fail "split token proof" "marker missing"
1477
+ printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
1478
+ # A browser session on a Console (API-billing) org: the client mints an API KEY. Not a
1479
+ # subscription token — refused, with the cause named and the raw capture kept.
1480
+ before_raw="$(ls "$ACC"/tmp/mint-failed.*.raw 2>/dev/null | wc -l | tr -d ' ')"
1481
+ out="$(FAKE_TOKEN_APIKEY=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1482
+ rc=$?
1483
+ [ "$rc" != "0" ] && t_ok "an API key minted by a Console session is refused" || t_fail "apikey rc" "rc=0"
1484
+ check "the API-key refusal names the cause" "API KEY" "$out"
1485
+ check "the API-key refusal points at the org" "Console" "$out"
1486
+ case "$out" in *sk-ant-api03-K*) t_fail "the API key never reaches the operator's screen" "leaked" ;; *) t_ok "the API key never reaches the operator's screen" ;; esac
1487
+ [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "an API key is never saved as the token" \
1488
+ || t_fail "apikey save" "server.token was overwritten"
1489
+ [ "$(ls "$ACC"/tmp/mint-failed.*.raw 2>/dev/null | wc -l | tr -d ' ')" -gt "$before_raw" ] \
1490
+ && t_ok "the raw capture is kept for a closer look" || t_fail "raw capture" "no .raw kept"
1491
+ [ "$(python3 -c 'import os, sys; print(oct(os.stat(sys.argv[1]).st_mode & 0o777))' "$(ls -t "$ACC"/tmp/mint-failed.*.raw | head -1)")" = "0o600" ] \
1492
+ && t_ok "the raw capture is private" || t_fail "raw capture mode" "not 0600"
1493
+ # A 79-character FRAGMENT that really is all there is (a paste from a narrow terminal)
1494
+ # with a probe that cannot decide: refused, and it says why.
1495
+ out="$(printf 'sk-ant-oat01-%s' "$(printf '%66s' '' | tr ' ' W)" | FAKE_PROBE_FLAKY=1 claude-accounts mint acct-04 --paste 2>&1)"
1401
1496
  rc=$?
1402
- [ "$rc" != "0" ] && t_ok "a wrapped capture is refused even when the probe is inconclusive" \
1403
- || t_fail "wrapped+inconclusive rc" "rc=0"
1404
- check "the inconclusive refusal still names the cause" "terminal wrapped it" "$out"
1497
+ [ "$rc" != "0" ] && t_ok "a bare fragment is refused even when the probe is inconclusive" \
1498
+ || t_fail "fragment+inconclusive rc" "rc=0"
1499
+ check "the fragment refusal names the truncation" "79 characters" "$out"
1405
1500
  [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "an unproven fragment saves nothing" \
1406
1501
  || t_fail "unproven fragment" "server.token was overwritten"
1407
1502
  # …while a COMPLETE token the probe cannot reach right now is saved as UNVERIFIED — the
@@ -4710,7 +4805,7 @@ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list 2>&1)"
4710
4805
  check "keychain login shown in the plain list" "auth=keychain" "$out"
4711
4806
 
4712
4807
  # 17b. the shim runs under a keychain-only account (file account parked by a limit)
4713
- printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
4808
+ printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
4714
4809
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
4715
4810
  check "shim selects the keychain-only account" "CFG=acct-02" "$out"
4716
4811