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.
- package/CLAUDE_ACCS_TASK.md +10 -5
- package/README.md +47 -19
- package/bin/claude +225 -64
- package/bin/claude-accounts +34 -9
- package/bin/codex +202 -41
- package/bin/codex-accounts +4 -1
- package/docs/ACCOUNT_OPERATIONS.md +9 -4
- 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/selector_policy.py +42 -7
- package/lib/selector_primitives.py +25 -16
- package/package.json +1 -1
- package/tests/run-tests.sh +412 -41
- 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 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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 (
|
|
289
|
-
|
|
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
|
-
#
|
|
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,
|
|
365
|
-
#
|
|
366
|
-
#
|
|
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
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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=$
|
|
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
|
-
|
|
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
|
|
1090
|
-
#
|
|
1091
|
-
#
|
|
1092
|
-
#
|
|
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
|
-
|
|
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=""
|
|
1097
|
-
local cand=()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [ "${
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
1137
|
-
# spans the whole pool
|
|
1138
|
-
# "pick any account at all". That is how a fresh
|
|
1139
|
-
# already at 80% of its weekly limit while
|
|
1140
|
-
# reassuring 2% from eleven days ago. It cost
|
|
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 —
|
|
1161
|
-
#
|
|
1162
|
-
# degraded ranking actively wrong, so it is
|
|
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=?%
|
|
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
|
|
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 '?')%
|
|
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
|