claude-multiacc 2.0.35 → 2.0.37
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 +68 -1
- package/bin/claude-accounts +163 -10
- package/bin/codex-accounts +23 -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 +108 -0
- package/tests/test_claude_reset.py +643 -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
|
@@ -353,6 +353,59 @@ client_marker_recovered() { # $1 account dir, $2 marker
|
|
|
353
353
|
return 1
|
|
354
354
|
}
|
|
355
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
|
+
|
|
356
409
|
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
357
410
|
local m="$1/.limited" reset="" tok
|
|
358
411
|
[ -f "$m" ] || return 1
|
|
@@ -367,6 +420,11 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
|
|
|
367
420
|
rm -f "$m" 2>/dev/null
|
|
368
421
|
return 1
|
|
369
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
|
|
370
428
|
if client_marker_recovered "$1" "$m"; then
|
|
371
429
|
printf '%s\n' "$CLIENT_RECOVERY_FETCHED" > "$1/.client-limit-cleared.$$" 2>/dev/null \
|
|
372
430
|
&& mv -f "$1/.client-limit-cleared.$$" "$1/.client-limit-cleared" 2>/dev/null \
|
|
@@ -785,7 +843,7 @@ sess_transcript() { # $1 acct dir, $2 session id
|
|
|
785
843
|
# rejects the very next request too, and that lands in a newer transcript.
|
|
786
844
|
client_limit_scan() { # $1 acct dir
|
|
787
845
|
local idx="$1/.sessions-index" memo="$1/.client-scan" id p line r t read_n=0 i last=""
|
|
788
|
-
local ln claim ts ck ak ttl cleared sk
|
|
846
|
+
local ln claim ts ck ak ttl cleared sk rr="" rr_seen=""
|
|
789
847
|
local ids=() claims=()
|
|
790
848
|
[ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
|
|
791
849
|
[ -f "$idx" ] || return 1
|
|
@@ -838,6 +896,15 @@ client_limit_scan() { # $1 acct dir
|
|
|
838
896
|
cleared=""
|
|
839
897
|
[ -f "$1/.client-limit-cleared" ] \
|
|
840
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
|
|
841
908
|
if num_ok "$cleared"; then
|
|
842
909
|
sk="$(iso_key "$(iso_of_epoch "$cleared")")"
|
|
843
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'))
|
|
@@ -2084,12 +2134,13 @@ for acct in manifest.get('accounts', []):
|
|
|
2084
2134
|
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
2085
2135
|
continue
|
|
2086
2136
|
|
|
2087
|
-
|
|
2137
|
+
headers = {
|
|
2088
2138
|
'Authorization': 'Bearer ' + bearer,
|
|
2089
2139
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
2090
2140
|
'Content-Type': 'application/json',
|
|
2091
|
-
'User-Agent':
|
|
2092
|
-
}
|
|
2141
|
+
'User-Agent': USER_AGENT,
|
|
2142
|
+
}
|
|
2143
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2093
2144
|
try:
|
|
2094
2145
|
resp = urllib.request.urlopen(req, timeout=15)
|
|
2095
2146
|
data = json.loads(resp.read().decode())
|
|
@@ -2348,7 +2399,23 @@ for acct in manifest.get('accounts', []):
|
|
|
2348
2399
|
wresets = [int(b['resets_epoch']) for b in live
|
|
2349
2400
|
if b['group'] != 'session' and b['percent'] == weeklyp
|
|
2350
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'
|
|
2351
2417
|
out = {'fetched_at': int(now), 'source': source}
|
|
2418
|
+
out.update(reset_view)
|
|
2352
2419
|
if live:
|
|
2353
2420
|
out['max_percent'] = maxp
|
|
2354
2421
|
if weeklyp is not None:
|
|
@@ -2381,6 +2448,62 @@ for acct in manifest.get('accounts', []):
|
|
|
2381
2448
|
say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
|
|
2382
2449
|
f'no usable telemetry (account ranks as unknown, not as empty)')
|
|
2383
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
|
+
if observed:
|
|
2485
|
+
# Stamped when SEEN, which is later than the reset itself: every park
|
|
2486
|
+
# written in between counts as "before" it. A weekly window cannot
|
|
2487
|
+
# re-exhaust in that gap, but a 5h one can — so the 5h refill only counts
|
|
2488
|
+
# when this very (post-reset) reading MEASURED the session window; a silent
|
|
2489
|
+
# one proves nothing (the acct-14 shape). One second back, so a park this
|
|
2490
|
+
# pass writes can never tie with the stamp.
|
|
2491
|
+
kept = [t for t in observed[1]
|
|
2492
|
+
if session_measured or t not in claude_reset.SESSION_LIMITS]
|
|
2493
|
+
observed = (observed[0] - 1, kept) if kept else None
|
|
2494
|
+
reset_record = claude_reset.latest(claude_reset.record_from(prev, now), observed,
|
|
2495
|
+
claude_reset.local_record(d, now))
|
|
2496
|
+
if observed and reset_record is observed:
|
|
2497
|
+
say(f"{aid}: limit reset observed (a grant's count dropped; "
|
|
2498
|
+
f"refilled {', '.join(observed[1])}) — lifting older parks")
|
|
2499
|
+
else:
|
|
2500
|
+
observed = None
|
|
2501
|
+
if reset_record:
|
|
2502
|
+
out['reset_redeemed_at'] = reset_record[0]
|
|
2503
|
+
# The same moment as a UTC YYYYmmddHHMMSS key: the shim compares marked_at
|
|
2504
|
+
# against it with builtins, no date(1) fork on the per-invocation path.
|
|
2505
|
+
out['reset_redeemed_key'] = time.strftime('%Y%m%d%H%M%S', time.gmtime(reset_record[0]))
|
|
2506
|
+
out['reset_cleared'] = ','.join(reset_record[1])
|
|
2384
2507
|
tmp = lpath + '.tmp'
|
|
2385
2508
|
with open(tmp, 'w') as f:
|
|
2386
2509
|
json.dump(out, f, indent=1)
|
|
@@ -2388,7 +2511,19 @@ for acct in manifest.get('accounts', []):
|
|
|
2388
2511
|
# The fetch went through with this account's own bearer => its auth is alive.
|
|
2389
2512
|
if clear_expired(d):
|
|
2390
2513
|
say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
|
|
2391
|
-
|
|
2514
|
+
if redeemed or observed:
|
|
2515
|
+
# The shim's transcript scan would otherwise re-create a park from the very
|
|
2516
|
+
# rejection the reset just answered — whether or not a marker exists right now
|
|
2517
|
+
# and whatever wrote it. Never move the watermark backwards.
|
|
2518
|
+
cleared_path = os.path.join(d, '.client-limit-cleared')
|
|
2519
|
+
try:
|
|
2520
|
+
prior = int(open(cleared_path).read().split()[0])
|
|
2521
|
+
except Exception:
|
|
2522
|
+
prior = 0
|
|
2523
|
+
with open(cleared_path + '.tmp', 'w') as f:
|
|
2524
|
+
f.write(f'{max(prior, reset_record[0])}\n')
|
|
2525
|
+
os.replace(cleared_path + '.tmp', cleared_path)
|
|
2526
|
+
offenders = [] if redeemed else [b for b in buckets if b['percent'] >= threshold]
|
|
2392
2527
|
mpath = os.path.join(d, '.limited')
|
|
2393
2528
|
if offenders:
|
|
2394
2529
|
# ONE bucket, described consistently: the marker's epoch is the reset of
|
|
@@ -2422,7 +2557,8 @@ for acct in manifest.get('accounts', []):
|
|
|
2422
2557
|
if first.isdigit() and len(first) <= 18:
|
|
2423
2558
|
cur_reset = int(first)
|
|
2424
2559
|
keep_client = ('reason=client-rate-limit' in cur
|
|
2425
|
-
and cur_reset > now and cur_reset >= reset_epoch
|
|
2560
|
+
and cur_reset > now and cur_reset >= reset_epoch
|
|
2561
|
+
and not claude_reset.marker_superseded(cur, reset_record))
|
|
2426
2562
|
except Exception:
|
|
2427
2563
|
pass
|
|
2428
2564
|
if keep_client:
|
|
@@ -2449,6 +2585,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2449
2585
|
# markers strand recovered accounts for days.
|
|
2450
2586
|
keep = False
|
|
2451
2587
|
client_recovered = False
|
|
2588
|
+
txt = ''
|
|
2452
2589
|
try:
|
|
2453
2590
|
txt = open(mpath).read()
|
|
2454
2591
|
first = txt.splitlines()[0] if txt else ''
|
|
@@ -2462,7 +2599,13 @@ for acct in manifest.get('accounts', []):
|
|
|
2462
2599
|
is_client = 'reason=client-rate-limit' in txt
|
|
2463
2600
|
# See weekly_marker() above for why these three come first and in
|
|
2464
2601
|
# this order; the shim applies the identical test per invocation.
|
|
2465
|
-
if
|
|
2602
|
+
if redeemed or claude_reset.marker_superseded(txt, reset_record):
|
|
2603
|
+
# A confirmed reset is first-hand proof the limits refilled — the
|
|
2604
|
+
# one thing that outranks even a client-reported weekly rejection
|
|
2605
|
+
# (the codex writer's identical rule). A marker written before it,
|
|
2606
|
+
# for a window it refilled, describes a state that no longer exists.
|
|
2607
|
+
keep = False
|
|
2608
|
+
elif active and not live:
|
|
2466
2609
|
keep = True
|
|
2467
2610
|
elif active and is_client and weekly_marker(txt):
|
|
2468
2611
|
keep = True
|
|
@@ -2483,14 +2626,24 @@ for acct in manifest.get('accounts', []):
|
|
|
2483
2626
|
os.remove(mpath)
|
|
2484
2627
|
if client_recovered:
|
|
2485
2628
|
cleared = os.path.join(d, '.client-limit-cleared')
|
|
2629
|
+
try:
|
|
2630
|
+
prior = int(open(cleared).read().split()[0])
|
|
2631
|
+
except Exception:
|
|
2632
|
+
prior = 0
|
|
2633
|
+
# Never backwards: a reset confirmed earlier in this same pass has
|
|
2634
|
+
# already stamped a later moment than the pass's start.
|
|
2486
2635
|
with open(cleared + '.tmp', 'w') as f:
|
|
2487
|
-
f.write(f'{int(now)}\n')
|
|
2636
|
+
f.write(f'{max(prior, int(now))}\n')
|
|
2488
2637
|
os.replace(cleared + '.tmp', cleared)
|
|
2489
2638
|
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
2490
2639
|
# nothing was reported (see informative() above). Log that, instead
|
|
2491
2640
|
# of a "0%" that reads like a proven-empty account — the exact
|
|
2492
2641
|
# misreading behind the 2026-09-04 incident.
|
|
2493
2642
|
seen = f'max {maxp}%' if live else 'no usable telemetry'
|
|
2643
|
+
if redeemed:
|
|
2644
|
+
seen = 'limit reset confirmed'
|
|
2645
|
+
elif claude_reset.marker_superseded(txt, reset_record):
|
|
2646
|
+
seen = 'written before a confirmed limit reset'
|
|
2494
2647
|
say(f'{aid}: marker cleared ({seen})')
|
|
2495
2648
|
elif not live:
|
|
2496
2649
|
# One line per account, so a no-data pass is legible in limits.log:
|
package/bin/codex-accounts
CHANGED
|
@@ -1162,6 +1162,15 @@ def session_marker(txt):
|
|
|
1162
1162
|
return 'five_hour' in b or '5h' in b or 'session' in b
|
|
1163
1163
|
|
|
1164
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
|
+
|
|
1165
1174
|
def weekly_marker(txt):
|
|
1166
1175
|
# Matched the way both shims match it (bin/claude ~303, same token list): the
|
|
1167
1176
|
# claude client writes client:seven_day / client:seven_day_opus, and the codex
|
|
@@ -1661,6 +1670,19 @@ for acct in manifest.get('accounts', []):
|
|
|
1661
1670
|
# The fetch went through with this account's own bearer => its auth is alive.
|
|
1662
1671
|
if clear_expired(d):
|
|
1663
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)
|
|
1664
1686
|
offenders = [] if reset_result.get('status') == 'redeemed' else [
|
|
1665
1687
|
b for b in buckets if b['percent'] >= threshold]
|
|
1666
1688
|
mpath = os.path.join(d, '.limited')
|
|
@@ -1769,7 +1791,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1769
1791
|
# 2026-09-04 and needed the identical brake with it.
|
|
1770
1792
|
cleared = os.path.join(d, '.client-limit-cleared')
|
|
1771
1793
|
with open(cleared + '.tmp', 'w') as f:
|
|
1772
|
-
f.write(f'{int(now)}\n')
|
|
1794
|
+
f.write(f'{max(_prior_mark(cleared), int(now))}\n')
|
|
1773
1795
|
os.replace(cleared + '.tmp', cleared)
|
|
1774
1796
|
# On a no-data pass `live` is empty and maxp is 0 only because
|
|
1775
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
|