claude-multiacc 2.0.20 → 2.0.22

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 918 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
- form a **30-percentage-point weekly-headroom band** around the account with the most
76
- remaining headroom (see below). The pool **rotates away from the account it just handed
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
77
83
  out** and samples the other accounts in that band at random. This prevents one nominally
78
84
  emptiest account from taking every launch while near-peers idle, while accounts more than
79
- 30 points behind remain protected. `CLAUDE_MULTIACC_HEADROOM_BAND=0` restores strict
80
- best-headroom selection; `CLAUDE_SHIM_SELECT=random` uses the whole eligible pool.
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).
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,13 +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) to form the 30-point band. Accounts
92
- inside that band are deliberately treated as peers and spread randomly; with a zero-width
93
- band the self-healing session bucket is the tiebreaker. An account at 10% weekly remains
94
- protected from one at 70% weekly because the latter is outside the band. (`score = weekly%
95
- × 1000 + session%` in strict mode, lowest wins.) **Exclusion** still fires on *any* bucket
96
- 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
97
123
  really does block right now — but that marker expires when the session resets, not days
98
124
  later.
99
125
 
@@ -151,6 +177,22 @@ buckets keep being tracked and the ≥90% rule keeps working. Unparseable entrie
151
177
  skipped, an all-legacy payload falls back to `five_hour`/`seven_day`, and a payload the
152
178
  code cannot read at all degrades that one account (fail open), never the run.
153
179
 
180
+ **A 0% bucket with no reset window is no data, not an empty account.** Real usage always
181
+ carries the window it resets in, so `percent: 0` with a null `resets_at` is the endpoint
182
+ declining to answer — and taking it at face value put two weekly-exhausted accounts at the
183
+ top of the ranking for 31 of ~60 picks on 2026-09-04. Only *informative* buckets (percent
184
+ above 0, or a parseable reset time) are aggregated, **per signal**: `weekly_percent` is the
185
+ peak over informative *weekly* buckets, `session_percent` over informative *session* ones,
186
+ and each is simply omitted when its own group has nothing informative in it — a weekly
187
+ figure is never borrowed from the session bucket. An account with no informative bucket at
188
+ all gets a `limits.json` with none of `max_percent`/`weekly_percent`/`session_percent`,
189
+ marked `"no_data": true`. Either way the account ranks **unknown** — selection needs BOTH
190
+ readings, so a half-filled document is as unknown as an empty one: never the band leader,
191
+ never past the session gate, never able to clear a marker, and a pool made only of them
192
+ logs `ranking=BLIND` instead of quietly tying every account. Accounts that really are idle
193
+ are untouched: 0% with a real reset window is informative and still ranks as empty, and one
194
+ blank per-model bucket beside real ones leaves the real ones in charge.
195
+
154
196
  Telemetry failures never block work: no fresh data ⇒ account treated as available. The
155
197
  endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
156
198
  and backs off on **every** non-2xx, not just a 429 — honoring `Retry-After`, and parking
@@ -248,8 +290,20 @@ wrongly parked account is worse than a missed limit:
248
290
 
249
291
  The resulting marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
250
292
  pass while its window is still open — first-hand evidence beats a usage payload that
251
- disagrees. Cost is bounded: the three newest sessions per account, a tail read rather than
252
- a whole transcript, and a clean result memoized for 20s
293
+ disagrees. A **weekly** rejection is absolute: nothing neither a shim invocation nor the
294
+ scheduled `limits` pass — retires it before its reset epoch, since a weekly bucket cannot
295
+ fall from a server-proven 100% back under the threshold inside that window. Claude names
296
+ that bucket from the API's own type (`bucket=client:seven_day`); codex has no such name in
297
+ its rollout files, only `primary`/`secondary` keys whose meaning moves, so it labels the
298
+ marker from the window itself — `window_minutes >= 1440` ⇒ `client:7d`, a shorter window ⇒
299
+ `client:5h`, and a window with no `window_minutes` at all stays `client:<key>` and remains
300
+ clearable (fail open, bounded by its own reset). A five-hour marker can
301
+ still be retired by telemetry fetched well after it
302
+ (`CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY`, 300s), which is what keeps a fresh account
303
+ from being stranded by an old session limit — before 2026-09-04 that carve-out also let
304
+ fake-zero telemetry retire weekly markers, re-admitting an exhausted account six times a
305
+ day. Cost is bounded: the three newest sessions per account, a tail read rather than a whole
306
+ transcript, and a clean result memoized for 20s
253
307
  (`CLAUDE_MULTIACC_CLIENT_SCAN_TTL`), which on a three-account pool of large live
