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/codex-accounts
CHANGED
|
@@ -359,7 +359,15 @@ for a in doc.get('accounts', []):
|
|
|
359
359
|
lim = json.load(open(lpath))
|
|
360
360
|
age = int(now - lim.get('fetched_at', 0))
|
|
361
361
|
parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
|
|
362
|
-
|
|
362
|
+
# A no_data document (all-zero buckets with no reported reset windows —
|
|
363
|
+
# the 2026-09-04 claude incident, mirrored here for parity) carries no
|
|
364
|
+
# percent fields at all. Printing "max None%" would read like a healthy
|
|
365
|
+
# account sitting at zero, which is the misreading that handed picks to
|
|
366
|
+
# exhausted accounts; name the state instead.
|
|
367
|
+
peak = lim.get('max_percent')
|
|
368
|
+
usable = isinstance(peak, (int, float)) and not isinstance(peak, bool)
|
|
369
|
+
head = f'max {peak}%' if usable else 'NO USABLE TELEMETRY — ranks as unknown'
|
|
370
|
+
print(f" limits : {' '.join(parts) or '(none)'} [{age}s old, {head}]")
|
|
363
371
|
except Exception as e:
|
|
364
372
|
print(f" limits : unreadable ({e})")
|
|
365
373
|
else:
|
|
@@ -1079,6 +1087,14 @@ CLIENT_ID = os.environ.get('CODEX_MULTIACC_CLIENT_ID',
|
|
|
1079
1087
|
REFRESH_MIN_EXPIRED = 300
|
|
1080
1088
|
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
1081
1089
|
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
1090
|
+
# A real client 429 beats an immediately-following usage response, which may be cached.
|
|
1091
|
+
# A later successful response under the threshold is newer first-hand evidence and must
|
|
1092
|
+
# release the account instead of preserving a false marker until a days-away reset.
|
|
1093
|
+
try:
|
|
1094
|
+
CLIENT_LIMIT_CONFIRM_DELAY = max(
|
|
1095
|
+
0, min(3600, int(os.environ.get('CODEX_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY', '300'))))
|
|
1096
|
+
except ValueError:
|
|
1097
|
+
CLIENT_LIMIT_CONFIRM_DELAY = 300
|
|
1082
1098
|
|
|
1083
1099
|
def say(msg):
|
|
1084
1100
|
if not quiet:
|
|
@@ -1086,6 +1102,48 @@ def say(msg):
|
|
|
1086
1102
|
with open(os.path.join(root, 'limits.log'), 'a') as f:
|
|
1087
1103
|
f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')
|
|
1088
1104
|
|
|
1105
|
+
def parse_iso(s):
|
|
1106
|
+
if not s:
|
|
1107
|
+
return None
|
|
1108
|
+
try:
|
|
1109
|
+
return datetime.datetime.fromisoformat(str(s).replace('Z', '+00:00')).timestamp()
|
|
1110
|
+
except Exception:
|
|
1111
|
+
return None
|
|
1112
|
+
|
|
1113
|
+
# The ONE rule for deleting a `.limited` marker on a clean pass. It is the writer half
|
|
1114
|
+
# of the shim's client_marker_recovered and has to be the SAME rule: the scheduled
|
|
1115
|
+
# limits pass runs every 15 minutes, so a writer that clears more freely than the shim
|
|
1116
|
+
# just undoes the shim's fix on its own timer. Before 2026-09-04 the claude writer did
|
|
1117
|
+
# exactly that — a truthful client:seven_day marker was deleted 300s after it was
|
|
1118
|
+
# written, on a pass whose every bucket said `percent 0, resets_at null`, i.e. on
|
|
1119
|
+
# nothing at all.
|
|
1120
|
+
# * a pass with NO informative bucket proves nothing, so it clears nothing;
|
|
1121
|
+
# * a client rejection naming a WEEKLY window outlives every reading until its own
|
|
1122
|
+
# reset — a weekly bucket cannot fall from the server-proven 100% that wrote the
|
|
1123
|
+
# marker to under the threshold while that window is still open, so a reading
|
|
1124
|
+
# that says it did is wrong by construction;
|
|
1125
|
+
# * a client rejection naming a session/5h window still clears once the pass is
|
|
1126
|
+
# informative and at least CLIENT_LIMIT_CONFIRM_DELAY newer than the marker (#22,
|
|
1127
|
+
# 2026-09-03: 5h markers stranded accounts sitting at 0% usage for days);
|
|
1128
|
+
# * error-cooldown is untouched: it keeps its own window out, exactly as before.
|
|
1129
|
+
def marker_bucket(txt):
|
|
1130
|
+
for part in txt.split():
|
|
1131
|
+
if part.startswith('bucket='):
|
|
1132
|
+
return part[7:]
|
|
1133
|
+
return ''
|
|
1134
|
+
|
|
1135
|
+
def weekly_marker(txt):
|
|
1136
|
+
# Matched the way both shims match it (bin/claude ~303, same token list): the
|
|
1137
|
+
# claude client writes client:seven_day / client:seven_day_opus, and the codex
|
|
1138
|
+
# client writes client:7d — a label bin/codex's client_limit_scan derives from the
|
|
1139
|
+
# rollout's window_minutes (>= 1440), NOT the raw `primary`/`secondary` key names it
|
|
1140
|
+
# wrote before 2026-09-04, which map to no fixed window and so were never protected
|
|
1141
|
+
# here. Any future weekly* name is caught too. A legacy client:primary/secondary
|
|
1142
|
+
# marker is therefore not weekly to this rule and stays clearable, exactly as it was
|
|
1143
|
+
# before the guard existed; it expires at its own reset regardless.
|
|
1144
|
+
b = marker_bucket(txt).lower()
|
|
1145
|
+
return 'seven_day' in b or '7d' in b or 'weekly' in b
|
|
1146
|
+
|
|
1089
1147
|
def jwt_claims(token):
|
|
1090
1148
|
try:
|
|
1091
1149
|
payload = str(token).split('.')[1]
|
|
@@ -1395,8 +1453,15 @@ for acct in manifest.get('accounts', []):
|
|
|
1395
1453
|
name = f'{scope}:{dur}' if scope else dur
|
|
1396
1454
|
try:
|
|
1397
1455
|
reset_epoch = int(float(win.get('reset_at')))
|
|
1456
|
+
reset_known = True
|
|
1398
1457
|
except (TypeError, ValueError):
|
|
1399
1458
|
reset_epoch = int(now + (secs or 3600))
|
|
1459
|
+
# Synthesized, not reported. Remembered because this writer FORMATS
|
|
1460
|
+
# resets_at itself: unlike the claude payload, the finished bucket cannot
|
|
1461
|
+
# be re-read later to tell a real window from a placeholder, and the
|
|
1462
|
+
# no-data rule below turns on exactly that distinction. Stripped before
|
|
1463
|
+
# the document is written so limits.json keeps its published shape.
|
|
1464
|
+
reset_known = False
|
|
1400
1465
|
return {
|
|
1401
1466
|
'name': name,
|
|
1402
1467
|
'kind': which,
|
|
@@ -1404,6 +1469,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1404
1469
|
'percent': pct,
|
|
1405
1470
|
'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
|
|
1406
1471
|
'resets_epoch': reset_epoch,
|
|
1472
|
+
'_reset_known': reset_known,
|
|
1407
1473
|
}
|
|
1408
1474
|
|
|
1409
1475
|
def scope_buckets(scope, rl):
|
|
@@ -1425,6 +1491,9 @@ for acct in manifest.get('accounts', []):
|
|
|
1425
1491
|
'kind': 'limit_reached', 'group': 'weekly', 'percent': 100,
|
|
1426
1492
|
'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
|
|
1427
1493
|
'resets_epoch': reset_epoch,
|
|
1494
|
+
# A server verdict, not a reported window: 100% is informative on its
|
|
1495
|
+
# own, so the no-data rule below never depends on this flag here.
|
|
1496
|
+
'_reset_known': False,
|
|
1428
1497
|
})
|
|
1429
1498
|
return out
|
|
1430
1499
|
|
|
@@ -1461,20 +1530,68 @@ for acct in manifest.get('accounts', []):
|
|
|
1461
1530
|
# THREE selection signals, same reset asymmetry as the claude pool:
|
|
1462
1531
|
# max_percent — peak of ALL buckets; drives >=90% EXCLUSION.
|
|
1463
1532
|
# weekly_percent — peak of the durable buckets; the PRIMARY ranking signal.
|
|
1464
|
-
# session_percent— peak of the self-healing ~5h buckets;
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1533
|
+
# session_percent— peak of the self-healing ~5h buckets; the session GATE's input:
|
|
1534
|
+
# the shim ranks only accounts at/under CODEX_MULTIACC_SESSION_GATE
|
|
1535
|
+
# (default 50) while any clear it. (A soft tiebreaker until
|
|
1536
|
+
# 2026-09-03 — the operator asked for session FIRST, then weekly.)
|
|
1537
|
+
# A bucket only feeds those three signals if it SAID something. An INFORMATIVE
|
|
1538
|
+
# bucket has a percent above 0, or a window the payload actually reported. The
|
|
1539
|
+
# claude pool learned this the hard way on 2026-09-04: its usage endpoint answered
|
|
1540
|
+
# EVERY bucket `percent: 0, resets_at: null` for acct-13/acct-14 while the client
|
|
1541
|
+
# was being rejected on them at their weekly limit, the writer recorded the zeros
|
|
1542
|
+
# verbatim, and two provably exhausted accounts led the weekly band for 31 of the
|
|
1543
|
+
# last ~60 picks. Real usage always comes with the window it resets in, so 0% with
|
|
1544
|
+
# no window is NO DATA, not an empty account. 0% WITH a real window stays
|
|
1545
|
+
# informative — a genuinely fresh account must still rank as empty — and one
|
|
1546
|
+
# uninformative bucket beside real ones leaves the real buckets ranking as today.
|
|
1547
|
+
def informative(b):
|
|
1548
|
+
return b['percent'] > 0 or bool(b.get('_reset_known'))
|
|
1549
|
+
|
|
1550
|
+
live = [b for b in buckets if informative(b)]
|
|
1551
|
+
weekly = [b['percent'] for b in live if b['group'] != 'session']
|
|
1552
|
+
session = [b['percent'] for b in live if b['group'] == 'session']
|
|
1553
|
+
# PER SIGNAL, never borrowed from another one — same rule as the claude writer.
|
|
1554
|
+
# Each of the three answers a different question, so each is written only when a
|
|
1555
|
+
# bucket of ITS OWN kind said something. Until 2026-09-04 weekly_percent fell back
|
|
1556
|
+
# to the overall peak and session_percent to 0: an account whose weekly windows
|
|
1557
|
+
# were all uninformative while its 5h window read 40% was recorded as 40% WEEKLY —
|
|
1558
|
+
# a number no window ever reported, and the signal the band ranks on — and the
|
|
1559
|
+
# mirror image (informative weekly, silent session) was recorded as session 0%,
|
|
1560
|
+
# which walks straight through the session gate. A signal nobody reported must be
|
|
1561
|
+
# ABSENT so the shim reads it as unknown; inventing one is the same mistake as
|
|
1562
|
+
# recording a fake zero, one layer up.
|
|
1563
|
+
maxp = max([b['percent'] for b in live] or [0])
|
|
1564
|
+
weeklyp = max(weekly) if weekly else None
|
|
1565
|
+
sessionp = max(session) if session else None
|
|
1470
1566
|
try:
|
|
1471
1567
|
reset_result = try_auto_redeem(d, aid, maxp, data, url, headers, say, int(now))
|
|
1472
1568
|
except Exception as e:
|
|
1473
1569
|
say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
|
|
1474
1570
|
reset_result = {'status': 'error'}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1571
|
+
# Internal only (see win_bucket): limits.json keeps the bucket shape every reader
|
|
1572
|
+
# — the shim, lib/report.py, app-robot — already knows.
|
|
1573
|
+
for b in buckets:
|
|
1574
|
+
b.pop('_reset_known', None)
|
|
1575
|
+
out = {'fetched_at': int(now), 'source': 'chatgpt'}
|
|
1576
|
+
if live:
|
|
1577
|
+
out['max_percent'] = maxp
|
|
1578
|
+
if weeklyp is not None:
|
|
1579
|
+
out['weekly_percent'] = weeklyp
|
|
1580
|
+
if sessionp is not None:
|
|
1581
|
+
out['session_percent'] = sessionp
|
|
1582
|
+
if not live:
|
|
1583
|
+
# Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
|
|
1584
|
+
# source, plan, the raw buckets) and write NONE of the percent signals: a
|
|
1585
|
+
# missing field makes the shim's fresh_field/cutoff_field reads fail, so the
|
|
1586
|
+
# account is UNKNOWN to both selection cuts — never the weekly band's leader,
|
|
1587
|
+
# never inside the session gate, and never able to clear a client-rate-limit
|
|
1588
|
+
# marker. Unknown is the honest reading; "0%" is what re-admitted two provably
|
|
1589
|
+
# exhausted claude accounts on 2026-09-04 (see informative() above).
|
|
1590
|
+
out['no_data'] = True
|
|
1591
|
+
say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
|
|
1592
|
+
f'no usable telemetry (account ranks as unknown, not as empty)')
|
|
1593
|
+
out['plan'] = str(data.get('plan_type') or '')
|
|
1594
|
+
out['buckets'] = buckets
|
|
1478
1595
|
if reset_result.get('status') == 'redeemed':
|
|
1479
1596
|
# The response proves the reset succeeded, but the usage GET happened before
|
|
1480
1597
|
# it. Make that snapshot stale immediately so it cannot re-exclude the newly
|
|
@@ -1503,14 +1620,37 @@ for acct in manifest.get('accounts', []):
|
|
|
1503
1620
|
# since any bucket over the threshold stays there until its own reset.
|
|
1504
1621
|
worst = max(offenders, key=lambda b: (int(b['resets_epoch']), b['percent']))
|
|
1505
1622
|
reset_epoch = int(worst['resets_epoch'])
|
|
1506
|
-
#
|
|
1507
|
-
with
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1623
|
+
# Never SHORTEN an active client-rate-limit marker. A client rejection is
|
|
1624
|
+
# first-hand server evidence with its own reset; this pass's worst offender
|
|
1625
|
+
# can be a mere session bucket an hour from resetting, and overwriting the
|
|
1626
|
+
# marker with that shorter horizon re-admits a provably exhausted account
|
|
1627
|
+
# early (codex review, 2026-09-04: a client:seven_day four days out replaced
|
|
1628
|
+
# by a 95% session bucket +1h). A LATER reset may still extend the exclusion.
|
|
1629
|
+
keep_client = False
|
|
1630
|
+
try:
|
|
1631
|
+
cur = open(mpath).read()
|
|
1632
|
+
first = cur.splitlines()[0] if cur else ''
|
|
1633
|
+
# The shims' own validity rule (num_ok: digits only, bounded length): a
|
|
1634
|
+
# signed/padded/absurd first line is a GARBLED marker to them, and a
|
|
1635
|
+
# garbled marker must be rewritten here, not preserved.
|
|
1636
|
+
if first.isdigit() and len(first) <= 18:
|
|
1637
|
+
cur_reset = int(first)
|
|
1638
|
+
keep_client = ('reason=client-rate-limit' in cur
|
|
1639
|
+
and cur_reset > now and cur_reset >= reset_epoch)
|
|
1640
|
+
except Exception:
|
|
1641
|
+
pass
|
|
1642
|
+
if keep_client:
|
|
1643
|
+
say(f"{aid}: keeping the client-reported marker (its reset reaches further "
|
|
1644
|
+
f"than this pass's worst offender)")
|
|
1645
|
+
else:
|
|
1646
|
+
# Atomic: a concurrent shim must never read a half-written marker.
|
|
1647
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
1648
|
+
f.write(f'{reset_epoch}\n')
|
|
1649
|
+
f.write(f"bucket={worst['name']} percent={worst['percent']} "
|
|
1650
|
+
f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
|
|
1651
|
+
f"reason=limits resets_at={worst['resets_at']}\n")
|
|
1652
|
+
os.replace(mpath + '.tmp', mpath)
|
|
1653
|
+
say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
|
|
1514
1654
|
else:
|
|
1515
1655
|
if os.path.exists(mpath):
|
|
1516
1656
|
# A shim-written marker outlives a clean limits pass while its own window
|
|
@@ -1522,18 +1662,62 @@ for acct in manifest.get('accounts', []):
|
|
|
1522
1662
|
# payload that disagrees must not unpark the account
|
|
1523
1663
|
# early and send work straight back into the wall.
|
|
1524
1664
|
keep = False
|
|
1665
|
+
client_recovered = False
|
|
1525
1666
|
try:
|
|
1526
1667
|
txt = open(mpath).read()
|
|
1527
1668
|
first = txt.splitlines()[0] if txt else ''
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1669
|
+
active = first.isdigit() and int(first) > now
|
|
1670
|
+
# Sync and atomic copies change mtimes, so prefer the marker's own
|
|
1671
|
+
# semantic timestamp; old marker formats fall back to mtime.
|
|
1672
|
+
marked_at = next((part[10:] for part in txt.split()
|
|
1673
|
+
if part.startswith('marked_at=')), '')
|
|
1674
|
+
marked_epoch = parse_iso(marked_at)
|
|
1675
|
+
if marked_epoch is None:
|
|
1676
|
+
marked_epoch = os.path.getmtime(mpath)
|
|
1677
|
+
recent_client = now - marked_epoch < CLIENT_LIMIT_CONFIRM_DELAY
|
|
1678
|
+
is_client = 'reason=client-rate-limit' in txt
|
|
1679
|
+
# See weekly_marker() above: ONE rule, shared with the claude writer.
|
|
1680
|
+
# 2026-09-04 changed this branch — every active client marker used to
|
|
1681
|
+
# be kept unconditionally, so a 5h rejection outlived a window that
|
|
1682
|
+
# refills in hours. Now it clears on an informative pass after the same
|
|
1683
|
+
# confirm delay claude uses, while a 7d one sticks until its reset.
|
|
1684
|
+
if reset_result.get('status') == 'redeemed':
|
|
1685
|
+
keep = False # a confirmed reset is first-hand proof it refilled
|
|
1686
|
+
elif active and not live:
|
|
1687
|
+
keep = True
|
|
1688
|
+
elif active and is_client and weekly_marker(txt):
|
|
1689
|
+
keep = True
|
|
1690
|
+
elif active and ('reason=error-cooldown' in txt
|
|
1691
|
+
or (is_client and recent_client)):
|
|
1531
1692
|
keep = True
|
|
1693
|
+
client_recovered = active and is_client and not keep
|
|
1532
1694
|
except Exception:
|
|
1533
1695
|
pass
|
|
1534
1696
|
if not keep:
|
|
1535
1697
|
os.remove(mpath)
|
|
1536
|
-
|
|
1698
|
+
if client_recovered:
|
|
1699
|
+
# The rollout that reported the spent window is STILL on disk, and
|
|
1700
|
+
# the shim's client_limit_scan re-reads its tail on the very next
|
|
1701
|
+
# launch — so deleting the marker without a watermark only rewrites
|
|
1702
|
+
# it seconds later, once per 15-minute pass, forever. Stamp the clear
|
|
1703
|
+
# the way bin/claude-accounts has since #22's five-hour clearing
|
|
1704
|
+
# landed (same filename, same epoch payload); bin/codex then skips
|
|
1705
|
+
# every report at or before it. Codex gained 5h clearing on
|
|
1706
|
+
# 2026-09-04 and needed the identical brake with it.
|
|
1707
|
+
cleared = os.path.join(d, '.client-limit-cleared')
|
|
1708
|
+
with open(cleared + '.tmp', 'w') as f:
|
|
1709
|
+
f.write(f'{int(now)}\n')
|
|
1710
|
+
os.replace(cleared + '.tmp', cleared)
|
|
1711
|
+
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
1712
|
+
# nothing was reported (see informative() above). Log that, instead
|
|
1713
|
+
# of a "0%" that reads like a proven-empty account — the exact
|
|
1714
|
+
# misreading behind the 2026-09-04 incident.
|
|
1715
|
+
seen = f'max {maxp}%' if live else 'no usable telemetry'
|
|
1716
|
+
say(f'{aid}: marker cleared ({seen})')
|
|
1717
|
+
elif not live:
|
|
1718
|
+
# One line per account, so a no-data pass is legible in limits.log:
|
|
1719
|
+
# the marker was not re-confirmed here, it was merely not disproved.
|
|
1720
|
+
say(f'{aid}: marker kept (no usable telemetry)')
|
|
1537
1721
|
if not quiet:
|
|
1538
1722
|
detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
|
|
1539
1723
|
print(f'{aid}: ok {detail}')
|
|
@@ -226,12 +226,18 @@ providers (`lib/report.py`), so a consumer writes one parser:
|
|
|
226
226
|
"token_age_days": 39},
|
|
227
227
|
"limited": false, "limit_reset_at": null, "limit_detail": null,
|
|
228
228
|
"usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
|
|
229
|
+
// a null percent = that signal had no informative bucket; all three
|
|
230
|
+
// null = limits.json "no_data": true
|
|
229
231
|
"max_percent": 62, "weekly_percent": 62, "session_percent": 18,
|
|
230
232
|
"buckets": [{"name": "weekly_scoped:Fable", "group": "weekly",
|
|
231
233
|
"percent": 62, "resets_at": "…Z"}]}
|
|
232
234
|
}],
|
|
233
235
|
"summary": {"total": 3, "active": 2, "limited": 1, "needs_login": 0,
|
|
234
|
-
"portable": 3, "selectable": 2
|
|
236
|
+
"portable": 3, "selectable": 2,
|
|
237
|
+
// pool-wide ranking verdict: fresh | degraded | blind | none.
|
|
238
|
+
// "fresh" needs ONE candidate with both percentages inside the window;
|
|
239
|
+
// "blind" = every account scores the same and selection is random.
|
|
240
|
+
"telemetry": "fresh", "ranking_blind": false},
|
|
235
241
|
"warnings": []
|
|
236
242
|
}
|
|
237
243
|
```
|
|
@@ -244,6 +250,21 @@ A consumer that only distinguishes *usable / parked / needs-a-human* can read
|
|
|
244
250
|
without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
|
|
245
251
|
whose reset time has passed does not count, an unreadable one does.
|
|
246
252
|
|
|
253
|
+
A percent field is **null** when that signal had no informative bucket to aggregate. The
|
|
254
|
+
writers keep the signals separate: `weekly_percent` comes from informative weekly buckets,
|
|
255
|
+
`session_percent` from informative session ones, and each is omitted on its own when its
|
|
256
|
+
group has nothing informative — a payload with a real session bucket and an all-zero weekly
|
|
257
|
+
one reports the session and leaves `weekly_percent` null rather than borrowing the session
|
|
258
|
+
number. `"no_data": true` appears only when NOTHING in the payload is informative (every
|
|
259
|
+
bucket 0% with no *parseable* reset window — the `buckets` array is kept verbatim for
|
|
260
|
+
diagnostics and, on codex, may still show a synthesized epoch), and then all three
|
|
261
|
+
percentages are absent together; a
|
|
262
|
+
weekly-only or session-only document carries no `no_data` flag. The rule behind it: 0% with
|
|
263
|
+
no window is the endpoint declining to answer rather than an idle account (2026-09-04 —
|
|
264
|
+
reading it as 0% ranked two weekly-exhausted accounts first). Either way the account ranks
|
|
265
|
+
*unknown*, not empty — selection needs BOTH percentages — and its `buckets` are still
|
|
266
|
+
emitted for diagnostics.
|
|
267
|
+
|
|
247
268
|
### Which accounts are portable
|
|
248
269
|
|
|
249
270
|
| Credential | Where it lives | Class | Can it be copied to another machine? |
|
|
@@ -333,9 +354,10 @@ derived from the pool root. Uninstalling an instance removes only that instance'
|
|
|
333
354
|
| `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
|
|
334
355
|
| `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
|
|
335
356
|
| `CLAUDE_MULTIACC_HEADROOM_BAND=0..100` | weekly points treated as peers; default `30`, `0` is strict |
|
|
357
|
+
| `CLAUDE_MULTIACC_SESSION_GATE=0..100` | max 5h-session usage that still gets ranked; default `50`, `100` disables the gate |
|
|
336
358
|
| `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | ignore the client's own rate-limit records |
|
|
337
359
|
| `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | clean client-limit scan cache; default 20s |
|
|
338
|
-
| `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` |
|
|
360
|
+
| `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` | five-hour recovery grace; default 300s (weekly never clears early) |
|
|
339
361
|
| `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
|
|
340
362
|
| `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool; legacy spelling is `CLAUDE_ACCOUNTS_DIR` |
|
|
341
363
|
| `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
|
|
@@ -343,8 +365,10 @@ derived from the pool root. Uninstalling an instance removes only that instance'
|
|
|
343
365
|
|
|
344
366
|
The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
|
|
345
367
|
`CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
|
|
346
|
-
`CODEX_SHIM_SELECT`, `
|
|
347
|
-
`
|
|
368
|
+
`CODEX_SHIM_SELECT`, `CODEX_MULTIACC_HEADROOM_BAND`, `CODEX_MULTIACC_SESSION_GATE`,
|
|
369
|
+
`CODEX_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY` (5h client-marker recovery grace, default
|
|
370
|
+
300s — weekly markers never clear early), `CODEX_ACCOUNTS_ROOT` (legacy
|
|
371
|
+
`CODEX_ACCOUNTS_DIR`), `CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.
|
|
348
372
|
|
|
349
373
|
## Verification
|
|
350
374
|
|
|
@@ -382,8 +406,9 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
382
406
|
before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
|
|
383
407
|
check every token immediately; `claude-accounts expired` reports token-only accounts
|
|
384
408
|
as `UNVERIFIED` until that proof exists.
|
|
385
|
-
- **Everything marked limited** — the shim still runs
|
|
386
|
-
|
|
409
|
+
- **Everything marked limited** — the shim still runs: the still-serving limited accounts
|
|
410
|
+
go through the same two cuts (session gate, then strict best-weekly) and one is handed
|
|
411
|
+
out anyway; check `selection.log` for `all-limited fallback=` lines.
|
|
387
412
|
- **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
|
|
388
413
|
`server` (BatchMode — needs key auth).
|
|
389
414
|
- **A service bypasses the shim** — it spawns an absolute path. Point its env
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# App-Robot Delivery Tracking Prompt
|
|
2
|
+
|
|
3
|
+
Replace `<TASK_URL>` with the delivery URL, then paste the full prompt into Claude Code.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Track app-robot delivery task <TASK_URL> to completion and actively repair anything
|
|
7
|
+
that prevents forward progress.
|
|
8
|
+
|
|
9
|
+
Operating rules:
|
|
10
|
+
|
|
11
|
+
1. Start with a read-only timeline audit: current task/step/run, step age, last changed
|
|
12
|
+
evidence, worker/execution ownership, retry count, external operations, blockers,
|
|
13
|
+
and production build SHA.
|
|
14
|
+
|
|
15
|
+
2. Treat 15 minutes without changed durable evidence as a stall. Do not accept log
|
|
16
|
+
activity, repeated probes, heartbeats, or newly minted attempts as progress.
|
|
17
|
+
|
|
18
|
+
3. Never repeat the same failed action more than once. On the second identical failure,
|
|
19
|
+
stop retrying, identify the shared root cause, add a focused regression test, open a
|
|
20
|
+
fix PR, pass exact-head CI, merge/deploy it, verify production, then resume the task.
|
|
21
|
+
|
|
22
|
+
4. Use app-robot's supported operations only. Preserve at-most-once semantics for store
|
|
23
|
+
submissions and other external mutations. Never bypass an operator-disabled store lane.
|
|
24
|
+
|
|
25
|
+
5. Check that every supervising turn has a claimable execution, bounded retry and launch
|
|
26
|
+
budgets, expiring reservations, and a periodic reconciler. Repair the platform if not.
|
|
27
|
+
|
|
28
|
+
6. Parallelize independent diagnosis and fixes, but serialize overlapping PRs. Batch
|
|
29
|
+
related fixes where safe. Merge overlapping PRs once in dependency order instead of
|
|
30
|
+
rebasing each after every squash.
|
|
31
|
+
|
|
32
|
+
7. Run focused impacted tests during development. If local test admission waits over two
|
|
33
|
+
minutes, diagnose the admission and database lifecycle and use CI as the merge gate.
|
|
34
|
+
Do not wait indefinitely or start duplicate suites.
|
|
35
|
+
|
|
36
|
+
8. Perform one bounded adversarial review of each original root-cause area, using at most
|
|
37
|
+
three reviewers. Aggregate confirmed findings into at most one follow-up PR per
|
|
38
|
+
subsystem. Do not review review-generated follow-ups unless they introduce a genuinely
|
|
39
|
+
new subsystem. Launch no recursive review fan-out.
|
|
40
|
+
|
|
41
|
+
9. Cap each review workflow at 20 minutes. At the deadline, aggregate completed evidence
|
|
42
|
+
and stop incomplete or redundant workers. Cap each implementation agent at 20 minutes
|
|
43
|
+
to produce a pushed commit or test result; otherwise stop it and narrow or reassign the
|
|
44
|
+
task. A stalled agent is not progress.
|
|
45
|
+
|
|
46
|
+
10. Do not wait for manual cleanup that is not on the delivery critical path. Convert the
|
|
47
|
+
cause into a bounded code fix and regression test, then let exact-head CI verify it.
|
|
48
|
+
|
|
49
|
+
11. Every 10 minutes report: current step, age, last real progress, active blocker, exact
|
|
50
|
+
remediation, queue/CI/deploy status, remaining critical path, and any wall-clock budget
|
|
51
|
+
deviation.
|
|
52
|
+
|
|
53
|
+
12. Persist a restart-safe checkpoint before context or account exhaustion: task/run IDs,
|
|
54
|
+
operation IDs, PR queue, production build, monitors, and exact next commands.
|
|
55
|
+
|
|
56
|
+
13. Target four hours for a new delivery task and two hours for a repair-only critical
|
|
57
|
+
path, excluding unavoidable external store review. Use these sub-budgets:
|
|
58
|
+
- initial audit: 10 minutes
|
|
59
|
+
- root-cause diagnosis: 20 minutes per independent failure
|
|
60
|
+
- implementation agent: 20 minutes to a pushed artifact
|
|
61
|
+
- review workflow: 20 minutes, maximum three reviewers
|
|
62
|
+
- unchanged CI or deploy: investigate after 15 minutes
|
|
63
|
+
Report a budget miss immediately; do not silently extend it.
|
|
64
|
+
|
|
65
|
+
14. Completion means: all enabled lanes have accepted outcomes; policy-disabled lanes are
|
|
66
|
+
recorded as notified person waits; all confirmed root-cause fixes are merged, deployed,
|
|
67
|
+
and production-verified; the repair queue is empty; bounded reviews are finished.
|
|
68
|
+
Then mark /goal complete.
|
|
69
|
+
|
|
70
|
+
First response: give me the evidence-based critical path and the maximum wall-clock budget
|
|
71
|
+
for each remaining step. Then begin; do not merely propose a plan.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The limits prevent an open-ended review-of-review loop. External store review can outlast
|
|
75
|
+
the target, but the task must reach a durable submitted or policy-blocked state before the
|
|
76
|
+
tracking goal is considered complete.
|
package/docs/UNIFIED_SELECTOR.md
CHANGED
|
@@ -20,19 +20,52 @@ The request schema is `claude-multiacc/pool-selection.v2` and contains:
|
|
|
20
20
|
- the policy (`default_claude`, `default_codex`, `default_both`, `explicit`,
|
|
21
21
|
`producer_retry`, or `reviewer`);
|
|
22
22
|
- any required provider, producer identity and explicit exclusions;
|
|
23
|
-
- the runner generation's candidate telemetry plus exact reservation history
|
|
23
|
+
- the runner generation's candidate telemetry plus exact reservation history;
|
|
24
|
+
- optionally `headroom_band` and `session_gate`, the two ranking knobs below.
|
|
25
|
+
|
|
26
|
+
Both knobs are optional so a caller pinned to an older panel build keeps working: each
|
|
27
|
+
is a decimal clamped to 0–100, and a value that is not a plain decimal (a bool, a list,
|
|
28
|
+
`null`, `1e5`) is refused as `invalid_request` naming that field rather than silently
|
|
29
|
+
widening selection. `headroom_band` defaults to `30`, `session_gate` to `50`.
|
|
24
30
|
|
|
25
31
|
Candidates must be active, fresh, provider-capable, not currently limited, and not
|
|
26
32
|
covered by a live reservation. Percentages are finite decimals clamped to 0–100.
|
|
27
33
|
Canonical account IDs are NFC-normalized and trimmed with case preserved; an
|
|
28
34
|
NFC/case-folded key rejects case-only duplicate identities before ranking.
|
|
29
|
-
Known quota ranks ahead of unknown quota; higher minimum weekly/session headroom
|
|
30
|
-
wins; equal capacity goes to the least-recently-selected account, then a stable
|
|
31
|
-
Claude-before-Codex/account-id tie order.
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
## Ranking: the session gate, then the weekly band
|
|
37
|
+
|
|
38
|
+
The eligible set is ranked in two cuts (operator's decision, 2026-09-03: "among
|
|
39
|
+
accounts where high session limits it must choose randomly from ones where highest
|
|
40
|
+
weekly limits").
|
|
41
|
+
|
|
42
|
+
1. **Session gate.** An account clears the gate when its quota is known and its session
|
|
43
|
+
(5h) usage is at most `session_gate` points. If any account clears it, only those are
|
|
44
|
+
ranked further — a nearly spent 5h bucket is about to reject the launch whatever the
|
|
45
|
+
weekly headroom says. If nobody clears it the gate steps aside and every known
|
|
46
|
+
account is ranked: the gate compares, it never empties the pool. Unknown quota never
|
|
47
|
+
clears it. `session_gate: 100` disables the gate.
|
|
48
|
+
2. **Weekly band.** Among the gated accounts, the largest weekly remaining is the
|
|
49
|
+
leader and `band_floor` is that minus `headroom_band`; every gated account at or
|
|
50
|
+
above the floor is a peer. The band is measured on WEEKLY remaining, not on
|
|
51
|
+
`min(weekly, session)` — session headroom already had its say in the gate.
|
|
52
|
+
`headroom_band: 0` restores strict best-weekly ranking.
|
|
53
|
+
3. **Inside the band.** The least-recently-selected peer wins, then a spread digest over
|
|
54
|
+
(reservation key, identity), then a stable Claude-before-Codex/account-id order. This
|
|
55
|
+
is what makes a burst of parallel launches fan out instead of stacking on one
|
|
56
|
+
account, and it is deterministic: the same request always yields the same winner.
|
|
57
|
+
|
|
58
|
+
With no usable quota anywhere the band cannot apply (`band_floor` is `null`) and the
|
|
59
|
+
plain ordering stands: known quota ahead of unknown, then highest effective headroom,
|
|
60
|
+
then least-recently-selected.
|
|
61
|
+
|
|
62
|
+
Success returns the concrete engine/account/runner generation, normalized score basis
|
|
63
|
+
(which still reports `effective_headroom` = min(weekly, session) for observability),
|
|
64
|
+
`headroom_band` / `band_floor` / `band_count`, `session_gate` / `session_ok_count` (how
|
|
65
|
+
many accounts cleared the gate; `0` means nobody did and the gate stepped aside), an
|
|
66
|
+
RFC 8785/SHA-256 candidate-snapshot digest and a selection digest. Both knobs are inputs
|
|
67
|
+
to the selection digest, because both can change which account wins. Stable failures are
|
|
68
|
+
`invalid_request`, `unsupported_schema`, `duplicate_candidate_identity`, and
|
|
36
69
|
`no_candidate`. A reviewer stays on the producer's provider and avoids its account when
|
|
37
70
|
another eligible account exists.
|
|
38
71
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/report.py
CHANGED
|
@@ -255,6 +255,14 @@ def _usage(d, stale_after):
|
|
|
255
255
|
# the reading cannot be used for degraded ranking.
|
|
256
256
|
if lim.get('weekly_resets_epoch'):
|
|
257
257
|
out['weekly_resets_epoch'] = lim['weekly_resets_epoch']
|
|
258
|
+
# The writer found no INFORMATIVE bucket (every percentage 0 with no reset window)
|
|
259
|
+
# and refused to invent numbers from it, so the ranking fields above are all None
|
|
260
|
+
# here and the account ranks as unknown rather than as empty — the 2026-09-04
|
|
261
|
+
# incident, where fake-zero telemetry made two exhausted accounts the band leaders.
|
|
262
|
+
# Emitted only when set, and always as a plain true, so a consumer that has never
|
|
263
|
+
# seen the flag reads exactly the shape it always did.
|
|
264
|
+
if lim.get('no_data'):
|
|
265
|
+
out['no_data'] = True
|
|
258
266
|
if lim.get('last_error'):
|
|
259
267
|
out['last_error'] = lim['last_error']
|
|
260
268
|
if lim.get('last_error_at'):
|
|
@@ -281,12 +289,34 @@ def _last_pick(root, aid):
|
|
|
281
289
|
|
|
282
290
|
|
|
283
291
|
|
|
292
|
+
def _rankable(u):
|
|
293
|
+
"""True when this reading can still rank its account in the shim: inside the
|
|
294
|
+
ranking window AND carrying BOTH of the fields selection reads (bin/claude
|
|
295
|
+
rank_weekly_of / rank_session_of, gated by pick_best's "known" rule and by
|
|
296
|
+
telem_blind, which uses exactly this rule). A no_data document — all-zero buckets
|
|
297
|
+
with no reset window, 2026-09-04 — is current and well-formed but carries neither,
|
|
298
|
+
so a pool full of them is BLIND, not fresh; reporting it as fresh is how an outage
|
|
299
|
+
hides behind a recent timestamp. BOTH, not either: the same writer change omits
|
|
300
|
+
fields per signal, and a one-signal document (weekly-only or session-only) is
|
|
301
|
+
unknown to pick_best just as completely — an either-test called such a pool fresh
|
|
302
|
+
while the shim was tying every account and picking at random. bool is an int in
|
|
303
|
+
Python but not a number to the shim's digit parser, so `weekly_percent: true` must
|
|
304
|
+
read as unusable here too."""
|
|
305
|
+
if not u or u.get('stale'):
|
|
306
|
+
return False
|
|
307
|
+
return all(isinstance(u.get(k), (int, float)) and not isinstance(u.get(k), bool)
|
|
308
|
+
for k in ('weekly_percent', 'session_percent'))
|
|
309
|
+
|
|
310
|
+
|
|
284
311
|
def telemetry_state(accounts, now=None):
|
|
285
312
|
"""Three-state verdict on how the shim is CURRENTLY ranking this pool, over the
|
|
286
313
|
same candidate set bin/claude uses: the eligible accounts, or — when every one of
|
|
287
314
|
them is limit-marked — the valid ones it falls back to.
|
|
288
315
|
|
|
289
|
-
'fresh' at least one candidate has telemetry inside the ranking window
|
|
316
|
+
'fresh' at least one candidate has telemetry inside the ranking window that
|
|
317
|
+
the shim can actually rank on — a COMPLETE reading, weekly and session
|
|
318
|
+
both (see _rankable: a no_data document, or a one-signal one, is
|
|
319
|
+
in-window and ranks nothing, so it does not make a pool fresh)
|
|
290
320
|
'degraded' none do, but EVERY candidate still has a weekly reading whose bucket
|
|
291
321
|
has not reset yet, so the shim ranks on those (all-or-nothing: one
|
|
292
322
|
unusable reading and the comparison is not apples-to-apples)
|
|
@@ -302,10 +332,16 @@ def telemetry_state(accounts, now=None):
|
|
|
302
332
|
cands = [a for a in usable if a.get('status') == 'active'] or usable
|
|
303
333
|
if not cands:
|
|
304
334
|
return 'none'
|
|
305
|
-
if any(a.get('usage')
|
|
335
|
+
if any(_rankable(a.get('usage')) for a in cands):
|
|
306
336
|
return 'fresh'
|
|
307
337
|
for a in cands:
|
|
308
338
|
u = a.get('usage') or {}
|
|
339
|
+
# DEGRADED means "everything is old, rank on old truths". A reading INSIDE the
|
|
340
|
+
# window that is merely unrankable (no_data, or one-signal) blocks degraded in
|
|
341
|
+
# the shim too (bin/claude assess_telemetry, codex review 2026-09-04): fresh
|
|
342
|
+
# emptiness is not an outage of age, and re-fetching will not improve it.
|
|
343
|
+
if not u.get('stale'):
|
|
344
|
+
return 'blind'
|
|
309
345
|
horizon = u.get('weekly_resets_epoch') or 0
|
|
310
346
|
pct = u.get('weekly_percent')
|
|
311
347
|
# bool is an int in Python but not a number to the shim's digit parser, so a
|