claude-multiacc 2.0.19 → 2.0.21

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.
@@ -128,11 +128,16 @@ MAC (source of truth) SERVER 138.197.36.107 (mirror)
128
128
 
129
129
  1. `CLAUDE_CONFIG_DIR` already set → pass straight through (scripts can pin).
130
130
  2. `CLAUDE_ACCOUNT=acct-03` set → use that account (even if limit-marked — explicit wins).
131
- 3. Else pick uniformly at random among accounts with valid auth
132
- (`.credentials.json` on Mac; `server.token` on server also exports
133
- `CLAUDE_CODE_OAUTH_TOKEN`) **that are not limit-marked** (no `.limited` marker).
134
- If EVERY account is marked, fall back to the least-utilized account and log a warning —
135
- degraded service beats a hard failure (the 100% rule).
131
+ 3. Else, among accounts with valid auth (`.credentials.json` on Mac; `server.token` on
132
+ server also exports `CLAUDE_CODE_OAUTH_TOKEN`) **that are not limit-marked** (no
133
+ `.limited` marker), select in two cuts (operator decision, 2026-09-03): first the
134
+ **session gate** only accounts whose fresh 5h usage is at most
135
+ `CLAUDE_MULTIACC_SESSION_GATE` (default 50) stay in, unless nobody clears it; then the
136
+ **30-point weekly headroom band** — accounts within `CLAUDE_MULTIACC_HEADROOM_BAND`
137
+ points of the lowest weekly usage are peers, sampled at random with the previous pick
138
+ avoided. If EVERY account is marked, the still-serving ones go through the same two cuts
139
+ with a strict (exact-tie) band and one is handed out with a warning — degraded service
140
+ beats a hard failure (the 100% rule). README.md is the authoritative description.
136
141
  4. Append `timestamp account cwd` to `selection.log` (never prompt text); print NOTHING;
137
142
  `exec` the dynamically-resolved real binary — stdin/stdout/exit codes pass through
138
143
  byte-identically → `-p` pipe-safe.
package/README.md CHANGED
@@ -16,7 +16,7 @@ completely independent — separate manifests, credentials, telemetry, and logs
16
16
  either provider can be used, re-authenticated, or emptied without touching the other.
17
17
 
18
18
  Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04
19
- (bash 5.2). The compatibility suite currently covers 645 sandboxed cases with no
19
+ (bash 5.2). The compatibility suite currently covers 834 sandboxed cases with no
20
20
  network/quota use; the unified selector has its own adversarial contract suite.
21
21
 
22
22
  ## Unified selector for app-robot
@@ -32,8 +32,9 @@ claude-multiacc select --request-json - --response-json -
32
32
 
33
33
  The command reads one strict UTF-8 JSON request and emits one JSON response using
34
34
  the versioned `claude-multiacc/pool-selection.v2` protocol. It ranks the caller's
35
- locked candidate snapshot by usable weekly/session headroom and returns one concrete
36
- provider/account plus canonical snapshot and selection digests. `both` is a policy,
35
+ locked candidate snapshot with the same two cuts as the shims — a session-headroom gate,
36
+ then a band on weekly headroom — and returns one concrete provider/account plus canonical
37
+ snapshot and selection digests. `both` is a policy,
37
38
  never an execution engine. The caller remains responsible for durable reservations,
38
39
  database locks, retries, and launching the selected CLI.
39
40
  Successful responses also carry `eligible_count` and `eligible_alternative_count`,
@@ -72,14 +73,26 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
72
73
  4. Among the accounts that remain valid **on this machine** (an OAuth login —
73
74
  `.credentials.json`, or a macOS Keychain item this session can open — or a
74
75
  `server.token` that passes its first inference preflight) and not limit-excluded,
