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.
- package/CLAUDE_ACCS_TASK.md +10 -5
- package/README.md +75 -20
- package/bin/claude +190 -63
- package/bin/claude-accounts +202 -34
- package/bin/codex +258 -46
- package/bin/codex-accounts +206 -22
- package/docs/ACCOUNT_OPERATIONS.md +31 -6
- package/docs/TRACK_PROMPT.md +76 -0
- package/docs/UNIFIED_SELECTOR.md +40 -7
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/report.py +38 -2
- package/lib/selector_policy.py +42 -7
- package/lib/selector_primitives.py +25 -16
- package/package.json +1 -1
- package/tests/run-tests.sh +1517 -37
- package/tests/test_selector.py +104 -2
package/CLAUDE_ACCS_TASK.md
CHANGED
|
@@ -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
|
|
132
|
-
|
|
133
|
-
`
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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. `
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
252
|
-
|
|
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 (
|
|
290
|
-
|
|
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
|
-
#
|
|
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
|
|
413
|
-
#
|
|
414
|
-
# sees one enormous tie, and selection
|
|
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
|
-
|
|
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
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
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
|
|
449
|
-
# best-headroom selection burned one account to its limit while equally
|
|
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)"
|
|
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
|
|
1180
|
-
#
|
|
1181
|
-
#
|
|
1182
|
-
#
|
|
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=""
|
|
1189
|
-
local cand=()
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
1201
|
-
[ "$
|
|
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
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
|
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
|
|
1236
|
-
# spans the whole pool
|
|
1237
|
-
# "pick any account at all". That is how a fresh
|
|
1238
|
-
# already at 80% of its weekly limit while
|
|
1239
|
-
# reassuring 2% from eleven days ago. It cost
|
|
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
|
-
|
|
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 —
|
|
1260
|
-
#
|
|
1261
|
-
# degraded ranking actively wrong, so it is
|
|
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}
|
|
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}
|
|
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}
|
|
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
|