claude-multiacc 2.0.24 → 2.0.26

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 CHANGED
@@ -384,6 +384,14 @@ Codex-specific notes:
384
384
  minutes even if the usage summary still says zero credits. A newly granted reset
385
385
  is redeemed during that pass and clears the old limit; no-credit results are
386
386
  checked again next pass, without waiting for the weekly reset date.
387
+ All authenticated accounts also report `usage.reset_credits_available` and
388
+ `usage.reset_credits_fetched_at` (ISO 8601) in `list/status/limits --json`.
389
+ The count comes from the credit endpoint, including below 95% used and with
390
+ automatic redemption disabled. The scheduled usage cadence bounds these reads.
391
+ Redemption shares that read, then reads back the remaining count after a POST;
392
+ no subtraction or embedded usage summary substitutes for provider evidence.
393
+ Zero means no available resets; missing fields mean the read was unavailable.
394
+ Credit identifiers remain local and selection does not use these fields.
387
395
  - **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
388
396
  addon's no-API-keys rule.
389
397
 
@@ -1434,7 +1434,21 @@ limits_distribute() {
1434
1434
  sync_is_replica && return 0
1435
1435
  command -v rsync >/dev/null 2>&1 || return 0
1436
1436
  [ "${CLAUDE_MULTIACC_NO_DISTRIBUTE:-0}" = "1" ] && return 0
1437
- ( limits_distribute_now >/dev/null 2>&1 & ) >/dev/null 2>&1
1437
+ # In its OWN session, not merely backgrounded. launchd tears down the job's
1438
+ # whole process group the moment `limits` exits, and a `( … & )` subshell is
1439
+ # still in that group: every scheduled pass spawned its push and launchd killed
1440
+ # it before one rsync had finished, with nothing logged — while this machine
1441
+ # refreshed every 15 minutes, the seven runner Macs ranked on readings last
1442
+ # pushed at 21:13Z until 01:10Z on 2026-09-09 (a foreground pass delivered them
1443
+ # in seconds). setsid puts the push outside the group; install.sh also marks
1444
+ # the agent AbandonProcessGroup, and either one alone is enough.
1445
+ "$PYBIN" -c '
1446
+ import os, sys
1447
+ if os.fork():
1448
+ sys.exit(0)
1449
+ os.setsid()
1450
+ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
1451
+ ' "$BIN_DIR/claude-accounts" >/dev/null 2>&1 </dev/null &
1438
1452
  return 0
1439
1453
  }
1440
1454
 
@@ -1472,21 +1486,26 @@ limits_distribute_now() {
1472
1486
  done
1473
1487
  if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
1474
1488
 
1489
+ local pushed=0 failed=0
1475
1490
  server="$(sync_target)"
1476
1491
  sroot="$(sync_target_root)"
1477
1492
  if [ -n "$server" ] && ! sync_target_is_local "$server" \
1478
1493
  && valid_ssh_target "$server" && valid_remote_path "$sroot"; then
1479
- limits_push_to "$server" "$sroot" "$list"
1494
+ if limits_push_to "$server" "$sroot" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
1480
1495
  fi
1481
1496
  # Peers are the OTHER Macs — the ones actually running tasks, and so the ones
1482
- # whose selection goes blind without this.
1483
- manifest_peers | while IFS="$(printf '\t')" read -r pt pr pp; do
1497
+ # whose selection goes blind without this. Read through process substitution,
1498
+ # not a pipe: the counters below must survive the loop.
1499
+ while IFS="$(printf '\t')" read -r pt pr pp; do
1484
1500
  [ "$pt" = "MALFORMED" ] && continue
1485
1501
  [ -n "$pt" ] && [ -n "$pr" ] || continue
1486
1502
  valid_ssh_target "$pt" || continue
1487
1503
  valid_remote_path "$pr" || continue
1488
- limits_push_to "$pt" "$pr" "$list"
1489
- done
1504
+ if limits_push_to "$pt" "$pr" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
1505
+ done < <(manifest_peers)
1506
+ # Every pass writes its outcome: a silent push is indistinguishable from a
1507
+ # killed one, which is exactly how the 2026-09-08 outage went unnoticed.
1508
+ log_to sync.log "limits distributed to $pushed target(s), $failed failed"
1490
1509
  rm -f "$list"
1491
1510
  rmdir "$lock" 2>/dev/null || true
1492
1511
  trap - EXIT
@@ -1497,8 +1516,7 @@ limits_push_to() { # $1 target, $2 remote root, $3 file list
1497
1516
  rsync -az --timeout=20 \
1498
1517
  -e 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new' \
1499
1518
  --files-from="$3" "$ACC_ROOT/" "$1:$2/" >>"$ACC_ROOT/sync.log" 2>&1 \
1500
- || log_to sync.log "limits push to $1 failed (telemetry there will age)"
1501
- return 0
1519
+ || { log_to sync.log "limits push to $1 failed (telemetry there will age)"; return 1; }
1502
1520
  }
1503
1521
 
1504
1522
  cmd_limits() {
@@ -2956,6 +2974,8 @@ case "${1:-help}" in
2956
2974
  sync) shift; cmd_sync "$@" ;;
2957
2975
  verify) shift; cmd_verify "$@" ;;
