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.
- package/README.md +10 -0
- package/bin/codex +1 -1
- package/bin/codex-accounts +28 -41
- package/docs/CODEX.md +43 -15
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.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__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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_pool_prepare.py +204 -0
- package/lib/codex_credit_controls.py +27 -0
- package/lib/codex_credit_selection.py +10 -4
- package/lib/codex_credits.py +28 -32
- package/lib/codex_limits_lock.py +56 -0
- package/lib/codex_usage_fetch.py +85 -0
- package/lib/common.sh +8 -2
- package/lib/selector_credit_scope.py +33 -3
- package/lib/selector_credits.py +10 -4
- package/lib/selector_funding.py +16 -14
- package/package.json +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.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/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/test_account_seeding.py +13 -0
- package/tests/test_claude_pool_prepare.py +107 -0
- package/tests/test_codex_reset_credits.py +42 -0
- package/tests/test_codex_reset_credits_polling.py +58 -1
- package/tests/test_codex_reset_fetch.py +123 -0
- package/tests/test_selector_cached_credits.py +135 -0
- package/tests/test_selector_credit_scope.py +5 -5
- package/tests/test_selector_credit_shim.py +3 -3
- 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
|
|
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
|
package/bin/codex-accounts
CHANGED
|
@@ -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 !
|
|
1092
|
-
|
|
1093
|
-
if [ "$
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
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',
|
|
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
|
-
|
|
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.
|
|
114
|
-
|
|
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
|
|
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
|
|
120
|
-
change.
|
|
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
|
|
131
|
-
|
|
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)
|
|
145
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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")
|
|
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
|
|
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)
|