claude-multiacc 2.0.51 → 2.0.53

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 (71) hide show
  1. package/README.md +10 -0
  2. package/bin/codex +1 -1
  3. package/bin/codex-accounts +28 -41
  4. package/docs/CODEX.md +43 -15
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  28. package/lib/claude_pool_prepare.py +204 -0
  29. package/lib/codex_credit_controls.py +27 -0
  30. package/lib/codex_credit_selection.py +10 -4
  31. package/lib/codex_credits.py +28 -32
  32. package/lib/codex_limits_lock.py +56 -0
  33. package/lib/codex_usage_fetch.py +85 -0
  34. package/lib/common.sh +8 -2
  35. package/lib/selector_credit_scope.py +33 -3
  36. package/lib/selector_credits.py +10 -4
  37. package/lib/selector_funding.py +16 -14
  38. package/package.json +2 -2
  39. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  63. package/tests/test_account_seeding.py +13 -0
  64. package/tests/test_claude_pool_prepare.py +107 -0
  65. package/tests/test_codex_reset_credits.py +42 -0
  66. package/tests/test_codex_reset_credits_polling.py +58 -1
  67. package/tests/test_codex_reset_fetch.py +123 -0
  68. package/tests/test_selector_cached_credits.py +135 -0
  69. package/tests/test_selector_credit_scope.py +5 -5
  70. package/tests/test_selector_credit_shim.py +3 -3
  71. package/tests/test_selector_credits.py +3 -2
@@ -9,17 +9,16 @@ import os
9
9
  import re
10
10
  import time
11
11
 
12
+ from codex_usage_fetch import TRANSPORT_ERRORS
13
+ from codex_credit_controls import limit_type as _limit_type, usage_controls as _controls
14
+
12
15
  _DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\Z")
13
16
  _IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,199}\Z")
14
- _LIMIT_TYPES = {
15
- "rate_limit_reached", "workspace_owner_credits_depleted", "workspace_member_credits_depleted",
16
- "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached",
17
- }
18
17
  _ERRORS = {
19
18
  "missing_credits", "invalid_credits", "account_mismatch", "unauthenticated",
20
19
  "usage_read_failed", "invalid_usage", "http_401", "http_403", "http_429", "http_error",
21
20
  "usage_reset_pending",
22
- }
21
+ } | TRANSPORT_ERRORS
23
22
 
24
23
 
25
24
  def _iso(value: float) -> str:
@@ -47,39 +46,24 @@ def _optional_bool(value):
47
46
  return value if type(value) is bool else None
48
47
 
49
48
 
50
- def _limit_type(value):
51
- if isinstance(value, dict):
52
- value = value.get("type", "unknown")
53
- if value is None:
54
- return None
55
- return value if isinstance(value, str) and value in _LIMIT_TYPES else "unknown"
56
-
57
-
58
49
  def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
59
50
  """Bind the provider's wallet and admission facts to the authenticated account."""
60
51
  if not isinstance(usage, dict):
61
52
  raise ValueError("invalid_usage")
62
53
  root = usage.get("rateLimits")
63
54
  root = root if isinstance(root, dict) else usage
64
- raw = root.get("credits")
65
- if not isinstance(raw, dict):
66
- raise ValueError("missing_credits")
67
- has_credits = raw.get("has_credits", raw.get("hasCredits"))
68
- if type(has_credits) is not bool or type(raw.get("unlimited")) is not bool:
69
- raise ValueError("invalid_credits")
70
55
  response_id = usage.get("account_id", usage.get("accountId"))
71
56
  expected, reported = _identity(account_id), _identity(response_id)
72
57
  if response_id is not None and not reported:
73
58
  raise ValueError("account_mismatch")
74
59
  if expected and reported and expected != reported:
75
60
  raise ValueError("account_mismatch")