2958
2976
  limits) shift; cmd_limits "$@" ;;
2977
+ # Internal: the detached telemetry push `limits` starts in its own session.
2978
+ limits-distribute-now) shift; limits_distribute_now ;;
2959
2979
  post-sync) shift; cmd_post_sync "$@" ;;
2960
2980
  health) shift; cmd_health "$@" ;;
2961
2981
  self-update) shift; cmd_self_update "$@" ;;
@@ -1068,7 +1068,7 @@ import base64, datetime, json, os, sys, time, urllib.request
1068
1068
  root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
1069
1069
  sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
1070
1070
  sys.path.insert(0, lib_dir)
1071
- from codex_reset import try_auto_redeem
1071
+ from codex_reset_telemetry import refresh_reset_credits
1072
1072
  now = time.time()
1073
1073
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
1074
1074
  MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
@@ -1564,15 +1564,17 @@ for acct in manifest.get('accounts', []):
1564
1564
  weeklyp = max(weekly) if weekly else None
1565
1565
  sessionp = max(session) if session else None
1566
1566
  try:
1567
- reset_result = try_auto_redeem(d, aid, maxp, data, url, headers, say, int(now))
1567
+ reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say, int(now))
1568
1568
  except Exception as e:
1569
1569
  say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
1570
1570
  reset_result = {'status': 'error'}
1571
+ reset_view = {}
1571
1572
  # Internal only (see win_bucket): limits.json keeps the bucket shape every reader
1572
1573
  # — the shim, lib/report.py, app-robot — already knows.
1573
1574
  for b in buckets:
1574
1575
  b.pop('_reset_known', None)
1575
1576
  out = {'fetched_at': int(now), 'source': 'chatgpt'}
1577
+ out.update(reset_view)
1576
1578
  if live:
1577
1579
  out['max_percent'] = maxp
1578
1580
  if weeklyp is not None:
package/install.sh CHANGED
@@ -183,6 +183,9 @@ mac_schedule_install() {
183
183
  </array>
184
184
  $(plist_env_block) <key>StartInterval</key><integer>900</integer>
185
185
  <key>RunAtLoad</key><true/>
186
+ <!-- The pass hands its readings to the peers from a detached push; launchd
187
+ must not kill that push with the job's process group. -->
188
+ <key>AbandonProcessGroup</key><true/>
186
189
  <key>StandardOutPath</key><string>/dev/null</string>
187
190
  <key>StandardErrorPath</key><string>/dev/null</string>
188
191
  </dict></plist>
@@ -238,6 +241,7 @@ EOF
238
241
  </array>
239
242
  $(plist_env_block) <key>StartInterval</key><integer>300</integer>
240
243
  <key>RunAtLoad</key><true/>
244
+ <key>AbandonProcessGroup</key><true/>
241
245
  <key>StandardOutPath</key><string>/dev/null</string>
242
246
  <key>StandardErrorPath</key><string>/dev/null</string>
243
247
  </dict></plist>
@@ -111,7 +111,7 @@ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) ->
111
111
 
112
112
  def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
113
113
  usage: dict, usage_url: str, headers: dict, say,
114
- now: int | None = None) -> dict:
114
+ now: int | None = None, credit_details: dict | None = None) -> dict:
115
115
  """Redeem one available reset and return a non-secret outcome document."""
116
116
  current = int(now if now is not None else time.time())
117
117
  finished, _ = reset_windows(usage, RESET_AT_USED_PERCENT)
@@ -125,7 +125,8 @@ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
125
125
  # Newly granted credits can precede the usage response's embedded summary.
