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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""Automatic redemption of Claude Code limit resets (the ``cedar_ember`` program).
|
|
2
|
+
|
|
3
|
+
Claude Code 2.1.280 shipped ``/limit-reset``: a subscription account can hold
|
|
4
|
+
"grants", each worth a number of resets, and a reset refills the limits the grant
|
|
5
|
+
names (``clears``: five_hour, seven_day, seven_day_overage_included, ...) while the
|
|
6
|
+
weekly reset day stays where it was. The first grant arrived with the Opus 5.5
|
|
7
|
+
launch on 2026-09-22 — one reset per Pro/Max account, usable until 2026-10-22.
|
|
8
|
+
|
|
9
|
+
This is the Claude half of lib/codex_reset.py: the scheduled limits pass redeems a
|
|
10
|
+
reset when a limit the grant refills is at least 95% used, or when the server reports
|
|
11
|
+
that limit exhausted. Two rules narrow that for Claude, because a Claude reset is ONE
|
|
12
|
+
irreversible shot that refills the WEEK (support.claude.com "What is a limit reset?",
|
|
13
|
+
2026-09-22: "Once you use it, you can't undo it"; "Your weekly limits still reset on
|
|
14
|
+
their usual day and time"):
|
|
15
|
+
|
|
16
|
+
* Only a WEEKLY limit (anything but ``five_hour``) triggers it. A full five-hour window
|
|
17
|
+
refills on its own within hours and the pool simply picks another account; spending
|
|
18
|
+
the week's refill on it throws nearly all of the reset away.
|
|
19
|
+
* It is held while the account's own weekly reset is less than
|
|
20
|
+
``CLAUDE_MULTIACC_RESET_MIN_HORIZON`` seconds away (default 24h): the week is about
|
|
21
|
+
to refill for free, and the grant is worth days of work later in its validity.
|
|
22
|
+
|
|
23
|
+
What differs from codex beyond that is only the transport:
|
|
24
|
+
|
|
25
|
+
* The allowance is NOT a second endpoint. The OAuth usage endpoint the limits pass
|
|
26
|
+
already reads carries the program's status block when asked for it
|
|
27
|
+
(``?cedar_ember=1&skip_spend=1`` — the exact read Claude Code's own ``/limit-reset``
|
|
28
|
+
makes). That endpoint's budget is roughly one call per account per hour, so a
|
|
29
|
+
second GET per pass would buy 429s that blind telemetry; piggybacking costs none.
|
|
30
|
+
* The server decides eligibility from WHO is asking. A request that does not identify
|
|
31
|
+
itself as the Claude Code CLI is answered ``eligible: false, ineligible_reason:
|
|
32
|
+
"surface"``. The writer therefore sends Claude Code's own User-Agent shape with the
|
|
33
|
+
installed CLI version and names this wrapper through the client's documented
|
|
34
|
+
``client-app/`` slot: ``claude-cli/<version> (external, cli, client-app/claude-multiacc)``.
|
|
35
|
+
* A claim is ``POST /api/organizations/<org>/reset_rate_limits`` with
|
|
36
|
+
``{"program": "cedar_ember", "grant_id": ..., "request_id": ...}``; the organization
|
|
37
|
+
is the account's own (``<acct>/.claude.json`` ``oauthAccount.organizationUuid``).
|
|
38
|
+
|
|
39
|
+
Idempotency mirrors codex: a pending request id is persisted BEFORE the POST, and the
|
|
40
|
+
id is derived deterministically from the account's organization, the grant, the grant's
|
|
41
|
+
remaining count and the weekly window, so every machine in the fleet that polls the same
|
|
42
|
+
account asks with the same id and a lost response is retried without spending twice. A
|
|
43
|
+
pending claim whose response was lost is settled by provider evidence — the grant's
|
|
44
|
+
``resets_left`` dropping below what it was when the claim was sent — never by guessing.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import datetime
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import time
|
|
54
|
+
import urllib.error
|
|
55
|
+
import urllib.parse
|
|
56
|
+
import urllib.request
|
|
57
|
+
import uuid
|
|
58
|
+
|
|
59
|
+
PROGRAM = "cedar_ember"
|
|
60
|
+
RESET_AT_USED_PERCENT = 95
|
|
61
|
+
REDEEM_COOLDOWN_SECONDS = 900
|
|
62
|
+
# Limit types that refill on their own within hours; never a reason to spend a reset.
|
|
63
|
+
SESSION_LIMITS = frozenset({"five_hour"})
|
|
64
|
+
DEFAULT_MIN_HORIZON_SECONDS = 86400
|
|
65
|
+
# A claim whose response was lost is retried with the same request id while it is
|
|
66
|
+
# younger than this, and dropped afterwards unless the status block proved it landed.
|
|
67
|
+
PENDING_TTL_SECONDS = 3600
|
|
68
|
+
STATE_FILE = ".usage-reset.json"
|
|
69
|
+
# How long limits.json carries the record of a confirmed reset. Every marker it can
|
|
70
|
+
# supersede was written before the reset and names a window at most a week long, so
|
|
71
|
+
# eight days outlives all of them on every machine the document is pushed to.
|
|
72
|
+
RESET_RECORD_TTL_SECONDS = 8 * 86400
|
|
73
|
+
CLIENT_APP = "claude-multiacc"
|
|
74
|
+
FALLBACK_USER_AGENT = "claude-multiacc/1.0"
|
|
75
|
+
|
|
76
|
+
# The client's own validation (Claude Code 2.1.280): it refuses to claim with an id
|
|
77
|
+
# outside these shapes, and so do we — a malformed id is a payload we do not understand.
|
|
78
|
+
_GRANT_ID = re.compile(r"^[a-z0-9_-]{1,40}$")
|
|
79
|
+
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
80
|
+
_LIMIT_TYPE = re.compile(r"^[a-z0-9_]{1,64}$")
|
|
81
|
+
_ORG_UUID = re.compile(r"^[0-9A-Fa-f-]{8,64}$")
|
|
82
|
+
_VERSION = re.compile(r"^\d+\.\d+\.\d+$")
|
|
83
|
+
_RESULTS = {"reset", "already_used", "not_limited", "cooldown", "ineligible", "unavailable"}
|
|
84
|
+
# Ineligibility that describes the REQUEST (which client asked, from where) rather than
|
|
85
|
+
# the account. The allowance behind such an answer is unknown, not zero.
|
|
86
|
+
_REQUEST_SIDE_REASONS = {"surface", "cli_version", "mobile", "unavailable", "unknown"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def auto_reset_enabled() -> bool:
|
|
90
|
+
"""Return whether automatic redemption is enabled (on by default)."""
|
|
91
|
+
return os.environ.get("CLAUDE_MULTIACC_AUTO_RESET", "1").strip().lower() \
|
|
92
|
+
not in {"0", "false", "no", "off"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def min_horizon() -> int:
|
|
96
|
+
"""Seconds before the natural weekly reset inside which a reset is not spent."""
|
|
97
|
+
try:
|
|
98
|
+
value = int(os.environ.get("CLAUDE_MULTIACC_RESET_MIN_HORIZON",
|
|
99
|
+
DEFAULT_MIN_HORIZON_SECONDS))
|
|
100
|
+
except ValueError:
|
|
101
|
+
return DEFAULT_MIN_HORIZON_SECONDS
|
|
102
|
+
return max(0, min(7 * 86400, value))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cli_version(real_path: str | None) -> str:
|
|
106
|
+
"""The installed Claude Code version ("2.1.280"), '' when it cannot be learned.
|
|
107
|
+
|
|
108
|
+
Never invented: the server gates on it, and a made-up version is a lie about which
|
|
109
|
+
client is asking. CLAUDE_MULTIACC_CLI_VERSION overrides (tests, pinned hosts). The
|
|
110
|
+
native installer's ``versions/<x.y.z>`` symlink target answers without a fork;
|
|
111
|
+
anything else (an npm cli.js) is asked ``--version`` under a hard timeout, because
|
|
112
|
+
this runs while the limits lock is held.
|
|
113
|
+
"""
|
|
114
|
+
override = os.environ.get("CLAUDE_MULTIACC_CLI_VERSION", "").strip()
|
|
115
|
+
if override:
|
|
116
|
+
return override if _VERSION.match(override) else ""
|
|
117
|
+
if not real_path:
|
|
118
|
+
return ""
|
|
119
|
+
name = os.path.basename(os.path.realpath(real_path))
|
|
120
|
+
if _VERSION.match(name):
|
|
121
|
+
return name
|
|
122
|
+
try:
|
|
123
|
+
import subprocess
|
|
124
|
+
env = dict(os.environ, CLAUDE_SHIM_ACTIVE="1")
|
|
125
|
+
done = subprocess.run([real_path, "--version"], capture_output=True, text=True,
|
|
126
|
+
timeout=10, stdin=subprocess.DEVNULL, env=env, check=False)
|
|
127
|
+
first = (done.stdout or "").strip().split()
|
|
128
|
+
return first[0] if first and _VERSION.match(first[0]) else ""
|
|
129
|
+
except Exception:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def user_agent(cli_version: str | None) -> str:
|
|
134
|
+
"""Claude Code's User-Agent shape for the installed CLI, naming this wrapper."""
|
|
135
|
+
version = (cli_version or "").strip()
|
|
136
|
+
if _VERSION.match(version):
|
|
137
|
+
return f"claude-cli/{version} (external, cli, client-app/{CLIENT_APP})"
|
|
138
|
+
return FALLBACK_USER_AGENT
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def claim_url(usage_url: str, org_uuid: str) -> str | None:
|
|
142
|
+
"""The claim endpoint on the usage endpoint's own origin (None for file:// fixtures)."""
|
|
143
|
+
explicit = os.environ.get("CLAUDE_MULTIACC_RESET_URL", "").strip()
|
|
144
|
+
if explicit:
|
|
145
|
+
return explicit.replace("{org}", org_uuid)
|
|
146
|
+
parsed = urllib.parse.urlsplit(usage_url)
|
|
147
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
148
|
+
return None
|
|
149
|
+
path = f"/api/organizations/{org_uuid}/reset_rate_limits"
|
|
150
|
+
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _oauth_account(account_dir: str) -> dict:
|
|
154
|
+
try:
|
|
155
|
+
with open(os.path.join(account_dir, ".claude.json"), encoding="utf-8") as handle:
|
|
156
|
+
doc = json.load(handle)
|
|
157
|
+
value = doc.get("oauthAccount") if isinstance(doc, dict) else None
|
|
158
|
+
return value if isinstance(value, dict) else {}
|
|
159
|
+
except Exception:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def organization_uuid(account_dir: str) -> str | None:
|
|
164
|
+
"""The account's own organization, as Claude Code recorded it for this config dir."""
|
|
165
|
+
org = str(_oauth_account(account_dir).get("organizationUuid") or "")
|
|
166
|
+
return org if _ORG_UUID.match(org) else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def account_uuid(account_dir: str) -> str | None:
|
|
170
|
+
"""The signed-in user within that organization (several Team seats share one org)."""
|
|
171
|
+
value = str(_oauth_account(account_dir).get("accountUuid") or "")
|
|
172
|
+
return value if _ORG_UUID.match(value) else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _epoch(value) -> float | None:
|
|
176
|
+
if not isinstance(value, str) or not value:
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
180
|
+
except ValueError:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _types(value) -> list[str]:
|
|
185
|
+
if not isinstance(value, list):
|
|
186
|
+
return []
|
|
187
|
+
return sorted({item for item in value if isinstance(item, str) and _LIMIT_TYPE.match(item)})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _count(value) -> int | None:
|
|
191
|
+
return value if type(value) is int and 0 <= value <= 1_000_000 else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _grant(raw) -> dict | None:
|
|
195
|
+
"""One grant, or None when it is not a shape the client itself would accept."""
|
|
196
|
+
if not isinstance(raw, dict):
|
|
197
|
+
return None
|
|
198
|
+
grant_id = raw.get("id")
|
|
199
|
+
resets_left = _count(raw.get("resets_left"))
|
|
200
|
+
if not isinstance(grant_id, str) or not _GRANT_ID.match(grant_id) or resets_left is None:
|
|
201
|
+
return None
|
|
202
|
+
percent = {}
|
|
203
|
+
for key, value in (raw.get("percent_used") or {}).items() if isinstance(
|
|
204
|
+
raw.get("percent_used"), dict) else []:
|
|
205
|
+
if isinstance(key, str) and _LIMIT_TYPE.match(key) and type(value) is int \
|
|
206
|
+
and 0 <= value <= 100:
|
|
207
|
+
percent[key] = value
|
|
208
|
+
return {
|
|
209
|
+
"id": grant_id,
|
|
210
|
+
"resets_left": resets_left,
|
|
211
|
+
"resets_total": _count(raw.get("resets_total")) or 0,
|
|
212
|
+
"starts_at": raw.get("starts_at") if isinstance(raw.get("starts_at"), str) else None,
|
|
213
|
+
"ends_at": raw.get("ends_at") if isinstance(raw.get("ends_at"), str) else None,
|
|
214
|
+
"clears": _types(raw.get("clears")),
|
|
215
|
+
"blocking": _types(raw.get("blocking")),
|
|
216
|
+
"paused": raw.get("paused") is True,
|
|
217
|
+
"usable_now": raw.get("usable_now") is True,
|
|
218
|
+
# The client defaults this to TRUE when absent: a grant that says nothing may
|
|
219
|
+
# only be used at a limit, which is the conservative reading.
|
|
220
|
+
"use_requires_limit": raw.get("use_requires_limit") is not False,
|
|
221
|
+
"percent_used": percent,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def parse_status(usage) -> dict | None:
|
|
226
|
+
"""The normalized ``cedar_ember`` block of a usage response, or None when absent/unreadable."""
|
|
227
|
+
block = usage.get(PROGRAM) if isinstance(usage, dict) else None
|
|
228
|
+
if not isinstance(block, dict) or not isinstance(block.get("eligible"), bool):
|
|
229
|
+
return None
|
|
230
|
+
grants = [grant for grant in map(_grant, block.get("grants") or []
|
|
231
|
+
if isinstance(block.get("grants"), list) else [])
|
|
232
|
+
if grant is not None]
|
|
233
|
+
next_id = block.get("next_grant_id")
|
|
234
|
+
if not any(grant["id"] == next_id for grant in grants):
|
|
235
|
+
next_id = None
|
|
236
|
+
reason = block.get("ineligible_reason")
|
|
237
|
+
return {
|
|
238
|
+
"eligible": block["eligible"],
|
|
239
|
+
"ineligible_reason": reason if isinstance(reason, str) else None,
|
|
240
|
+
"at_limit": block.get("at_limit") is True,
|
|
241
|
+
"exhausted": _types(block.get("exhausted")),
|
|
242
|
+
"grants": grants,
|
|
243
|
+
"next_grant_id": next_id,
|
|
244
|
+
"weekly_resets_at": block.get("weekly_resets_at")
|
|
245
|
+
if isinstance(block.get("weekly_resets_at"), str) else None,
|
|
246
|
+
"cooldown_until": block.get("cooldown_until")
|
|
247
|
+
if isinstance(block.get("cooldown_until"), str) else None,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _live(grant: dict, now: float) -> bool:
|
|
252
|
+
ends = _epoch(grant["ends_at"])
|
|
253
|
+
return ends is None or ends > now
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def credits_view(status: dict | None, now: int) -> dict:
|
|
257
|
+
"""Measured reset allowance for limits.json, or {} when the read proves nothing.
|
|
258
|
+
|
|
259
|
+
Same contract as the codex report: zero is a measurement, a missing field is an
|
|
260
|
+
unknown. A block that is ineligible because of who ASKED (surface, CLI version) says
|
|
261
|
+
nothing about the account, so it reports nothing rather than a false zero.
|
|
262
|
+
"""
|
|
263
|
+
if status is None:
|
|
264
|
+
return {}
|
|
265
|
+
if not status["eligible"] and (status["ineligible_reason"] or "unknown") in _REQUEST_SIDE_REASONS:
|
|
266
|
+
return {}
|
|
267
|
+
count = sum(grant["resets_left"] for grant in status["grants"] if _live(grant, now))
|
|
268
|
+
if count > 1_000_000:
|
|
269
|
+
return {}
|
|
270
|
+
return {"reset_credits_available": count, "reset_credits_fetched_at": int(now)}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _usable_grant(status: dict, now: float) -> dict | None:
|
|
274
|
+
"""The grant the client would offer now (its ``next_grant_id``), if it can be used."""
|
|
275
|
+
grant = next((g for g in status["grants"] if g["id"] == status["next_grant_id"]), None)
|
|
276
|
+
if grant is None or not grant["usable_now"] or grant["paused"] or grant["resets_left"] <= 0:
|
|
277
|
+
return None
|
|
278
|
+
starts = _epoch(grant["starts_at"])
|
|
279
|
+
if not _live(grant, now) or (starts is not None and starts > now):
|
|
280
|
+
return None
|
|
281
|
+
return grant
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _percent(grant: dict, usage: dict, limit_type: str) -> int | None:
|
|
285
|
+
value = grant["percent_used"].get(limit_type)
|
|
286
|
+
if value is not None:
|
|
287
|
+
return value
|
|
288
|
+
# The payload also names most limit types at the top level; use that reading when
|
|
289
|
+
# the grant did not carry one for this type.
|
|
290
|
+
bucket = usage.get(limit_type) if isinstance(usage, dict) else None
|
|
291
|
+
if isinstance(bucket, dict):
|
|
292
|
+
try:
|
|
293
|
+
return max(0, min(100, int(round(float(bucket.get("utilization"))))))
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return None
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def reset_trigger(status: dict, grant: dict, usage: dict) -> tuple[list[str], list[str]]:
|
|
300
|
+
"""(exhausted WEEKLY limits the grant refills, weekly limits it refills at >=95%)."""
|
|
301
|
+
clears = set(grant["clears"]) - SESSION_LIMITS
|
|
302
|
+
finished = sorted(clears & set(status["exhausted"]))
|
|
303
|
+
near = sorted(limit for limit in clears
|
|
304
|
+
if (_percent(grant, usage, limit) or 0) >= RESET_AT_USED_PERCENT)
|
|
305
|
+
return finished, near
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---- the reset record the whole fleet honours -------------------------------------
|
|
309
|
+
# A confirmed reset has to lift every park that predates it, on EVERY machine: peers
|
|
310
|
+
# only ever receive `.limited` pushes (never deletions), and the app-robot panel keeps
|
|
311
|
+
# its own verdicts. So the writer records `reset_redeemed_at` (epoch) and
|
|
312
|
+
# `reset_cleared` (comma-separated limit types) in limits.json, which IS pushed, and a
|
|
313
|
+
# marker is superseded when it was written at or before that moment for a window the
|
|
314
|
+
# reset refilled. bin/claude's reset_supersedes_marker is the same rule in bash.
|
|
315
|
+
|
|
316
|
+
def bucket_limit_type(name: str) -> str | None:
|
|
317
|
+
"""The provider limit type a bucket or marker name describes; '*' for a park that
|
|
318
|
+
names no window (it came from a refused session, so any account-level refill lifts
|
|
319
|
+
it); None for a model window this code does not know."""
|
|
320
|
+
token = (name or "").strip()
|
|
321
|
+
lower = token.lower()
|
|
322
|
+
if lower.startswith("client:"):
|
|
323
|
+
rest = lower[len("client:"):]
|
|
324
|
+
return rest if _LIMIT_TYPE.match(rest) else None
|
|
325
|
+
if lower.startswith("weekly_scoped:"):
|
|
326
|
+
model = lower[len("weekly_scoped:"):]
|
|
327
|
+
if "fable" in model:
|
|
328
|
+
return "seven_day_overage_included"
|
|
329
|
+
for family in ("opus", "sonnet"):
|
|
330
|
+
if family in model:
|
|
331
|
+
return f"seven_day_{family}"
|
|
332
|
+
return None
|
|
333
|
+
if lower in {"session", "five_hour", "5h"} or lower.startswith("session"):
|
|
334
|
+
return "five_hour"
|
|
335
|
+
if lower in {"weekly_all", "seven_day", "weekly", "7d"}:
|
|
336
|
+
return "seven_day"
|
|
337
|
+
if lower in {"", "panel:observed", "limit_reached", "unknown"}:
|
|
338
|
+
return "*"
|
|
339
|
+
return lower if _LIMIT_TYPE.match(lower) else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def record_from(doc: dict, now: float) -> tuple[int, list[str]] | None:
|
|
343
|
+
"""(redeemed_at, cleared types) from a limits document, or None when absent/expired."""
|
|
344
|
+
if not isinstance(doc, dict):
|
|
345
|
+
return None
|
|
346
|
+
at = doc.get("reset_redeemed_at")
|
|
347
|
+
raw = doc.get("reset_cleared")
|
|
348
|
+
if type(at) is not int or at <= 0 or now - at > RESET_RECORD_TTL_SECONDS:
|
|
349
|
+
return None
|
|
350
|
+
cleared = [t for t in str(raw or "").split(",") if _LIMIT_TYPE.match(t)]
|
|
351
|
+
return (at, cleared) if cleared else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def grants_seen(status: dict | None, now: float, redeemed: dict | None = None) -> dict:
|
|
355
|
+
"""{grant id: resets_left} for the live grants this pass read — the baseline the next
|
|
356
|
+
pass compares against. A claim this pass made is recorded at its post-claim count, so
|
|
357
|
+
the pass after it never mistakes its own reset for someone else's."""
|
|
358
|
+
if status is None:
|
|
359
|
+
return {}
|
|
360
|
+
seen = {g["id"]: g["resets_left"] for g in status["grants"] if _live(g, now)}
|
|
361
|
+
if redeemed and redeemed.get("grant_id") in seen:
|
|
362
|
+
left = redeemed.get("resets_left")
|
|
363
|
+
seen[redeemed["grant_id"]] = left if type(left) is int \
|
|
364
|
+
else max(0, seen[redeemed["grant_id"]] - 1)
|
|
365
|
+
return seen
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def observed_reset(prev: dict, status: dict | None, now: float) -> tuple[int, list[str]] | None:
|
|
369
|
+
"""A reset this machine did NOT claim — a fleet peer's, or a human's /limit-reset —
|
|
370
|
+
seen as one grant's OWN remaining count dropping since the last pass. Never inferred
|
|
371
|
+
from the total: that also drops when an unused grant simply expires, and reading an
|
|
372
|
+
expiry as a refill would lift truthful weekly parks (the 2026-09-04 failure class)."""
|
|
373
|
+
seen = prev.get("reset_grants_seen") if isinstance(prev, dict) else None
|
|
374
|
+
if not isinstance(seen, dict) or status is None:
|
|
375
|
+
return None
|
|
376
|
+
hit = [g for g in status["grants"] if _live(g, now)
|
|
377
|
+
and type(seen.get(g["id"])) is int and g["resets_left"] < seen[g["id"]]]
|
|
378
|
+
cleared = sorted({t for g in hit for t in g["clears"]})
|
|
379
|
+
return (int(now), cleared) if cleared else None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def local_record(account_dir: str, now: float) -> tuple[int, list[str]] | None:
|
|
383
|
+
"""The redeeming machine's own receipt, for a pass that died between the claim and
|
|
384
|
+
the limits.json write."""
|
|
385
|
+
state = _load(os.path.join(account_dir, STATE_FILE))
|
|
386
|
+
at = state.get("redeemed_at")
|
|
387
|
+
cleared = _types(state.get("cleared"))
|
|
388
|
+
if state.get("state") != "complete" or type(at) is not int or not cleared \
|
|
389
|
+
or now - at > RESET_RECORD_TTL_SECONDS:
|
|
390
|
+
return None
|
|
391
|
+
return at, cleared
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def latest(*records):
|
|
395
|
+
"""The most recent of several (epoch, cleared) records, ignoring absent ones."""
|
|
396
|
+
present = [r for r in records if r]
|
|
397
|
+
return max(present, key=lambda r: r[0]) if present else None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _marked_epoch(text: str) -> float | None:
|
|
401
|
+
for part in text.split():
|
|
402
|
+
if part.startswith("marked_at="):
|
|
403
|
+
return _epoch(part[len("marked_at="):])
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def marker_superseded(text: str, record: tuple[int, list[str]] | None) -> bool:
|
|
408
|
+
"""Was this `.limited` marker written before a confirmed reset that refilled its window?"""
|
|
409
|
+
if not record or not isinstance(text, str):
|
|
410
|
+
return False
|
|
411
|
+
at, cleared = record
|
|
412
|
+
marked = _marked_epoch(text)
|
|
413
|
+
if marked is None or marked > at:
|
|
414
|
+
return False
|
|
415
|
+
bucket = next((part[len("bucket="):] for part in text.split()
|
|
416
|
+
if part.startswith("bucket=")), "")
|
|
417
|
+
limit_type = bucket_limit_type(bucket)
|
|
418
|
+
if limit_type == "*":
|
|
419
|
+
return bool(cleared)
|
|
420
|
+
return limit_type in cleared
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _load(path: str) -> dict:
|
|
424
|
+
try:
|
|
425
|
+
with open(path, encoding="utf-8") as handle:
|
|
426
|
+
value = json.load(handle)
|
|
427
|
+
return value if isinstance(value, dict) else {}
|
|
428
|
+
except Exception:
|
|
429
|
+
return {}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _write(path: str, value: dict) -> None:
|
|
433
|
+
temp = f"{path}.tmp.{os.getpid()}"
|
|
434
|
+
descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
435
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
436
|
+
json.dump(value, handle, indent=2, sort_keys=True)
|
|
437
|
+
handle.write("\n")
|
|
438
|
+
os.replace(temp, path)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _remove(path: str) -> None:
|
|
442
|
+
try:
|
|
443
|
+
os.remove(path)
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def request_id(org_uuid: str, subject: str, grant: dict, status: dict) -> str:
|
|
449
|
+
"""One fleet-stable id per (account, grant, remaining count, week).
|
|
450
|
+
|
|
451
|
+
Every machine polling this account sees the same organization, user, grant, count
|
|
452
|
+
and weekly window, so they converge on ONE claim; after a reset lands the count
|
|
453
|
+
drops, so the next episode of a multi-reset grant gets a new id instead of a
|
|
454
|
+
replay. The user is part of the seed because Team seats share an organization.
|
|
455
|
+
"""
|
|
456
|
+
seed = json.dumps({"program": PROGRAM, "org": org_uuid, "subject": subject,
|
|
457
|
+
"grant": grant["id"],
|
|
458
|
+
"resets_left": grant["resets_left"],
|
|
459
|
+
"week": status.get("weekly_resets_at") or ""}, sort_keys=True)
|
|
460
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _post(url: str, headers: dict, payload: dict) -> tuple[int, dict, int]:
|
|
464
|
+
"""(HTTP status, JSON object body or {}, Retry-After seconds or 0)."""
|
|
465
|
+
request = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
|
466
|
+
headers=headers, method="POST")
|
|
467
|
+
try:
|
|
468
|
+
response = urllib.request.urlopen(request, timeout=30)
|
|
469
|
+
status, raw = response.status, response.read()
|
|
470
|
+
except urllib.error.HTTPError as error:
|
|
471
|
+
try:
|
|
472
|
+
wait = max(0, int(error.headers.get("Retry-After") or 0))
|
|
473
|
+
except (TypeError, ValueError):
|
|
474
|
+
wait = 0
|
|
475
|
+
return error.code, {}, wait
|
|
476
|
+
try:
|
|
477
|
+
value = json.loads(raw.decode())
|
|
478
|
+
except Exception:
|
|
479
|
+
value = None
|
|
480
|
+
return status, value if isinstance(value, dict) else {}, 0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# A claim the server REFUSED outright (401/403/429/other 4xx) was provably not processed;
|
|
484
|
+
# retrying it on every pass is how the usage endpoint once talked itself into a days-long
|
|
485
|
+
# 429 (CLAUDE.md, 2026-08-11). It backs off like every other non-2xx here does, honouring
|
|
486
|
+
# Retry-After, doubling from 15 minutes to a day. A 5xx or a lost connection may have
|
|
487
|
+
# landed, so those stay pending and are retried with the same request id instead.
|
|
488
|
+
BACKOFF_FLOOR_SECONDS = 900
|
|
489
|
+
BACKOFF_CEILING_SECONDS = 86400
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _backoff(state: dict, code: int, retry_after: int, current: int) -> dict:
|
|
493
|
+
previous = state.get("backoff") if type(state.get("backoff")) is int else 0
|
|
494
|
+
floor = 3600 if code in (401, 403) else BACKOFF_FLOOR_SECONDS
|
|
495
|
+
wait = min(BACKOFF_CEILING_SECONDS, max(floor, 2 * previous))
|
|
496
|
+
wait = max(wait, min(BACKOFF_CEILING_SECONDS, retry_after))
|
|
497
|
+
return {"backoff": wait, "retry_after": current + wait, "last_error": f"HTTP {code}"}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _usage_peak(usage) -> int:
|
|
501
|
+
peak = 0
|
|
502
|
+
for limit in (usage.get("limits") or []) if isinstance(usage, dict) else []:
|
|
503
|
+
if isinstance(limit, dict) and type(limit.get("percent")) in (int, float):
|
|
504
|
+
peak = max(peak, int(limit["percent"]))
|
|
505
|
+
return peak
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _complete(state_path: str, pending: dict, outcome: str, now: int, cleared=None,
|
|
509
|
+
resets_left=None) -> dict:
|
|
510
|
+
# The wall clock AFTER the claim, not the pass's start: a marker the client wrote
|
|
511
|
+
# while this pass was in flight describes the pre-reset state and must be covered.
|
|
512
|
+
at = max(int(now), int(time.time()))
|
|
513
|
+
cleared = sorted(set(cleared or []) or set(pending.get("clears") or []))
|
|
514
|
+
complete = {"schema": 1, "program": PROGRAM, "state": "complete", "outcome": outcome,
|
|
515
|
+
"grant_id": pending.get("grant_id"), "request_id": pending.get("request_id"),
|
|
516
|
+
"redeemed_at": at, "suppress_until": at + REDEEM_COOLDOWN_SECONDS,
|
|
517
|
+
"cleared": cleared}
|
|
518
|
+
_write(state_path, complete)
|
|
519
|
+
result = {"status": "redeemed", "program": PROGRAM, "outcome": outcome,
|
|
520
|
+
"grant_id": pending.get("grant_id"), "cleared": cleared,
|
|
521
|
+
"redeemed_at": at}
|
|
522
|
+
if resets_left is not None:
|
|
523
|
+
result["resets_left"] = resets_left
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict | None,
|
|
528
|
+
usage_url: str, headers: dict, say, now: int | None = None) -> dict:
|
|
529
|
+
"""Redeem one reset when policy says so and return a non-secret outcome document."""
|
|
530
|
+
current = int(now if now is not None else time.time())
|
|
531
|
+
if not auto_reset_enabled():
|
|
532
|
+
return {"status": "not_eligible", "reason": "disabled"}
|
|
533
|
+
if status is None:
|
|
534
|
+
return {"status": "no_status"}
|
|
535
|
+
state_path = os.path.join(account_dir, STATE_FILE)
|
|
536
|
+
state = _load(state_path)
|
|
537
|
+
backing_off = type(state.get("retry_after")) is int and state["retry_after"] > current
|
|
538
|
+
if state.get("state") == "pending":
|
|
539
|
+
grant = next((g for g in status["grants"] if g["id"] == state.get("grant_id")), None)
|
|
540
|
+
before = state.get("resets_left_before")
|
|
541
|
+
if grant is not None and type(before) is int and grant["resets_left"] < before:
|
|
542
|
+
# The response was lost, but the server's own count moved: it landed.
|
|
543
|
+
say(f"{account_id}: limit reset confirmed by the server's allowance "
|
|
544
|
+
f"(claim {state.get('request_id')})")
|
|
545
|
+
return _complete(state_path, state, "confirmed", current,
|
|
546
|
+
resets_left=grant["resets_left"])
|
|
547
|
+
started = state.get("started_at")
|
|
548
|
+
if not backing_off and (type(started) is not int
|
|
549
|
+
or current - started > PENDING_TTL_SECONDS):
|
|
550
|
+
_remove(state_path)
|
|
551
|
+
state = {}
|
|
552
|
+
if backing_off:
|
|
553
|
+
return {"status": "pending" if state.get("state") == "pending" else "backoff",
|
|
554
|
+
"reason": state.get("last_error")}
|
|
555
|
+
if state.get("state") == "backoff":
|
|
556
|
+
state = {}
|
|
557
|
+
if state.get("state") == "complete" and int(state.get("suppress_until") or 0) > current:
|
|
558
|
+
return {"status": "cooldown", "outcome": state.get("outcome")}
|
|
559
|
+
if not status["eligible"]:
|
|
560
|
+
reason = status["ineligible_reason"] or "unknown"
|
|
561
|
+
if reason in _REQUEST_SIDE_REASONS and _usage_peak(usage) >= RESET_AT_USED_PERCENT:
|
|
562
|
+
# Not a fact about the account: the server would not answer THIS client. On the
|
|
563
|
+
# one machine that can redeem, that silently turns redemption off fleet-wide.
|
|
564
|
+
say(f"{account_id}: limit-reset status refused for this client ({reason}; "
|
|
565
|
+
f"User-Agent {headers.get('User-Agent')!r}) — automatic redemption is OFF "
|
|
566
|
+
f"on this host")
|
|
567
|
+
return {"status": "ineligible", "reason": reason}
|
|
568
|
+
cooldown = _epoch(status["cooldown_until"])
|
|
569
|
+
if cooldown is not None and cooldown > current:
|
|
570
|
+
return {"status": "cooldown", "reason": "server"}
|
|
571
|
+
grant = _usable_grant(status, current)
|
|
572
|
+
if grant is None:
|
|
573
|
+
return {"status": "no_credit"}
|
|
574
|
+
finished, near = reset_trigger(status, grant, usage)
|
|
575
|
+
if not finished and not near:
|
|
576
|
+
return {"status": "not_eligible"}
|
|
577
|
+
if grant["blocking"]:
|
|
578
|
+
# The client's own rule: a limit this grant does not refill is exhausted too,
|
|
579
|
+
# so refilling the others would not let the account work.
|
|
580
|
+
return {"status": "not_eligible", "reason": "blocked",
|
|
581
|
+
"blocking": grant["blocking"]}
|
|
582
|
+
if grant["use_requires_limit"] and not finished:
|
|
583
|
+
return {"status": "not_eligible", "reason": "waiting_for_limit"}
|
|
584
|
+
natural = _epoch(status["weekly_resets_at"])
|
|
585
|
+
if natural is not None and natural - current < min_horizon():
|
|
586
|
+
say(f"{account_id}: limit reset held — {', '.join(finished or near)} is at its "
|
|
587
|
+
f"limit but the week refills on its own at {status['weekly_resets_at']}")
|
|
588
|
+
return {"status": "not_eligible", "reason": "natural_reset_soon"}
|
|
589
|
+
org = organization_uuid(account_dir)
|
|
590
|
+
url = claim_url(usage_url, org) if org else None
|
|
591
|
+
if not url:
|
|
592
|
+
say(f"{account_id}: limit reset available but the account's organization is "
|
|
593
|
+
f"unknown here (no .claude.json oauthAccount); not claiming")
|
|
594
|
+
return {"status": "error", "reason": "no_org"}
|
|
595
|
+
retry = state.get("state") == "pending" and state.get("grant_id") == grant["id"] \
|
|
596
|
+
and _REQUEST_ID.match(str(state.get("request_id") or ""))
|
|
597
|
+
if not retry:
|
|
598
|
+
state = {"schema": 1, "program": PROGRAM, "state": "pending",
|
|
599
|
+
"grant_id": grant["id"],
|
|
600
|
+
"request_id": request_id(org, account_uuid(account_dir) or account_id,
|
|
601
|
+
grant, status),
|
|
602
|
+
"resets_left_before": grant["resets_left"], "started_at": current,
|
|
603
|
+
"clears": grant["clears"], "trigger": finished or near}
|
|
604
|
+
_write(state_path, state)
|
|
605
|
+
payload = {"program": PROGRAM, "grant_id": state["grant_id"],
|
|
606
|
+
"request_id": state["request_id"]}
|
|
607
|
+
try:
|
|
608
|
+
code, response, retry_after = _post(url, headers, payload)
|
|
609
|
+
except Exception as error:
|
|
610
|
+
say(f"{account_id}: limit reset claim failed ({type(error).__name__}); "
|
|
611
|
+
f"retry is idempotent")
|
|
612
|
+
return {"status": "pending"}
|
|
613
|
+
if 400 <= code < 500:
|
|
614
|
+
backoff = _backoff(state, code, retry_after, current)
|
|
615
|
+
if retry:
|
|
616
|
+
# This send was refused, but an EARLIER one whose answer was lost may have
|
|
617
|
+
# landed: keep the receipt so a count drop can still confirm it.
|
|
618
|
+
state.update(backoff)
|
|
619
|
+
_write(state_path, state)
|
|
620
|
+
else:
|
|
621
|
+
_write(state_path, {"schema": 1, "program": PROGRAM, "state": "backoff",
|
|
622
|
+
"grant_id": state["grant_id"], **backoff})
|
|
623
|
+
say(f"{account_id}: limit reset claim refused (HTTP {code}); "
|
|
624
|
+
f"not asking again for {backoff['backoff']}s")
|
|
625
|
+
return {"status": "pending" if retry else "refused", "reason": f"http_{code}"}
|
|
626
|
+
if not 200 <= code < 300:
|
|
627
|
+
say(f"{account_id}: limit reset claim failed (HTTP {code}); retry is idempotent")
|
|
628
|
+
return {"status": "pending"}
|
|
629
|
+
outcome = response.get("result") if response.get("result") in _RESULTS else "unavailable"
|
|
630
|
+
cleared = _types(response.get("cleared"))
|
|
631
|
+
left = _count(response.get("resets_left"))
|
|
632
|
+
if outcome in {"reset", "already_used"}:
|
|
633
|
+
said = "redeemed" if outcome == "reset" else "already redeemed"
|
|
634
|
+
say(f"{account_id}: limit reset {said} automatically "
|
|
635
|
+
f"({', '.join(finished or near)} at limit)")
|
|
636
|
+
return _complete(state_path, state, outcome, current, cleared, left)
|
|
637
|
+
if outcome == "unavailable":
|
|
638
|
+
say(f"{account_id}: limit reset not confirmed (server unavailable); retry is idempotent")
|
|
639
|
+
return {"status": "pending", "outcome": outcome}
|
|
640
|
+
if retry and outcome in {"cooldown", "not_limited", "ineligible"}:
|
|
641
|
+
# A REPLAY of a claim whose first answer was lost. Claude Code reads these very
|
|
642
|
+
# answers to a retried claim as "your earlier try may have gone through": the
|
|
643
|
+
# first send may have refilled the limits, which is exactly why this one finds
|
|
644
|
+
# nothing to do. Keep the receipt; a later count drop confirms it, the TTL ends it.
|
|
645
|
+
return {"status": "pending", "outcome": outcome}
|
|
646
|
+
# not_limited / ineligible / cooldown on a FIRST send: the server used nothing.
|
|
647
|
+
_remove(state_path)
|
|
648
|
+
return {"status": outcome}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, say, now):
|
|
652
|
+
"""Read the allowance from this pass's usage response, redeem if due, report both.
|
|
653
|
+
|
|
654
|
+
The view reported after a claim is the server's own post-claim count (the claim
|
|
655
|
+
response's ``resets_left`` plus the untouched grants); a claim whose outcome is
|
|
656
|
+
unknown reports no count at all, since a reset may have been spent.
|
|
657
|
+
"""
|
|
658
|
+
status = parse_status(usage)
|
|
659
|
+
view = credits_view(status, now)
|
|
660
|
+
result = try_auto_redeem(account_dir, account_id, usage, status, usage_url, headers,
|
|
661
|
+
say, now)
|
|
662
|
+
if result.get("status") == "redeemed":
|
|
663
|
+
left = result.get("resets_left")
|
|
664
|
+
if view and type(left) is int:
|
|
665
|
+
others = sum(g["resets_left"] for g in status["grants"]
|
|
666
|
+
if g["id"] != result.get("grant_id") and _live(g, now))
|
|
667
|
+
view = {"reset_credits_available": others + left,
|
|
668
|
+
"reset_credits_fetched_at": int(now)}
|
|
669
|
+
else:
|
|
670
|
+
view = {}
|
|
671
|
+
elif result.get("status") == "pending":
|
|
672
|
+
view = {}
|
|
673
|
+
return result, view
|