76
- limit = root.get("rate_limit")
77
- limit = limit if isinstance(limit, dict) else {}
78
- ordinary = usage.get("ordinary_usage_allowed", usage.get("ordinaryUsageAllowed", limit.get("allowed")))
79
- reached = root.get("rate_limit_reached_type", root.get("rateLimitReachedType", limit.get("limit_reached_type")))
80
- spend = root.get("spend_control")
81
- spend = spend if isinstance(spend, dict) else {}
82
- spend_reached = root.get("spend_control_reached", root.get("spendControlReached", spend.get("reached")))
61
+ raw = root.get("credits")
62
+ if not isinstance(raw, dict):
63
+ raise ValueError("missing_credits")
64
+ has_credits = raw.get("has_credits", raw.get("hasCredits"))
65
+ if type(has_credits) is not bool or type(raw.get("unlimited")) is not bool:
66
+ raise ValueError("invalid_credits")
83
67
  try:
84
68
  balance = _balance(raw.get("balance"))
85
69
  except ValueError as error:
@@ -87,9 +71,7 @@ def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
87
71
  return {
88
72
  "has_credits": has_credits, "unlimited": raw["unlimited"], "balance": balance,
89
73
  "fetched_at": _iso(now), "account_id": reported or expected,
90
- "ordinary_usage_allowed": _optional_bool(ordinary),
91
- "spend_control_reached": _optional_bool(spend_reached),
92
- "limit_reached_type": _limit_type(reached),
74
+ **_controls(usage, root),
93
75
  }
94
76
 
95
77
 
@@ -132,9 +114,11 @@ def credit_view(value, now: float | None = None, stale_after: int = 900) -> dict
132
114
 
133
115
 
134
116
  def failed_credits(previous, error: str, now: float) -> dict | None:
135
- """A failed read invalidates spending evidence without inventing a zero balance."""
117
+ """Keep the last measured amount and its age when a telemetry read fails."""
136
118
  out = credit_view(previous, now)
137
119
  if out is not None:
120
+ if out.get("last_error") in ("account_mismatch", "unauthenticated"):
121
+ return out
138
122
  out.update(last_error=error if error in _ERRORS else "usage_read_failed",
139
123
  last_error_at=_iso(now), stale=True)
140
124
  return out
@@ -142,9 +126,21 @@ def failed_credits(previous, error: str, now: float) -> dict | None:
142
126
 
143
127
  def collect_credits(usage, account_id: str, now: float, previous=None) -> dict | None:
144
128
  try:
145
- return snapshot_from_usage(usage, account_id, now)
129
+ result = snapshot_from_usage(usage, account_id, now)
130
+ if previous and result["has_credits"] and not result["unlimited"] and result["balance"] is None:
131
+ raise ValueError("invalid_credits")
132
+ return result
146
133
  except ValueError as error:
147
- return failed_credits(previous, str(error), now)
134
+ result = failed_credits(previous, str(error), now)
135
+ if result is not None and str(error) in ("missing_credits", "invalid_credits"):
136
+ root = usage.get("rateLimits")
137
+ root = root if isinstance(root, dict) else usage
138
+ controls = _controls(usage, root)
139
+ if controls["spend_control_reached"] is True:
140
+ result["spend_control_reached"] = True
141
+ if controls["limit_reached_type"] not in (None, "rate_limit_reached"):
142
+ result["limit_reached_type"] = controls["limit_reached_type"]
143
+ return result
148
144
 
149
145
 
150
146
  def record_read_failure(path: str, previous: dict, error: str, now: float) -> None:
