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/bin/claude-accounts
CHANGED
|
@@ -379,7 +379,14 @@ for a in doc.get('accounts', []):
|
|
|
379
379
|
# entirely and picks at random, so the reading below is decoration.
|
|
380
380
|
stale = age is None or age > STALE_AFTER
|
|
381
381
|
flag = ' << STALE — NOT USED FOR RANKING' if stale else ''
|
|
382
|
-
|
|
382
|
+
# A no_data document (all-zero buckets with no reset windows, 2026-09-04)
|
|
383
|
+
# carries no percent fields at all. Printing "max None%" would read like a
|
|
384
|
+
# healthy account sitting at zero — the exact misreading that handed 31 of
|
|
385
|
+
# ~60 picks to two exhausted accounts — so name the state instead.
|
|
386
|
+
peak = lim.get('max_percent')
|
|
387
|
+
usable = isinstance(peak, (int, float)) and not isinstance(peak, bool)
|
|
388
|
+
head = f'max {peak}%' if usable else 'NO USABLE TELEMETRY — ranks as unknown'
|
|
389
|
+
print(f" limits : {' '.join(parts) or '(none)'} [{shown}, {head}]{flag}")
|
|
383
390
|
err = lim.get('last_error')
|
|
384
391
|
if isinstance(err, str) and err:
|
|
385
392
|
when = lim.get('last_error_at')
|
|
@@ -428,14 +435,46 @@ if verdict == 'unknown':
|
|
|
428
435
|
print(f"RANKING STATE UNKNOWN: could not compute the pool-wide telemetry verdict "
|
|
429
436
|
f"({verdict_err}). Check the per-account ages above by hand.")
|
|
430
437
|
elif verdict == 'blind':
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
438
|
+
# Two ways to be blind, and they take OPPOSITE advice. STALE: the fetches stopped,
|
|
439
|
+
# so fetch again and, if that keeps failing, log in — the eleven-day 2026-08 outage.
|
|
440
|
+
# CURRENT-BUT-UNUSABLE: the endpoint answered inside the window and said nothing
|
|
441
|
+
# rankable (a no_data document — all-zero buckets with no reset window, 2026-09-04 —
|
|
442
|
+
# or a reading carrying only one of the two percentages). There the credential is
|
|
443
|
+
# working perfectly; telling the operator to re-login sends them after a fault that
|
|
444
|
+
# does not exist, and --force just re-asks for the same emptiness. The candidate
|
|
445
|
+
# rule below is telemetry_state's own, so this text can never name a state the
|
|
446
|
+
# verdict did not come from.
|
|
447
|
+
usable_rows = [a for a in doc_rows if a.get('status') in ('active', 'limited')]
|
|
448
|
+
cands = [a for a in usable_rows if a.get('status') == 'active'] or usable_rows
|
|
449
|
+
current = [a for a in cands if (a.get('usage') or {})
|
|
450
|
+
and not (a.get('usage') or {}).get('stale')]
|
|
451
|
+
nodata = [a['id'] for a in current if (a.get('usage') or {}).get('no_data')]
|
|
452
|
+
if current:
|
|
453
|
+
print("RANKING IS BLIND: usage telemetry is INSIDE the "
|
|
454
|
+
f"{STALE_AFTER}s window but carries no reading the shim can rank on, so "
|
|
455
|
+
"every account scores the same and `claude` picks at RANDOM — including "
|
|
456
|
+
"accounts that are nearly out of weekly headroom.")
|
|
457
|
+
if nodata:
|
|
458
|
+
print(" why : the usage endpoint returned no usable data for "
|
|
459
|
+
f"{', '.join(nodata)} — all-zero buckets with no reset window, which "
|
|
460
|
+
'the writer records as "no_data": true rather than as 0% usage. '
|
|
461
|
+
"Those fetches authenticated; a re-login does NOT fix this.")
|
|
462
|
+
else:
|
|
463
|
+
print(" why : the readings are incomplete — ranking needs BOTH a weekly and "
|
|
464
|
+
"a session percentage (see the 'limits' lines above); an account with "
|
|
465
|
+
"only one of them is unknown to selection, exactly as if it had none.")
|
|
466
|
+
print(" fix : nothing local to repair — the endpoint has to answer with real "
|
|
467
|
+
"buckets again. `claude-accounts limits --force` re-asks; while it keeps "
|
|
468
|
+
"answering this way, selection stays random.")
|
|
469
|
+
else:
|
|
470
|
+
print("RANKING IS BLIND: no account has usage telemetry inside the "
|
|
471
|
+
f"{STALE_AFTER}s window, and the last readings are too old to mean anything, "
|
|
472
|
+
"so every account scores the same and `claude` picks at RANDOM — including "
|
|
473
|
+
"accounts that are nearly out of weekly headroom.")
|
|
474
|
+
print(" why : see the 'telemetry' lines above (a setup token cannot read the usage "
|
|
475
|
+
"endpoint — it has no user:profile scope; only an OAuth login on this machine can)")
|
|
476
|
+
print(" fix : claude-accounts limits --force # then, if it still fails:")
|
|
477
|
+
print(" claude-accounts login <acct-NN> # per account, on THIS machine")
|
|
439
478
|
elif verdict == 'degraded':
|
|
440
479
|
print("RANKING IS DEGRADED: no account has telemetry inside the "
|
|
441
480
|
f"{STALE_AFTER}s window, so `claude` is ranking on the last readings whose "
|
|
@@ -1402,7 +1441,22 @@ limits_distribute() {
|
|
|
1402
1441
|
limits_distribute_now() {
|
|
1403
1442
|
local lock="$ACC_ROOT/tmp/limits-push.lock"
|
|
1404
1443
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
1405
|
-
mkdir "$lock" 2>/dev/null
|
|
1444
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
1445
|
+
# A DETACHED push that is killed (logout, reboot, pkill) never runs its EXIT trap,
|
|
1446
|
+
# and mkdir can never take a lock dir nobody will remove: one stranded lock
|
|
1447
|
+
# silently stopped ALL telemetry distribution on the live pool from 2026-09-03
|
|
1448
|
+
# 00:29 until it was deleted by hand on 2026-09-04 — 32 hours in which every peer
|
|
1449
|
+
# ranked on whatever limits.json it happened to already have, which is the exact
|
|
1450
|
+
# blindness this push exists to prevent, and nothing anywhere said so. A push is
|
|
1451
|
+
# seconds of rsync under hard timeouts (--timeout=20, ConnectTimeout=10), so a
|
|
1452
|
+
# lock older than ten minutes belongs to a process that is gone: break it and
|
|
1453
|
+
# retake it. If the retake still fails, a live pusher owns it and this pass skips,
|
|
1454
|
+
# exactly as before.
|
|
1455
|
+
[ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 600 ] || return 0
|
|
1456
|
+
rm -rf "$lock" 2>/dev/null
|
|
1457
|
+
mkdir "$lock" 2>/dev/null || return 0
|
|
1458
|
+
log_to sync.log "stale limits-push lock broken (older than 600s); distributing"
|
|
1459
|
+
fi
|
|
1406
1460
|
trap 'rmdir "$lock" 2>/dev/null || true' EXIT
|
|
1407
1461
|
local server sroot list id d
|
|
1408
1462
|
list="$ACC_ROOT/tmp/limits-push.$$"
|
|
@@ -1544,6 +1598,34 @@ def parse_iso(s):
|
|
|
1544
1598
|
except Exception:
|
|
1545
1599
|
return None
|
|
1546
1600
|
|
|
1601
|
+
# The ONE rule for deleting a `.limited` marker on a clean pass. It is the writer half
|
|
1602
|
+
# of the shim's client_marker_recovered and has to be the SAME rule: the scheduled
|
|
1603
|
+
# limits pass runs every 15 minutes, so a writer that clears more freely than the shim
|
|
1604
|
+
# just undoes the shim's fix on its own timer. Before 2026-09-04 it did exactly that —
|
|
1605
|
+
# a truthful client:seven_day marker was deleted 300s after it was written, on a pass
|
|
1606
|
+
# whose every bucket said `percent 0, resets_at null`, i.e. on nothing at all.
|
|
1607
|
+
# * a pass with NO informative bucket proves nothing, so it clears nothing;
|
|
1608
|
+
# * a client rejection naming a WEEKLY window outlives every reading until its own
|
|
1609
|
+
# reset — a weekly bucket cannot fall from the server-proven 100% that wrote the
|
|
1610
|
+
# marker to under the threshold while that window is still open, so a reading
|
|
1611
|
+
# that says it did is wrong by construction;
|
|
1612
|
+
# * a client rejection naming a session/5h window still clears once the pass is
|
|
1613
|
+
# informative and at least CLIENT_LIMIT_CONFIRM_DELAY newer than the marker (#22,
|
|
1614
|
+
# 2026-09-03: 5h markers stranded accounts sitting at 0% usage for days);
|
|
1615
|
+
# * error-cooldown is untouched: it keeps its own window out, exactly as before.
|
|
1616
|
+
def marker_bucket(txt):
|
|
1617
|
+
for part in txt.split():
|
|
1618
|
+
if part.startswith('bucket='):
|
|
1619
|
+
return part[7:]
|
|
1620
|
+
return ''
|
|
1621
|
+
|
|
1622
|
+
def weekly_marker(txt):
|
|
1623
|
+
# Matched the way both shims match it (bin/claude ~303, same token list): the
|
|
1624
|
+
# claude client writes client:seven_day / client:seven_day_opus, the codex client
|
|
1625
|
+
# writes client:7d, and any future weekly* name is caught too.
|
|
1626
|
+
b = marker_bucket(txt).lower()
|
|
1627
|
+
return 'seven_day' in b or '7d' in b or 'weekly' in b
|
|
1628
|
+
|
|
1547
1629
|
# `.expired` — the persistent "this account cannot authenticate" marker the shim
|
|
1548
1630
|
# honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
|
|
1549
1631
|
# 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
|
|
@@ -2156,12 +2238,41 @@ for acct in manifest.get('accounts', []):
|
|
|
2156
2238
|
# weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
|
|
2157
2239
|
# ranking signal, because weekly headroom only returns on the
|
|
2158
2240
|
# account's fixed weekly reset (days away).
|
|
2159
|
-
# session_percent— peak of the self-healing 5h bucket;
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2241
|
+
# session_percent— peak of the self-healing 5h bucket; the session GATE's input:
|
|
2242
|
+
# the shim ranks only accounts at/under CLAUDE_MULTIACC_SESSION_GATE
|
|
2243
|
+
# (default 50) while any clear it. (A soft tiebreaker until
|
|
2244
|
+
# 2026-09-03 — the operator asked for session FIRST, then weekly.)
|
|
2245
|
+
# A bucket only feeds those three signals if it SAID something. An INFORMATIVE
|
|
2246
|
+
# bucket has a percent above 0, or a parseable reset window. 2026-09-04: for
|
|
2247
|
+
# acct-13/acct-14 the usage endpoint answered EVERY bucket `percent: 0,
|
|
2248
|
+
# resets_at: null` while Claude Code was being rejected on those same accounts
|
|
2249
|
+
# with "You've hit your weekly limit · resets Sep 8"; this writer recorded the
|
|
2250
|
+
# zeros verbatim, which made two provably exhausted accounts the leaders of the
|
|
2251
|
+
# weekly band and handed them 31 of the last ~60 picks. A truthful bucket ALWAYS
|
|
2252
|
+
# carries the window it resets in, so 0% with no window is NO DATA, not an empty
|
|
2253
|
+
# account. 0% WITH a real window stays informative — a genuinely fresh account
|
|
2254
|
+
# must still rank as empty — and one uninformative bucket beside real ones (the
|
|
2255
|
+
# acct-16 shape: `weekly_scoped:Fable` 0/null next to a real session and
|
|
2256
|
+
# weekly_all) leaves the real buckets ranking exactly as they do today.
|
|
2257
|
+
def informative(b):
|
|
2258
|
+
return b['percent'] > 0 or parse_iso(b.get('resets_at')) is not None
|
|
2259
|
+
|
|
2260
|
+
live = [b for b in buckets if informative(b)]
|
|
2261
|
+
weekly = [b['percent'] for b in live if b['group'] != 'session']
|
|
2262
|
+
session = [b['percent'] for b in live if b['group'] == 'session']
|
|
2263
|
+
# PER SIGNAL, never borrowed from another one. Each of the three answers a
|
|
2264
|
+
# different question, so each is written only when a bucket of ITS OWN kind said
|
|
2265
|
+
# something. Until 2026-09-04 weekly_percent fell back to the overall peak and
|
|
2266
|
+
# session_percent to 0: an account whose weekly buckets were all uninformative
|
|
2267
|
+
# while its 5h bucket read 40% was recorded as 40% WEEKLY — a number no bucket
|
|
2268
|
+
# ever reported, and the signal the band ranks on — and the mirror image
|
|
2269
|
+
# (informative weekly, silent session) was recorded as session 0%, which walks
|
|
2270
|
+
# straight through the session gate. A signal nobody reported must be ABSENT so
|
|
2271
|
+
# the shim reads it as unknown; inventing one is the same mistake as recording a
|
|
2272
|
+
# fake zero, one layer up.
|
|
2273
|
+
maxp = max([b['percent'] for b in live] or [0])
|
|
2274
|
+
weeklyp = max(weekly) if weekly else None
|
|
2275
|
+
sessionp = max(session) if session else None
|
|
2165
2276
|
# How long weekly_percent keeps meaning something. A weekly bucket only ever RISES
|
|
2166
2277
|
# until its reset, so before that moment a stale percent is still a valid lower
|
|
2167
2278
|
# bound and the shim can rank on it when nothing fresher exists; after it, the
|
|
@@ -2170,13 +2281,32 @@ for acct in manifest.get('accounts', []):
|
|
|
2170
2281
|
# It must come from the bucket weekly_percent actually CAME FROM: a low monthly
|
|
2171
2282
|
# bucket resetting in an hour says nothing about an 80% weekly one that resets in
|
|
2172
2283
|
# five days, and taking the minimum over all of them would throw the 80% away.
|
|
2173
|
-
wresets = [int(b['resets_epoch']) for b in
|
|
2284
|
+
wresets = [int(b['resets_epoch']) for b in live
|
|
2174
2285
|
if b['group'] != 'session' and b['percent'] == weeklyp
|
|
2175
2286
|
and isinstance(b.get('resets_epoch'), int)]
|
|
2176
|
-
out = {'fetched_at': int(now), 'source': source
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2287
|
+
out = {'fetched_at': int(now), 'source': source}
|
|
2288
|
+
if live:
|
|
2289
|
+
out['max_percent'] = maxp
|
|
2290
|
+
if weeklyp is not None:
|
|
2291
|
+
out['weekly_percent'] = weeklyp
|
|
2292
|
+
if sessionp is not None:
|
|
2293
|
+
out['session_percent'] = sessionp
|
|
2294
|
+
if weeklyp is not None:
|
|
2295
|
+
# Written with weekly_percent or not at all: the horizon describes THAT
|
|
2296
|
+
# reading, and the shim's degraded path needs both or neither.
|
|
2297
|
+
out['weekly_resets_epoch'] = min(wresets) if wresets else 0
|
|
2298
|
+
if not live:
|
|
2299
|
+
# Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
|
|
2300
|
+
# source, the raw buckets) and write NONE of the three percent signals: a
|
|
2301
|
+
# missing field makes the shim's fresh_field/cutoff_field reads fail, so the
|
|
2302
|
+
# account is UNKNOWN to both selection cuts — never the weekly band's leader,
|
|
2303
|
+
# never inside the session gate, and never able to clear a client-rate-limit
|
|
2304
|
+
# marker. Unknown is the honest reading; "0%" is what re-admitted two provably
|
|
2305
|
+
# exhausted accounts on 2026-09-04 (see informative() above).
|
|
2306
|
+
out['no_data'] = True
|
|
2307
|
+
say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
|
|
2308
|
+
f'no usable telemetry (account ranks as unknown, not as empty)')
|
|
2309
|
+
out['buckets'] = buckets
|
|
2180
2310
|
tmp = lpath + '.tmp'
|
|
2181
2311
|
with open(tmp, 'w') as f:
|
|
2182
2312
|
json.dump(out, f, indent=1)
|
|
@@ -2202,14 +2332,37 @@ for acct in manifest.get('accounts', []):
|
|
|
2202
2332
|
worst = max(account_level or offenders,
|
|
2203
2333
|
key=lambda b: (int(b['resets_epoch']), b['percent']))
|
|
2204
2334
|
reset_epoch = int(worst['resets_epoch'])
|
|
2205
|
-
#
|
|
2206
|
-
with
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2335
|
+
# Never SHORTEN an active client-rate-limit marker. A client rejection is
|
|
2336
|
+
# first-hand server evidence with its own reset; this pass's worst offender
|
|
2337
|
+
# can be a mere session bucket an hour from resetting, and overwriting the
|
|
2338
|
+
# marker with that shorter horizon re-admits a provably exhausted account
|
|
2339
|
+
# early (codex review, 2026-09-04: a client:seven_day four days out replaced
|
|
2340
|
+
# by a 95% session bucket +1h). A LATER reset may still extend the exclusion.
|
|
2341
|
+
keep_client = False
|
|
2342
|
+
try:
|
|
2343
|
+
cur = open(mpath).read()
|
|
2344
|
+
first = cur.splitlines()[0] if cur else ''
|
|
2345
|
+
# The shims' own validity rule (num_ok: digits only, bounded length): a
|
|
2346
|
+
# signed/padded/absurd first line is a GARBLED marker to them, and a
|
|
2347
|
+
# garbled marker must be rewritten here, not preserved.
|
|
2348
|
+
if first.isdigit() and len(first) <= 18:
|
|
2349
|
+
cur_reset = int(first)
|
|
2350
|
+
keep_client = ('reason=client-rate-limit' in cur
|
|
2351
|
+
and cur_reset > now and cur_reset >= reset_epoch)
|
|
2352
|
+
except Exception:
|
|
2353
|
+
pass
|
|
2354
|
+
if keep_client:
|
|
2355
|
+
say(f"{aid}: keeping the client-reported marker (its reset reaches further "
|
|
2356
|
+
f"than this pass's worst offender)")
|
|
2357
|
+
else:
|
|
2358
|
+
# Atomic: a concurrent shim must never read a half-written marker.
|
|
2359
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
2360
|
+
f.write(f'{reset_epoch}\n')
|
|
2361
|
+
f.write(f"bucket={worst['name']} percent={worst['percent']} "
|
|
2362
|
+
f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
|
|
2363
|
+
f"reason=limits resets_at={worst['resets_at']}\n")
|
|
2364
|
+
os.replace(mpath + '.tmp', mpath)
|
|
2365
|
+
say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
|
|
2213
2366
|
else:
|
|
2214
2367
|
if os.path.exists(mpath):
|
|
2215
2368
|
# A shim-written marker outlives a clean limits pass while its own window
|
|
@@ -2232,11 +2385,17 @@ for acct in manifest.get('accounts', []):
|
|
|
2232
2385
|
if marked_epoch is None:
|
|
2233
2386
|
marked_epoch = os.path.getmtime(mpath)
|
|
2234
2387
|
recent_client = now - marked_epoch < CLIENT_LIMIT_CONFIRM_DELAY
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2388
|
+
is_client = 'reason=client-rate-limit' in txt
|
|
2389
|
+
# See weekly_marker() above for why these three come first and in
|
|
2390
|
+
# this order; the shim applies the identical test per invocation.
|
|
2391
|
+
if active and not live:
|
|
2392
|
+
keep = True
|
|
2393
|
+
elif active and is_client and weekly_marker(txt):
|
|
2394
|
+
keep = True
|
|
2395
|
+
elif active and ('reason=error-cooldown' in txt
|
|
2396
|
+
or (is_client and recent_client)):
|
|
2239
2397
|
keep = True
|
|
2398
|
+
client_recovered = active and is_client and not keep
|
|
2240
2399
|
except Exception:
|
|
2241
2400
|
pass
|
|
2242
2401
|
if not keep:
|
|
@@ -2246,7 +2405,16 @@ for acct in manifest.get('accounts', []):
|
|
|
2246
2405
|
with open(cleared + '.tmp', 'w') as f:
|
|
2247
2406
|
f.write(f'{int(now)}\n')
|
|
2248
2407
|
os.replace(cleared + '.tmp', cleared)
|
|
2249
|
-
|
|
2408
|
+
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
2409
|
+
# nothing was reported (see informative() above). Log that, instead
|
|
2410
|
+
# of a "0%" that reads like a proven-empty account — the exact
|
|
2411
|
+
# misreading behind the 2026-09-04 incident.
|
|
2412
|
+
seen = f'max {maxp}%' if live else 'no usable telemetry'
|
|
2413
|
+
say(f'{aid}: marker cleared ({seen})')
|
|
2414
|
+
elif not live:
|
|
2415
|
+
# One line per account, so a no-data pass is legible in limits.log:
|
|
2416
|
+
# the marker was not re-confirmed here, it was merely not disproved.
|
|
2417
|
+
say(f'{aid}: marker kept (no usable telemetry)')
|
|
2250
2418
|
if not quiet:
|
|
2251
2419
|
detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
|
|
2252
2420
|
print(f'{aid}: ok {detail}')
|