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
package/README.md CHANGED
@@ -326,6 +326,16 @@ maintains only while a run is alive. The shim spawns a short detached poll befor
326
326
  session's own start time — in `<acct>/.sessions-index`; selection then tail-scans those
327
327
  transcripts.
328
328
 
329
+ Fresh account seeding creates `~/.claude/projects` before linking each account, even
330
+ if stock Claude has never run on that machine. For an existing pool with private
331
+ `<acct>/projects` trees, run `python3 lib/claude_pool_prepare.py apply`. It
332
+ defers a private-history migration while a pooled Claude session is active,
333
+ merges transcripts into the shared tree when idle, archives
334
+ conflicting versions under `~/.claude-accounts/history-repair/`, and sets the
335
+ interactive default to `bypassPermissions` in the global and pooled account
336
+ settings. `check` reports readiness without changing files. The bypass default is
337
+ an operator choice for trusted runner machines; ordinary installs do not change it.
338
+
329
339
  Everything about the rule is built to under-claim rather than over-claim, because a
330
340
  wrongly parked account is worse than a missed limit:
331
341
 
package/bin/codex CHANGED
@@ -1348,7 +1348,7 @@ else
1348
1348
  [ "$AR_PROBE" = "1" ] \
1349
1349
  || sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
1350
1350
  # Keep the same ChatGPT login. The helper never enables API keys and fails closed
1351
- # on stale/unknown wallets, spend controls and unresolved paid refusals.
1351
+ # on unknown wallets, authentication errors, spend controls and unresolved paid refusals.
1352
1352
  elif [ "${CODEX_MULTIACC_CREDITS:-1}" != "0" ] && [ "${#paid[@]}" -gt 0 ] \
1353
1353
  && credit_pick="$("$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" \
1354
1354
  pick "$now" "${paid[@]}" 2>/dev/null)" && [ -n "$credit_pick" ]; then
@@ -1075,6 +1075,12 @@ cmd_relogin() {
1075
1075
  [ -z "$failed" ]
1076
1076
  }
1077
1077
 