@@ -0,0 +1,56 @@
1
+ """Keep a live Codex usage collector's lock even when a slow pass exceeds two minutes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+
14
+
15
+ def process_birth(pid: int) -> str:
16
+ try:
17
+ os.kill(pid, 0)
18
+ result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="],
19
+ capture_output=True, text=True, timeout=5, check=False)
20
+ return result.stdout.strip() if result.returncode == 0 else ""
21
+ except (OSError, subprocess.TimeoutExpired):
22
+ return ""
23
+
24
+
25
+ def change_lock(path: Path, pid: int, release: bool = False) -> bool:
26
+ """Serialize ownership changes; the owner process lifetime governs the lease."""
27
+ birth = process_birth(pid)
28
+ if not birth:
29
+ return False
30
+ with path.with_suffix(".guard").open("a") as guard:
31
+ fcntl.flock(guard, fcntl.LOCK_EX)
32
+ owner = {}
33
+ try:
34
+ owner = json.loads((path / "owner.json").read_text())
35
+ except (OSError, ValueError):
36
+ pass
37
+ ours = owner == {"pid": pid, "birth": birth}
38
+ if release:
39
+ if ours:
40
+ shutil.rmtree(path)
41
+ return ours
42
+ if path.exists():
43
+ owner_pid = owner.get("pid") if isinstance(owner, dict) else None
44
+ if isinstance(owner_pid, int) and owner_pid > 0:
45
+ if owner.get("birth") and process_birth(owner_pid) == owner["birth"]:
46
+ return False
47
+ elif time.time() - path.stat().st_mtime < 120:
48
+ return False
49
+ shutil.rmtree(path)
50
+ path.mkdir()
51
+ (path / "owner.json").write_text(json.dumps({"pid": pid, "birth": birth}))
52
+ return True
53
+
54
+
55
+ if __name__ == "__main__":
56
+ sys.exit(0 if change_lock(Path(sys.argv[2]), int(sys.argv[3]), sys.argv[1] == "release") else 1)
@@ -0,0 +1,85 @@
1
+ """Read-only usage transport with bounded retries and non-secret failure categories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from email.utils import parsedate_to_datetime
6
+ import errno
7
+ import json
8
+ import socket
9
+ import ssl
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ TRANSPORT_ERRORS = {
15
+ "network_unreachable", "dns_unavailable", "usage_timeout", "connection_reset",
16
+ "tls_error", "edge_challenge",
17
+ }
18
+
19
+
20
+ def failure_kind(error: Exception) -> str:
21
+ """Classify the exception without retaining URLs, headers, bodies, or tokens."""
22
+ if isinstance(error, urllib.error.HTTPError):
23
+ if ((error.headers or {}).get("cf-mitigated") or "").lower() == "challenge":
24
+ return "edge_challenge"
25
+ return f"http_{error.code}" if error.code in (401, 403, 429) else "http_error"
26
+ reason = error.reason if isinstance(error, urllib.error.URLError) else error
27
+ if isinstance(reason, socket.gaierror):
28
+ return "dns_unavailable"
29
+ if isinstance(reason, (socket.timeout, TimeoutError)):
30
+ return "usage_timeout"
31
+ if isinstance(reason, ssl.SSLError):
32
+ return "tls_error"
33
+ if isinstance(reason, OSError):
34
+ if reason.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN):
35
+ return "network_unreachable"
36
+ if reason.errno in (errno.ECONNRESET, errno.ECONNREFUSED, errno.EPIPE):
37
+ return "connection_reset"
38
+ if isinstance(error, (ValueError, UnicodeError)):
39
+ return "invalid_usage"
40
+ return "usage_read_failed"
41
+
42
+
43
+ def retryable(error: Exception) -> bool:
44
+ if isinstance(error, urllib.error.HTTPError):
45
+ return error.code in (500, 502, 503, 504) and not (error.headers or {}).get("Retry-After")
46
+ return failure_kind(error) in TRANSPORT_ERRORS - {"tls_error", "edge_challenge"}
47
+
48
+
49
+ def fetch_usage(request, budget: float = 15):
50
+ """Retry once within a monotonic budget; OS DNS/socket operations retain their own timeouts."""
51
+ deadline = time.monotonic() + budget
52
+ for attempt in range(2):
53
+ remaining = max(0.1, deadline - time.monotonic())
54
+ try:
55
+ with urllib.request.urlopen(request, timeout=min(10, remaining)) as response:
56
+ result = json.loads(response.read().decode())
57
+ if not isinstance(result, dict):
58
+ raise ValueError("invalid usage object")
59
+ return result
60
+ except Exception as error:
61
+ if isinstance(error, urllib.error.HTTPError):
62
+ error.close()
63
+ if attempt or not retryable(error) or time.monotonic() >= deadline:
64
+ raise
65
+
66
+
67
+ def retry_delay(error: Exception, previous: dict, now: float) -> int:
68
+ """Honor Retry-After; otherwise back off transient failures without hammering an account."""
69
+ header = (error.headers or {}).get("Retry-After") if isinstance(error, urllib.error.HTTPError) else None
70
+ if header:
71
+ try:
72
+ delay = int(header)
73
+ except (TypeError, ValueError):
74
+ try:
75
+ delay = int(parsedate_to_datetime(header).timestamp() - now)
76
+ except (TypeError, ValueError, OverflowError):
77
+ delay = 0
78
+ if delay > 0:
79
+ return delay
80
+ try:
81
+ prior = int(previous.get("backoff", 60))
82
+ except (TypeError, ValueError):
83
+ prior = 60
84
+ ceiling = 1800 if failure_kind(error) == "http_429" else 900
85
+ return min(ceiling, max(120, prior * 2))
package/lib/common.sh CHANGED
@@ -781,8 +781,14 @@ PYEOF
781
781
  if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
782
782
  ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
783
783
  fi
784
- if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
785
- ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
784
+ # A fresh machine may seed pooled accounts before stock Claude has ever created
785
+ # ~/.claude/projects. Create the shared tree first or each account will create
786
+ # its own private history, making --resume depend on the next random pick.
787
+ if [ ! -e "$d/projects" ] && [ ! -L "$d/projects" ]; then
788
+ mkdir -p -m 700 "$HOME/.claude" "$HOME/.claude/projects" 2>/dev/null || true
789
+ if [ "$d" != "$HOME/.claude" ] && [ -d "$HOME/.claude/projects" ]; then
790
+ ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
791
+ fi
786
792
  fi
787
793
  seed_mcp_registry "$d"
788
794
  }
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from decimal import Decimal
6
6
 
7
7
  from selector_primitives import timestamp
8
+ from selector_credits import HARD_CREDIT_ERRORS, normalize_credits
8
9
 
9
10
  FINANCIAL_FIELDS = ("has_credits", "unlimited", "balance", "account_id")
10
11
 
@@ -17,7 +18,8 @@ def actor_email(raw: dict) -> str | None:
17
18
  def wallet_available(credits: dict) -> bool:
18
19
  balance = credits.get("balance")
19
20
  funds = credits["unlimited"] or (credits["has_credits"] and balance is not None and Decimal(balance) > 0)
20
- return bool(credits["fresh"] and not credits["last_error"] and funds)
21
+ return bool(credits["account_id"] and credits["fetched_at"]
22
+ and credits["last_error"] not in HARD_CREDIT_ERRORS and funds)
21
23
 
22
24
 
23
25
  def merge_wallet(local: dict, wallet: dict | None) -> dict:
@@ -25,7 +27,7 @@ def merge_wallet(local: dict, wallet: dict | None) -> dict:
25
27
  return local
26
28
  merged = dict(local)
27
29
  merged.update({key: wallet[key] for key in FINANCIAL_FIELDS})
