claude-multiacc 2.0.34 → 2.0.36
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/README.md +31 -0
- package/bin/claude +86 -1
- package/bin/claude-accounts +180 -10
- package/bin/codex-accounts +46 -1
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/claude_reset.py +673 -0
- package/lib/common.sh +3 -1
- package/lib/report.py +18 -0
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +206 -0
- package/tests/test_claude_reset.py +622 -0
- package/tests/test_codex_reset.py +35 -0
package/README.md
CHANGED
|
@@ -177,6 +177,37 @@ buckets keep being tracked and the ≥90% rule keeps working. Unparseable entrie
|
|
|
177
177
|
skipped, an all-legacy payload falls back to `five_hour`/`seven_day`, and a payload the
|
|
178
178
|
code cannot read at all degrades that one account (fail open), never the run.
|
|
179
179
|
|
|
180
|
+
**Limit resets are redeemed automatically, but only when one earns its keep.** Claude
|
|
181
|
+
Code 2.1.280 added `/limit-reset`: an account can hold a *grant* (the Opus 5.5 launch gave
|
|
182
|
+
every Pro/Max account one reset, usable until 2026-10-22) that refills its five-hour and
|
|
183
|
+
weekly limits at once while the weekly reset day stays put. The same usage call carries the
|
|
184
|
+
grant's status (`?cedar_ember=1&skip_spend=1`, Claude Code's own read, so it costs nothing
|
|
185
|
+
against the endpoint's hourly budget), and the limits pass claims it with
|
|
186
|
+
`POST /api/organizations/<org>/reset_rate_limits` when a **weekly** limit the grant refills
|
|
187
|
+
is ≥ 95% used or reported exhausted — the codex rule, narrowed because a Claude reset is one
|
|
188
|
+
irreversible shot. A full five-hour window alone never spends it (it refills in hours and the
|
|
189
|
+
pool picks another account), and it is held while the account's own week refills in under
|
|
190
|
+
24 h (`CLAUDE_MULTIACC_RESET_MIN_HORIZON`, seconds). The claim carries a request id derived
|
|
191
|
+
from the account, grant, remaining count and week, persisted before the POST, so a lost
|
|
192
|
+
response is retried with the same id and fleet peers converge on one claim; a claim whose
|
|
193
|
+
answer never came back is settled by the grant's own remaining count dropping, and a claim
|
|
194
|
+
the server refuses outright backs off (honouring `Retry-After`) instead of being re-sent every
|
|
195
|
+
pass. A confirmed reset zeroes the pre-reset reading's `fetched_at`, withdraws its numbers,
|
|
196
|
+
and drops the account's `.limited` marker — including a client-reported weekly one, which no
|
|
197
|
+
telemetry can otherwise lift — with the same `.client-limit-cleared` watermark a telemetry
|
|
198
|
+
clear writes. Because peers are pushed markers but never their deletion, the reset also rides
|
|
199
|
+
in `limits.json` for 8 days (`reset_redeemed_at`, `reset_cleared`): every machine's shim,
|
|
200
|
+
`list`/`status` and the app-robot panel treat any park written before it, for a window it
|
|
201
|
+
refilled, as lifted. A reset claimed elsewhere (a peer, or `/limit-reset` by hand) enters the
|
|
202
|
+
same record when one grant's own count drops. Only an OAuth login can
|
|
203
|
+
claim (the same credential the usage read proved). `reset_credits_available` /
|
|
204
|
+
`reset_credits_fetched_at` report the measured allowance exactly as the codex pool does. The
|
|
205
|
+
server answers eligibility by client (`ineligible_reason: surface` to anything that is not
|
|
206
|
+
the CLI), so the writer sends Claude Code's own User-Agent with the installed version and
|
|
207
|
+
`client-app/claude-multiacc`; with no readable version it names itself plainly and claims
|
|
208
|
+
nothing. `CLAUDE_MULTIACC_AUTO_RESET=0` is the kill switch. The separate `juniper_tide`
|
|
209
|
+
session reset (it spends weekly budget) is never touched.
|
|
210
|
+
|
|
180
211
|
**A 0% bucket with no reset window is no data, not an empty account.** Real usage always
|
|
181
212
|
carries the window it resets in, so `percent: 0` with a null `resets_at` is the endpoint
|
|
182
213
|
declining to answer — and taking it at face value put two weekly-exhausted accounts at the
|
package/bin/claude
CHANGED
|
@@ -301,6 +301,24 @@ client_marker_recovered() { # $1 account dir, $2 marker
|
|
|
301
301
|
*) bucket="" ;;
|
|
302
302
|
esac
|
|
303
303
|
case "$(sel_lc "$bucket")" in *seven_day*|*7d*|*weekly*) return 1 ;; esac
|
|
304
|
+
# ...and a SESSION rejection is only disproved by a reading that actually measured
|
|
305
|
+
# the session window. `peak` below is max_percent, which is the WEEKLY buckets' peak
|
|
306
|
+
# whenever the 5h bucket says nothing — clearing a five_hour marker on it borrows
|
|
307
|
+
# across signals exactly as the writer refuses to. 2026-09-22, acct-14: telemetry
|
|
308
|
+
# fetched WHILE the client was being rejected still read the session bucket
|
|
309
|
+
# `percent: 0, resets_at: null`, so the writer flags that 0 as inferred; the marker
|
|
310
|
+
# was cleared on it, `--resume` landed on the account, and the run died in a minute.
|
|
311
|
+
# A silent session bucket proves nothing about a 429 the client actually received.
|
|
312
|
+
case "$(sel_lc "$bucket")" in
|
|
313
|
+
*five_hour*|*5h*|*session*)
|
|
314
|
+
# A literal test, not limits_field: that parser is integer-only and its
|
|
315
|
+
# `[^0-9]*` would happily skip past `true` and return a digit from the NEXT
|
|
316
|
+
# field, which would read as "inferred" on every document.
|
|
317
|
+
LC_ALL=C grep -q '"session_inferred"[[:space:]]*:[[:space:]]*true' \
|
|
318
|
+
"$1/limits.json" 2>/dev/null && return 1
|
|
319
|
+
cutoff_field "$1" session_percent >/dev/null 2>&1 || return 1
|
|
320
|
+
;;
|
|
321
|
+
esac
|
|
304
322
|
fetched="$(telem_fetched_at "$1" 2>/dev/null || echo 0)"
|
|
305
323
|
# Defaulting peak to 100 when the reading is missing/stale is the second half of the
|
|
306
324
|
# same rule: an UNINFORMATIVE reading must never clear a marker. It is what keeps a
|
|
@@ -335,6 +353,59 @@ client_marker_recovered() { # $1 account dir, $2 marker
|
|
|
335
353
|
return 1
|
|
336
354
|
}
|
|
337
355
|
|
|
356
|
+
# A confirmed limit reset (lib/claude_reset.py) lifts every park written BEFORE it for a
|
|
357
|
+
# window it refilled. The writer records it in limits.json — reset_redeemed_at (epoch) and
|
|
358
|
+
# reset_cleared (comma-separated limit types) — because that document is what reaches
|
|
359
|
+
# every machine: peers are pushed `.limited` files but never their deletion, so a marker
|
|
360
|
+
# the source cleared lives on elsewhere until its own reset, days away for a weekly one.
|
|
361
|
+
# Same rule as claude_reset.marker_superseded; a marker without marked_at never matches.
|
|
362
|
+
# Note the panel's `weekly_all` can name the Fable window; the launch grant refills both.
|
|
363
|
+
reset_supersedes_marker() { # $1 acct dir, $2 marker file
|
|
364
|
+
# Hot path (every account with a marker, every invocation): ONE sed for the record, the
|
|
365
|
+
# writer's precomputed UTC key instead of a date(1) fork, builtins for the rest.
|
|
366
|
+
local rec at="" key="" cleared="" line="" marked mk bucket t
|
|
367
|
+
[ -f "$1/limits.json" ] || return 1
|
|
368
|
+
# h/g restore the line between expressions, so a one-line document (every field on
|
|
369
|
+
# one line) yields all three just as the writer's indented one does.
|
|
370
|
+
rec="$(LC_ALL=C sed -n -e h \
|
|
371
|
+
-e 's/.*"reset_redeemed_at"[^0-9]*\([0-9][0-9]*\).*/A \1/p' -e g \
|
|
372
|
+
-e 's/.*"reset_redeemed_key"[^0-9]*\([0-9]\{14\}\).*/K \1/p' -e g \
|
|
373
|
+
-e 's/.*"reset_cleared"[[:space:]]*:[[:space:]]*"\([a-z0-9_,]*\)".*/C \1/p' \
|
|
374
|
+
"$1/limits.json" 2>/dev/null)"
|
|
375
|
+
[ -n "$rec" ] || return 1
|
|
376
|
+
# Split on the tags, not on newlines: sed keeps a final line's missing newline, so a
|
|
377
|
+
# one-line document runs the tagged values together.
|
|
378
|
+
case "$rec" in *"A "*) at="${rec#*A }"; at="${at%%[!0-9]*}" ;; esac
|
|
379
|
+
case "$rec" in *"K "*) key="${rec#*K }"; key="${key%%[!0-9]*}" ;; esac
|
|
380
|
+
case "$rec" in *"C "*) cleared="${rec#*C }"; cleared="${cleared%%[!a-z0-9_,]*}" ;; esac
|
|
381
|
+
num_ok "$at" && [ -n "$cleared" ] || return 1
|
|
382
|
+
[ $((now - at)) -le 691200 ] || return 1
|
|
383
|
+
if [ "${#key}" -ne 14 ]; then
|
|
384
|
+
key="$(iso_of_epoch "$at")"; key="${key//[!0-9]/}"; key="${key:0:14}"
|
|
385
|
+
fi
|
|
386
|
+
{ IFS= read -r line; IFS= read -r line; } < "$2" 2>/dev/null
|
|
387
|
+
case "$line" in *marked_at=*) marked="${line#*marked_at=}"; marked="${marked%% *}" ;; *) return 1 ;; esac
|
|
388
|
+
mk="${marked%%.*}"; mk="${mk//[!0-9]/}"; mk="${mk:0:14}"
|
|
389
|
+
num_ok "$mk" && num_ok "$key" && [ "${#mk}" -eq 14 ] && [ "${#key}" -eq 14 ] || return 1
|
|
390
|
+
[ "$mk" -le "$key" ] || return 1
|
|
391
|
+
bucket=""
|
|
392
|
+
case "$line" in *bucket=*) bucket="${line#*bucket=}"; bucket="${bucket%% *}" ;; esac
|
|
393
|
+
bucket="$(sel_lc "$bucket")"
|
|
394
|
+
case "$bucket" in
|
|
395
|
+
client:*) t="${bucket#client:}" ;;
|
|
396
|
+
weekly_scoped:*fable*) t=seven_day_overage_included ;;
|
|
397
|
+
weekly_scoped:*opus*) t=seven_day_opus ;;
|
|
398
|
+
weekly_scoped:*sonnet*) t=seven_day_sonnet ;;
|
|
399
|
+
weekly_scoped:*) return 1 ;;
|
|
400
|
+
session*|five_hour|5h) t=five_hour ;;
|
|
401
|
+
weekly_all|seven_day|weekly|7d) t=seven_day ;;
|
|
402
|
+
''|panel:observed|limit_reached|unknown) return 0 ;;
|
|
403
|
+
*) t="$bucket" ;;
|
|
404
|
+
esac
|
|
405
|
+
case ",$cleared," in *",$t,"*) return 0 ;; esac
|
|
406
|
+
return 1
|
|
407
|
+
}
|
|
408
|
+
|
|
338
409
|
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
339
410
|
local m="$1/.limited" reset="" tok
|
|
340
411
|
[ -f "$m" ] || return 1
|
|
@@ -349,6 +420,11 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
|
|
|
349
420
|
rm -f "$m" 2>/dev/null
|
|
350
421
|
return 1
|
|
351
422
|
fi
|
|
423
|
+
if reset_supersedes_marker "$1" "$m"; then
|
|
424
|
+
rm -f "$m" 2>/dev/null
|
|
425
|
+
sel_log "$(basename "$1") marker lifted — written before a confirmed limit reset"
|
|
426
|
+
return 1
|
|
427
|
+
fi
|
|
352
428
|
if client_marker_recovered "$1" "$m"; then
|
|
353
429
|
printf '%s\n' "$CLIENT_RECOVERY_FETCHED" > "$1/.client-limit-cleared.$$" 2>/dev/null \
|
|
354
430
|
&& mv -f "$1/.client-limit-cleared.$$" "$1/.client-limit-cleared" 2>/dev/null \
|
|
@@ -767,7 +843,7 @@ sess_transcript() { # $1 acct dir, $2 session id
|
|
|
767
843
|
# rejects the very next request too, and that lands in a newer transcript.
|
|
768
844
|
client_limit_scan() { # $1 acct dir
|
|
769
845
|
local idx="$1/.sessions-index" memo="$1/.client-scan" id p line r t read_n=0 i last=""
|
|
770
|
-
local ln claim ts ck ak ttl cleared sk
|
|
846
|
+
local ln claim ts ck ak ttl cleared sk rr="" rr_seen=""
|
|
771
847
|
local ids=() claims=()
|
|
772
848
|
[ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
|
|
773
849
|
[ -f "$idx" ] || return 1
|
|
@@ -820,6 +896,15 @@ client_limit_scan() { # $1 acct dir
|
|
|
820
896
|
cleared=""
|
|
821
897
|
[ -f "$1/.client-limit-cleared" ] \
|
|
822
898
|
&& { IFS= read -r cleared < "$1/.client-limit-cleared" 2>/dev/null || cleared=""; }
|
|
899
|
+
# A confirmed limit reset answers every rejection before it, on every machine the
|
|
900
|
+
# writer's limits.json reaches (the local watermark file never leaves the source).
|
|
901
|
+
if [ -z "$rr_seen" ]; then
|
|
902
|
+
rr_seen=1
|
|
903
|
+
rr="$(limits_field "$1" reset_redeemed_at 2>/dev/null)" || rr=""
|
|
904
|
+
fi
|
|
905
|
+
if num_ok "$rr" && { ! num_ok "$cleared" || [ "$rr" -gt "$cleared" ]; }; then
|
|
906
|
+
cleared="$rr"
|
|
907
|
+
fi
|
|
823
908
|
if num_ok "$cleared"; then
|
|
824
909
|
sk="$(iso_key "$(iso_of_epoch "$cleared")")"
|
|
825
910
|
if num_ok "$ck" && num_ok "$sk"; then
|
package/bin/claude-accounts
CHANGED
|
@@ -81,7 +81,11 @@ USAGE
|
|
|
81
81
|
or runner daemon distributes.
|
|
82
82
|
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
83
83
|
claude-accounts limits [--quiet] [--force] [--json]
|
|
84
|
-
refresh usage buckets, apply >=90% markers.
|
|
84
|
+
refresh usage buckets, apply >=90% markers. When a WEEKLY limit is at least
|
|
85
|
+
95% used or exhausted, automatically redeems the account's Claude Code limit
|
|
86
|
+
reset (/limit-reset) with an idempotent request — never for a five-hour
|
|
87
|
+
window alone, and not while the week refills on its own within 24h.
|
|
88
|
+
Auto-refreshes long-expired
|
|
85
89
|
OAuth access tokens via the refresh-token grant (rotated credential is
|
|
86
90
|
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
87
91
|
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
@@ -100,6 +104,9 @@ ENV
|
|
|
100
104
|
CLAUDE_ACCOUNT pin the shim to one account
|
|
101
105
|
CLAUDE_SHIM_RETRY=0 disable -p auto-retry
|
|
102
106
|
CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
107
|
+
CLAUDE_MULTIACC_AUTO_RESET=0 disable automatic limit-reset redemption
|
|
108
|
+
CLAUDE_MULTIACC_RESET_MIN_HORIZON seconds before the natural weekly reset
|
|
109
|
+
inside which a reset is held (default 86400)
|
|
103
110
|
EOF
|
|
104
111
|
}
|
|
105
112
|
|
|
@@ -207,6 +214,22 @@ doc = json.load(open(sys.argv[1]))
|
|
|
207
214
|
root = sys.argv[2]
|
|
208
215
|
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
209
216
|
from audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def superseded_by_reset(d):
|
|
220
|
+
"""lib/report.py's rule (and the shim's reset_supersedes_marker): a park written
|
|
221
|
+
before a confirmed limit reset, for a window it refilled, no longer binds."""
|
|
222
|
+
try:
|
|
223
|
+
import claude_reset, time as _time
|
|
224
|
+
with open(os.path.join(d, '.limited'), errors='replace') as handle:
|
|
225
|
+
text = handle.read(4096)
|
|
226
|
+
try:
|
|
227
|
+
ldoc = json.load(open(os.path.join(d, 'limits.json')))
|
|
228
|
+
except Exception:
|
|
229
|
+
ldoc = {}
|
|
230
|
+
return claude_reset.marker_superseded(text, claude_reset.record_from(ldoc, _time.time()))
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
210
233
|
machine = sys.argv[4]
|
|
211
234
|
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
|
|
212
235
|
accounts = [a for a in doc.get('accounts', [])
|
|
@@ -224,6 +247,8 @@ for a in accounts:
|
|
|
224
247
|
if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
|
|
225
248
|
auth.append('token')
|
|
226
249
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
250
|
+
if limited and superseded_by_reset(d):
|
|
251
|
+
limited = False # written before a confirmed limit reset; the shim lifts it
|
|
227
252
|
flags = []
|
|
228
253
|
if st['state'] == 'expired':
|
|
229
254
|
flags.append('EXPIRED-LOGIN')
|
|
@@ -284,6 +309,22 @@ doc = json.load(open(sys.argv[1]))
|
|
|
284
309
|
root = sys.argv[2]
|
|
285
310
|
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
286
311
|
from audit import audit_account, oauth_login # noqa: E402 (shared with the shim's rule)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def superseded_by_reset(d):
|
|
315
|
+
"""lib/report.py's rule (and the shim's reset_supersedes_marker): a park written
|
|
316
|
+
before a confirmed limit reset, for a window it refilled, no longer binds."""
|
|
317
|
+
try:
|
|
318
|
+
import claude_reset, time as _time
|
|
319
|
+
with open(os.path.join(d, '.limited'), errors='replace') as handle:
|
|
320
|
+
text = handle.read(4096)
|
|
321
|
+
try:
|
|
322
|
+
ldoc = json.load(open(os.path.join(d, 'limits.json')))
|
|
323
|
+
except Exception:
|
|
324
|
+
ldoc = {}
|
|
325
|
+
return claude_reset.marker_superseded(text, claude_reset.record_from(ldoc, _time.time()))
|
|
326
|
+
except Exception:
|
|
327
|
+
return False
|
|
287
328
|
machine = sys.argv[4]
|
|
288
329
|
now = time.time()
|
|
289
330
|
# Must match the shim's window (bin/claude), or status would call data "fresh" that
|
|
@@ -412,7 +453,9 @@ for a in doc.get('accounts', []):
|
|
|
412
453
|
else:
|
|
413
454
|
print(" limits : never fetched")
|
|
414
455
|
mpath = os.path.join(d, '.limited')
|
|
415
|
-
if os.path.isfile(mpath):
|
|
456
|
+
if os.path.isfile(mpath) and superseded_by_reset(d):
|
|
457
|
+
print(" marker : lifted — written before a confirmed limit reset (eligible)")
|
|
458
|
+
elif os.path.isfile(mpath):
|
|
416
459
|
try:
|
|
417
460
|
lines = open(mpath).read().splitlines()
|
|
418
461
|
reset = int(lines[0]) if lines and lines[0].isdigit() else 0
|
|
@@ -1574,13 +1617,20 @@ cmd_limits() {
|
|
|
1574
1617
|
case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
|
|
1575
1618
|
[ "$threshold" -gt 90 ] && threshold=90
|
|
1576
1619
|
[ "$threshold" -lt 1 ] && threshold=90
|
|
1577
|
-
|
|
1620
|
+
# Claude Code's own User-Agent (with the REAL installed version) is what makes the
|
|
1621
|
+
# usage endpoint answer the limit-reset status for this account; the writer learns the
|
|
1622
|
+
# version from the real binary (lib/claude_reset.py cli_version, bounded by a timeout).
|
|
1623
|
+
local real_claude
|
|
1624
|
+
real_claude="$(find_real_claude "$_self" 2>/dev/null)" || real_claude=""
|
|
1625
|
+
"$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" "$real_claude" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
|
|
1578
1626
|
import hashlib, json, os, sys, time, urllib.request
|
|
1579
1627
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1580
1628
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1581
1629
|
from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
|
|
1630
|
+
import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
|
|
1582
1631
|
|
|
1583
1632
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
1633
|
+
USER_AGENT = claude_reset.user_agent(claude_reset.cli_version(sys.argv[7] if len(sys.argv) > 7 else ''))
|
|
1584
1634
|
now = time.time()
|
|
1585
1635
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
1586
1636
|
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '240'))
|
|
@@ -1651,6 +1701,15 @@ def marker_bucket(txt):
|
|
|
1651
1701
|
return part[7:]
|
|
1652
1702
|
return ''
|
|
1653
1703
|
|
|
1704
|
+
def session_marker(txt):
|
|
1705
|
+
# The self-healing 5h/session window, named the way each client writes it:
|
|
1706
|
+
# claude's own type is `five_hour`, codex labels a short window `client:5h`, and a
|
|
1707
|
+
# bare `session` covers the writer's own marker. Anything else (a weekly name, an
|
|
1708
|
+
# unparseable line) is NOT a session marker and keeps its existing handling.
|
|
1709
|
+
b = marker_bucket(txt).lower()
|
|
1710
|
+
return 'five_hour' in b or '5h' in b or 'session' in b
|
|
1711
|
+
|
|
1712
|
+
|
|
1654
1713
|
def weekly_marker(txt):
|
|
1655
1714
|
# Matched the way both shims match it (bin/claude ~303, same token list): the
|
|
1656
1715
|
# claude client writes client:seven_day / client:seven_day_opus, the codex client
|
|
@@ -2075,12 +2134,13 @@ for acct in manifest.get('accounts', []):
|
|
|
2075
2134
|
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
2076
2135
|
continue
|
|
2077
2136
|
|
|
2078
|
-
|
|
2137
|
+
headers = {
|
|
2079
2138
|
'Authorization': 'Bearer ' + bearer,
|
|
2080
2139
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
2081
2140
|
'Content-Type': 'application/json',
|
|
2082
|
-
'User-Agent':
|
|
2083
|
-
}
|
|
2141
|
+
'User-Agent': USER_AGENT,
|
|
2142
|
+
}
|
|
2143
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2084
2144
|
try:
|
|
2085
2145
|
resp = urllib.request.urlopen(req, timeout=15)
|
|
2086
2146
|
data = json.loads(resp.read().decode())
|
|
@@ -2325,6 +2385,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2325
2385
|
# So: a session bucket that said nothing inside a document that DID answer reads 0.
|
|
2326
2386
|
# A document where NOTHING answered still writes no signals at all (see below) —
|
|
2327
2387
|
# the 2026-09-04 all-zero shape stays unknown, which is the whole point.
|
|
2388
|
+
session_measured = bool(session)
|
|
2328
2389
|
if sessionp is None and live:
|
|
2329
2390
|
sessionp = 0
|
|
2330
2391
|
# How long weekly_percent keeps meaning something. A weekly bucket only ever RISES
|
|
@@ -2338,13 +2399,39 @@ for acct in manifest.get('accounts', []):
|
|
|
2338
2399
|
wresets = [int(b['resets_epoch']) for b in live
|
|
2339
2400
|
if b['group'] != 'session' and b['percent'] == weeklyp
|
|
2340
2401
|
and isinstance(b.get('resets_epoch'), int)]
|
|
2402
|
+
# Limit resets ride on this very response (the usage URL asks for the cedar_ember
|
|
2403
|
+
# status block), so reading the allowance costs no call against the endpoint's
|
|
2404
|
+
# hourly budget. Same policy as codex: a limit the grant refills at >=95%, or one
|
|
2405
|
+
# the server reports exhausted, spends the grant. Only an OAuth login can claim —
|
|
2406
|
+
# the same credential this fetch just proved works. Fail OPEN like everything else.
|
|
2407
|
+
reset_result, reset_view = {'status': 'not_eligible'}, {}
|
|
2408
|
+
if source == 'oauth':
|
|
2409
|
+
try:
|
|
2410
|
+
reset_result, reset_view = claude_reset.refresh_reset_credits(
|
|
2411
|
+
d, aid, data, url, headers, say, int(now))
|
|
2412
|
+
except Exception as e:
|
|
2413
|
+
say(f'{aid}: limit reset automation failed unexpectedly ({type(e).__name__}); '
|
|
2414
|
+
f'failing open')
|
|
2415
|
+
reset_result, reset_view = {'status': 'error'}, {}
|
|
2416
|
+
redeemed = reset_result.get('status') == 'redeemed'
|
|
2341
2417
|
out = {'fetched_at': int(now), 'source': source}
|
|
2418
|
+
out.update(reset_view)
|
|
2342
2419
|
if live:
|
|
2343
2420
|
out['max_percent'] = maxp
|
|
2344
2421
|
if weeklyp is not None:
|
|
2345
2422
|
out['weekly_percent'] = weeklyp
|
|
2346
2423
|
if sessionp is not None:
|
|
2347
2424
|
out['session_percent'] = sessionp
|
|
2425
|
+
if sessionp is not None and not session_measured:
|
|
2426
|
+
# Ranking may use this 0 (that is the whole point — an idle account has to get
|
|
2427
|
+
# back into the band), but NOTHING may treat it as proof of recovery. 2026-09-22,
|
|
2428
|
+
# acct-14: a client:five_hour marker written from a real 429 at 13:06:31Z, and
|
|
2429
|
+
# telemetry fetched at 13:04:16Z — WHILE the account was being rejected — still
|
|
2430
|
+
# reported the session bucket `percent: 0, resets_at: null`. So a silent session
|
|
2431
|
+
# bucket does NOT prove an idle account; the endpoint simply may not report the
|
|
2432
|
+
# 5h window at all. The marker was cleared anyway, `--resume` landed straight on
|
|
2433
|
+
# it, and the run was rejected inside a minute. Recovery has to be MEASURED.
|
|
2434
|
+
out['session_inferred'] = True
|
|
2348
2435
|
if weeklyp is not None:
|
|
2349
2436
|
# Written with weekly_percent or not at all: the horizon describes THAT
|
|
2350
2437
|
# reading, and the shim's degraded path needs both or neither.
|
|
@@ -2361,6 +2448,52 @@ for acct in manifest.get('accounts', []):
|
|
|
2361
2448
|
say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
|
|
2362
2449
|
f'no usable telemetry (account ranks as unknown, not as empty)')
|
|
2363
2450
|
out['buckets'] = buckets
|
|
2451
|
+
reset_record = None
|
|
2452
|
+
reset_status = claude_reset.parse_status(data) if source == 'oauth' else None
|
|
2453
|
+
if reset_status is not None:
|
|
2454
|
+
out['reset_grants_seen'] = claude_reset.grants_seen(
|
|
2455
|
+
reset_status, now, reset_result if redeemed else None)
|
|
2456
|
+
elif isinstance(prev.get('reset_grants_seen'), dict):
|
|
2457
|
+
out['reset_grants_seen'] = prev['reset_grants_seen']
|
|
2458
|
+
observed = None
|
|
2459
|
+
if redeemed:
|
|
2460
|
+
# The claim response proves the limits refilled, but this usage GET happened
|
|
2461
|
+
# BEFORE it. Make the snapshot stale at once so it cannot re-exclude the account
|
|
2462
|
+
# (the codex writer's rule), and take the pre-reset numbers OUT of it: several
|
|
2463
|
+
# readers ignore freshness — the shim's forced-fallback check reads buckets
|
|
2464
|
+
# directly, the degraded path trusts a stale weekly until its horizon, and the
|
|
2465
|
+
# app-robot panel reads session/weekly percentages with no age test at all — and
|
|
2466
|
+
# every one of them would keep parking an account that was just refilled. The
|
|
2467
|
+
# buckets the reset did not touch stay; the next pass reads server truth.
|
|
2468
|
+
cleared = set(reset_result.get('cleared') or [])
|
|
2469
|
+
out['fetched_at'] = 0
|
|
2470
|
+
for key in ('weekly_resets_epoch', 'max_percent', 'weekly_percent',
|
|
2471
|
+
'session_percent', 'session_inferred', 'no_data'):
|
|
2472
|
+
out.pop(key, None)
|
|
2473
|
+
out['buckets'] = [b for b in buckets
|
|
2474
|
+
if claude_reset.bucket_limit_type(b['name']) not in cleared]
|
|
2475
|
+
out['auto_reset'] = reset_result
|
|
2476
|
+
reset_record = (int(reset_result['redeemed_at']), sorted(cleared))
|
|
2477
|
+
else:
|
|
2478
|
+
# A reset is honoured fleet-wide through THIS document (peers never see a marker
|
|
2479
|
+
# deleted), so the record rides along on every later pass until nothing it could
|
|
2480
|
+
# supersede can still exist. A reset this machine did not claim (a peer's, or a
|
|
2481
|
+
# human's /limit-reset) enters it here, from the grant's own count dropping; this
|
|
2482
|
+
# GET already came after it, so its numbers stand.
|
|
2483
|
+
observed = claude_reset.observed_reset(prev, reset_status, now)
|
|
2484
|
+
reset_record = claude_reset.latest(claude_reset.record_from(prev, now), observed,
|
|
2485
|
+
claude_reset.local_record(d, now))
|
|
2486
|
+
if observed and reset_record is observed:
|
|
2487
|
+
say(f"{aid}: limit reset observed (a grant's count dropped; "
|
|
2488
|
+
f"refilled {', '.join(observed[1])}) — lifting older parks")
|
|
2489
|
+
else:
|
|
2490
|
+
observed = None
|
|
2491
|
+
if reset_record:
|
|
2492
|
+
out['reset_redeemed_at'] = reset_record[0]
|
|
2493
|
+
# The same moment as a UTC YYYYmmddHHMMSS key: the shim compares marked_at
|
|
2494
|
+
# against it with builtins, no date(1) fork on the per-invocation path.
|
|
2495
|
+
out['reset_redeemed_key'] = time.strftime('%Y%m%d%H%M%S', time.gmtime(reset_record[0]))
|
|
2496
|
+
out['reset_cleared'] = ','.join(reset_record[1])
|
|
2364
2497
|
tmp = lpath + '.tmp'
|
|
2365
2498
|
with open(tmp, 'w') as f:
|
|
2366
2499
|
json.dump(out, f, indent=1)
|
|
@@ -2368,7 +2501,19 @@ for acct in manifest.get('accounts', []):
|
|
|
2368
2501
|
# The fetch went through with this account's own bearer => its auth is alive.
|
|
2369
2502
|
if clear_expired(d):
|
|
2370
2503
|
say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
|
|
2371
|
-
|
|
2504
|
+
if redeemed or observed:
|
|
2505
|
+
# The shim's transcript scan would otherwise re-create a park from the very
|
|
2506
|
+
# rejection the reset just answered — whether or not a marker exists right now
|
|
2507
|
+
# and whatever wrote it. Never move the watermark backwards.
|
|
2508
|
+
cleared_path = os.path.join(d, '.client-limit-cleared')
|
|
2509
|
+
try:
|
|
2510
|
+
prior = int(open(cleared_path).read().split()[0])
|
|
2511
|
+
except Exception:
|
|
2512
|
+
prior = 0
|
|
2513
|
+
with open(cleared_path + '.tmp', 'w') as f:
|
|
2514
|
+
f.write(f'{max(prior, reset_record[0])}\n')
|
|
2515
|
+
os.replace(cleared_path + '.tmp', cleared_path)
|
|
2516
|
+
offenders = [] if redeemed else [b for b in buckets if b['percent'] >= threshold]
|
|
2372
2517
|
mpath = os.path.join(d, '.limited')
|
|
2373
2518
|
if offenders:
|
|
2374
2519
|
# ONE bucket, described consistently: the marker's epoch is the reset of
|
|
@@ -2402,7 +2547,8 @@ for acct in manifest.get('accounts', []):
|
|
|
2402
2547
|
if first.isdigit() and len(first) <= 18:
|
|
2403
2548
|
cur_reset = int(first)
|
|
2404
2549
|
keep_client = ('reason=client-rate-limit' in cur
|
|
2405
|
-
and cur_reset > now and cur_reset >= reset_epoch
|
|
2550
|
+
and cur_reset > now and cur_reset >= reset_epoch
|
|
2551
|
+
and not claude_reset.marker_superseded(cur, reset_record))
|
|
2406
2552
|
except Exception:
|
|
2407
2553
|
pass
|
|
2408
2554
|
if keep_client:
|
|
@@ -2429,6 +2575,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2429
2575
|
# markers strand recovered accounts for days.
|
|
2430
2576
|
keep = False
|
|
2431
2577
|
client_recovered = False
|
|
2578
|
+
txt = ''
|
|
2432
2579
|
try:
|
|
2433
2580
|
txt = open(mpath).read()
|
|
2434
2581
|
first = txt.splitlines()[0] if txt else ''
|
|
@@ -2442,10 +2589,23 @@ for acct in manifest.get('accounts', []):
|
|
|
2442
2589
|
is_client = 'reason=client-rate-limit' in txt
|
|
2443
2590
|
# See weekly_marker() above for why these three come first and in
|
|
2444
2591
|
# this order; the shim applies the identical test per invocation.
|
|
2445
|
-
if
|
|
2592
|
+
if redeemed or claude_reset.marker_superseded(txt, reset_record):
|
|
2593
|
+
# A confirmed reset is first-hand proof the limits refilled — the
|
|
2594
|
+
# one thing that outranks even a client-reported weekly rejection
|
|
2595
|
+
# (the codex writer's identical rule). A marker written before it,
|
|
2596
|
+
# for a window it refilled, describes a state that no longer exists.
|
|
2597
|
+
keep = False
|
|
2598
|
+
elif active and not live:
|
|
2446
2599
|
keep = True
|
|
2447
2600
|
elif active and is_client and weekly_marker(txt):
|
|
2448
2601
|
keep = True
|
|
2602
|
+
elif active and is_client and session_marker(txt) and not session_measured:
|
|
2603
|
+
# Same rule as the weekly one, one window down: a rejection the CLIENT
|
|
2604
|
+
# reported is only disproved by telemetry that actually looked at the
|
|
2605
|
+
# window it names. `max_percent` here comes from the WEEKLY buckets
|
|
2606
|
+
# when the session bucket is silent, and clearing a 5h marker on it is
|
|
2607
|
+
# the cross-signal borrow the aggregation above refuses to make.
|
|
2608
|
+
keep = True
|
|
2449
2609
|
elif active and ('reason=error-cooldown' in txt
|
|
2450
2610
|
or (is_client and recent_client)):
|
|
2451
2611
|
keep = True
|
|
@@ -2456,14 +2616,24 @@ for acct in manifest.get('accounts', []):
|
|
|
2456
2616
|
os.remove(mpath)
|
|
2457
2617
|
if client_recovered:
|
|
2458
2618
|
cleared = os.path.join(d, '.client-limit-cleared')
|
|
2619
|
+
try:
|
|
2620
|
+
prior = int(open(cleared).read().split()[0])
|
|
2621
|
+
except Exception:
|
|
2622
|
+
prior = 0
|
|
2623
|
+
# Never backwards: a reset confirmed earlier in this same pass has
|
|
2624
|
+
# already stamped a later moment than the pass's start.
|
|
2459
2625
|
with open(cleared + '.tmp', 'w') as f:
|
|
2460
|
-
f.write(f'{int(now)}\n')
|
|
2626
|
+
f.write(f'{max(prior, int(now))}\n')
|
|
2461
2627
|
os.replace(cleared + '.tmp', cleared)
|
|
2462
2628
|
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
2463
2629
|
# nothing was reported (see informative() above). Log that, instead
|
|
2464
2630
|
# of a "0%" that reads like a proven-empty account — the exact
|
|
2465
2631
|
# misreading behind the 2026-09-04 incident.
|
|
2466
2632
|
seen = f'max {maxp}%' if live else 'no usable telemetry'
|
|
2633
|
+
if redeemed:
|
|
2634
|
+
seen = 'limit reset confirmed'
|
|
2635
|
+
elif claude_reset.marker_superseded(txt, reset_record):
|
|
2636
|
+
seen = 'written before a confirmed limit reset'
|
|
2467
2637
|
say(f'{aid}: marker cleared ({seen})')
|
|
2468
2638
|
elif not live:
|
|
2469
2639
|
# One line per account, so a no-data pass is legible in limits.log:
|
package/bin/codex-accounts
CHANGED
|
@@ -1154,6 +1154,23 @@ def marker_bucket(txt):
|
|
|
1154
1154
|
return part[7:]
|
|
1155
1155
|
return ''
|
|
1156
1156
|
|
|
1157
|
+
def session_marker(txt):
|
|
1158
|
+
# The self-healing ~5h window as each client names it: codex labels a short window
|
|
1159
|
+
# `client:5h`, claude's own type is `five_hour`, and a bare `session` covers the
|
|
1160
|
+
# writer's own marker. Kept identical to bin/claude-accounts' copy.
|
|
1161
|
+
b = marker_bucket(txt).lower()
|
|
1162
|
+
return 'five_hour' in b or '5h' in b or 'session' in b
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _prior_mark(path):
|
|
1166
|
+
"""The existing .client-limit-cleared epoch (0 when absent/garbled): the watermark
|
|
1167
|
+
only ever moves forward, so a reset stamped earlier in a pass is never undone."""
|
|
1168
|
+
try:
|
|
1169
|
+
return int(open(path).read().split()[0])
|
|
1170
|
+
except Exception:
|
|
1171
|
+
return 0
|
|
1172
|
+
|
|
1173
|
+
|
|
1157
1174
|
def weekly_marker(txt):
|
|
1158
1175
|
# Matched the way both shims match it (bin/claude ~303, same token list): the
|
|
1159
1176
|
# claude client writes client:seven_day / client:seven_day_opus, and the codex
|
|
@@ -1599,6 +1616,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1599
1616
|
# one is never silent — it reports at/near 100% with the reset it is waiting on.
|
|
1600
1617
|
# So a missing or silent session reads 0 whenever the payload answered at all; a
|
|
1601
1618
|
# payload where NOTHING answered still writes no signals and stays unknown below.
|
|
1619
|
+
session_measured = bool(session)
|
|
1602
1620
|
if sessionp is None and live:
|
|
1603
1621
|
sessionp = 0
|
|
1604
1622
|
try:
|
|
@@ -1619,6 +1637,13 @@ for acct in manifest.get('accounts', []):
|
|
|
1619
1637
|
out['weekly_percent'] = weeklyp
|
|
1620
1638
|
if sessionp is not None:
|
|
1621
1639
|
out['session_percent'] = sessionp
|
|
1640
|
+
if sessionp is not None and not session_measured:
|
|
1641
|
+
# Ranking may use this 0 — an idle account has to get back into the band — but
|
|
1642
|
+
# nothing may read it as proof of recovery. codex reports no window at all for a
|
|
1643
|
+
# closed one, so silence here is the NORMAL shape and says nothing whatsoever
|
|
1644
|
+
# about a 429 the client actually received. See the claude writer for the live
|
|
1645
|
+
# case that proved it (acct-14, 2026-09-22).
|
|
1646
|
+
out['session_inferred'] = True
|
|
1622
1647
|
if not live:
|
|
1623
1648
|
# Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
|
|
1624
1649
|
# source, plan, the raw buckets) and write NONE of the percent signals: a
|
|
@@ -1645,6 +1670,19 @@ for acct in manifest.get('accounts', []):
|
|
|
1645
1670
|
# The fetch went through with this account's own bearer => its auth is alive.
|
|
1646
1671
|
if clear_expired(d):
|
|
1647
1672
|
say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
|
|
1673
|
+
if reset_result.get('status') == 'redeemed':
|
|
1674
|
+
# bin/codex's rollout scan would otherwise re-create a park from the very
|
|
1675
|
+
# rejection this reset answered — whether or not a marker exists right now. The
|
|
1676
|
+
# clean pass below only stamps this for an active client marker; a redemption
|
|
1677
|
+
# needs it unconditionally (same rule as the claude writer). Never backwards.
|
|
1678
|
+
cleared_path = os.path.join(d, '.client-limit-cleared')
|
|
1679
|
+
try:
|
|
1680
|
+
prior = int(open(cleared_path).read().split()[0])
|
|
1681
|
+
except Exception:
|
|
1682
|
+
prior = 0
|
|
1683
|
+
with open(cleared_path + '.tmp', 'w') as f:
|
|
1684
|
+
f.write(f'{max(prior, int(time.time()))}\n')
|
|
1685
|
+
os.replace(cleared_path + '.tmp', cleared_path)
|
|
1648
1686
|
offenders = [] if reset_result.get('status') == 'redeemed' else [
|
|
1649
1687
|
b for b in buckets if b['percent'] >= threshold]
|
|
1650
1688
|
mpath = os.path.join(d, '.limited')
|
|
@@ -1727,6 +1765,13 @@ for acct in manifest.get('accounts', []):
|
|
|
1727
1765
|
keep = True
|
|
1728
1766
|
elif active and is_client and weekly_marker(txt):
|
|
1729
1767
|
keep = True
|
|
1768
|
+
elif active and is_client and session_marker(txt) and not session_measured:
|
|
1769
|
+
# Same rule one window down: a rejection the CLIENT reported is only
|
|
1770
|
+
# disproved by telemetry that looked at the window it names. With the
|
|
1771
|
+
# session window silent, max_percent is the WEEKLY peak, and clearing
|
|
1772
|
+
# a 5h marker on it is the cross-signal borrow the aggregation above
|
|
1773
|
+
# refuses to make.
|
|
1774
|
+
keep = True
|
|
1730
1775
|
elif active and ('reason=error-cooldown' in txt
|
|
1731
1776
|
or (is_client and recent_client)):
|
|
1732
1777
|
keep = True
|
|
@@ -1746,7 +1791,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1746
1791
|
# 2026-09-04 and needed the identical brake with it.
|
|
1747
1792
|
cleared = os.path.join(d, '.client-limit-cleared')
|
|
1748
1793
|
with open(cleared + '.tmp', 'w') as f:
|
|
1749
|
-
f.write(f'{int(now)}\n')
|
|
1794
|
+
f.write(f'{max(_prior_mark(cleared), int(now))}\n')
|
|
1750
1795
|
os.replace(cleared + '.tmp', cleared)
|
|
1751
1796
|
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
1752
1797
|
# nothing was reported (see informative() above). Log that, instead
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|