126
126
  # The scheduled usage cadence bounds these reads, including spent accounts.
127
127
  try:
128
- available_count, credit_id = _credit_to_redeem(_request(list_url, headers))
128
+ details = credit_details if credit_details is not None else _request(list_url, headers)
129
+ available_count, credit_id = _credit_to_redeem(details)
129
130
  except Exception as error:
130
131
  say(f"{account_id}: usage reset availability failed ({type(error).__name__}); failing open")
131
132
  return {"status": "error"}
@@ -0,0 +1,34 @@
1
+ """Observe reset allowances at the usage cadence, independently of redemption eligibility."""
2
+
3
+ import time
4
+
5
+ from codex_reset import _request, _reset_urls, try_auto_redeem
6
+
7
+
8
+ def _read_credits(url, headers, account_id, say):
9
+ try:
10
+ details = _request(url, headers)
11
+ count = details.get("available_count")
12
+ if type(count) is not int or not 0 <= count <= 1_000_000:
13
+ raise ValueError("invalid reset allowance")
14
+ return details, {"reset_credits_available": count,
15
+ "reset_credits_fetched_at": int(time.time())}
16
+ except Exception as error:
17
+ say(f"{account_id}: usage reset availability failed ({type(error).__name__})")
18
+ return {}, {}
19
+
20
+
21
+ def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now):
22
+ """Read all accounts, share the GET with redemption, then read back after a mutation.
23
+
24
+ Failed reads omit the count; neither an embedded summary nor subtraction proves
25
+ the remaining allowance after another machine may have redeemed a credit.
26
+ Disabling automatic redemption still permits this read-only telemetry.
27
+ """
28
+ list_url, _ = _reset_urls(usage_url)
29
+ details, view = _read_credits(list_url, headers, account_id, say)
30
+ result = try_auto_redeem(account_dir, account_id, used_percent, usage, usage_url,
31
+ headers, say, now, credit_details=details)
32
+ if result.get("status") not in {"not_eligible", "cooldown", "no_credit", "error"}:
33
+ _, view = _read_credits(list_url, headers, account_id, say)
34
+ return result, view
package/lib/report.py CHANGED
@@ -242,6 +242,10 @@ def _usage(d, stale_after):
242
242
  out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
243
243
  'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
244
244
  'session_percent': lim.get('session_percent'), 'buckets': buckets}
245
+ reset_count = lim.get('reset_credits_available')
246
+ reset_stamp = _iso(lim.get('reset_credits_fetched_at'))
247
+ if type(reset_count) is int and 0 <= reset_count <= 1_000_000 and reset_stamp:
248
+ out.update(reset_credits_available=reset_count, reset_credits_fetched_at=reset_stamp)
245
249
  try:
246
250
  out['age_seconds'] = max(0, int(time.time() - float(fetched)))
247
251
  except (TypeError, ValueError):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.24",
3
+ "version": "2.0.26",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3759,6 +3759,7 @@ mkdir -p "$RSFAKE"
3759
3759
  cat > "$RSFAKE/rsync" <<'EOF'
3760
3760
  #!/usr/bin/env bash
3761
3761
  printf 'RSYNC %s\n' "$*" >> "${RSLOG:?}"
3762
+ printf 'PGID %s\n' "$(ps -o pgid= -p $$ | tr -d ' ')" >> "$RSLOG"
3762
3763
  for a in "$@"; do
3763
3764
  case "$a" in
3764
3765
  --files-from=*)
@@ -3795,6 +3796,23 @@ for peer in gas@mini-3 gas@mini-4; do
3795
3796
  && t_ok "telemetry is pushed to manifest peer $peer" \
3796
3797
  || t_fail "limits distribute peer" "$peer never received a push: $(grep '^RSYNC' "$RSLOG")"
3797
3798
  done
