claude-multiacc 2.0.21 → 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/bin/codex CHANGED
@@ -108,11 +108,38 @@ fi
108
108
  # scraped number goes through here.
109
109
  num_ok() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "${#1}" -le 18 ]; }
110
110
 
111
+ iso_of_epoch() { # $1 seconds -> UTC ISO8601 ('' when neither date(1) dialect works)
112
+ date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null
113
+ }
114
+
115
+ # Comparable digit string for an ISO timestamp: 2026-08-21T17:07:59.321Z -> 20260821170759.
116
+ # Locale-proof (plain integers), and short enough that num_ok always passes. Byte-parallel
117
+ # with bin/claude (~637), because client_limit_scan below compares a rollout record's
118
+ # timestamp against the .client-limit-cleared watermark exactly the way that shim does.
119
+ iso_key() { local t="${1%%.*}"; t="$(printf '%s' "$t" | LC_ALL=C tr -cd '0-9')"; printf '%s\n' "${t}"; }
120
+
111
121
  sel_log() {
112
122
  printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
113
123
  }
114
124
 
115
125
  marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
126
+ # Parity with bin/claude's client_marker_recovered, by construction: this shim has NO
127
+ # telemetry-based clearing path. A marker leaves here only when its OWN reset epoch has
128
+ # passed, so no usage reading — informative or not — can unpark an account.
129
+ # The bucket names this pool actually carries are written by client_limit_scan below
130
+ # from the rollout's own `window_minutes`: `bucket=client:7d` (>= 1440 minutes) and
131
+ # `bucket=client:5h` (under it), plus a bare `bucket=client:primary|secondary` for a
132
+ # report that named no window at all. Those two raw key names are what the scan wrote
133
+ # before 2026-09-04 and they are NOT weekly tokens — live payloads report `primary` as
134
+ # the 10080-minute window — so the weekly guard could never match a codex marker.
135
+ # That guard is the rule 2026-09-04 forced on the claude side: acct-13/acct-14 were
136
+ # served fake-zero telemetry (every bucket percent 0, resets_at null), which read as 0%
137
+ # and deleted their truthful client:seven_day markers, and an interactive session was
138
+ # handed both weekly-exhausted accounts in a row. Codex's one telemetry-driven clear
139
+ # lives in `codex-accounts limits`: it keeps a client:7d marker until its own reset and
140
+ # clears an aged client:5h one on an informative pass (#22, 2026-09-03), stamping
141
+ # `.client-limit-cleared` as it does so this scan cannot re-mark from the same rollout.
142
+ # If a recovery path is ever added HERE, it must apply that identical rule.
116
143
  local m="$1/.limited" reset=""
117
144
  [ -f "$m" ] || return 1
118
145
  IFS= read -r reset < "$m" 2>/dev/null || reset=""