1078
+ codex_limits_lock_release() {
1079
+ [ -n "${LIMITS_LOCK:-}" ] && "$PYBIN" "$LIB_DIR/codex_limits_lock.py" release "$LIMITS_LOCK" "$$"
1080
+ LIMITS_LOCK=""
1081
+ return 0
1082
+ }
1083
+
1078
1084
  cmd_limits() {
1079
1085
  require_manifest
1080
1086
  local quiet=0 force=0 json=0
@@ -1088,23 +1094,14 @@ cmd_limits() {
1088
1094
  done
1089
1095
  local lock="$ACC_ROOT/.locks/limits"
1090
1096
  mkdir -p "$ACC_ROOT/.locks"
1091
- if ! mkdir "$lock" 2>/dev/null; then
1092
- local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
1093
- if [ "$age" -lt 120 ]; then
1094
- # A --json caller still gets the document (built from the state on disk) —
1095
- # a machine-readable verb must never answer a concurrent run with silence.
1096
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1097
- [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1098
- return 0
1099
- fi
1100
- rm -rf "$lock"
1101
- if ! mkdir "$lock" 2>/dev/null; then
1102
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1103
- return 0
1104
- fi
1097
+ if ! "$PYBIN" "$LIB_DIR/codex_limits_lock.py" acquire "$lock" "$$"; then
1098
+ # JSON callers still get the cached document while a live refresh owns the lock.
1099
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1100
+ [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1101
+ return 0
1105
1102
  fi
1106
1103
  LIMITS_LOCK="$lock"
1107
- trap limits_lock_release EXIT
1104
+ trap codex_limits_lock_release EXIT
1108
1105
  rotate_log limits.log
1109
1106
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
1110
1107
  # never loosen it, or an account could sit at 95% and still be selected.
@@ -1121,7 +1118,7 @@ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
1121
1118
  sys.path.insert(0, lib_dir)
1122
1119
  from codex_reset_telemetry import refresh_reset_credits
1123
1120
  from codex_credits import collect_credits, failed_credits, record_read_failure
1124
- now = time.time()
1121
+ from codex_usage_fetch import fetch_usage, failure_kind, retry_delay
1125
1122
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
1126
1123
  MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
1127
1124
 
@@ -1396,6 +1393,7 @@ def refresh_oauth(aid, d, cpath):
1396
1393
  return tok
1397
1394
 
1398
1395
  for acct in manifest.get('accounts', []):
1396
+ now = time.time()
1399
1397
  aid = acct['id']
1400
1398
  d = os.path.join(root, aid)
1401
1399
  if not os.path.isdir(d):
@@ -1418,7 +1416,8 @@ for acct in manifest.get('accounts', []):
1418
1416
  continue
1419
1417
  retry_at = prev.get('retry_after', 0)
1420
1418
  if retry_at > now:
1421
- say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
1419
+ reason = '429' if prev.get('last_error') in (None, 'http_429') else 'usage read failure'
1420
+ say(f'{aid}: backing off after {reason} ({int(retry_at - now)}s left); limits left as-is')
1422
1421
  continue
1423
1422
 
1424
1423
  bearer = None
@@ -1448,7 +1447,7 @@ for acct in manifest.get('accounts', []):
1448
1447
  if not bearer:
1449
1448
  # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
1450
1449
  say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
1451
- record_read_failure(lpath, prev, 'unauthenticated', now)
1450
+ record_read_failure(lpath, prev, 'unauthenticated', time.time())
1452
1451
  continue
1453
1452
  if not account_id:
1454
1453
  account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
@@ -1464,28 +1463,16 @@ for acct in manifest.get('accounts', []):
1464
1463
  headers['chatgpt-account-id'] = account_id
1465
1464
  req = urllib.request.Request(url, headers=headers)
1466
1465
  try:
1467
- resp = urllib.request.urlopen(req, timeout=15)
1468
- data = json.loads(resp.read().decode())
1469
- except urllib.error.HTTPError as e:
1470
- if e.code == 429:
1471
- # Respect Retry-After; otherwise exponential backoff capped at 30 min.
1472
- try:
1473
- wait = int(e.headers.get('Retry-After') or 0)
1474
- except (TypeError, ValueError):
1475
- wait = 0
1476
- if wait <= 0:
1477
- wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
1478
- prev['retry_after'] = int(now + wait)
1479
- prev['backoff'] = wait
1480
- say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
1481
- else:
1482
- say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
1483
- error = f'http_{e.code}' if e.code in (401, 403, 429) else 'http_error'
1484
- record_read_failure(lpath, prev, error, now)
1485
- continue
1466
+ data = fetch_usage(req)
1467
+ now = time.time()
1486
1468
  except Exception as e:
1487
- say(f'{aid}: usage fetch failed ({e}); failing open')
1488
- record_read_failure(lpath, prev, 'usage_read_failed', now)
1469
+ now = time.time()
1470
+ error = failure_kind(e)
1471
+ wait = retry_delay(e, prev, now)
1472
+ prev.update(retry_after=int(now + wait), backoff=wait)
1473
+ label = 'rate limited (429)' if error == 'http_429' else f'usage fetch failed ({error})'
1474
+ say(f'{aid}: {label}; backing off {wait}s; failing open')
1475
+ record_read_failure(lpath, prev, error, now)
1489
1476
  continue
1490
1477
 
1491
1478
  def pct_of(v):
@@ -1595,7 +1582,7 @@ for acct in manifest.get('accounts', []):
1595
1582
  walk(data, [])
1596
1583
  except Exception as e:
1597
1584
  say(f'{aid}: unexpected usage payload shape ({e}); failing open')
1598
- record_read_failure(lpath, prev, 'invalid_usage', now)
1585
+ record_read_failure(lpath, prev, 'invalid_usage', time.time())
1599
1586
  continue
1600
1587
  # Successful fetch: fresh buckets replace everything, backoff state is dropped.
1601
1588
  # THREE selection signals, same reset asymmetry as the claude pool:
@@ -1848,7 +1835,7 @@ PYEOF
1848
1835
  local refresh_rc=$?
1849
1836
  # Release the lock BEFORE the report: a --json caller must not hold the refresh
1850
1837
  # lock while a consumer reads its output.
1851
- limits_lock_release
1838
+ codex_limits_lock_release
1852
1839
  trap - EXIT
1853
1840
  # The refresher fails open per account; a NON-zero status means the pass itself
1854
1841
  # broke (unreadable manifest, dead python). Report the state anyway — stale data
package/docs/CODEX.md CHANGED
@@ -110,14 +110,35 @@ Wallet freshness uses its own `fetched_at`, independently of the usage windows a
110
110
  earned-reset redemption. Failed usage or malformed wallet reads retain the last known
111
111
  balance and timestamp with bounded `last_error`/`last_error_at` fields and `stale: true`.
112
112
  A successful wallet read clears that failure. Old or future-dated snapshots also become
113
- stale; the default report horizon is 900 seconds, matching Codex telemetry. An account
114
- with no previous wallet stays unknown after a failed read. Reports never expose raw
113
+ stale; the default report horizon is 900 seconds, matching Codex telemetry. This freshness
114
+ label is informational for ordinary credit admission: a previously confirmed positive balance
115
+ or explicit unlimited allowance remains usable through failed refreshes and old observations,
116
+ without a fallback age limit. A failed usage HTTP 401/403 is a telemetry failure, not proof
117
+ that the native CLI's inference login is invalid. Missing authentication, an identity mismatch,
118
+ failed refusal-record access, actual authentication parks and provider spending restrictions
119
+ still deny admission. Invalid or more than 30-seconds-future timestamps never authorize it.
120
+ An account with no previous wallet stays unknown after a failed read. Reports never expose raw
115
121
  provider errors, arbitrary response fields or authentication material through this object.
122
+ Partial wallet payloads retain measured funds and can add explicit provider spending restrictions;
123
+ they never clear a previous restriction. A successful full wallet read is required to clear it.
116
124
  A successful earned reset marks pre-reset admission facts `usage_reset_pending` until
117
- the next usage read, so newly refilled included quota is used before paid fallback.
125
+ the next usage read. Cached funds remain usable during that gap; the selector may still label
126
+ the capacity as credit-backed until telemetry confirms the refill. The native Codex client
127
+ uses the provider's live allowance and determines actual billing independently of that label.
118
128
 
119
- Collection adds no network request, inference, purchase, redemption or billing-mode
120
- change. Earned `usage.reset_credits_available` remain separate: they reset included
129
+ Collection adds no separate balance request, inference, purchase, redemption or billing-mode
130
+ change. The existing usage GET retries a transient routing, DNS, timeout or 5xx failure once
131
+ within a 15-second monotonic budget, with each socket timeout capped at 10 seconds; operating-system
132
+ DNS/socket operations can exceed that budget. HTTP 401/403/429 and responses with `Retry-After`
133
+ are never retried immediately. Failures back off per account, honoring `Retry-After` seconds or
134
+ dates, otherwise doubling from 120 seconds to 900 seconds (1800 for 429). Failed polls keep
135
+ confirmed balances and their original timestamps. Successful observations use the response time,
136
+ not the start of a potentially slow whole-pool pass. Logs and reports distinguish
137
+ `network_unreachable`, `dns_unavailable`, `usage_timeout`, `connection_reset`, `tls_error` and
138
+ `edge_challenge` without exposing raw errors. A live collector's PID-and-birth-bound lock does
139
+ not expire after two minutes; dead collectors' locks can be reclaimed without overlapping a live pass.
140
+
141
+ Earned `usage.reset_credits_available` remain separate: they reset included
121
142
  quota, whereas this wallet funds usage after included quota is exhausted. API-key
122
143
  authentication remains unsupported. Consumers must preserve the wallet's timestamp,
123
144
  identity, spend-control refusals and failure state when deciding whether credit-backed
@@ -126,9 +147,11 @@ The shim's wallet-and-member-bound paid-refusal receipt is reported separately a
126
147
  `usage.credit_blocked_at`, `usage.credit_blocked_balance`, and `usage.limited_reason: "credits"`.
127
148
  A genuine paid quota refusal writes `.credit-refused.json` and a `.limited` marker with
128
149
  `reason=credit-refused`. Authentication and temporary service errors do not create credit
129
- refusals. Paid work can retry after 600 seconds and a strictly newer successful observation;
130
- a newer increased balance permits earlier recovery. Zero funds, missing observations and
131
- provider spending restrictions still deny paid work. Included quota can recover independently.
150
+ refusals. Paid work can retry after 600 seconds and a strictly newer successful, fresh observation;
151
+ a newer increased balance permits earlier recovery, also requiring a successful fresh read.
152
+ A cached positive balance alone never clears an actual paid refusal. Confirmed finite zero,
153
+ missing monetary observations and provider spending restrictions still deny paid work.
154
+ Included quota can recover independently.
132
155
  Copies of the same member share the receipt; different known members of a workspace do not.
133
156
 
134
157
  ### Credit-aware selection
@@ -141,8 +164,9 @@ this fallback. Explicit account pins continue to represent the caller's choice.
141
164
  `multiacc-select --capabilities` advertises `codex_credits_v1`. A caller enables the additive
142
165
  policy with `credit_fallback: true` and may request only paid candidates through
143
166
  `required_funding_tier: "credits"`. `credit_max_age_seconds` defaults to 900 (accepted range
144
- 30–3600); observations more than 30 seconds in the future cannot authorize spending.
145
- Optional candidate fields are:
167
+ 30–3600) for freshness reporting and paid-refusal recovery. It does not expire a confirmed
168
+ positive balance for ordinary credit admission. Observations more than 30 seconds in the future
169
+ cannot authorize spending. Optional candidate fields are:
146
170
 
147
171
  | Field | Meaning |
148
172
  | --- | --- |
@@ -158,11 +182,15 @@ Successful opted-in responses include `credit_capability`, `funding_tier` (`incl
158
182
  balance, billing identity, unlimited flag and active holder count; when supplied, the actor
159
183
  email is also preserved in the normalized proof and score. New monetary observations replace
160
184
  older balances across wallet copies; balances are never summed or merged by maximum.
161
- Failed monetary observations cannot resurrect an older positive balance. Member permissions,
162
- actor observation timestamps and local authentication/identity failures remain separate from
163
- shared finances. Refusals propagate only within the same wallet/member identity; unknown
164
- actors are handled conservatively. Paid ranking compares balance per active holder, then
165
- uses the existing deterministic rotation for ties.
185
+ Financial and member-control observations are ordered by their actual `fetched_at`, never a
186
+ later failed refresh's `last_error_at`. An old cached positive balance cannot replace a newer
187
+ confirmed zero; an old cached allowance cannot erase a newer confirmed member restriction.
188
+ Explicit unlimited allowance remains distinct from finite balances, including null or zero.
189
+ Member permissions, actor observation timestamps and local authentication/identity failures
190
+ remain separate from shared finances. Refusals propagate only within the same wallet/member
191
+ identity; an unknown member's refusal stays with its runner and account slot. Failed or partial reads
192
+ retain restrictive member controls until that source successfully refreshes them. Paid ranking compares
193
+ balance per active holder, then uses the existing deterministic rotation for ties.
166
194
 
167
195
  The schema and selector version remain `pool-selection.v2` / `2.0.1`. Requests without the
168
196
  credit opt-in retain their prior outputs and digests. Extra credit candidate fields without
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare one local Claude pool's shared history and interactive permission default."""
3
+
4
+ import argparse
5
+ import fcntl
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def account_dirs(root: Path) -> list[Path]:
14
+ manifest = root / "accounts.json"
15
+ if not manifest.is_file():
16
+ return []
17
+ data = json.loads(manifest.read_text())
18
+ ids = [item["id"] for item in data.get("accounts", [])]
19
+ if any(not re.fullmatch(r"acct-[0-9]{2}", value) for value in ids):
20
+ raise ValueError("invalid account id in manifest")
21
+ return [root / value for value in ids if (root / value).is_dir()]
22
+
23
+
24
+ def same_history(path: Path, shared: Path) -> bool:
25
+ return path.is_dir() and path.resolve() == shared.resolve()
26
+
27
+
28
+ def active_pool_session(accounts: list[Path]) -> bool:
29
+ for account in accounts:
30
+ for marker in (account / "sessions").glob("*.json"):
31
+ if marker.stem.isdecimal():
32
+ try:
33
+ os.kill(int(marker.stem), 0)
34
+ except ProcessLookupError:
35
+ continue
36
+ except PermissionError:
37
+ return True
38
+ return True
39
+ return False
40
+
41
+
42
+ def save_conflict(path: Path, archive: Path, account: str, relative: Path) -> None:
43
+ destination = archive / "conflicts" / account / relative
44
+ destination.parent.mkdir(parents=True, exist_ok=True)
45
+ number = 1
46
+ while os.path.lexists(destination):
47
+ destination = destination.with_name(f"{relative.name}.{number}")
48
+ number += 1
49
+ path.rename(destination)
50
+
51
+
52
+ def merge_history(source: Path, shared: Path, archive: Path) -> tuple[int, int]:
53
+ moved = conflicts = 0
54
+ for base, directories, files in os.walk(source):
55
+ current = Path(base)
56
+ relative = current.relative_to(source)
57
+ destination = shared / relative
58
+ destination.mkdir(parents=True, exist_ok=True)
59
+ for name in list(directories):
60
+ if (current / name).is_symlink():
61
+ directories.remove(name)
62
+ files.append(name)
63
+ for name in files:
64
+ item = current / name
65
+ target = destination / name
66
+ if not os.path.lexists(target):
67
+ item.rename(target)
68
+ moved += 1
69
+ elif item.lstat().st_mtime_ns > target.lstat().st_mtime_ns:
70
+ save_conflict(target, archive, source.name, relative / name)
71
+ item.rename(target)
72
+ conflicts += 1
73
+ else:
74
+ save_conflict(item, archive, source.name, relative / name)
75
+ conflicts += 1
76
+ for base, _, _ in os.walk(source, topdown=False):
77
+ Path(base).rmdir()
78
+ return moved, conflicts
79
+
80
+
81
+ def prepare_history(home: Path, root: Path, accounts: list[Path]) -> tuple[int, int]:
82
+ shared = home / ".claude" / "projects"
83
+ if os.path.lexists(shared) and not shared.is_dir():
84
+ raise ValueError("global Claude history is not a directory")
85
+ for account in accounts:
86
+ projects = account / "projects"
87
+ if (account.is_symlink() and os.path.lexists(projects) and not same_history(projects, shared)
88
+ or projects.is_symlink() and not same_history(projects, shared)
89
+ or os.path.lexists(projects) and not projects.is_dir()):
90
+ raise ValueError(f"custom history entry for {account.name}")
91
+ if active_pool_session(accounts) and any(
92
+ (account / "projects").is_dir() and not same_history(account / "projects", shared)
93
+ for account in accounts):
94
+ raise RuntimeError("active Claude pool session; retry history repair after it exits")
95
+ shared.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
96
+ if not shared.is_dir():
97
+ source = next((d / "projects" for d in accounts if not d.is_symlink()
98
+ and (d / "projects").is_dir() and not (d / "projects").is_symlink()), None)
99
+ if source is None:
100
+ shared.mkdir(mode=0o700)
101
+ else:
102
+ source.rename(shared)
103
+ source.symlink_to(shared, target_is_directory=True)
104
+ archive = root / "history-repair"
105
+ originals = archive / "originals"
106
+ for account in accounts:
107
+ projects = account / "projects"
108
+ if same_history(projects, shared):
109
+ continue
110
+ if projects.is_dir():
111
+ originals.mkdir(parents=True, exist_ok=True)
112
+ archive.chmod(0o700)
113
+ originals.chmod(0o700)
114
+ saved = originals / account.name
115
+ number = 1
116
+ while saved.exists():
117
+ saved = originals / f"{account.name}-{number}"
118
+ number += 1
119
+ projects.rename(saved)
120
+ projects.symlink_to(shared, target_is_directory=True)
121
+ moved = conflicts = 0
122
+ if originals.is_dir():
123
+ for source in sorted(originals.iterdir()):
124
+ count, duplicates = merge_history(source, shared, archive)
125
+ moved += count
126
+ conflicts += duplicates
127
+ return moved, conflicts
128
+
129
+
130
+ def set_default_mode(path: Path) -> bool:
131
+ data = json.loads(path.read_text()) if path.exists() else {}
132
+ permissions = data.setdefault("permissions", {})
133
+ if not isinstance(permissions, dict):
134
+ raise ValueError(f"invalid permissions in {path}")
135
+ if permissions.get("defaultMode") == "bypassPermissions":
136
+ return False
137
+ permissions["defaultMode"] = "bypassPermissions"
138
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
139
+ descriptor, temporary = tempfile.mkstemp(prefix=".settings-", dir=path.parent)
140
+ try:
141
+ with os.fdopen(descriptor, "w") as stream:
142
+ json.dump(data, stream, indent=2, ensure_ascii=False)
143
+ stream.write("\n")
144
+ os.chmod(temporary, mode)
145
+ os.replace(temporary, path)
146
+ finally:
147
+ if os.path.exists(temporary):
148
+ os.unlink(temporary)
149
+ return True
150
+
151
+
152
+ def validate_settings(paths: list[Path]) -> None:
153
+ for path in paths:
154
+ if path.is_symlink():
155
+ raise ValueError(f"custom settings link for {path.parent.name}")
156
+ if path.exists():
157
+ data = json.loads(path.read_text())
158
+ if not isinstance(data, dict) or not isinstance(data.get("permissions", {}), dict):
159
+ raise ValueError(f"invalid settings for {path.parent.name}")
160
+
161
+
162
+ def is_ready(home: Path, accounts: list[Path]) -> bool:
163
+ shared = home / ".claude" / "projects"
164
+ if not shared.is_dir() or any(not same_history(d / "projects", shared) for d in accounts):
165
+ return False
166
+ for path in [home / ".claude" / "settings.json", *(d / "settings.json" for d in accounts)]:
167
+ try:
168
+ if json.loads(path.read_text())["permissions"]["defaultMode"] != "bypassPermissions":
169
+ return False
170
+ except (OSError, ValueError, KeyError, TypeError):
171
+ return False
172
+ return True
173
+
174
+
175
+ def main() -> int:
176
+ parser = argparse.ArgumentParser(description=__doc__)
177
+ parser.add_argument("verb", choices=("check", "apply"))
178
+ parser.add_argument("--home", type=Path, default=Path.home())
179
+ parser.add_argument("--root", type=Path, default=None)
180
+ args = parser.parse_args()
181
+ home = args.home.resolve()
182
+ root = (args.root or Path(os.environ.get("CLAUDE_ACCOUNTS_ROOT", home / ".claude-accounts"))).resolve()
183
+ if args.verb == "check":
184
+ accounts = account_dirs(root)
185
+ ready = is_ready(home, accounts)
186
+ print(json.dumps({"ready": ready, "accounts": len(accounts)}))
187
+ return 0 if ready else 1
188
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
189
+ descriptor = os.open(root / ".pool-prepare.lock", os.O_CREAT | os.O_RDWR, 0o600)
190
+ with os.fdopen(descriptor, "w") as lock:
191
+ fcntl.flock(lock, fcntl.LOCK_EX)
192
+ accounts = account_dirs(root)
193
+ settings = [home / ".claude" / "settings.json", *(d / "settings.json" for d in accounts)]
194
+ validate_settings(settings)
195
+ moved, conflicts = prepare_history(home, root, accounts)
196
+ changed = sum(set_default_mode(path) for path in settings)
197
+ ready = is_ready(home, accounts)
198
+ print(json.dumps({"ready": ready, "accounts": len(accounts), "files_moved": moved,
199
+ "conflicts_archived": conflicts, "settings_updated": changed}))
200
+ return 0 if ready else 1
201
+
202
+
203
+ if __name__ == "__main__":
204
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ """Normalize explicit provider spending controls independently of wallet amounts."""
2
+
3
+ _LIMIT_TYPES = {
4
+ "rate_limit_reached", "workspace_owner_credits_depleted", "workspace_member_credits_depleted",
5
+ "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached",
6
+ }
7
+
8
+
9
+ def limit_type(value):
10
+ if isinstance(value, dict):
11
+ value = value.get("type", "unknown")
12
+ if value is None:
13
+ return None
14
+ return value if isinstance(value, str) and value in _LIMIT_TYPES else "unknown"
15
+
16
+
17
+ def usage_controls(usage, root):
18
+ limit = root.get("rate_limit")
19
+ limit = limit if isinstance(limit, dict) else {}
20
+ ordinary = usage.get("ordinary_usage_allowed", usage.get("ordinaryUsageAllowed", limit.get("allowed")))
21
+ reached = root.get("rate_limit_reached_type", root.get("rateLimitReachedType", limit.get("limit_reached_type")))
22
+ spend = root.get("spend_control")
23
+ spend = spend if isinstance(spend, dict) else {}
24
+ spend_reached = root.get("spend_control_reached", root.get("spendControlReached", spend.get("reached")))
25
+ return {"ordinary_usage_allowed": ordinary if type(ordinary) is bool else None,
26
+ "spend_control_reached": spend_reached if type(spend_reached) is bool else None,
27
+ "limit_reached_type": limit_type(reached)}
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from selector_credits import balance_value, normalize_credits, refusal_recovered
16
16
  from selector_funding import credit_rank, wallet_snapshots
17
- from selector_credit_scope import merge_wallet
17
+ from selector_credit_scope import actor_observations, merge_actor, merge_wallet
18
18
  from codex_credit_local_scope import pool_actor, merged_local_refusals, local_refusal_key
19
19
  from selector_primitives import canonical_sha256, timestamp
20
20
 
@@ -51,9 +51,10 @@ def marker_allows(directory, now):
51
51
  return False
52
52
 
53
53
 
54
- def candidate(directory, now, wallets, refusals):
54
+ def candidate(directory, now, wallets, refusals, observations):
55
55
  data = read_json(directory / "limits.json")
56
56
  credits = normalize_credits(data.get("credits"), datetime.fromtimestamp(now, timezone.utc))
57
+ credits = merge_actor(credits, observations.get(local_refusal_key(directory, credits)))
57
58
  credits = merge_wallet(credits, wallets.get(credits["account_id"]))
58
59
  refusal = dict(credit_refusal_view(directory), **refusals.get(local_refusal_key(directory, credits), {}))
59
60
  if not credits["available"] or not refusal_recovered(refusal, credits, datetime.fromtimestamp(now, timezone.utc)):
@@ -88,10 +89,15 @@ def active_holders(root, now, cleanup=False):
88
89
 
89
90
 
90
91
  def pick(now, directories):
91
- snapshots = [read_json(Path(directory) / "limits.json") for directory in directories]
92
+ snapshots = [dict(read_json(Path(directory) / "limits.json"),
93
+ credit_actor_email=pool_actor(Path(directory)),
94
+ runner_id=str(Path(directory).parent.resolve()), account_id=Path(directory).name)
95
+ for directory in directories]
96
+ observations = actor_observations(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
92
97
  wallets = wallet_snapshots(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
93
98
  refusals = merged_local_refusals(directories, read_json, credit_refusal_view)
94
- rows = [row for directory in directories if (row := candidate(Path(directory), now, wallets, refusals))]
99
+ rows = [row for directory in directories
100
+ if (row := candidate(Path(directory), now, wallets, refusals, observations))]
95
101
  if not rows:
96
102
  return ""
97
103
  holders = active_holders(Path(directories[0]).parent, now)