75
- pick the one with the **most remaining
76
- headroom** (see below). On equal headroom the pool **rotates away from the account it
77
- just handed out** and samples the rest at random so quitting a session and starting
78
- another one moves you along the pool, while a burst of parallel `claude -p` runs still
79
- spreads (a strict "oldest first" order would send every member of the burst to the same
80
- account). `CLAUDE_SHIM_SELECT=random` restores plain uniform-random selection.
81
- 5. If every account is limit-excluded the highest-headroom *authenticable* account
82
- anyway + a warning in `selection.log` (degraded beats down: the 100% rule).
76
+ rank in two cuts (see below). **Session gate first:** keep only the accounts whose
77
+ 5-hour bucket is known-fresh and at most 50% used an account about to be rejected on
78
+ its session does not deserve a launch whatever its weekly headroom. If *nobody* clears
79
+ the gate it steps aside and every candidate stays in: the gate compares, it never
80
+ empties the pool. **Then the weekly band:** among the accounts that cleared the gate,
81
+ form a **30-percentage-point weekly-headroom band** around the one with the most
82
+ remaining weekly headroom. The pool **rotates away from the account it just handed
83
+ out** and samples the other accounts in that band at random. This prevents one nominally
84
+ emptiest account from taking every launch while near-peers idle, while accounts more than
85
+ 30 points behind remain protected. `CLAUDE_MULTIACC_SESSION_GATE=100` turns the gate off
86
+ (default `50`); `CLAUDE_MULTIACC_HEADROOM_BAND=0` restores strict best-weekly selection
87
+ (default `30`); `CLAUDE_SHIM_SELECT=random` uses the whole eligible pool. Every pick
88
+ records both cuts in `selection.log`: `band=`/`band-count=` (the band width and how many
89
+ peers it held) and `session-gate=`/`session-ok=` (the gate and how many candidates
90
+ cleared it — `session-ok=0` means nobody did and the gate stepped aside; in
91
+ `CLAUDE_SHIM_SELECT=random` mode the line reads `session-gate=off`, because no cut was made).
92
+ 5. If every account is limit-excluded → the still-serving limited accounts (a bucket at
93
+ 90–99%, not an exhausted one) go through the same two cuts — session gate, then strict
94
+ best-weekly — and one is handed out anyway, with an `all-limited fallback=` line in
95
+ `selection.log` (degraded beats down: the 100% rule).
83
96
  6. If nothing is usable at all → stock passthrough, with the reason in `selection.log`
84
97
  (and, on a terminal, one line naming the accounts to fix).
85
98
 
@@ -87,12 +100,26 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
87
100
  Fable request consumes at once: the 5-hour **session** bucket, the **weekly all-models**
88
101
  bucket, and the **weekly Fable** bucket. Anthropic's docs confirm these reset on very
89
102
  different horizons — the session bucket refills every ~5 hours, but weekly buckets only