@@ -289,7 +316,7 @@ rl_field() { # rl_field <json fragment> <key> -> leading integer of that key's v
289
316
  # Prints "<reset-epoch> <window>"; fails when there is none.
290
317
  client_limit_scan() { # $1 acct dir
291
318
  local day f line frag pct reset best=0 bestwin="" scanned=0 thr="${CODEX_MULTIACC_THRESHOLD:-90}" which
292
- local memo="$1/.client-scan" last="" ttl
319
+ local memo="$1/.client-scan" last="" ttl mins win ts ck sk cleared
293
320
  [ "${CODEX_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
294
321
  sessions_owned "$1" || return 1
295
322
  # A CLEAN result is remembered for a few seconds so a tight loop of `codex exec` runs
@@ -351,15 +378,57 @@ EOF
351
378
  | LC_ALL=C grep -a '^{".*"rate_limits"' \
352
379
  | tail -1)"
353
380
  [ -n "$line" ] || continue
381
+ # A clean `codex-accounts limits` pass that DELETED this account's client marker
382
+ # supersedes every report at or before its stamp. Without this watermark the clear
383
+ # achieved nothing: the rollout is still on disk, so the very next launch re-read
384
+ # the same tail and rewrote the same park — a delete/rewrite flap once per pass,
385
+ # which is exactly what codex gaining 5h clearing bought us on 2026-09-04.
386
+ # bin/claude's scan (~800) has consumed the same file, with the same name and the
387
+ # same comparison, since the #22 five-hour clearing landed.
388
+ ts="$(printf '%s' "$line" | LC_ALL=C sed -n \
389
+ 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
390
+ ck="$(iso_key "$ts")"
391
+ cleared=""
392
+ [ -f "$1/.client-limit-cleared" ] \
393
+ && { IFS= read -r cleared < "$1/.client-limit-cleared" 2>/dev/null || cleared=""; }
394
+ if num_ok "$cleared"; then
395
+ sk="$(iso_key "$(iso_of_epoch "$cleared")")"
396
+ if num_ok "$ck" && num_ok "$sk"; then
397
+ [ "$ck" -le "$sk" ] && continue
398
+ elif [ "$(file_mtime "$f")" -le "$cleared" ]; then
399
+ # An undatable report falls back to the rollout's mtime, exactly as the claude
400
+ # scan does: no timestamp, no way to prove it is newer than the clear.
401
+ continue
402
+ fi
403
+ fi
354
404
  for which in primary secondary; do
355
405
  frag="$(printf '%s' "$line" | LC_ALL=C sed -n "s/.*\"$which\"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p")"
356
406
  [ -n "$frag" ] || continue
357
407
  pct="$(rl_field "$frag" used_percent)"
358
408
  reset="$(rl_field "$frag" resets_at)"
409
+ mins="$(rl_field "$frag" window_minutes)"
359
410
  num_ok "$pct" || continue
360
411
  num_ok "$reset" || continue
361
412
  [ "$pct" -ge "$thr" ] || continue
362
- if [ "$reset" -gt "$best" ]; then best="$reset"; bestwin="$which:$pct"; fi
413
+ # The marker's bucket has to name the KIND of window that was spent, because that
414
+ # token is the only thing the weekly guard (codex-accounts weekly_marker) can read.
415
+ # `primary`/`secondary` are just the rollout's key names and map to nothing fixed —
416
+ # live payloads report primary as the 10080-minute window — so a marker named after
417
+ # them was never protected by that guard (2026-09-04). window_minutes rides in the
418
+ # same fragment, so the label is derived HERE, at write time: a day or more is the
419
+ # weekly window (7d), which only refills on its multi-day reset and therefore
420
+ # outlives every later usage payload; anything shorter is the self-healing 5h
421
+ # bucket that #22 (2026-09-03) had to keep clearable. A report carrying no
422
+ # window_minutes keeps the raw key name and so stays clearable too — the same
423
+ # fail-open direction, still bounded by the marker's own reset epoch.
424
+ if num_ok "$mins" && [ "$mins" -ge 1440 ]; then
425
+ win="7d"
426
+ elif num_ok "$mins"; then
427
+ win="5h"
428
+ else
429
+ win="$which"
430
+ fi
431
+ if [ "$reset" -gt "$best" ]; then best="$reset"; bestwin="$win:$pct"; fi
363
432
  done
364
433
  [ "$best" -gt "$now" ] && break 2
365
434
  done
@@ -372,7 +441,13 @@ EOF
372
441
  printf '%s %s\n' "$best" "$bestwin"
373
442
  }
374
443
 
375
- mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 window:pct
444
+ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 window:pct (window: 7d|5h|primary|secondary)
445
+ # $3's window half is the SEMANTIC label client_limit_scan derived from window_minutes,
446
+ # so line 2 reads `bucket=client:7d` / `bucket=client:5h` and the writer's weekly guard
447
+ # matches it. A `client:primary` / `client:secondary` marker still on disk was written
448
+ # before 2026-09-04: it names no window, so it keeps the pre-guard, clearable behavior
449
+ # and simply expires at its own reset epoch. The sel_log line below keeps showing the
450
+ # window:pct detail either way.
376
451
  local m="$1/.limited" cur=""
377
452
  # Never shorten a marker that already reaches further out, and never rewrite the
378
453
  # same one on every invocation.
