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.
Files changed (33) hide show
  1. package/README.md +84 -6
  2. package/bin/claude +122 -6
  3. package/bin/claude-accounts +146 -9
  4. package/bin/codex +116 -7
  5. package/bin/codex-accounts +137 -5
  6. package/docs/ACCOUNT_OPERATIONS.md +34 -0
  7. package/docs/CODEX.md +29 -2
  8. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  17. package/lib/claude_reset.py +70 -35
  18. package/lib/codex_reset.py +31 -4
  19. package/lib/codex_reset_telemetry.py +4 -2
  20. package/lib/common.sh +19 -0
  21. package/lib/mcp_registry.py +1372 -0
  22. package/package.json +2 -2
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  29. package/tests/run-tests.sh +588 -0
  30. package/tests/test_account_seeding.py +40 -0
  31. package/tests/test_claude_reset.py +61 -11
  32. package/tests/test_codex_reset.py +43 -2
  33. package/tests/test_mcp_registry.py +597 -0
@@ -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: 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.
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 = 86400
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 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
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) -> dict:
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) >= RESET_AT_USED_PERCENT:
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
- finished, near = reset_trigger(status, grant, usage)
575
- if not finished and not near:
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 finished:
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
- 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']}")
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": finished or near}
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:
@@ -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) -> dict:
115
- """Redeem one available reset and return a non-secret outcome document."""
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
- if not auto_reset_enabled() or (used_percent < RESET_AT_USED_PERCENT and not finished):
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,