28
- merged["available"] = bool(local["fresh"] and not local["last_error"]
30
+ merged["available"] = bool(local["fetched_at"] and local["last_error"] not in HARD_CREDIT_ERRORS
29
31
  and not local["spend_control_reached"] and wallet["available"])
30
32
  return merged
31
33
 
@@ -41,7 +43,7 @@ def actor_key(raw: dict) -> tuple:
41
43
  def actor_refusals(candidates: list[dict]) -> dict:
42
44
  refusals = {}
43
45
  for raw in candidates:
44
- if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error"):
46
+ if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error") == "account_mismatch":
45
47
  continue
46
48
  try:
47
49
  _, blocked = timestamp(raw["credit_blocked_at"])
@@ -53,3 +55,31 @@ def actor_refusals(candidates: list[dict]) -> dict:
53
55
  refusals[key] = {"credit_blocked_at": blocked,
54
56
  "credit_blocked_balance": raw.get("credit_blocked_balance")}
55
57
  return refusals
58
+
59
+
60
+ def actor_observations(candidates: list[dict], now, max_age: int) -> dict:
61
+ observations = {}
62
+ for raw in candidates:
63
+ credits = normalize_credits(raw.get("credits"), now, max_age)
64
+ if not credits["fetched_at"] or credits["last_error"] == "account_mismatch":
65
+ continue
66
+ key = actor_key(raw)
67
+ sticky = bool(credits["last_error"] and credits["spend_control_reached"])
68
+ rank = (sticky, credits["fetched_at"], credits["spend_control_reached"],
69
+ credits["ordinary_usage_allowed"] is False)
70
+ previous = observations.get(key)
71
+ if previous is None or rank > previous[0]:
72
+ observations[key] = rank, credits
73
+ return {key: value[1] for key, value in observations.items()}
74
+
75
+
76
+ def merge_actor(local: dict, observation: dict | None) -> dict:
77
+ # A partial response can confirm a new restriction while retaining old money's
78
+ # timestamp. A peer's newer money is not proof that this local restriction lifted.
79
+ if observation is None or (local["last_error"] and local["spend_control_reached"]):
80
+ return local
81
+ merged = dict(local)
82
+ for key in ("ordinary_usage_allowed", "spend_control_reached", "limit_reached_type"):
83
+ merged[key] = observation[key]
84
+ merged["available"] = wallet_available(local) and not merged["spend_control_reached"]
85
+ return merged
@@ -8,6 +8,7 @@ from decimal import Decimal, InvalidOperation
8
8
  from selector_primitives import DECIMAL_RE, timestamp
9
9
 
10
10
  CAPABILITY = "codex_credits_v1"
11
+ HARD_CREDIT_ERRORS = {"account_mismatch", "unauthenticated", "refusal_read_failed"}
11
12
  CREDIT_FIELDS = {
12
13
  "has_credits", "unlimited", "balance", "fetched_at", "account_id", "ordinary_usage_allowed",
13
14
  "spend_control_reached", "limit_reached_type", "last_error", "last_error_at", "stale", "age_seconds"}
@@ -63,7 +64,10 @@ def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
63
64
  account = account.strip() if isinstance(account, str) and len(account) <= 256 else None
64
65
  try:
65
66
  observed, fetched_at = timestamp(raw.get("fetched_at"))
66
- fresh = -30 <= (now - observed).total_seconds() <= max_age and raw.get("stale") is not True
67
+ age = (now - observed).total_seconds()
68
+ fresh = -30 <= age <= max_age and raw.get("stale") is not True
69
+ if age < -30:
70
+ fetched_at = None
67
71
  except (ValueError, TypeError):
68
72
  fetched_at, fresh = None, False
69
73
  reached = raw.get("limit_reached_type")
@@ -78,8 +82,9 @@ def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
78
82
  "ordinary_usage_allowed": raw.get("ordinary_usage_allowed")
79
83
  if isinstance(raw.get("ordinary_usage_allowed"), bool) else None,
80
84
  "spend_control_reached": restricted, "limit_reached_type": reached,
81
- "last_error": bool(raw.get("last_error")), "fresh": fresh,
82
- "available": bool(fresh and account and available and not restricted and not raw.get("last_error"))}
85
+ "last_error": raw.get("last_error") or False, "fresh": fresh,
86
+ "available": bool(fetched_at and account and available and not restricted
87
+ and raw.get("last_error") not in HARD_CREDIT_ERRORS)}
83
88
 
84
89
 
85
90
  def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
@@ -96,4 +101,5 @@ def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
96
101
  current = credits.get("balance")
97
102
  increased = old is not None and current is not None and Decimal(current) > Decimal(old)
98
103
  cooled = (now - blocked_time).total_seconds() >= 600
99
- return bool(fetched > blocked_time and credits["available"] and (increased or cooled))
104
+ return bool(fetched > blocked_time and credits["available"] and credits["fresh"]
105
+ and not credits["last_error"] and (increased or cooled))
@@ -5,8 +5,9 @@ from __future__ import annotations
5
5
  from decimal import Decimal, localcontext
6
6
 
7
7
  from selector_credits import CAPABILITY, normalize_credits, refusal_recovered
8
- from selector_credit_scope import actor_email, actor_key, actor_refusals, merge_wallet, wallet_available
9
- from selector_primitives import timestamp
8
+ from selector_credit_scope import (
9
+ actor_email, actor_key, actor_observations, actor_refusals, merge_actor, merge_wallet, wallet_available,
10
+ )
10
11
 
11
12
 
12
13
  def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