3799
+ # The push must run OUTSIDE the calling job's process group: launchd kills that group
3800
+ # the moment `limits` exits, and a `( … & )` subshell is still in it — on 2026-09-08 the
3801
+ # source Mac refreshed every 15 minutes while every scheduled push died before its first
3802
+ # rsync, and the seven runner Macs ranked on 21:13Z readings until 01:10Z the next day.
3803
+ own_pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
3804
+ push_pgids="$(grep '^PGID ' "$RSLOG" | sort -u | sed 's/^PGID //' | tr '\n' ' ')"
3805
+ case " $push_pgids " in
3806
+ *" $own_pgid "*) t_fail "detached push session" "a push ran inside the caller's process group $own_pgid (launchd would kill it)" ;;
3807
+ *) [ -n "$push_pgids" ] && t_ok "the telemetry push runs in its own session, outside the job's process group" \
3808
+ || t_fail "detached push session" "no push recorded a process group" ;;
3809
+ esac
3810
+ grep -q "limits distributed to 3 target(s), 0 failed" "$DPOOL/sync.log" \
3811
+ && t_ok "every distribute pass records its outcome in sync.log" \
3812
+ || t_fail "distribute summary" "sync.log: $(tail -3 "$DPOOL/sync.log" 2>/dev/null | tr '\n' '|')"
3813
+ [ "$(grep -c '<key>AbandonProcessGroup</key><true/>' "$REPO_DIR/install.sh")" = "2" ] \
3814
+ && t_ok "install.sh abandons the process group of both limits agents" \
3815
+ || t_fail "AbandonProcessGroup" "expected both limits plists to carry AbandonProcessGroup"
3798
3816
  # ...and every target gets the SAME list: each account's reading, plus any marker.
3799
3817
  for f in "acct-01/limits.json" "acct-02/limits.json" "acct-02/.limited"; do
3800
3818
  [ "$(grep -c "^FILE $f\$" "$RSLOG")" = "3" ] \
@@ -50,6 +50,10 @@ class ResetHandler(BaseHTTPRequestHandler):
50
50
  length = int(self.headers.get("Content-Length") or 0)
51
51
  state["posts"].append(json.loads(self.rfile.read(length)))
52
52
  status, body = state["responses"].pop(0)
53
+ if status == 200 and body.get("code") == "reset":
54
+ state["credits"]["available_count"] -= 1
55
+ if "credits_after_post" in state:
56
+ state["credits"] = state["credits_after_post"]
53
57
  encoded = json.dumps(body).encode()
54
58
  self.send_response(status)
55
59
  self.send_header("Content-Type", "application/json")
@@ -144,7 +148,7 @@ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
144
148
  "rate_limit_reset_credits": {"available_count": 1}}
145
149
  result = self.run_limits()
146
150
  self.assertEqual(result.returncode, 0, result.stderr)
147
- self.assertEqual(self.server.state["credit_gets"], 0)
151
+ self.assertEqual(self.server.state["credit_gets"], 1)
148
152
  self.assertEqual(self.server.state["posts"], [])
149
153
  self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
150
154
 
@@ -167,7 +171,7 @@ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
167
171
  self.assertEqual(len(self.server.state["posts"]), 2)
168
172
  keys = [request["redeem_request_id"] for request in self.server.state["posts"]]
169
173
  self.assertEqual(keys[0], keys[1])
170
- self.assertEqual(self.server.state["credit_gets"], 1)
174
+ self.assertEqual(self.server.state["credit_gets"], 4)
171
175
 
172
176
 
173
177
  class CodexMarkerNamesOneBucketTest(CodexPoolSandbox, unittest.TestCase):
@@ -57,7 +57,7 @@ class ResetPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
57
57
  self.grant_credit()
58
58
  self.next_pass()
59
59
  self.run_scheduled()
60
- self.assertEqual(self.server.state["credit_gets"], 3)
60
+ self.assertEqual(self.server.state["credit_gets"], 4)
61
61
  self.assertEqual(self.server.state["posts"][0]["credit_id"], "newly-granted")
62
62
  self.assertFalse(marker.exists())
63
63
  receipt = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
@@ -79,13 +79,13 @@ class ResetPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
79
79
  self.grant_credit()
80
80
  self.next_pass()
81
81
  self.run_scheduled()
82
- self.assertEqual(self.server.state["credit_gets"], 2)
82
+ self.assertEqual(self.server.state["credit_gets"], 3)
83
83
  self.assertFalse((self.pool / "acct-01/.limited").exists())
84
84
 
85
- def test_disabled_automation_does_not_query_or_spend_credits(self):
85
+ def test_disabled_automation_reports_without_spending_credits(self):
86
86
  self.grant_credit()
87
87
  self.run_scheduled(enabled="0")
88
- self.assertEqual(self.server.state["credit_gets"], 0)
88
+ self.assertEqual(self.server.state["credit_gets"], 1)
89
89
  self.assertEqual(self.server.state["posts"], [])
90
90
  self.assertTrue((self.pool / "acct-01/.limited").exists())
91
91
 
@@ -0,0 +1,82 @@
1
+ """The public pool report carries measured allowances for active and limited accounts."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import time
7
+ import unittest
8
+
9
+ import test_codex_reset as fixtures
10
+
11
+
12
+ class ResetReportingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
13
+ def report(self, used=60, enabled="1"):
14
+ self.server.state["usage"] = {"rate_limit": {"allowed": True,
15
+ "primary_window": {"used_percent": used, "limit_window_seconds": 604800,
16
+ "reset_at": int(time.time()) + 86400}},
17
+ "rate_limit_reset_credits": {"available_count": 0}}
18
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
19
+ env = dict(os.environ, CODEX_ACCOUNTS_ROOT=str(self.pool), CODEX_MULTIACC_NO_SYNC="1",
20
+ CODEX_MULTIACC_AUTO_RESET=enabled, CODEX_MULTIACC_USAGE_URL=f"{base}/usage",
21
+ PYTHONDONTWRITEBYTECODE="1")
22
+ for key in ("CODEX_MULTIACC_RESET_CREDITS_URL", "CODEX_MULTIACC_RESET_CONSUME_URL"):
23
+ env.pop(key, None)
24
+ result = subprocess.run([fixtures.REPO / "bin/codex-accounts", "limits", "--force", "--json"],
25
+ capture_output=True, text=True, env=env, timeout=20, check=False)
26
+ self.assertEqual(result.returncode, 0, result.stderr)
27
+ self.assertNotIn("private-credit", result.stdout)
28
+ return json.loads(result.stdout)["accounts"][0]
29
+
30
+ def test_active_account_reports_credits_without_redeeming(self):
31
+ self.server.state["credits"] = {"available_count": 3, "credits": [
32
+ {"id": "private-credit", "status": "available"}]}
33
+ account = self.report()
34
+ self.assertEqual(account["usage"]["reset_credits_available"], 3)
35
+ self.assertTrue(account["usage"]["reset_credits_fetched_at"].endswith("Z"))
36
+ self.assertEqual(self.server.state["credit_gets"], 1)
37
+ self.assertEqual(self.server.state["posts"], [])
38
+
39
+ def test_spent_account_reports_real_zero(self):
40
+ self.server.state["credits"] = {"available_count": 0, "credits": []}
41
+ account = self.report(100)
42
+ self.assertTrue(account["limited"])
43
+ self.assertEqual(account["usage"]["reset_credits_available"], 0)
44
+
45
+ def test_redemption_reports_readback_instead_of_pre_reset_allowance(self):
46
+ self.server.state["credits"] = {"available_count": 2, "credits": []}
47
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
48
+ account = self.report(100)
49
+ self.assertEqual(account["usage"]["reset_credits_available"], 1)
50
+ self.assertTrue(account["usage"]["reset_credits_fetched_at"])
51
+ self.assertEqual(self.server.state["credit_gets"], 2)
52
+ self.assertEqual(len(self.server.state["posts"]), 1)
53
+
54
+ def test_failed_or_invalid_reads_clear_previous_allowance_without_hiding_usage(self):
55
+ self.server.state["credits"] = {"available_count": 2}
56
+ self.report()
57
+ for count in (None, "2", True, -1, 1.5, 1_000_001):
58
+ with self.subTest(count=count):
59
+ self.server.state["credits"] = {"available_count": count}
60
+ usage = self.report()["usage"]
61
+ self.assertNotIn("reset_credits_available", usage)
62
+ self.assertNotIn("reset_credits_fetched_at", usage)
63
+ self.assertEqual(usage["weekly_percent"], 60)
64
+ self.assertEqual(self.server.state["posts"], [])
65
+
66
+ def test_disabled_redemption_still_reports_allowance(self):
67
+ self.server.state["credits"] = {"available_count": 2}
68
+ self.assertEqual(self.report(100, enabled="0")["usage"]["reset_credits_available"], 2)
69
+ self.assertEqual(self.server.state["posts"], [])
70
+
71
+ def test_failed_readback_does_not_reuse_the_allowance_before_redemption(self):
72
+ self.server.state["credits"] = {"available_count": 2, "credits": []}
73
+ self.server.state["credits_after_post"] = []
74
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
75
+ account = self.report(100)
76
+ self.assertFalse(account["limited"])
77
+ self.assertNotIn("reset_credits_available", account["usage"])
78
+ self.assertEqual(len(self.server.state["posts"]), 1)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ unittest.main()