90
- refill on the account's fixed weekly reset (days away). So the picker ranks primarily on
91
- **weekly headroom** (the peak of the durable buckets) and uses the self-healing session
92
- bucket only as a tiebreaker. An account sitting at 85% session / 10% weekly outranks one
93
- at 20% session / 70% weekly: the first only needs a few hours to recover, the second is
94
- burning headroom that's gone until next week. (`score = weekly% × 1000 + session%`,
95
- lowest wins.) **Exclusion** still fires on *any* bucket 90% a full session bucket
103
+ refill on the account's fixed weekly reset (days away). The two buckets therefore answer
104
+ two different questions, and the picker asks them in that order (operator's decision,
105
+ 2026-09-03: *"among accounts where high session limits it must choose randomly from ones
106
+ where highest weekly limits"*).
107
+
108
+ - **Session is a gate, not a tiebreaker.** A candidate clears the gate when its 5-hour
109
+ usage is *known* (fresh telemetry) and at most `CLAUDE_MULTIACC_SESSION_GATE` points
110
+ spent — default `50`, `100` disables the gate. Unknown session usage never clears it.
111
+ An account that is 85% through its session is minutes from a rejection, so it waits
112
+ while anyone fresher is available; if nobody clears the gate, everybody is ranked.
113
+ - **Weekly headroom picks the winner.** Among the accounts that cleared the gate, the
114
+ picker ranks on **weekly headroom** (the peak of the durable buckets) and treats
115
+ everything within `CLAUDE_MULTIACC_HEADROOM_BAND` points of the leader — default `30`,
116
+ `0` is strict best-weekly-only — as a peer, spreading launches over that band at
117
+ random. Session usage is **not** a tiebreaker inside the band: it acts only through
118
+ the gate. So an account at 10% weekly stays protected from one at 70% weekly, but
119
+ 10% weekly with an 85%-spent session loses to 70% weekly with a 20% one, because the
120
+ first never reaches the band.
121
+
122
+ **Exclusion** still fires on *any* bucket ≥ 90% — a full session bucket
96
123
  really does block right now — but that marker expires when the session resets, not days
97
124
  later.
98
125
 
@@ -285,8 +312,9 @@ above about the claude pool holds for the codex pool with these translations:
285
312
  | Anthropic OAuth usage and Fable buckets | Codex usage endpoint and per-model buckets |
286
313
  | `CLAUDE_*` pool controls | equivalent `CODEX_*` controls |
287
314
 
288
- Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
289
- exclusion, equal scores rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
315
+ Same selection rule (session gate first `CODEX_MULTIACC_SESSION_GATE`, default 50 —
316
+ then the 30-point weekly-headroom band with random spread, ≥90% any-bucket
317
+ exclusion, peers rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
290
318
  credential/policy scoping and soft expiry), same fail-open guarantees, same sync
291
319
  safety guards. The `codex` shim engages the buffered auto-retry only for
292
320
  `codex exec` runs with finite stdin, exactly like `-p` on the claude side.
package/bin/claude CHANGED
@@ -3,7 +3,9 @@
3
3
  # Every invocation runs under a randomly picked subscription account with limit headroom.
4
4
  # Self-contained on purpose: no sourcing, so a broken repo file can never break `claude`.
5
5
  # Selection: CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN passthrough > CLAUDE_ACCOUNT pin >
6
- # random among limit-eligible accounts > least-utilized fallback (degraded beats down).
6
+ # two cuts — the 50-point session gate, then random within the 30-point weekly headroom
7
+ # band (previous pick avoided) > all-limited fallback: the same two cuts over the
8
+ # still-serving limited accounts, strict weekly (degraded beats down).
7
9
  # Accounts whose login is DEAD (expired refresh token, or a `.expired` marker from a
8
10
  # failed auth) are never selected — not even as the all-limited fallback — because they
9
11
  # fail every call outright; `claude-accounts expired` / `relogin` fix them.
@@ -271,6 +273,47 @@ scoped_over_tok() { # $1 = acct dir
271
273
  '
272
274
  }
273
275
 
276
+ CLIENT_RECOVERY_FETCHED=0
277
+ CLIENT_RECOVERY_PEAK=100
278
+ client_marker_recovered() { # $1 account dir, $2 marker
279
+ local fetched peak delay marked mk cutoff ck newer=0
280
+ # A client rejection is stronger than an immediately-following usage read: that
281
+ # endpoint can lag or serve a cached bucket. It is not stronger forever. Once a
282
+ # successful, below-threshold reading was fetched at least five minutes after the
283
+ # marker, it is newer first-hand evidence that the account recovered (or that an
284
+ # old/shared transcript was attributed incorrectly). Keeping such a marker until its
285
+ # claimed reset stranded five 0%-usage accounts in the live pool on 2026-09-03.
286
+ if grep -q 'reason=client-rate-limit' "$2" 2>/dev/null; then
287
+ fetched="$(telem_fetched_at "$1" 2>/dev/null || echo 0)"
288
+ peak="$(cutoff_field "$1" max_percent 2>/dev/null || echo 100)"
289
+ delay="${CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY:-300}"
290
+ num_ok "$delay" || delay=300
291
+ [ "$delay" -gt 3600 ] && delay=3600
292
+ marked="$(LC_ALL=C sed -n 's/.*marked_at=\([^ ]*\).*/\1/p' "$2" 2>/dev/null | head -1)"
293
+ mk="$(iso_key "$marked")"
294
+ cutoff=0
295
+ if num_ok "$fetched" && [ "$fetched" -ge "$delay" ]; then
296
+ cutoff=$((fetched - delay))
297
+ fi
298
+ ck="$(iso_key "$(iso_of_epoch "$cutoff")")"
299
+ # Sync and atomic copies change mtimes, so prefer the marker's semantic timestamp.
300
+ # Old marker formats fall back to mtime until the next writer upgrades them.
301
+ if num_ok "$mk" && [ "${#mk}" -eq 14 ] && num_ok "$ck"; then
302
+ [ "$mk" -le "$ck" ] && newer=1
303
+ elif num_ok "$fetched" \
304
+ && [ "$(file_mtime "$2")" -le "$((fetched - delay))" ]; then
305
+ newer=1
306
+ fi
307
+ if [ "$newer" = 1 ] && num_ok "$peak" \
308
+ && [ "$peak" -lt "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]; then
309
+ CLIENT_RECOVERY_FETCHED="$fetched"
310
+ CLIENT_RECOVERY_PEAK="$peak"
311
+ return 0
312
+ fi
313
+ fi
314
+ return 1
315
+ }
316
+
274
317
  marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
275
318
  local m="$1/.limited" reset="" tok
276
319
  [ -f "$m" ] || return 1
@@ -285,6 +328,14 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
285
328
  rm -f "$m" 2>/dev/null
286
329
  return 1
287
330
  fi
331
+ if client_marker_recovered "$1" "$m"; then
332
+ printf '%s\n' "$CLIENT_RECOVERY_FETCHED" > "$1/.client-limit-cleared.$$" 2>/dev/null \
333
+ && mv -f "$1/.client-limit-cleared.$$" "$1/.client-limit-cleared" 2>/dev/null \
334
+ || rm -f "$1/.client-limit-cleared.$$" 2>/dev/null
335
+ rm -f "$m" 2>/dev/null
336
+ sel_log "$(basename "$1") client limit cleared by newer telemetry (${CLIENT_RECOVERY_PEAK}%)"
337
+ return 1
338
+ fi
288
339
  # A park scoped to ONE model does not stop a run that will use another one.
289
340
  tok="$(scoped_tok_of_marker "$1")"
290
341
  if [ -n "$tok" ] && ! scoped_blocks_run "$tok"; then
@@ -361,9 +412,9 @@ stale_weekly() { # $1 = acct dir
361
412
  }
362
413
 
363
414
  # TRUE when this account contributes nothing to ranking: no in-window telemetry at all.
364
- # When every candidate is blind, every score is the same neutral constant, pick_best
365
- # sees one enormous tie, and selection quietly becomes uniform random — the failure
366
- # this whole file exists to prevent.
415
+ # When every candidate is blind, no candidate has a known reading, pick_best sees one
416
+ # enormous tie, and selection quietly becomes uniform random — the failure this whole
417
+ # file exists to prevent.
367
418
  telem_blind() { # $1 = acct dir
368
419
  ! within_window "$1" "$STALE_AFTER"
369
420
  }
@@ -376,24 +427,64 @@ age_human() { # $1 = seconds
376
427
  else printf '%dm\n' $((s / 60)); fi
377
428
  }
378
429
 
379
- # RANKING score lower is better (more headroom). Weekly headroom dominates: a weekly
380
- # bucket only refills on the account's fixed weekly reset (days away), while the 5h
381
- # session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
382
- # confirm this reset asymmetry an account whose only near-full bucket is the cheap
383
- # session one must NOT rank behind one burning durable weekly headroom.)
384
- # score = weekly%*1000 + session% weekly,session in [0,100]
385
- # Stale/unreadable telemetry ranks LAST (weekly 100, session 100), never "free"
386
- # EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale weekly reading is
387
- # used instead. Unknown data must never beat a truthful usage reading; when every
388
- # candidate is unknown, the equal worst-case scores still preserve fail-open selection.
430
+ # RANKING — two cuts, no score, no tiebreaker (operator's ask, 2026-09-03: "among
431
+ # accounts where high session limits it must choose randomly from ones where highest
432
+ # weekly limits"). Session used to be a mild tiebreaker inside the score, which meant an
433
+ # account whose 5h bucket was nearly spent about to be rejected whatever its weekly
434
+ # headroom ranked exactly like a fresh one. It is now the FIRST cut instead:
435
+ # 1. SESSION GATE — candidates with a KNOWN session reading <= SESSION_GATE are the
436
+ # only ones ranked further. Unknown session never clears it. If NOBODY clears it the
437
+ # gate steps aside and everyone is ranked: the gate compares, it never empties the
438
+ # pool.
439
+ # 2. WEEKLY BAND — among those, the lowest KNOWN weekly usage leads and everything
440
+ # within HEADROOM_BAND points of it is a peer; peers are sampled at random with the
441
+ # previous pick skipped. Weekly still dominates because a weekly bucket only refills
442
+ # on the account's fixed weekly reset (days away) while the 5h session bucket
443
+ # self-heals — the gate is about "will this run be rejected now", not about headroom.
444
+ # Stale/unreadable telemetry ranks LAST (unknown weekly, and unknown never clears the
445
+ # gate), never "free" — EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale
446
+ # weekly reading is used instead. Unknown data must never beat a truthful usage reading;
447
+ # when every candidate is unknown they tie, which preserves fail-open selection.
389
448
  SEL_DEGRADED=0
390
- sel_score_of() { # $1 = acct dir
391
- local w s
392
- if ! w="$(fresh_field "$1" weekly_percent)" && ! w="$(fresh_field "$1" max_percent)"; then
393
- if [ "$SEL_DEGRADED" = 1 ]; then w="$(stale_weekly "$1")" || w=100; else w=100; fi
449
+
450
+ # The direct shims use the same 30-point headroom band as pool-selection.v2, on WEEKLY
451
+ # usage. Strict best-headroom selection burned one account to its limit while equally
452
+ # healthy neighbours idled. A caller can set 0 to restore strict ranking (exact weekly
453
+ # ties only). The operator confirmed on 2026-09-03 that the 30-point band stays.
454
+ HEADROOM_BAND="${CLAUDE_MULTIACC_HEADROOM_BAND:-30}"
455
+ case "$HEADROOM_BAND" in ''|*[!0-9]*|??????*) HEADROOM_BAND=30 ;; esac
456
+ [ "$HEADROOM_BAND" -gt 100 ] && HEADROOM_BAND=100
457
+
458
+ # The session gate: a candidate is "healthy enough to run now" when its KNOWN 5h usage is
459
+ # at or under this many points. It runs BEFORE the weekly band, so a nearly-spent session
460
+ # bucket takes an account out of contention however good its weekly headroom is. 100
461
+ # disables the gate (every known-session account clears it).
462
+ SESSION_GATE="${CLAUDE_MULTIACC_SESSION_GATE:-50}"
463
+ case "$SESSION_GATE" in ''|*[!0-9]*|??????*) SESSION_GATE=50 ;; esac
464
+ [ "$SESSION_GATE" -gt 100 ] && SESSION_GATE=100
465
+ # What the selection log prints for the gate: "off" in random mode, where no gate ran —
466
+ # the log must never claim a cut that was not made.
467
+ SESSION_GATE_LOG="$SESSION_GATE"
468
+
469
+ # weekly_percent ONLY. A reading without it is unknown here, exactly as it is unknown to
470
+ # pool-selection.v2 (which never sees max_percent). The old max_percent fallback let a
471
+ # weekly-less file rank — and, once the session gate existed, CLEAR the gate — on a number
472
+ # that may well be the session bucket's own peak (codex review, 2026-09-04).
473
+ rank_weekly_of() { # $1 = acct dir -> comparable weekly use, or fail when unknown
474
+ local w
475
+ if w="$(fresh_field "$1" weekly_percent)"; then
476
+ printf '%s\n' "$w"
477
+ return 0
394
478
  fi
395
- s="$(fresh_field "$1" session_percent)" || s=100
396
- printf '%s\n' $((w * 1000 + s))
479
+ [ "$SEL_DEGRADED" = 1 ] || return 1
480
+ stale_weekly "$1"
481
+ }
482
+
483
+ rank_session_of() { # $1 = acct dir -> fresh session use, or fail when unknown
484
+ # Deliberately fresh-only, with no stale/degraded fallback: an old 5h reading says
485
+ # nothing about a bucket that self-heals every five hours, and an unknown session must
486
+ # not clear the gate.
487
+ fresh_field "$1" session_percent
397
488
  }
398
489
 
399
490
  # The .limited marker's own fields (line 1: reset epoch; line 2: "bucket=…
@@ -621,14 +712,14 @@ sess_transcript() { # $1 acct dir, $2 session id
621
712
  }
622
713
 
623
714
  # Newest still-in-force rejection this account's own sessions recorded.
624
- # Prints "<reset-epoch> <rateLimitType>"; fails when there is none.
715
+ # Prints "<reset-epoch> <rateLimitType> <rejection-ISO>"; fails when there is none.
625
716
  # This runs on EVERY invocation, so it is bounded on purpose: newest session first,
626
717
  # stop at the first in-force rejection, and never read more than QUOTA_SCAN_MAX_FILES
627
718
  # transcripts. Missing an older rejection costs nothing — a limit that is still in force
628
719
  # rejects the very next request too, and that lands in a newer transcript.
629
720
  client_limit_scan() { # $1 acct dir
630
721
  local idx="$1/.sessions-index" memo="$1/.client-scan" id p line r t read_n=0 i last=""
631
- local ln claim ts ck ak ttl
722
+ local ln claim ts ck ak ttl cleared sk
632
723
  local ids=() claims=()
633
724
  [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
634
725
  [ -f "$idx" ] || return 1
@@ -672,12 +763,28 @@ client_limit_scan() { # $1 acct dir
672
763
  | tail -1)"
673
764
  [ -n "$line" ] || continue
674
765
  case "$line" in *'"status":"rejected"'*|*'"status": "rejected"'*) ;; *) continue ;; esac
766
+ # A later successful usage read below the threshold supersedes an older rejection.
767
+ # Without this watermark, clearing its marker achieved nothing: the next launch
768
+ # scanned the same transcript and recreated the same days-long park.
769
+ ts="$(printf '%s' "$line" | LC_ALL=C sed -n \
770
+ 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
771
+ ck="$(iso_key "$ts")"
772
+ cleared=""
773
+ [ -f "$1/.client-limit-cleared" ] \
774
+ && { IFS= read -r cleared < "$1/.client-limit-cleared" 2>/dev/null || cleared=""; }
775
+ if num_ok "$cleared"; then
776
+ sk="$(iso_key "$(iso_of_epoch "$cleared")")"
777
+ if num_ok "$ck" && num_ok "$sk"; then
778
+ [ "$ck" -le "$sk" ] && continue
779
+ elif [ "$(file_mtime "$p")" -le "$cleared" ]; then
780
+ continue
781
+ fi
782
+ fi
675
783
  # A rejection recorded BEFORE this account took the session over belongs to whoever
676
784
  # was running it then, not to us. Undatable => not attributed (fail open).
677
785
  if [ -n "$claim" ]; then
678
- ts="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[^"]*\)".*/\1/p')"
679
786
  [ -n "$ts" ] || continue
