claude-multiacc 2.0.37 → 2.0.39
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 +84 -6
- package/bin/claude +122 -6
- package/bin/claude-accounts +146 -9
- package/bin/codex +116 -7
- package/bin/codex-accounts +137 -5
- package/docs/ACCOUNT_OPERATIONS.md +34 -0
- package/docs/CODEX.md +29 -2
- 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__/mcp_registry.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 +70 -35
- package/lib/codex_reset.py +31 -4
- package/lib/codex_reset_telemetry.py +4 -2
- package/lib/common.sh +19 -0
- package/lib/mcp_registry.py +1372 -0
- package/package.json +2 -2
- 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 +588 -0
- package/tests/test_account_seeding.py +40 -0
- package/tests/test_claude_reset.py +61 -11
- package/tests/test_codex_reset.py +43 -2
- package/tests/test_mcp_registry.py +597 -0
package/lib/claude_reset.py
CHANGED
|
@@ -6,19 +6,15 @@ names (``clears``: five_hour, seven_day, seven_day_overage_included, ...) while
|
|
|
6
6
|
weekly reset day stays where it was. The first grant arrived with the Opus 5.5
|
|
7
7
|
launch on 2026-09-22 — one reset per Pro/Max account, usable until 2026-10-22.
|
|
8
8
|
|
|
9
|
-
This is the Claude half of lib/codex_reset.py
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
9
|
+
This is the Claude half of lib/codex_reset.py, and it follows the operator's rule:
|
|
10
|
+
an account the pool has PARKED on a window the grant refills gets its reset, so it goes
|
|
11
|
+
back to work instead of sitting out the wall. Parked means exactly what the shim means
|
|
12
|
+
by it — a bucket the grant clears at or above the pool's own exclusion threshold (90%
|
|
13
|
+
by default, the manifest's `threshold`), a limit the server reports exhausted, or an
|
|
14
|
+
active `.limited` park (a 429 the client received, or the app-robot panel's park) on
|
|
15
|
+
such a window. The one hold: when every such park lifts on its own within
|
|
16
|
+
``CLAUDE_MULTIACC_RESET_MIN_HORIZON`` seconds (default 1h), the reset is kept — a
|
|
17
|
+
reset is one irreversible shot (support.claude.com "What is a limit reset?").
|
|
22
18
|
|
|
23
19
|
What differs from codex beyond that is only the transport:
|
|
24
20
|
|
|
@@ -61,7 +57,7 @@ RESET_AT_USED_PERCENT = 95
|
|
|
61
57
|
REDEEM_COOLDOWN_SECONDS = 900
|
|
62
58
|
# Limit types that refill on their own within hours; never a reason to spend a reset.
|
|
63
59
|
SESSION_LIMITS = frozenset({"five_hour"})
|
|
64
|
-
DEFAULT_MIN_HORIZON_SECONDS =
|
|
60
|
+
DEFAULT_MIN_HORIZON_SECONDS = 3600
|
|
65
61
|
# A claim whose response was lost is retried with the same request id while it is
|
|
66
62
|
# younger than this, and dropped afterwards unless the status block proved it landed.
|
|
67
63
|
PENDING_TTL_SECONDS = 3600
|
|
@@ -296,13 +292,51 @@ def _percent(grant: dict, usage: dict, limit_type: str) -> int | None:
|
|
|
296
292
|
return None
|
|
297
293
|
|
|
298
294
|
|
|
299
|
-
def
|
|
300
|
-
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
295
|
+
def _lift_epoch(limit_type: str, status: dict, usage: dict) -> float | None:
|
|
296
|
+
"""When a parked window refills on its own: the session window's reset, or the week's."""
|
|
297
|
+
if limit_type in SESSION_LIMITS:
|
|
298
|
+
for limit in (usage.get("limits") or []) if isinstance(usage, dict) else []:
|
|
299
|
+
if isinstance(limit, dict) and str(limit.get("kind") or "").startswith("session"):
|
|
300
|
+
found = _epoch(limit.get("resets_at"))
|
|
301
|
+
if found is not None:
|
|
302
|
+
return found
|
|
303
|
+
bucket = usage.get("five_hour") if isinstance(usage, dict) else None
|
|
304
|
+
return _epoch(bucket.get("resets_at")) if isinstance(bucket, dict) else None
|
|
305
|
+
return _epoch(status.get("weekly_resets_at"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _active_park(account_dir: str, now: float) -> tuple[str | None, float] | None:
|
|
309
|
+
"""(limit type, lift epoch) of the account's `.limited` park, when it is a USAGE park
|
|
310
|
+
still in force and not already lifted by a reset on record; else None."""
|
|
311
|
+
try:
|
|
312
|
+
with open(os.path.join(account_dir, ".limited"), errors="replace") as handle:
|
|
313
|
+
text = handle.read(4096)
|
|
314
|
+
until = int(text.splitlines()[0])
|
|
315
|
+
except (OSError, ValueError, IndexError):
|
|
316
|
+
return None
|
|
317
|
+
if until <= now or not ("reason=limits" in text or "reason=client-rate-limit" in text):
|
|
318
|
+
return None
|
|
319
|
+
record = latest(record_from(_load(os.path.join(account_dir, "limits.json")), now),
|
|
320
|
+
local_record(account_dir, now))
|
|
321
|
+
if marker_superseded(text, record):
|
|
322
|
+
return None
|
|
323
|
+
bucket = next((part[len("bucket="):] for part in text.split()
|
|
324
|
+
if part.startswith("bucket=")), "")
|
|
325
|
+
return bucket_limit_type(bucket), float(until)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def parked_on(status: dict, grant: dict, usage: dict, account_dir: str, threshold: int,
|
|
329
|
+
now: float) -> list[tuple[str, float | None]]:
|
|
330
|
+
"""Every (limit type, when it lifts on its own) the pool parks this account on,
|
|
331
|
+
among the windows this grant refills."""
|
|
332
|
+
clears = set(grant["clears"])
|
|
333
|
+
parks = [(t, _lift_epoch(t, status, usage)) for t in sorted(clears & set(status["exhausted"]))]
|
|
334
|
+
parks += [(t, _lift_epoch(t, status, usage)) for t in sorted(clears)
|
|
335
|
+
if (_percent(grant, usage, t) or 0) >= threshold]
|
|
336
|
+
park = _active_park(account_dir, now)
|
|
337
|
+
if park and (park[0] == "*" or park[0] in clears):
|
|
338
|
+
parks.append(park)
|
|
339
|
+
return parks
|
|
306
340
|
|
|
307
341
|
|
|
308
342
|
# ---- the reset record the whole fleet honours -------------------------------------
|
|
@@ -525,7 +559,8 @@ def _complete(state_path: str, pending: dict, outcome: str, now: int, cleared=No
|
|
|
525
559
|
|
|
526
560
|
|
|
527
561
|
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
|
|
562
|
+
usage_url: str, headers: dict, say, now: int | None = None,
|
|
563
|
+
threshold: int = RESET_AT_USED_PERCENT) -> dict:
|
|
529
564
|
"""Redeem one reset when policy says so and return a non-secret outcome document."""
|
|
530
565
|
current = int(now if now is not None else time.time())
|
|
531
566
|
if not auto_reset_enabled():
|
|
@@ -558,7 +593,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
558
593
|
return {"status": "cooldown", "outcome": state.get("outcome")}
|
|
559
594
|
if not status["eligible"]:
|
|
560
595
|
reason = status["ineligible_reason"] or "unknown"
|
|
561
|
-
if reason in _REQUEST_SIDE_REASONS and _usage_peak(usage) >=
|
|
596
|
+
if reason in _REQUEST_SIDE_REASONS and _usage_peak(usage) >= threshold:
|
|
562
597
|
# Not a fact about the account: the server would not answer THIS client. On the
|
|
563
598
|
# one machine that can redeem, that silently turns redemption off fleet-wide.
|
|
564
599
|
say(f"{account_id}: limit-reset status refused for this client ({reason}; "
|
|
@@ -571,20 +606,20 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
571
606
|
grant = _usable_grant(status, current)
|
|
572
607
|
if grant is None:
|
|
573
608
|
return {"status": "no_credit"}
|
|
574
|
-
|
|
575
|
-
if not
|
|
609
|
+
parks = parked_on(status, grant, usage, account_dir, threshold, current)
|
|
610
|
+
if not parks:
|
|
576
611
|
return {"status": "not_eligible"}
|
|
612
|
+
parked = sorted({t for t, _ in parks})
|
|
577
613
|
if grant["blocking"]:
|
|
578
614
|
# The client's own rule: a limit this grant does not refill is exhausted too,
|
|
579
615
|
# so refilling the others would not let the account work.
|
|
580
616
|
return {"status": "not_eligible", "reason": "blocked",
|
|
581
617
|
"blocking": grant["blocking"]}
|
|
582
|
-
if grant["use_requires_limit"] and not
|
|
618
|
+
if grant["use_requires_limit"] and not set(grant["clears"]) & set(status["exhausted"]):
|
|
583
619
|
return {"status": "not_eligible", "reason": "waiting_for_limit"}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
f"limit but the week refills on its own at {status['weekly_resets_at']}")
|
|
620
|
+
if all(lift is not None and lift - current < min_horizon() for _, lift in parks):
|
|
621
|
+
say(f"{account_id}: limit reset held — parked on {', '.join(parked)}, which "
|
|
622
|
+
f"lifts on its own within {min_horizon() // 60} minutes")
|
|
588
623
|
return {"status": "not_eligible", "reason": "natural_reset_soon"}
|
|
589
624
|
org = organization_uuid(account_dir)
|
|
590
625
|
url = claim_url(usage_url, org) if org else None
|
|
@@ -600,7 +635,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
600
635
|
"request_id": request_id(org, account_uuid(account_dir) or account_id,
|
|
601
636
|
grant, status),
|
|
602
637
|
"resets_left_before": grant["resets_left"], "started_at": current,
|
|
603
|
-
"clears": grant["clears"], "trigger":
|
|
638
|
+
"clears": grant["clears"], "trigger": parked}
|
|
604
639
|
_write(state_path, state)
|
|
605
640
|
payload = {"program": PROGRAM, "grant_id": state["grant_id"],
|
|
606
641
|
"request_id": state["request_id"]}
|
|
@@ -631,8 +666,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
631
666
|
left = _count(response.get("resets_left"))
|
|
632
667
|
if outcome in {"reset", "already_used"}:
|
|
633
668
|
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)")
|
|
669
|
+
say(f"{account_id}: limit reset {said} automatically (parked on {', '.join(parked)})")
|
|
636
670
|
return _complete(state_path, state, outcome, current, cleared, left)
|
|
637
671
|
if outcome == "unavailable":
|
|
638
672
|
say(f"{account_id}: limit reset not confirmed (server unavailable); retry is idempotent")
|
|
@@ -648,7 +682,8 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
648
682
|
return {"status": outcome}
|
|
649
683
|
|
|
650
684
|
|
|
651
|
-
def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, say, now
|
|
685
|
+
def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, say, now,
|
|
686
|
+
threshold=RESET_AT_USED_PERCENT):
|
|
652
687
|
"""Read the allowance from this pass's usage response, redeem if due, report both.
|
|
653
688
|
|
|
654
689
|
The view reported after a claim is the server's own post-claim count (the claim
|
|
@@ -658,7 +693,7 @@ def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, sa
|
|
|
658
693
|
status = parse_status(usage)
|
|
659
694
|
view = credits_view(status, now)
|
|
660
695
|
result = try_auto_redeem(account_dir, account_id, usage, status, usage_url, headers,
|
|
661
|
-
say, now)
|
|
696
|
+
say, now, threshold)
|
|
662
697
|
if result.get("status") == "redeemed":
|
|
663
698
|
left = result.get("resets_left")
|
|
664
699
|
if view and type(left) is int:
|
package/lib/codex_reset.py
CHANGED
|
@@ -22,6 +22,23 @@ from codex_reset_windows import reset_windows
|
|
|
22
22
|
RESET_AT_USED_PERCENT = 95
|
|
23
23
|
REDEEM_COOLDOWN_SECONDS = 900
|
|
24
24
|
STATE_FILE = ".usage-reset.json"
|
|
25
|
+
# A park that lifts on its own within this long keeps the credit (operator rule,
|
|
26
|
+
# 2026-09-22: a PARKED account uses its reset; only a sub-hour wall is not worth one).
|
|
27
|
+
PARK_HOLD_SECONDS = 3600
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _active_park(account_dir: str, now: int) -> int | None:
|
|
31
|
+
"""The epoch a USAGE park on this account lifts at (a >=threshold pass, or a 429 the
|
|
32
|
+
client received), or None. Error cool-downs and expired markers are not parks."""
|
|
33
|
+
try:
|
|
34
|
+
with open(os.path.join(account_dir, ".limited"), errors="replace") as handle:
|
|
35
|
+
text = handle.read(4096)
|
|
36
|
+
until = int(text.splitlines()[0])
|
|
37
|
+
except (OSError, ValueError, IndexError):
|
|
38
|
+
return None
|
|
39
|
+
if until <= now or not ("reason=limits" in text or "reason=client-rate-limit" in text):
|
|
40
|
+
return None
|
|
41
|
+
return until
|
|
25
42
|
|
|
26
43
|
|
|
27
44
|
def auto_reset_enabled() -> bool:
|
|
@@ -111,11 +128,20 @@ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) ->
|
|
|
111
128
|
|
|
112
129
|
def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
113
130
|
usage: dict, usage_url: str, headers: dict, say,
|
|
114
|
-
now: int | None = None, credit_details: dict | None = None
|
|
115
|
-
|
|
131
|
+
now: int | None = None, credit_details: dict | None = None,
|
|
132
|
+
threshold: int = RESET_AT_USED_PERCENT) -> dict:
|
|
133
|
+
"""Redeem one available reset and return a non-secret outcome document.
|
|
134
|
+
|
|
135
|
+
Eligible whenever the pool has PARKED the account: a window at/over the pool's own
|
|
136
|
+
exclusion threshold (90 by default — the 95% line left accounts at 90-94% parked for
|
|
137
|
+
days with credits unused), a finished limit, or an active usage park that lifts more
|
|
138
|
+
than PARK_HOLD_SECONDS from now (a 429 the telemetry may not show)."""
|
|
116
139
|
current = int(now if now is not None else time.time())
|
|
117
140
|
finished, _ = reset_windows(usage, RESET_AT_USED_PERCENT)
|
|
118
|
-
|
|
141
|
+
park = _active_park(account_dir, current)
|
|
142
|
+
parked = park is not None and park - current >= PARK_HOLD_SECONDS
|
|
143
|
+
if not auto_reset_enabled() or (used_percent < min(threshold, RESET_AT_USED_PERCENT)
|
|
144
|
+
and not finished and not parked):
|
|
119
145
|
return {"status": "not_eligible"}
|
|
120
146
|
state, disposition = _pending_request(account_dir, current)
|
|
121
147
|
if disposition == "cooldown":
|
|
@@ -151,7 +177,8 @@ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
|
151
177
|
"redeemed_at": current, "suppress_until": current + REDEEM_COOLDOWN_SECONDS,
|
|
152
178
|
"windows_reset": int(response.get("windows_reset") or 0)}
|
|
153
179
|
_write(os.path.join(account_dir, STATE_FILE), complete)
|
|
154
|
-
say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used"
|
|
180
|
+
say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used"
|
|
181
|
+
+ ("" if used_percent >= threshold or finished else " (parked by a client-reported limit)"))
|
|
155
182
|
return {"status": "redeemed", "outcome": outcome,
|
|
156
183
|
"windows_reset": complete["windows_reset"], "redeemed_at": current}
|
|
157
184
|
try:
|
|
@@ -18,7 +18,8 @@ def _read_credits(url, headers, account_id, say):
|
|
|
18
18
|
return {}, {}
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now
|
|
21
|
+
def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now,
|
|
22
|
+
threshold=None):
|
|
22
23
|
"""Read all accounts, share the GET with redemption, then read back after a mutation.
|
|
23
24
|
|
|
24
25
|
Failed reads omit the count; neither an embedded summary nor subtraction proves
|
|
@@ -27,8 +28,9 @@ def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_ur
|
|
|
27
28
|
"""
|
|
28
29
|
list_url, _ = _reset_urls(usage_url)
|
|
29
30
|
details, view = _read_credits(list_url, headers, account_id, say)
|
|
31
|
+
extra = {} if threshold is None else {"threshold": threshold}
|
|
30
32
|
result = try_auto_redeem(account_dir, account_id, used_percent, usage, usage_url,
|
|
31
|
-
headers, say, now, credit_details=details)
|
|
33
|
+
headers, say, now, credit_details=details, **extra)
|
|
32
34
|
if result.get("status") not in {"not_eligible", "cooldown", "no_credit", "error"}:
|
|
33
35
|
_, view = _read_credits(list_url, headers, account_id, say)
|
|
34
36
|
return result, view
|
package/lib/common.sh
CHANGED
|
@@ -685,6 +685,23 @@ codex_share_state_index() { # $1 acct dir — seed time: whatever index names ex
|
|
|
685
685
|
# shared sessions/ symlink (=> `codex resume` finds every session) with the
|
|
686
686
|
# shared rollout index that tree needs, and a shared AGENTS.md symlink so
|
|
687
687
|
# global instructions apply under any account.
|
|
688
|
+
# MCP servers for EVERY account (lib/mcp_registry.py). Both clients keep MCP servers in
|
|
689
|
+
# the CONFIG DIR (`<dir>/.claude.json`, `<CODEX_HOME>/config.toml`), so under the pool
|
|
690
|
+
# there is one MCP configuration per account and a stock `claude mcp add` lands in ONE
|
|
691
|
+
# random one. The pool's registry (mcp-servers.json, synced) plus the machine-local
|
|
692
|
+
# overlay (mcp-servers.local.json, never synced) are reconciled into every account here,
|
|
693
|
+
# at seed time, and by the shims right before exec. Fail-open: a seed never dies on it.
|
|
694
|
+
seed_mcp_registry() { # $1 = acct dir
|
|
695
|
+
local d="$1" off
|
|
696
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then off="${CODEX_MULTIACC_MCP:-1}"; else off="${CLAUDE_MULTIACC_MCP:-1}"; fi
|
|
697
|
+
[ "$off" = "0" ] && return 0
|
|
698
|
+
[ -f "$ACC_ROOT/mcp-servers.json" ] || [ -f "$ACC_ROOT/mcp-servers.local.json" ] || return 0
|
|
699
|
+
[ -f "$LIB_DIR/mcp_registry.py" ] || return 0
|
|
700
|
+
"$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" apply \
|
|
701
|
+
--account-dir "$d" --fail-open --quiet >/dev/null || true
|
|
702
|
+
return 0
|
|
703
|
+
}
|
|
704
|
+
|
|
688
705
|
seed_account_dir() { # $1 = acct dir
|
|
689
706
|
local d="$1"
|
|
690
707
|
mkdir -p "$d"
|
|
@@ -704,6 +721,7 @@ seed_account_dir() { # $1 = acct dir
|
|
|
704
721
|
ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
|
|
705
722
|
fi
|
|
706
723
|
codex_share_state_index "$d"
|
|
724
|
+
seed_mcp_registry "$d"
|
|
707
725
|
return 0
|
|
708
726
|
fi
|
|
709
727
|
if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
|
@@ -730,6 +748,7 @@ PYEOF
|
|
|
730
748
|
if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
731
749
|
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
732
750
|
fi
|
|
751
|
+
seed_mcp_registry "$d"
|
|
733
752
|
}
|
|
734
753
|
|
|
735
754
|
# Short-lived mutation lock: serialize only the brief critical sections (id reservation,
|