claude-multiacc 2.0.35 → 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.
@@ -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