@@ -14,21 +15,20 @@ def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
14
15
  for raw in candidates:
15
16
  credits = normalize_credits(raw.get("credits"), now, max_age)
16
17
  key = credits["account_id"]
17
- error = (raw.get("credits") or {}).get("last_error")
18
- if not key or error in {"account_mismatch", "unauthenticated", "http_401", "http_403"}:
18
+ if not key:
19
19
  continue
20
- credits["available"] = wallet_available(credits)
20
+ # The snapshot predates a failed refresh: its confirmed money remains true
21
+ # even when this slot's current credentials no longer authenticate.
22
+ credits["available"] = wallet_available(dict(credits, last_error=False))
21
23
  observed = credits["fetched_at"] or ""
22
- if error:
23
- try:
24
- _, failed_at = timestamp(raw["credits"].get("last_error_at"))
25
- observed = max(observed, failed_at)
26
- except ValueError:
27
- pass
28
- credits["wallet_observed_at"] = observed
24
+ confirmed = bool(observed) and (credits["unlimited"]
25
+ or (raw.get("credits") or {}).get("has_credits") is False
26
+ or (credits["has_credits"] and credits["balance"] is not None))
27
+ credits["wallet_observed_at"], credits["wallet_confirmed"] = observed, confirmed
29
28
  previous = wallets.get(key)
30
- rank = (observed, not credits["available"])
31
- if previous is None or rank > (previous["wallet_observed_at"], not previous["available"]):
29
+ rank = (confirmed, observed, not credits["available"])
30
+ if previous is None or rank > (previous["wallet_confirmed"], previous["wallet_observed_at"],
31
+ not previous["available"]):
32
32
  wallets[key] = credits
33
33
  return wallets
34
34
 
@@ -37,12 +37,14 @@ def apply_funding(rows: list[dict], candidates: list[dict], now, request: dict)
37
37
  max_age = request.get("credit_max_age_seconds", 900)
38
38
  wallets = wallet_snapshots(candidates, now, max_age)
39
39
  refusals = actor_refusals(candidates)
40
+ observations = actor_observations(candidates, now, max_age)
40
41
  holders = {}
41
42
  for raw in candidates:
42
43
  account = (raw.get("credits") or {}).get("account_id")
43
44
  holders[account] = max(holders.get(account, 0), raw.get("credit_active_holders", 0))
44
45
  for row, raw in zip(rows, candidates):
45
46
  credits = normalize_credits(raw.get("credits"), now, max_age)
47
+ credits = merge_actor(credits, observations.get(actor_key(raw)))
46
48
  credits = merge_wallet(credits, wallets.get(credits["account_id"]))
47
49
  receipt = dict(raw, **refusals.get(actor_key(raw), {}))
48
50
  known = row["quota_known"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.51",
3
+ "version": "2.0.53",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
59
  "test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
60
60
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
61
- "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
61
+ "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
62
62
  "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
63
63
  "test:commands": "python3 tests/test_packaged_commands.py"
64
64
  },
@@ -87,6 +87,19 @@ class AccountSeedingTests(unittest.TestCase):
87
87
  self.seed(provider)
88
88
  self.assertEqual((account / name).read_text(), original)
89
89
 
90
+ def test_claude_history_is_shared_even_before_stock_claude_runs(self):
91
+ shared = self.home / ".claude" / "projects"
92
+ self.assertFalse(shared.exists())
93
+ first = self.seed("claude")
94
+ second = self.seed("claude", self.home / ".claude-accounts" / "acct-02")
95
+ self.assertTrue(shared.is_dir())
96
+ self.assertEqual((first / "projects").resolve(), shared.resolve())
97
+ self.assertEqual((second / "projects").resolve(), shared.resolve())
98
+ session = first / "projects" / "-work" / "session.jsonl"
99
+ session.parent.mkdir()
100
+ session.write_text("session\n")
101
+ self.assertEqual((second / "projects" / "-work" / "session.jsonl").read_text(), "session\n")
102
+
90
103
  def test_local_sync_repairs_only_manifest_accounts(self):
91
104
  for provider, name, _ in PROVIDERS:
92
105
  with self.subTest(provider=provider):