254
308
  transcripts is ~2 ms per run amortized.
255
309
 
@@ -286,8 +340,9 @@ above about the claude pool holds for the codex pool with these translations:
286
340
  | Anthropic OAuth usage and Fable buckets | Codex usage endpoint and per-model buckets |
287
341
  | `CLAUDE_*` pool controls | equivalent `CODEX_*` controls |
288
342
 
289
- Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
290
- exclusion, equal scores rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
343
+ Same selection rule (session gate first `CODEX_MULTIACC_SESSION_GATE`, default 50 —
344
+ then the 30-point weekly-headroom band with random spread, ≥90% any-bucket
345
+ exclusion, peers rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
291
346
  credential/policy scoping and soft expiry), same fail-open guarantees, same sync
292
347
  safety guards. The `codex` shim engages the buffered auto-retry only for
293
348
  `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 within the 30-point headroom band > 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.
@@ -274,7 +276,7 @@ scoped_over_tok() { # $1 = acct dir
274
276
  CLIENT_RECOVERY_FETCHED=0
275
277
  CLIENT_RECOVERY_PEAK=100
276
278
  client_marker_recovered() { # $1 account dir, $2 marker
277
- local fetched peak delay marked mk cutoff ck newer=0
279
+ local fetched peak delay marked mk cutoff ck newer=0 bucket
278
280
  # A client rejection is stronger than an immediately-following usage read: that
279
281
  # endpoint can lag or serve a cached bucket. It is not stronger forever. Once a
280
282
  # successful, below-threshold reading was fetched at least five minutes after the
@@ -282,7 +284,28 @@ client_marker_recovered() { # $1 account dir, $2 marker
282
284
  # old/shared transcript was attributed incorrectly). Keeping such a marker until its
283
285
  # claimed reset stranded five 0%-usage accounts in the live pool on 2026-09-03.
284
286
  if grep -q 'reason=client-rate-limit' "$2" 2>/dev/null; then
287
+ # ...but a WEEKLY rejection is stronger than any telemetry until its own reset.
288
+ # Line 2 carries bucket=client:<type> (five_hour, seven_day, seven_day_opus, …).
289
+ # A seven-day window only refills on its multi-day reset, so it cannot have fallen
290
+ # from the server-proven 100% that wrote this marker to under the threshold while
291
+ # the window is still open: a reading that says otherwise is wrong by construction.
292
+ # 2026-09-04: acct-13/acct-14 were served fake-zero telemetry (every bucket
293
+ # percent 0, resets_at null), which read as 0% here and deleted their truthful
294
+ # client:seven_day markers ~6x/day each; an interactive session was handed both
295
+ # accounts in a row and was rejected by each with "You've hit your weekly limit".
296
+ # five_hour/session markers keep clearing exactly as before — that window self-heals
297
+ # within hours, which is why #22 (2026-09-03) had to clear it.
298
+ bucket="$(sed -n 2p "$2" 2>/dev/null)"
299
+ case "$bucket" in
300
+ *bucket=*) bucket="${bucket#*bucket=}"; bucket="${bucket%% *}" ;;
301
+ *) bucket="" ;;
302
+ esac
303
+ case "$(sel_lc "$bucket")" in *seven_day*|*7d*|*weekly*) return 1 ;; esac
285
304
  fetched="$(telem_fetched_at "$1" 2>/dev/null || echo 0)"
305
+ # Defaulting peak to 100 when the reading is missing/stale is the second half of the
306
+ # same rule: an UNINFORMATIVE reading must never clear a marker. It is what keeps a
307
+ # no_data account (2026-09-04: all-zero buckets with no reset windows write no
308
+ # max_percent at all) from unparking itself.
286
309
  peak="$(cutoff_field "$1" max_percent 2>/dev/null || echo 100)"
287
310
  delay="${CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY:-300}"
288
311
  num_ok "$delay" || delay=300
@@ -409,12 +432,39 @@ stale_weekly() { # $1 = acct dir
409
432
  limits_field "$1" weekly_percent
410
433
  }
411
434
 
412
- # TRUE when this account contributes nothing to ranking: no in-window telemetry at all.
413
- # When every candidate is blind, every score is the same neutral constant, pick_best
414
- # sees one enormous tie, and selection quietly becomes uniform random — the failure
415
- # this whole file exists to prevent.
435
+ # TRUE when this account contributes nothing to ranking: no in-window telemetry, or an
436
+ # in-window document that is not a COMPLETE reading. When every candidate is blind,
437
+ # no candidate has a known reading, pick_best sees one enormous tie, and selection
438
+ # quietly becomes uniform random — the failure this whole file exists to prevent.
439
+ # A fresh timestamp is not the same thing as a usable reading: since 2026-09-04 the
440
+ # writer refuses to invent percentages from all-zero buckets with no reset window and
441
+ # writes `no_data` instead, so such a document is current, well-formed, and ranks
442
+ # nothing. Judging blindness on fetched_at alone called a pool of those "fresh" while
443
+ # every account read as unknown and selection was in fact a coin flip.
444
+ # BOTH fields are required, exactly as pick_best's "known" rule requires them (and
445
+ # pool-selection.v2's quota_known): the same 2026-09-04 writer change also emits
446
+ # one-signal documents per signal (weekly-only when no session bucket is informative,
447
+ # and the converse), and an EITHER test called a pool of those sighted while pick_best
448
+ # read every one of them as unknown and tied the whole pool — the coin flip again, this
449
+ # time with no ranking=BLIND line and a status saying "fresh". The degraded path takes
450
+ # one more guard for the same reason (assess_telemetry): a reading counts toward
451
+ # DEGRADED only when it is genuinely OUT of the ranking window — degraded exists for an
452
+ # outage, and a FRESH weekly-only document must not smuggle the very reading the
453
+ # quota_known rule just refused back in as "old truth" (codex review, 2026-09-04).
454
+ # A no_data document (no weekly_percent, no horizon) stays un-degradable either way.
455
+ # Asked through limits_field, the SAME parser rank_weekly_of/rank_session_of use, so
456
+ # this can never disagree with them about what "known" means. Both calls only ever run
457
+ # for a blind-looking account (assess_telemetry stops at the first ranking one).
458
+ # TELEM_FRESH_UNUSABLE is set as a side effect for the operator warning: the pool is
459
+ # then blind with CURRENT data, which is a different fault (and a different fix) from
460
+ # telemetry that merely went stale. Set here because the window test is already paid
461
+ # for; this file is deliberately fork-frugal (see sessions_owned).
462
+ TELEM_FRESH_UNUSABLE=0
416
463
  telem_blind() { # $1 = acct dir
417
- ! within_window "$1" "$STALE_AFTER"
464
+ within_window "$1" "$STALE_AFTER" || return 0
465
+ limits_field "$1" weekly_percent >/dev/null 2>&1 || { TELEM_FRESH_UNUSABLE=1; return 0; }
466
+ limits_field "$1" session_percent >/dev/null 2>&1 || { TELEM_FRESH_UNUSABLE=1; return 0; }
467
+ return 1
418
468
  }
419
469
 
420
470
  # Human age for the warning line: seconds -> "3h" / "11d". Never fails.
@@ -425,36 +475,52 @@ age_human() { # $1 = seconds
425
475
  else printf '%dm\n' $((s / 60)); fi
426
476
  }
427
477
 
428
- # RANKING score lower is better (more headroom). Weekly headroom dominates: a weekly
429
- # bucket only refills on the account's fixed weekly reset (days away), while the 5h
430
- # session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
431
- # confirm this reset asymmetry an account whose only near-full bucket is the cheap
432
- # session one must NOT rank behind one burning durable weekly headroom.)
433
- # score = weekly%*1000 + session% weekly,session in [0,100]
434
- # Stale/unreadable telemetry ranks LAST (weekly 100, session 100), never "free"
435
- # EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale weekly reading is
436
- # used instead. Unknown data must never beat a truthful usage reading; when every
437
- # candidate is unknown, the equal worst-case scores still preserve fail-open selection.
478
+ # RANKING — two cuts, no score, no tiebreaker (operator's ask, 2026-09-03: "among
479
+ # accounts where high session limits it must choose randomly from ones where highest
480
+ # weekly limits"). Session used to be a mild tiebreaker inside the score, which meant an
481
+ # account whose 5h bucket was nearly spent about to be rejected whatever its weekly
482
+ # headroom ranked exactly like a fresh one. It is now the FIRST cut instead:
483
+ # 1. SESSION GATE — candidates with a KNOWN session reading <= SESSION_GATE are the
484
+ # only ones ranked further. Unknown session never clears it. If NOBODY clears it the
485
+ # gate steps aside and everyone is ranked: the gate compares, it never empties the
486
+ # pool.
487
+ # 2. WEEKLY BAND — among those, the lowest KNOWN weekly usage leads and everything
488
+ # within HEADROOM_BAND points of it is a peer; peers are sampled at random with the
489
+ # previous pick skipped. Weekly still dominates because a weekly bucket only refills
490
+ # on the account's fixed weekly reset (days away) while the 5h session bucket
491
+ # self-heals — the gate is about "will this run be rejected now", not about headroom.
492
+ # Stale/unreadable telemetry ranks LAST (unknown weekly, and unknown never clears the
493
+ # gate), never "free" — EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale
494
+ # weekly reading is used instead. Unknown data must never beat a truthful usage reading;
495
+ # when every candidate is unknown they tie, which preserves fail-open selection.
438
496
  SEL_DEGRADED=0
439
- sel_score_of() { # $1 = acct dir
440
- local w s
441
- if ! w="$(fresh_field "$1" weekly_percent)" && ! w="$(fresh_field "$1" max_percent)"; then
442
- if [ "$SEL_DEGRADED" = 1 ]; then w="$(stale_weekly "$1")" || w=100; else w=100; fi
443
- fi
444
- s="$(fresh_field "$1" session_percent)" || s=100
445
- printf '%s\n' $((w * 1000 + s))
446
- }
447
497
 
448
- # The direct shims use the same 30-point headroom band as pool-selection.v2. Strict
449
- # best-headroom selection burned one account to its limit while equally healthy
450
- # neighbours idled. A caller can set 0 to restore strict ranking.
498
+ # The direct shims use the same 30-point headroom band as pool-selection.v2, on WEEKLY
499
+ # usage. Strict best-headroom selection burned one account to its limit while equally
500
+ # healthy neighbours idled. A caller can set 0 to restore strict ranking (exact weekly
501
+ # ties only). The operator confirmed on 2026-09-03 that the 30-point band stays.
451
502
  HEADROOM_BAND="${CLAUDE_MULTIACC_HEADROOM_BAND:-30}"
452
503
  case "$HEADROOM_BAND" in ''|*[!0-9]*|??????*) HEADROOM_BAND=30 ;; esac
453
504
  [ "$HEADROOM_BAND" -gt 100 ] && HEADROOM_BAND=100
454
505
 
506
+ # The session gate: a candidate is "healthy enough to run now" when its KNOWN 5h usage is
507
+ # at or under this many points. It runs BEFORE the weekly band, so a nearly-spent session
508
+ # bucket takes an account out of contention however good its weekly headroom is. 100
509
+ # disables the gate (every known-session account clears it).
510
+ SESSION_GATE="${CLAUDE_MULTIACC_SESSION_GATE:-50}"
511
+ case "$SESSION_GATE" in ''|*[!0-9]*|??????*) SESSION_GATE=50 ;; esac
512
+ [ "$SESSION_GATE" -gt 100 ] && SESSION_GATE=100
513
+ # What the selection log prints for the gate: "off" in random mode, where no gate ran —
514
+ # the log must never claim a cut that was not made.
515
+ SESSION_GATE_LOG="$SESSION_GATE"
516
+
517
+ # weekly_percent ONLY. A reading without it is unknown here, exactly as it is unknown to
518
+ # pool-selection.v2 (which never sees max_percent). The old max_percent fallback let a
519
+ # weekly-less file rank — and, once the session gate existed, CLEAR the gate — on a number
520
+ # that may well be the session bucket's own peak (codex review, 2026-09-04).
455
521
  rank_weekly_of() { # $1 = acct dir -> comparable weekly use, or fail when unknown
456
522
  local w
457
- if w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)"; then
523
+ if w="$(fresh_field "$1" weekly_percent)"; then
458
524
  printf '%s\n' "$w"
459
525
  return 0
460
526
  fi
@@ -462,6 +528,13 @@ rank_weekly_of() { # $1 = acct dir -> comparable weekly use, or fail when unknow
462
528
  stale_weekly "$1"
463
529
  }
464
530
 
531
+ rank_session_of() { # $1 = acct dir -> fresh session use, or fail when unknown
532
+ # Deliberately fresh-only, with no stale/degraded fallback: an old 5h reading says
533
+ # nothing about a bucket that self-heals every five hours, and an unknown session must
534
+ # not clear the gate.
535
+ fresh_field "$1" session_percent
536
+ }
537
+
465
538
  # The .limited marker's own fields (line 1: reset epoch; line 2: "bucket=…
466
539
  # percent=… … reason=…"). All tolerant: an unreadable or bare marker answers
467
540
  # "unknown", never an error — these feed the all-limited fallback only.
@@ -1176,45 +1249,71 @@ remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the r
1176
1249
  return 0
1177
1250
  }
1178
1251
 
1179
- # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
1180
- # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
1181
- # Sets PICK_DIR/PICK_SCORE as globalsit must never touch "$@", which holds the
1182
- # user's claude arguments.
1252
+ # Pick an account by the two-cut rule above: the session gate first, then random among
1253
+ # the peers inside the 30-point WEEKLY band, with the account handed out last time
1254
+ # skipped. No score and no tiebreaker the operator asked for a random choice among the
1255
+ # best-weekly accounts (2026-09-03), so session only ever acts through the gate.
1256
+ # Sets PICK_DIR/PICK_BAND_COUNT/PICK_GATE_COUNT as globals — it must never touch "$@",
1257
+ # which holds the user's claude arguments.
1183
1258
  PICK_DIR=""
1184
- PICK_SCORE=""
1185
1259
  PICK_BAND_COUNT=0
1260
+ PICK_GATE_COUNT=0
1186
1261
  PICK_STRICT=0
1187
1262
  pick_best() { # args: candidate dirs
1188
- local d w avoid best="" bestv=1000000 bestw=101 ceiling=100 ties=0 i n match
1189
- local cand=() score=() weekly=() known=() pool=()
1263
+ local d w s k sk avoid best="" bestw=101 band ceiling ties=0 i n
1264
+ local cand=() weekly=() known=() gated=() pool=()
1190
1265
  avoid="$(last_pick_id)"
1266
+ PICK_GATE_COUNT=0
1191
1267
  for d in "$@"; do
1192
1268
  cand+=("$d")
1193
- score+=("$(sel_score_of "$d")")
1194
- if w="$(rank_weekly_of "$d")"; then weekly+=("$w"); known+=(1)
1195
- else weekly+=(100); known+=(0); fi
1269
+ w="$(rank_weekly_of "$d")" && k=1 || k=0
1270
+ s="$(rank_session_of "$d")" && sk=1 || sk=0
1271
+ # "Known" takes BOTH readings — pool-selection.v2's quota_known rule. A weekly figure
1272
+ # on its own neither ranks nor clears the gate, and a session figure on its own could
1273
+ # otherwise be the sole gate-clearer and win the all-gated tie over an account whose
1274
+ # truthful weekly reading merely failed the gate. The one exception is a DEGRADED pool
1275
+ # (SEL_DEGRADED=1): nothing is fresh anywhere, the gate has necessarily stepped aside,
1276
+ # and a still-valid stale weekly reading is the only truth there is. (The writers DO
1277
+ # emit one-signal documents — per-signal informative aggregation, 2026-09-04 — and
1278
+ # such a document is exactly as unknown here as an empty one; parity with
1279
+ # lib/selector_policy.py's quota_known.)
1280
+ if [ "$k" = 1 ] && { [ "$sk" = 1 ] || [ "$SEL_DEGRADED" = 1 ]; }; then
1281
+ weekly+=("$w"); known+=(1)
1282
+ else
1283
+ weekly+=(100); known+=(0)
1284
+ fi
1285
+ if [ "$k" = 1 ] && [ "$sk" = 1 ] && [ "$s" -le "$SESSION_GATE" ]; then
1286
+ gated+=(1); PICK_GATE_COUNT=$((PICK_GATE_COUNT + 1))
1287
+ else
1288
+ gated+=(0)
1289
+ fi
1196
1290
  done
1197
1291
  n=${#cand[@]}
1198
1292
  i=0
1199
1293
  while [ "$i" -lt "$n" ]; do
1200
- [ "${score[$i]}" -lt "$bestv" ] && bestv="${score[$i]}"
1201
- [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -lt "$bestw" ] && bestw="${weekly[$i]}"
1294
+ # Nobody clears the gate => everybody does: the gate compares, it never empties the pool.
1295
+ [ "$PICK_GATE_COUNT" -eq 0 ] && gated[$i]=1
1296
+ if [ "${gated[$i]}" = 1 ] && [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -lt "$bestw" ]; then
1297
+ bestw="${weekly[$i]}"
1298
+ fi
1202
1299
  i=$((i + 1))
1203
1300
  done
1204
- ceiling=$((bestw + HEADROOM_BAND)); [ "$ceiling" -gt 100 ] && ceiling=100
1301
+ band="$HEADROOM_BAND"
1302
+ [ "$PICK_STRICT" = 1 ] && band=0
1303
+ ceiling=$((bestw + band)); [ "$ceiling" -gt 100 ] && ceiling=100
1205
1304
  i=0
1206
1305
  while [ "$i" -lt "$n" ]; do
1207
- match=0
1208
- if [ "$PICK_STRICT" = 0 ] && [ "$HEADROOM_BAND" -gt 0 ] && [ "$bestw" -le 100 ]; then
1209
- [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -le "$ceiling" ] && match=1
1210
- else
1211
- [ "${score[$i]}" -eq "$bestv" ] && match=1
1306
+ if [ "${gated[$i]}" = 1 ]; then
1307
+ if [ "$bestw" -gt 100 ]; then
1308
+ pool+=("$i") # no weekly reading anywhere: all gated tie
1309
+ elif [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -le "$ceiling" ]; then
1310
+ pool+=("$i")
1311
+ fi
1212
1312
  fi
1213
- [ "$match" = 1 ] && pool+=("$i")
1214
1313
  i=$((i + 1))
1215
1314
  done
1216
1315
  PICK_BAND_COUNT=${#pool[@]}
1217
- # Reservoir-sample inside the band, skipping the account just handed out.
1316
+ # Reservoir-sample the peers, skipping the account just handed out.
1218
1317
  for i in "${pool[@]}"; do
1219
1318
  if [ "${cand[$i]##*/}" != "$avoid" ]; then
1220
1319
  ties=$((ties + 1))
@@ -1228,15 +1327,15 @@ pick_best() { # args: candidate dirs
1228
1327
  done
1229
1328
  fi
1230
1329
  PICK_DIR="$best"
1231
- PICK_SCORE="$(sel_score_of "$best")"
1232
1330
  }
1233
1331
 
1234
1332
  # Telemetry going stale is not a per-run detail, it is a pool-wide outage: with no
1235
- # in-window data ANYWHERE every account scores the identical NEUTRAL value, the tie
1236
- # spans the whole pool, and "pick the account with the most headroom" silently becomes
1237
- # "pick any account at all". That is how a fresh session lands on the one account
1238
- # already at 80% of its weekly limit while `claude-accounts status` still shows a
1239
- # reassuring 2% from eleven days ago. It cost eleven days of blind picks once.
1333
+ # in-window data ANYWHERE every account reads as UNKNOWN no weekly, and nothing that
1334
+ # can clear the session gate — so the tie spans the whole pool and "pick the account with
1335
+ # the most headroom" silently becomes "pick any account at all". That is how a fresh
1336
+ # session lands on the one account already at 80% of its weekly limit while
1337
+ # `claude-accounts status` still shows a reassuring 2% from eleven days ago. It cost
1338
+ # eleven days of blind picks once.
1240
1339
  # Two answers, and the order matters: rank on whatever old readings are still true
1241
1340
  # BEFORE picking, and say out loud which of the two happened.
1242
1341
  blind=1 # 1 = no candidate has in-window telemetry
@@ -1245,6 +1344,9 @@ degraded=0 # 1 = blind, but every candidate had a stale reading still wor
1245
1344
  assess_telemetry() { # args: the dirs actually being chosen between
1246
1345
  local d f n=0 stale_ok=0
1247
1346
  blind=1; blind_age=0; degraded=0
1347
+ # Re-armed per assessment: the fallback path assesses a second candidate set, and a
1348
+ # verdict from the first one must not colour the warning printed for the second.
1349
+ TELEM_FRESH_UNUSABLE=0
1248
1350
  for d in "$@"; do
1249
1351
  n=$((n + 1))
1250
1352
  if ! telem_blind "$d"; then blind=0; return 0; fi
@@ -1253,13 +1355,21 @@ assess_telemetry() { # args: the dirs actually being chosen between
1253
1355
  # never fetched at all must not make the pool look older than it is.
1254
1356
  [ "$f" -gt 0 ] && { [ "$blind_age" -eq 0 ] || [ $((now - f)) -lt "$blind_age" ]; } \
1255
1357
  && blind_age=$((now - f))
1256
- stale_weekly "$d" >/dev/null && stale_ok=$((stale_ok + 1))
1358
+ # Only a reading that is genuinely OUT of the ranking window may count toward
1359
+ # DEGRADED: degraded exists for an outage ("nothing fresh anywhere, rank on old
1360
+ # truths"), not for a fresh document that simply lacks a signal. A fresh
1361
+ # weekly-only file already read as blind above (one-signal is unknown, 2026-09-04);
1362
+ # letting its still-in-window weekly enable degraded would rank the pool on the
1363
+ # very reading the two-cut rule just refused (codex review, 2026-09-04).
1364
+ if ! within_window "$d" "$STALE_AFTER"; then
1365
+ stale_weekly "$d" >/dev/null && stale_ok=$((stale_ok + 1))
1366
+ fi
1257
1367
  done
1258
1368
  # All or nothing. A candidate whose reading has no horizon — a limits.json written
1259
- # before this field existed, or one whose week has already turned — scores neutral
1260
- # 50, and 50 would beat a NEIGHBOUR's true-but-worse 70. Mixing the two makes the
1261
- # degraded ranking actively wrong, so it is only used when every candidate can be
1262
- # compared on the same footing.
1369
+ # before this field existed, or one whose week has already turned — stays UNKNOWN even
1370
+ # in degraded mode, so it would drop out of the band entirely while its neighbours rank
1371
+ # on stale numbers. Mixing the two makes the degraded ranking actively wrong, so it is
1372
+ # only used when every candidate can be compared on the same footing.
1263
1373
  [ "$n" -gt 0 ] && [ "$stale_ok" -eq "$n" ] && degraded=1
1264
1374
  return 0
1265
1375
  }
@@ -1273,6 +1383,8 @@ if [ "${#eligible[@]}" -gt 0 ]; then
1273
1383
  if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
1274
1384
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
1275
1385
  PICK_BAND_COUNT=${#eligible[@]}
1386
+ PICK_GATE_COUNT=${#eligible[@]}
1387
+ SESSION_GATE_LOG=off
1276
1388
  else
1277
1389
  pick_best "${eligible[@]}"
1278
1390
  fi
@@ -1404,10 +1516,12 @@ if [ "$blind" = 1 ]; then
1404
1516
  if [ "$degraded" = 1 ]; then
1405
1517
  sel_log "$acct weekly=$(stale_weekly "$pick" || echo '?')% session=?%" \
1406
1518
  "ranking=DEGRADED telemetry-age=${blind_age}s band=${HEADROOM_BAND}" \
1407
- "band-count=${PICK_BAND_COUNT} pwd=$PWD"
1519
+ "band-count=${PICK_BAND_COUNT} session-gate=${SESSION_GATE_LOG}" \
1520
+ "session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1408
1521
  else
1409
1522
  sel_log "$acct weekly=?% session=?% ranking=BLIND telemetry-age=${blind_age}s" \
1410
- "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} pwd=$PWD"
1523
+ "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT}" \
1524
+ "session-gate=${SESSION_GATE_LOG} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1411
1525
  fi
1412
1526
  # Terminal only, at most hourly — a service-spawned `claude -p` must keep its stderr
1413
1527
  # byte-clean, and this is advice, never a failure.
@@ -1417,9 +1531,21 @@ if [ "$blind" = 1 ]; then
1417
1531
  [ -f "$n" ] && last="$(file_mtime "$n")"
1418
1532
  if [ $((now - last)) -gt 3600 ]; then
1419
1533
  : 2>/dev/null > "$n" || true
1534
+ # Three faults, three fixes, and telling them apart is the whole point of this
1535
+ # line. Telemetry that went STALE is fixed by fetching again (and, if that keeps
1536
+ # failing, by an OAuth login). Telemetry that arrived CURRENT and said nothing
1537
+ # rankable — a 2026-09-04 no_data document, or a one-signal one — is not stale at
1538
+ # all: refetching returns the same emptiness and a re-login fixes nothing, so
1539
+ # advising one sends the operator down the wrong path (E4, 2026-09-04). The state
1540
+ # is already known here: telem_blind sets TELEM_FRESH_UNUSABLE while testing the
1541
+ # window it had to test anyway.
1542
+ # (degraded && FRESH_UNUSABLE cannot happen: a fresh-but-unusable candidate is
1543
+ # not counted by assess_telemetry, and degraded is all-or-nothing.)
1420
1544
  if [ "$degraded" = 1 ]; then
1421
1545
  printf 'claude-multiacc: usage telemetry is %s old — ranking on the last readings that are still valid, not on current usage. Fix: claude-accounts limits --force, then claude-accounts status\n' \
1422
1546
  "$(age_human "$blind_age")" >&2
1547
+ elif [ "$TELEM_FRESH_UNUSABLE" = 1 ]; then
1548
+ printf 'claude-multiacc: usage telemetry is CURRENT but carries no usable reading for any account (the endpoint answered with no percentages, or only half of them) — selection is running blind (random, not by headroom). Refetching and re-logging in will not change it: claude-accounts status\n' >&2
1423
1549
  elif [ "$blind_age" -gt 0 ]; then
1424
1550
  printf 'claude-multiacc: usage telemetry is %s old for EVERY account and too old to mean anything — selection is running blind (random, not by headroom). Fix: claude-accounts limits --force, then claude-accounts status\n' \
1425
1551
  "$(age_human "$blind_age")" >&2
@@ -1431,7 +1557,8 @@ if [ "$blind" = 1 ]; then
1431
1557
  else
1432
1558
  sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')%" \
1433
1559
  "session=$(fresh_field "$pick" session_percent || echo '?')%" \
1434
- "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} pwd=$PWD"
1560
+ "band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT}" \
1561
+ "session-gate=${SESSION_GATE_LOG} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
1435
1562
  fi
1436
1563
 
1437
1564
  export CLAUDE_SHIM_ACTIVE=1