680
- ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
787
+ ak="$(iso_key "$claim")"
681
788
  num_ok "$ck" || continue
682
789
  num_ok "$ak" || continue
683
790
  [ "$ck" -lt "$ak" ] && continue
@@ -687,7 +794,7 @@ client_limit_scan() { # $1 acct dir
687
794
  num_ok "$r" || continue
688
795
  [ "$r" -gt "$now" ] || continue
689
796
  t="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"rateLimitType"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p')"
690
- printf '%s %s\n' "$r" "${t:-unknown}"
797
+ printf '%s %s %s\n' "$r" "${t:-unknown}" "${ts:--}"
691
798
  return 0
692
799
  done
693
800
  printf '%s\n' "$now" 2>/dev/null > "$memo.$$" \
@@ -749,8 +856,8 @@ mark_client_auth_dead() { # $1 acct dir
749
856
  fi
750
857
  }
751
858
 
752
- mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
753
- local m="$1/.limited" cur=""
859
+ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type, $4 rejection ISO
860
+ local m="$1/.limited" cur="" marked="$4" mk
754
861
  # Never shorten a marker that already reaches further out (a weekly park must
755
862
  # survive a 5h report), and never rewrite the same one on every invocation.
756
863
  if [ -f "$m" ]; then
@@ -758,9 +865,13 @@ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
758
865
  num_ok "$cur" || cur=0
759
866
  [ "$cur" -ge "$2" ] && return 0
760
867
  fi
868
+ mk="$(iso_key "$marked")"
869
+ if ! num_ok "$mk" || [ "${#mk}" -ne 14 ]; then
870
+ marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
871
+ fi
761
872
  {
762
873
  echo "$2"
763
- echo "bucket=client:$3 percent=100 marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=client-rate-limit"
874
+ echo "bucket=client:$3 percent=100 marked_at=$marked reason=client-rate-limit"
764
875
  } 2>/dev/null > "$1/.limited.$$" \
765
876
  && mv -f "$1/.limited.$$" "$m" 2>/dev/null \
766
877
  || rm -f "$1/.limited.$$" 2>/dev/null || true
@@ -1023,7 +1134,11 @@ for d in "$ACC_ROOT"/acct-*; do
1023
1134
  # `claude-accounts status`, to a concurrent run in another terminal, and to sync.
1024
1135
  # The cost is bounded by the scan's own file budget and its clean-result memo.
1025
1136
  if lim="$(client_limit_scan "$d")"; then
1026
- mark_client_limit "$d" "${lim%% *}" "${lim##* }"
1137
+ lim_reset="${lim%% *}"
1138
+ lim_rest="${lim#* }"
1139
+ lim_type="${lim_rest%% *}"
1140
+ lim_marked="${lim_rest#* }"
1141
+ mark_client_limit "$d" "$lim_reset" "$lim_type" "$lim_marked"
1027
1142
  continue
1028
1143
  fi
1029
1144
  over_threshold "$d" && continue
@@ -1086,58 +1201,91 @@ remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the r
1086
1201
  return 0
1087
1202
  }
1088
1203
 
1089
- # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
1090
- # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
1091
- # Sets PICK_DIR/PICK_SCORE as globalsit must never touch "$@", which holds the
1092
- # user's claude arguments.
1204
+ # Pick an account by the two-cut rule above: the session gate first, then random among
1205
+ # the peers inside the 30-point WEEKLY band, with the account handed out last time
1206
+ # skipped. No score and no tiebreaker the operator asked for a random choice among the
1207
+ # best-weekly accounts (2026-09-03), so session only ever acts through the gate.
1208
+ # Sets PICK_DIR/PICK_BAND_COUNT/PICK_GATE_COUNT as globals — it must never touch "$@",
1209
+ # which holds the user's claude arguments.
1093
1210
  PICK_DIR=""
1094
- PICK_SCORE=""
1211
+ PICK_BAND_COUNT=0
1212
+ PICK_GATE_COUNT=0
1213
+ PICK_STRICT=0
1095
1214
  pick_best() { # args: candidate dirs
1096
- local d avoid best="" bestv=1000000 ties=0 i n
1097
- local cand=() score=()
1215
+ local d w s k sk avoid best="" bestw=101 band ceiling ties=0 i n
1216
+ local cand=() weekly=() known=() gated=() pool=()
1098
1217
  avoid="$(last_pick_id)"
1218
+ PICK_GATE_COUNT=0
1099
1219
  for d in "$@"; do
1100
1220
  cand+=("$d")
1101
- score+=("$(sel_score_of "$d")")
1221
+ w="$(rank_weekly_of "$d")" && k=1 || k=0
1222
+ s="$(rank_session_of "$d")" && sk=1 || sk=0
1223
+ # "Known" takes BOTH readings — pool-selection.v2's quota_known rule. A weekly figure
1224
+ # on its own neither ranks nor clears the gate, and a session figure on its own could
1225
+ # otherwise be the sole gate-clearer and win the all-gated tie over an account whose
1226
+ # truthful weekly reading merely failed the gate. The one exception is a DEGRADED pool
1227
+ # (SEL_DEGRADED=1): nothing is fresh anywhere, the gate has necessarily stepped aside,
1228
+ # and a still-valid stale weekly reading is the only truth there is. (Neither writer
1229
+ # emits one field without the other; this is parity with lib/selector_policy.py.)
1230
+ if [ "$k" = 1 ] && { [ "$sk" = 1 ] || [ "$SEL_DEGRADED" = 1 ]; }; then
1231
+ weekly+=("$w"); known+=(1)
1232
+ else
1233
+ weekly+=(100); known+=(0)
1234
+ fi
1235
+ if [ "$k" = 1 ] && [ "$sk" = 1 ] && [ "$s" -le "$SESSION_GATE" ]; then
1236
+ gated+=(1); PICK_GATE_COUNT=$((PICK_GATE_COUNT + 1))
1237
+ else
1238
+ gated+=(0)
1239
+ fi
1102
1240
  done
1103
1241
  n=${#cand[@]}
1104
1242
  i=0
1105
1243
  while [ "$i" -lt "$n" ]; do
1106
- [ "${score[$i]}" -lt "$bestv" ] && bestv="${score[$i]}"
1244
+ # Nobody clears the gate => everybody does: the gate compares, it never empties the pool.
1245
+ [ "$PICK_GATE_COUNT" -eq 0 ] && gated[$i]=1
1246
+ if [ "${gated[$i]}" = 1 ] && [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -lt "$bestw" ]; then
1247
+ bestw="${weekly[$i]}"
1248
+ fi
1107
1249
  i=$((i + 1))
1108
1250
  done
1109
- # Reservoir-sample among the equally-best, skipping the account just handed out.
1251
+ band="$HEADROOM_BAND"
1252
+ [ "$PICK_STRICT" = 1 ] && band=0
1253
+ ceiling=$((bestw + band)); [ "$ceiling" -gt 100 ] && ceiling=100
1110
1254
  i=0
1111
1255
  while [ "$i" -lt "$n" ]; do
1112
- if [ "${score[$i]}" -eq "$bestv" ] && [ "${cand[$i]##*/}" != "$avoid" ]; then
1256
+ if [ "${gated[$i]}" = 1 ]; then
1257
+ if [ "$bestw" -gt 100 ]; then
1258
+ pool+=("$i") # no weekly reading anywhere: all gated tie
1259
+ elif [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -le "$ceiling" ]; then
1260
+ pool+=("$i")
1261
+ fi
1262
+ fi
1263
+ i=$((i + 1))
1264
+ done
1265
+ PICK_BAND_COUNT=${#pool[@]}
1266
+ # Reservoir-sample the peers, skipping the account just handed out.
1267
+ for i in "${pool[@]}"; do
1268
+ if [ "${cand[$i]##*/}" != "$avoid" ]; then
1113
1269
  ties=$((ties + 1))
1114
1270
  [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
1115
1271
  fi
1116
- i=$((i + 1))
1117
1272
  done
1118
1273
  if [ -z "$best" ]; then
1119
- # The only account at the best score IS the one just used — degraded rotation beats
1120
- # refusing to pick (and in a two-account pool this is the other half of the
1121
- # alternation).
1122
- i=0
1123
- while [ "$i" -lt "$n" ]; do
1124
- if [ "${score[$i]}" -eq "$bestv" ]; then
1125
- ties=$((ties + 1))
1126
- [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
1127
- fi
1128
- i=$((i + 1))
1274
+ for i in "${pool[@]}"; do
1275
+ ties=$((ties + 1))
1276
+ [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
1129
1277
  done
1130
1278
  fi
1131
1279
  PICK_DIR="$best"
1132
- PICK_SCORE="$bestv"
1133
1280
  }
1134
1281
 
1135
1282
  # Telemetry going stale is not a per-run detail, it is a pool-wide outage: with no
1136
- # in-window data ANYWHERE every account scores the identical NEUTRAL value, the tie
1137
- # spans the whole pool, and "pick the account with the most headroom" silently becomes
1138
- # "pick any account at all". That is how a fresh session lands on the one account
1139
- # already at 80% of its weekly limit while `claude-accounts status` still shows a
1140
- # reassuring 2% from eleven days ago. It cost eleven days of blind picks once.
1283
+ # in-window data ANYWHERE every account reads as UNKNOWN no weekly, and nothing that
1284
+ # can clear the session gate — so the tie spans the whole pool and "pick the account with
1285
+ # the most headroom" silently becomes "pick any account at all". That is how a fresh
1286
+ # session lands on the one account already at 80% of its weekly limit while
1287
+ # `claude-accounts status` still shows a reassuring 2% from eleven days ago. It cost
1288
+ # eleven days of blind picks once.
1141
1289
  # Two answers, and the order matters: rank on whatever old readings are still true
1142
1290
  # BEFORE picking, and say out loud which of the two happened.
1143
1291
  blind=1 # 1 = no candidate has in-window telemetry
@@ -1157,10 +1305,10 @@ assess_telemetry() { # args: the dirs actually being chosen between
1157
1305
  stale_weekly "$d" >/dev/null && stale_ok=$((stale_ok + 1))
1158
1306
  done
1159
1307
  # All or nothing. A candidate whose reading has no horizon — a limits.json written
1160
- # before this field existed, or one whose week has already turned — scores neutral
1161
- # 50, and 50 would beat a NEIGHBOUR's true-but-worse 70. Mixing the two makes the
1162
- # degraded ranking actively wrong, so it is only used when every candidate can be
1163
- # compared on the same footing.
1308
+ # before this field existed, or one whose week has already turned — stays UNKNOWN even
1309
+ # in degraded mode, so it would drop out of the band entirely while its neighbours rank
1310
+ # on stale numbers. Mixing the two makes the degraded ranking actively wrong, so it is
1311
+ # only used when every candidate can be compared on the same footing.
1164
1312
  [ "$n" -gt 0 ] && [ "$stale_ok" -eq "$n" ] && degraded=1
1165
1313
  return 0
1166
1314
  }
@@ -1173,6 +1321,9 @@ if [ "${#eligible[@]}" -gt 0 ]; then
1173
1321
  [ "$degraded" = 1 ] && SEL_DEGRADED=1
1174
1322
  if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
1175
1323
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
1324
+ PICK_BAND_COUNT=${#eligible[@]}
1325
+ PICK_GATE_COUNT=${#eligible[@]}
1326
+ SESSION_GATE_LOG=off
1176
1327
  else
1177
1328
  pick_best "${eligible[@]}"
1178
1329
  fi
@@ -1191,7 +1342,9 @@ else
1191
1342
  if [ "${#soft[@]}" -gt 0 ]; then
1192
1343
  assess_telemetry "${soft[@]}"
1193
1344
  [ "$degraded" = 1 ] && SEL_DEGRADED=1
1345
+ PICK_STRICT=1
1194
1346
  pick_best "${soft[@]}"
1347
+ PICK_STRICT=0
1195
1348
  else
1196
1349
  # Every account is exhausted RIGHT NOW: nothing serves, so hand out the one
1197
1350
  # that unblocks first — its rejection window is the shortest.
@@ -1300,9 +1453,14 @@ if [ "$blind" = 1 ]; then
1300
1453
  # DEGRADED still ranks, on old readings that remain true; BLIND cannot rank at all and
1301
1454
  # is a coin flip. Calling both of them "random" would send someone hunting the wrong bug.
1302
1455
  if [ "$degraded" = 1 ]; then
1303
- sel_log "$acct weekly=$(stale_weekly "$pick" || echo '?')% session=?% ranking=DEGRADED telemetry-age=${blind_age}s pwd=$PWD"
1456
+ sel_log "$acct weekly=$(stale_weekly "$pick" || echo '?')% session=?%" \
1457
+ "ranking=DEGRADED telemetry-age=${blind_age}s band=${HEADROOM_BAND}" \
1458
+ "band-count=${PICK_BAND_COUNT} session-gate=${SESSION_GATE_LOG}" \
1459
+ "session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1304
1460
  else
1305
- sel_log "$acct weekly=?% session=?% ranking=BLIND telemetry-age=${blind_age}s pwd=$PWD"
1461
+ sel_log "$acct weekly=?% session=?% ranking=BLIND telemetry-age=${blind_age}s" \
1462
+ "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT}" \
1463
+ "session-gate=${SESSION_GATE_LOG} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1306
1464
  fi
1307
1465
  # Terminal only, at most hourly — a service-spawned `claude -p` must keep its stderr
1308
1466
  # byte-clean, and this is advice, never a failure.
@@ -1324,7 +1482,10 @@ if [ "$blind" = 1 ]; then
1324
1482
  fi
1325
1483
  fi
1326
1484
  else
1327
- sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
1485
+ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')%" \
1486
+ "session=$(fresh_field "$pick" session_percent || echo '?')%" \
1487
+ "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT}" \
1488
+ "session-gate=${SESSION_GATE_LOG} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1328
1489
  fi
1329
1490
 
1330
1491
  export CLAUDE_SHIM_ACTIVE=1