@@ -559,8 +634,10 @@ pick_best() { # args: candidate dirs
559
634
  # otherwise be the sole gate-clearer and win the all-gated tie over an account whose
560
635
  # truthful weekly reading merely failed the gate. The one exception is a DEGRADED pool
561
636
  # (SEL_DEGRADED=1): nothing is fresh anywhere, the gate has necessarily stepped aside,
562
- # and a still-valid stale weekly reading is the only truth there is. (Neither writer
563
- # emits one field without the other; this is parity with lib/selector_policy.py.)
637
+ # and a still-valid stale weekly reading is the only truth there is. (The writers DO
638
+ # emit one-signal documents per-signal informative aggregation, 2026-09-04 and
639
+ # such a document is exactly as unknown here as an empty one; parity with
640
+ # lib/selector_policy.py's quota_known.)
564
641
  if [ "$k" = 1 ] && { [ "$sk" = 1 ] || [ "$SEL_DEGRADED" = 1 ]; }; then
565
642
  weekly+=("$w"); known+=(1)
566
643
  else
@@ -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
- print(f" limits : {' '.join(parts) or '(none)'} [{age}s old, max {lim.get('max_percent')}%]")
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
 
@@ -1465,19 +1534,64 @@ for acct in manifest.get('accounts', []):
1465
1534
  # the shim ranks only accounts at/under CODEX_MULTIACC_SESSION_GATE
1466
1535
  # (default 50) while any clear it. (A soft tiebreaker until
1467
1536
  # 2026-09-03 — the operator asked for session FIRST, then weekly.)
1468
- maxp = max([b['percent'] for b in buckets] or [0])
1469
- weekly = [b['percent'] for b in buckets if b['group'] != 'session']
1470
- session = [b['percent'] for b in buckets if b['group'] == 'session']
1471
- weeklyp = max(weekly) if weekly else maxp
1472
- sessionp = max(session) if session else 0
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
1473
1566
  try:
1474
1567
  reset_result = try_auto_redeem(d, aid, maxp, data, url, headers, say, int(now))
1475
1568
  except Exception as e:
1476
1569
  say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
1477
1570
  reset_result = {'status': 'error'}
1478
- out = {'fetched_at': int(now), 'source': 'chatgpt', 'max_percent': maxp,
1479
- 'weekly_percent': weeklyp, 'session_percent': sessionp,
1480
- 'plan': str(data.get('plan_type') or ''), 'buckets': buckets}
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
1481
1595
  if reset_result.get('status') == 'redeemed':
1482
1596
  # The response proves the reset succeeded, but the usage GET happened before
1483
1597
  # it. Make that snapshot stale immediately so it cannot re-exclude the newly
@@ -1506,14 +1620,37 @@ for acct in manifest.get('accounts', []):
1506
1620
  # since any bucket over the threshold stays there until its own reset.
1507
1621
  worst = max(offenders, key=lambda b: (int(b['resets_epoch']), b['percent']))
1508
1622
  reset_epoch = int(worst['resets_epoch'])
1509
- # Atomic: a concurrent shim must never read a half-written marker.
1510
- with open(mpath + '.tmp', 'w') as f:
1511
- f.write(f'{reset_epoch}\n')
1512
- f.write(f"bucket={worst['name']} percent={worst['percent']} "
1513
- f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
1514
- f"reason=limits resets_at={worst['resets_at']}\n")
1515
- os.replace(mpath + '.tmp', mpath)
1516
- say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
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']})")
1517
1654
  else:
1518
1655
  if os.path.exists(mpath):
1519
1656
  # A shim-written marker outlives a clean limits pass while its own window
@@ -1525,18 +1662,62 @@ for acct in manifest.get('accounts', []):
1525
1662
  # payload that disagrees must not unpark the account
1526
1663
  # early and send work straight back into the wall.
1527
1664
  keep = False
1665
+ client_recovered = False
1528
1666
  try:
1529
1667
  txt = open(mpath).read()
1530
1668
  first = txt.splitlines()[0] if txt else ''
1531
- if reset_result.get('status') != 'redeemed' \
1532
- and ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1533
- and first.isdigit() and int(first) > now:
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)):
1534
1692
  keep = True
1693
+ client_recovered = active and is_client and not keep
1535
1694
  except Exception:
1536
1695
  pass
1537
1696
  if not keep:
1538
1697
  os.remove(mpath)
1539
- say(f'{aid}: marker cleared (max {maxp}%)')
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)')
1540
1721
  if not quiet:
1541
1722
  detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
1542
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? |
@@ -336,7 +357,7 @@ derived from the pool root. Uninstalling an instance removes only that instance'
336
357
  | `CLAUDE_MULTIACC_SESSION_GATE=0..100` | max 5h-session usage that still gets ranked; default `50`, `100` disables the gate |
337
358
  | `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | ignore the client's own rate-limit records |
338
359
  | `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | clean client-limit scan cache; default 20s |
339
- | `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` | low-telemetry recovery grace; default 300s |
360
+ | `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` | five-hour recovery grace; default 300s (weekly never clears early) |
340
361
  | `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
341
362
  | `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool; legacy spelling is `CLAUDE_ACCOUNTS_DIR` |
342
363
  | `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
@@ -345,8 +366,9 @@ derived from the pool root. Uninstalling an instance removes only that instance'
345
366
  The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
346
367
  `CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
347
368
  `CODEX_SHIM_SELECT`, `CODEX_MULTIACC_HEADROOM_BAND`, `CODEX_MULTIACC_SESSION_GATE`,
348
- `CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`), `CODEX_MULTIACC_SYNC_TARGET`,
349
- `CODEX_MULTIACC_THRESHOLD`.
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`.
350
372
 
351
373
  ## Verification
352
374
 
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') and not a['usage'].get('stale') for a in cands):
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.21",
3
+ "version": "2.0.22",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {