claude-multiacc 2.0.44 → 2.0.46

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 (37) hide show
  1. package/README.md +14 -6
  2. package/bin/claude +8 -4
  3. package/bin/claude-accounts +111 -44
  4. package/bin/codex +18 -4
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/AUTORESUME.md +6 -0
  8. package/docs/VERIFICATION.md +8 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +32 -4
  21. package/lib/autoresume.py +5 -2
  22. package/lib/codex_audit.py +8 -1
  23. package/package.json +1 -1
  24. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  36. package/tests/run-tests.sh +253 -1
  37. package/tests/test_autoresume.py +43 -6
package/README.md CHANGED
@@ -147,7 +147,13 @@ this one is *down*, and handing work to it guarantees a hard failure. Reported s
147
147
  Code for that account. Telemetry cannot see this — those accounts' usage endpoint
148
148
  answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
149
149
  park deliberately survives credential refreshes (a new access token says nothing about
150
- an org policy).
150
+ an org policy). Every org park carries a 6-hour `soft_until` (a re-observed block
151
+ renews it), and a `verify` that the **usage limiter** refuses ("You've hit your weekly
152
+ limit") lifts it at once: the provider checks the org's Claude Code gate before the
153
+ limiter, so a limit answer proves the org lets the account through (acct-15,
154
+ 2026-09-27: a lapsed subscription paid again stayed BLOCKED until this rule existed).
155
+ The line still reads `FAIL` — the account cannot work until the limit resets — with a
156
+ `usage-limited` note saying which park it lifted.
151
157
 
152
158
  `claude-accounts expired` lists what is excluded and also labels a token-only account
153
159
  `UNVERIFIED` until `claude-accounts verify` or the shim's first-use preflight proves it.
@@ -155,11 +161,13 @@ Exclusion self-heals, but only against the evidence it was based on: a **credent
155
161
  park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
156
162
  by another process — or when a usage fetch authenticates; a **policy** park (org block)
157
163
  ignores both, because a fresh access token says nothing about whether the org re-enabled
158
- Claude Code, and only a passing real call or a re-login lifts it. Parks the **shim**
159
- writes from a single failed run additionally carry a `soft_until` stamp and expire on
160
- their own — the shim's guess never outlives the evidence for it — while parks
161
- `claude-accounts` writes from proof (an `invalid_grant` refresh response, a failed real
162
- call) stay until the account works again. If *every* account is unusable the shim still
164
+ Claude Code: only a real call that got past the org gate (a passing `verify`, or one
165
+ refused only by a usage limit), a re-login, or its own 6-hour `soft_until` lifts it.
166
+ Parks the **shim** writes from a single failed run additionally carry a `soft_until`
167
+ stamp and expire on their own — the shim's guess never outlives the evidence for it —
168
+ while parks `claude-accounts` writes from proof of a dead credential (an `invalid_grant`
169
+ refresh response, a real call that failed to authenticate) stay until the account works
170
+ again. If *every* account is unusable the shim still
163
171
  fails open into stock `claude`, and logs why to `selection.log`.
164
172
 
165
173
  The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
package/bin/claude CHANGED
@@ -1581,8 +1581,10 @@ creds_dead() { # $1 = acct dir
1581
1581
  # A marker written by the SHIM (a guess from one failed run) also carries
1582
1582
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
1583
1583
  # a misread never costs an account permanently. Markers written by claude-accounts —
1584
- # a refresh grant that answered "revoked", a real verify call — carry no soft_until and
1585
- # stay until the account provably works again.
1584
+ # a refresh grant that answered "revoked", a real verify call that failed to
1585
+ # authenticate — carry no soft_until and stay until the account provably works again.
1586
+ # An org block is the exception: verify's carries the shim's 6 h soft_until too, and a
1587
+ # verify the usage limiter refused lifts it (the limiter answers only past the org gate).
1586
1588
  expired_marked() { # $1 = acct dir
1587
1589
  local m="$1/.expired" mt f soft reason
1588
1590
  [ -f "$m" ] || return 1
@@ -2276,11 +2278,13 @@ fi
2276
2278
  # PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
2277
2279
  # is parked until a re-login / a fetch that authenticates clears it.
2278
2280
  # PARK_ORG — the account's organization turned Claude Code access off; no re-login
2279
- # fixes that.
2281
+ # fixes that. "ask your admin to enable ACCESS", never the bare prefix:
2282
+ # the same client ends a 404 model_not_found with "…or ask your admin to
2283
+ # enable this model", which says nothing about the organization.
2280
2284
  # Both shim-written parks carry a soft_until stamp, so even a false positive returns to
2281
2285
  # the pool on its own — the shim's guess must never outlive the evidence for it.
2282
2286
  PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
2283
- PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
2287
+ PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable access|not authorized to use claude code'
2284
2288
  # A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
2285
2289
  # Switch to another model, or manage usage credits..." — the account still has
2286
2290
  # capacity for every other model, and the endpoint says so in the message itself.
@@ -1715,7 +1715,7 @@ cmd_limits() {
1715
1715
  import hashlib, json, os, sys, time, urllib.request
1716
1716
  sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
1717
1717
  import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
1718
- from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
1718
+ from audit import creds_doc_state, org_block_in_force # noqa: E402 (one rule for "can this credential work")
1719
1719
  import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
1720
1720
 
1721
1721
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
@@ -1812,11 +1812,12 @@ def weekly_marker(txt):
1812
1812
  def mark_expired(d, slug, detail=''):
1813
1813
  mpath = os.path.join(d, '.expired')
1814
1814
  # An org-blocked marker is the strongest statement there is about an account and
1815
- # only `verify` or a re-login may lift it (see clear_expired). Overwriting its
1816
- # REASON with a weaker one is how it gets lifted by accident: the next successful
1817
- # fetch sees a reason clear_expired is willing to drop, and the block vanishes.
1815
+ # telemetry can never lift it (see clear_expired). Overwriting its REASON with a
1816
+ # weaker one is how it gets lifted by accident: the next successful fetch sees a
1817
+ # reason clear_expired is willing to drop, and the block vanishes. One whose
1818
+ # soft_until has run out binds nothing any more and outranks nothing either.
1818
1819
  try:
1819
- if 'reason=org-blocked' in open(mpath, errors='replace').read():
1820
+ if org_block_in_force(open(mpath, errors='replace').read(), now):
1820
1821
  return
1821
1822
  except OSError:
1822
1823
  pass
@@ -1840,7 +1841,9 @@ def clear_expired(d):
1840
1841
  un-parked every dead-token account minutes after the shim parked it, and every
1841
1842
  fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
1842
1843
  token (the shim's newer-file rule) or a real call that used the token lifts it.
1843
- Only a passing `verify` (a real call) or a re-login lifts the excepted ones."""
1844
+ Only a real call lifts the excepted ones — a passing `verify`, or one the usage
1845
+ limiter refused (the limiter answers only past the credential and the org gate) —
1846
+ or a re-login; an org block also lapses at its soft_until (6 h)."""
1844
1847
  mpath = os.path.join(d, '.expired')
1845
1848
  try:
1846
1849
  body = open(mpath, errors='replace').read()
@@ -2788,7 +2791,8 @@ import hashlib, json, os, re, subprocess, sys, time
2788
2791
 
2789
2792
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
2790
2793
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
2791
- from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
2794
+ from audit import (audit_account, creds_state, creds_doc_state, # noqa: E402 (shared with the shim's rule)
2795
+ marker_slug, org_block_in_force)
2792
2796
  import keychain # noqa: E402
2793
2797
  from claude_token_identity import check_identity # noqa: E402
2794
2798
  from claude_reset import cli_version # noqa: E402
@@ -2803,14 +2807,43 @@ AUTH_ERR = re.compile(
2803
2807
  r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
2804
2808
  r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
2805
2809
  r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
2810
+ # `ask your admin to enable ACCESS`, never the bare prefix: the same client ends a 404
2811
+ # model_not_found with "…or ask your admin to enable this model", which is about the
2812
+ # model and never about the organization (the real refusal still matches three ways).
2806
2813
  ORG_ERR = re.compile(
2807
2814
  r'organization has disabled|subscription access.*disabl|disabled claude subscription'
2808
- r'|ask your admin to enable|not authorized to use claude code', re.I)
2815
+ r'|ask your admin to enable access|not authorized to use claude code', re.I)
2816
+ # A USAGE-LIMIT refusal, tested after BROKEN_CLI and BEFORE ORG_ERR/AUTH_ERR — bin/claude's
2817
+ # rule "a rate limit wins the classification". It is also EXCULPATORY: the provider checks
2818
+ # the credential, then the organization's Claude Code gate, and only then the usage
2819
+ # limiter. acct-15 (2026-09-27) proved the order: already at weekly_all 100%, a real call
2820
+ # came back with the ORG refusal, and once the subscription was paid again the same
2821
+ # call came back "You've hit your weekly limit". So a limit refusal proves the credential
2822
+ # that answered authenticated AND its organization lets Claude Code through: verify lifts
2823
+ # a park exactly as a PASS would. Before this, that answer failed with no hint, the
2824
+ # org-blocked park stayed, and every re-check on that Mac kept a paid account BLOCKED.
2825
+ #
2826
+ # The shim's LIMITPAT/MODEL_LIMITPAT, minus what proves nothing about the account: the
2827
+ # shim only picks a cooldown over a park and can afford "overloaded"/529 there; here a
2828
+ # match LIFTS a park. The client's usage-limiter texts ("You've hit your weekly limit ·
2829
+ # resets …", "You've reached your Fable limit. Switch to another model…", "You're out of
2830
+ # usage credits") are covered; a bare 429 is not — a headerless 429 is the throttle the
2831
+ # client itself prints as "Server is temporarily limiting requests (not your usage
2832
+ # limit)", which can answer before any credential is looked at. NOT_A_LIMIT vetoes those
2833
+ # (that text contains "usage limit") and the overload family outright.
2834
+ LIMIT_ERR = re.compile(
2835
+ r"(hit|reached) your [^.\n]{0,40}(limit|budget)|out of usage|usage limit"
2836
+ r"|limit (reached|exceeded)|rate[ _-]?limit|switch to another model", re.I)
2837
+ NOT_A_LIMIT = re.compile(
2838
+ r'not your usage limit|temporarily limiting|overloaded|\b529\b|high load', re.I)
2839
+ # bin/claude PARK_SOFT_ORG: an org policy will not change in minutes, but a verdict that
2840
+ # never expires kept a paid-again account out of the pool until a person noticed.
2841
+ PARK_SOFT_ORG = 21600
2809
2842
  # EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
2810
2843
  # it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
2811
2844
  # machine fault whose output happens to contain those three digits parks a live
2812
- # login. `mark_expired` writes no `soft_until`, so that verdict is permanent
2813
- # until a person clears it. These patterns say the CLI never reached the API,
2845
+ # login. `mark_expired` writes no `soft_until` for an auth park, so that verdict is
2846
+ # permanent until a person clears it. These patterns say the CLI never reached the API,
2814
2847
  # which is a fact about the machine and never about the login.
2815
2848
  # See health.log 2026-08-24: `env: node: No such file or directory` failed
2816
2849
  # every account in one pass.
@@ -2826,23 +2859,60 @@ def mark_expired(d, slug, detail=''):
2826
2859
  """Park an account the shim must stop selecting. Verify is the strongest signal
2827
2860
  there is — a real inference call that came back 'not authenticated'."""
2828
2861
  mpath = os.path.join(d, '.expired')
2829
- # Same rule as the limits path: an org block outranks every other reason and only
2830
- # a PASSING verify or a re-login may lift it. Rewriting its reason with a weaker
2831
- # one is how it gets lifted by accident later.
2832
- try:
2833
- if 'reason=org-blocked' in open(mpath, errors='replace').read():
2834
- return
2835
- except OSError:
2836
- pass
2862
+ t = time.time()
2863
+ # Same rule as the limits path: an org block outranks every other reason while it
2864
+ # binds. Rewriting its reason with a weaker one is how it gets lifted by accident
2865
+ # later. Only the org refusal itself may rewrite it — and does: a re-observed block
2866
+ # is a fresh park with a fresh soft_until, not a stale one left to run out.
2867
+ if slug != 'org-blocked':
2868
+ try:
2869
+ if org_block_in_force(open(mpath, errors='replace').read(), t):
2870
+ return
2871
+ except OSError:
2872
+ pass
2873
+ # The shim's field order (bin/claude park_line), so every reader parses one shape.
2874
+ soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
2837
2875
  try:
2838
2876
  with open(mpath + '.tmp', 'w') as f:
2839
- f.write(f'{int(time.time())}\n')
2840
- f.write(f"reason={slug} marked_at="
2841
- f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
2877
+ f.write(f'{int(t)}\n')
2878
+ f.write(f"reason={slug} {soft}marked_at="
2879
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
2842
2880
  os.replace(mpath + '.tmp', mpath)
2843
2881
  except Exception:
2844
2882
  pass
2845
2883
 
2884
+ def lift_park(d, uses_token):
2885
+ """A real call reached the account: drop its park, and return the reason it had
2886
+ ('' when nothing was removed). The PASS rule, shared with a usage-limit refusal.
2887
+ WHICH credential answered matters: an answer on the OAuth login says nothing about
2888
+ a portable token a real call rejected; clearing that park re-opened the 401 loop
2889
+ the marker exists to stop (the shim re-picked the account, exported the dead
2890
+ token, parked it again — 2026-08-29)."""
2891
+ mpath = os.path.join(d, '.expired')
2892
+ try:
2893
+ marker = open(mpath, errors='replace').read()
2894
+ except OSError:
2895
+ marker = ''
2896
+ if not uses_token and 'reason=setup-token-invalid' in marker:
2897
+ return ''
2898
+ try:
2899
+ os.remove(mpath)
2900
+ except OSError:
2901
+ return ''
2902
+ return marker_slug(marker) or 'unlabelled'
2903
+
2904
+ def record_token_verified(d, token):
2905
+ """Proof that a real call authenticated with exactly this portable token."""
2906
+ try:
2907
+ digest = hashlib.sha256(token.encode()).hexdigest()
2908
+ temp = os.path.join(d, '.server-token-verified.tmp')
2909
+ with open(temp, 'w') as f:
2910
+ f.write(digest + '\n')
2911
+ os.chmod(temp, 0o600)
2912
+ os.replace(temp, os.path.join(d, '.server-token-verified'))
2913
+ except OSError:
2914
+ pass
2915
+
2846
2916
  for acct in manifest.get('accounts', []):
2847
2917
  aid = acct['id']
2848
2918
  d = os.path.join(root, aid)
@@ -2933,29 +3003,10 @@ for acct in manifest.get('accounts', []):
2933
3003
  out = (r.stdout or '').strip()
2934
3004
  if r.returncode == 0 and 'ok' in out.lower():
2935
3005
  # A real call succeeded: this account is definitively alive — but WHICH
2936
- # credential answered matters. A pass on the OAuth login says nothing about a
2937
- # portable token a real call rejected; clearing that park here re-opened the
2938
- # 401 loop the marker exists to stop (the shim re-picked the account, exported
2939
- # the dead token, parked it again — 2026-08-29).
2940
- try:
2941
- marker = open(os.path.join(d, '.expired'), errors='replace').read()
2942
- except OSError:
2943
- marker = ''
2944
- if uses_token or 'reason=setup-token-invalid' not in marker:
2945
- try:
2946
- os.remove(os.path.join(d, '.expired'))
2947
- except OSError:
2948
- pass
3006
+ # credential answered matters (lift_park).
3007
+ lift_park(d, uses_token)
2949
3008
  if uses_token:
2950
- try:
2951
- digest = hashlib.sha256(env['CLAUDE_CODE_OAUTH_TOKEN'].encode()).hexdigest()
2952
- temp = os.path.join(d, '.server-token-verified.tmp')
2953
- with open(temp, 'w') as f:
2954
- f.write(digest + '\n')
2955
- os.chmod(temp, 0o600)
2956
- os.replace(temp, os.path.join(d, '.server-token-verified'))
2957
- except OSError:
2958
- pass
3009
+ record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
2959
3010
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
2960
3011
  if not uses_token and identity['state'] == 'mismatch':
2961
3012
  print(f' portable setup-token belongs to a different organization; OAuth login passed; '
@@ -2964,16 +3015,32 @@ for acct in manifest.get('accounts', []):
2964
3015
  raw = (r.stderr or '').strip()
2965
3016
  err = raw[:200]
2966
3017
  hint = ''
3018
+ # The limiter's refusal is printed on STDOUT (health.log 2026-09-28: out="You've
3019
+ # hit your weekly limit · resets 1am (Europe/Istanbul)" err=''), so it is read
3020
+ # from both streams, whole.
3021
+ said = f'{out}\n{raw}'
2967
3022
  # STDERR only, and the WHOLE of it: stdout is the model's own answer,
2968
3023
  # and a session that merely discusses "Cannot find module" must not be
2969
3024
  # able to excuse a real dead login.
2970
3025
  if BROKEN_CLI.search(raw):
2971
3026
  hint = (' — the Claude CLI cannot run on this machine; not a login '
2972
3027
  'problem, nothing was parked')
3028
+ elif LIMIT_ERR.search(said) and not NOT_A_LIMIT.search(said):
3029
+ # Still a FAIL — the account cannot serve work right now, and the runner
3030
+ # counts it — but the usage limiter only answers a credential that
3031
+ # authenticated for an organization that lets Claude Code through (see
3032
+ # LIMIT_ERR), so this is the PASS path's lift, word for word.
3033
+ lifted = lift_park(d, uses_token)
3034
+ if uses_token:
3035
+ record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
3036
+ hint = ' — usage-limited, not a login or org problem'
3037
+ if lifted:
3038
+ hint += f'; lifted its {lifted} park'
2973
3039
  elif ORG_ERR.search(out) or ORG_ERR.search(err):
2974
3040
  # Not an auth problem: the account authenticates fine, its organization
2975
3041
  # has simply turned Claude Code subscription access off. Park it — a
2976
- # re-login changes nothing — and say what actually helps.
3042
+ # re-login changes nothing — and say what actually helps. The park carries
3043
+ # the shim's 6 h soft_until, and a re-observed block refreshes it.
2977
3044
  mark_expired(d, 'org-blocked',
2978
3045
  "the account's organization has disabled Claude Code access")
2979
3046
  hint = (f' — ORG BLOCKED, excluded from the pool; '
package/bin/codex CHANGED
@@ -700,7 +700,8 @@ has_auth() {
700
700
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
701
701
  # a misread never costs an account permanently. Markers written by codex-accounts —
702
702
  # a refresh grant that answered invalid_grant, a failed real call — carry no soft_until
703
- # and stay until the account provably works again.
703
+ # and stay until the account provably works again; an org block is the exception and
704
+ # carries the shim's 6 h soft_until wherever it was written.
704
705
  expired_marked() { # $1 = acct dir
705
706
  local m="$1/.expired" mt soft reason
706
707
  [ -f "$m" ] || return 1
@@ -708,8 +709,8 @@ expired_marked() { # $1 = acct dir
708
709
  # CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
709
710
  # evidence they were about. A POLICY park (org-blocked: a workspace admin turned
710
711
  # Codex off for the account) is about the account, not the credential: refreshing
711
- # its token does not re-enable Codex, so only a passing real call or a re-login
712
- # lifts it.
712
+ # its token does not re-enable Codex, so only a passing real call, a re-login or
713
+ # its own soft_until lifts it.
713
714
  if [ "$reason" != "org-blocked" ]; then
714
715
  mt="$(file_mtime "$m")"
715
716
  if [ -f "$1/auth.json" ] && [ "$(file_mtime "$1/auth.json")" -gt "$mt" ]; then
@@ -1362,8 +1363,21 @@ fi
1362
1363
  pick="$PICK_DIR"
1363
1364
  # A probe answers here, before anything that would count as a launch: no rotation
1364
1365
  # memory, no limits kick, no pick line in selection.log — and never an exec.
1366
+ # A limited tier also names when it lifts (resets_at=<epoch>, only when known): hard is
1367
+ # the soonest reset among the exhausted accounts, soft the picked account's own marker.
1368
+ # This is the machine twin of the tty-only notice above — callers read stdout, and a
1369
+ # service's stderr stays byte-clean.
1365
1370
  if [ "$AR_PROBE" = "1" ]; then
1366
- printf 'pick=%s tier=%s\n' "${pick##*/}" "$AR_TIER"
1371
+ ar_reset=0
1372
+ case "$AR_TIER" in
1373
+ hard) ar_reset="$best_reset" ;;
1374
+ soft) ar_reset="$(limited_reset_of "$pick")" ;;
1375
+ esac
1376
+ if [ "$ar_reset" -gt 0 ] 2>/dev/null; then
1377
+ printf 'pick=%s tier=%s resets_at=%s\n' "${pick##*/}" "$AR_TIER" "$ar_reset"
1378
+ else
1379
+ printf 'pick=%s tier=%s\n' "${pick##*/}" "$AR_TIER"
1380
+ fi
1367
1381
  exit 0
1368
1382
  fi
1369
1383
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
@@ -1245,7 +1245,8 @@ def clear_expired(d):
1245
1245
  """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
1246
1246
  EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
1247
1247
  they are just barred from Codex inference, so telemetry says nothing about them.
1248
- Only a passing `verify` (a real call) or a re-login lifts that."""
1248
+ Only a passing `verify` (a real call) or a re-login lifts that, or its own
1249
+ soft_until (6 h) runs out."""
1249
1250
  mpath = os.path.join(d, '.expired')
1250
1251
  try:
1251
1252
  if 'reason=org-blocked' in open(mpath, errors='replace').read():
@@ -1886,9 +1887,9 @@ ORG_ERR = re.compile(
1886
1887
  # API at all — a missing native package, a missing node, an unrunnable binary —
1887
1888
  # so they are facts about this MACHINE, not about the login. Parking on one
1888
1889
  # converts an indeterminate probe into "this login is dead", and that verdict
1889
- # does not self-heal: `mark_expired` here writes no `soft_until`, so the shim
1890
- # reads it as proven dead and keeps it, while the panel refuses to re-import
1891
- # the token until its fingerprint changes.
1890
+ # does not self-heal: `mark_expired` here writes no `soft_until` for an auth
1891
+ # park, so the shim reads it as proven dead and keeps it, while the panel
1892
+ # refuses to re-import the token until its fingerprint changes.
1892
1893
  #
1893
1894
  # Not hypothetical. health.log 2026-08-24T06:37:02Z: all four accounts
1894
1895
  # `FAIL rc=127 err='env: node: No such file or directory'` — one broken PATH
@@ -1907,15 +1908,24 @@ BROKEN_CLI = re.compile(
1907
1908
  r'|cannot execute binary file|bad CPU type in executable|exec format error',
1908
1909
  re.I | re.M)
1909
1910
 
1911
+ # bin/codex PARK_SOFT_ORG: a workspace policy will not change in minutes, but a verdict
1912
+ # that never expires keeps a re-enabled account out of the pool until a person notices
1913
+ # (claude side, acct-15, 2026-09-27: an org block outlived the org's own repair).
1914
+ PARK_SOFT_ORG = 21600
1915
+
1910
1916
  def mark_expired(d, slug, detail=''):
1911
1917
  """Park an account the shim must stop selecting. Verify is the strongest signal
1912
- there is — a real inference call that came back 'not authenticated'."""
1918
+ there is — a real inference call that came back 'not authenticated'. An org block
1919
+ carries the shim's soft_until, and a re-observed one is rewritten with a fresh one."""
1913
1920
  mpath = os.path.join(d, '.expired')
1921
+ t = time.time()
1922
+ # The shim's field order (bin/codex park line), so every reader parses one shape.
1923
+ soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
1914
1924
  try:
1915
1925
  with open(mpath + '.tmp', 'w') as f:
1916
- f.write(f'{int(time.time())}\n')
1917
- f.write(f"reason={slug} marked_at="
1918
- f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
1926
+ f.write(f'{int(t)}\n')
1927
+ f.write(f"reason={slug} {soft}marked_at="
1928
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
1919
1929
  os.replace(mpath + '.tmp', mpath)
1920
1930
  except Exception:
1921
1931
  pass
@@ -33,7 +33,11 @@ claude-accounts relogin acct-01 --token # portable token instead (Mac + server
33
33
 
34
34
  `relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
35
35
  clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
36
- one at any time; a PASS clears the park automatically.
36
+ one at any time; a PASS clears the park automatically, and so does a call the usage
37
+ limiter refuses (`FAIL … — usage-limited, not a login or org problem; lifted its
38
+ org-blocked park`): the org's Claude Code gate answers before the limiter, so a limit
39
+ answer proves the org lets the account through. A `BLOCKED` park also lapses on its own
40
+ after 6 hours (`soft_until`), and the pool tries the account again.
37
41
 
38
42
  `relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
39
43
  the wrong email), one account at a time, and syncs to the server once at the end.
@@ -293,6 +293,12 @@ CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_AR_AVOID=acct-07:1790000000 claude
293
293
  - `soft`: only the all-limited fallback;
294
294
  - `hard`: only exhausted accounts are left;
295
295
  - `none`: nothing is usable; exit 3.
296
+ - **`resets_at`** (codex only): a `soft` or `hard` answer may end with
297
+ ` resets_at=<epoch>`, when that tier's reset is known. For `hard` it is the soonest
298
+ reset among the exhausted accounts. For `soft` it is the picked account's own reset.
299
+ An `eligible` answer never carries it. This is the machine-readable form of the
300
+ terminal-only "every usable account is at a limit" notice; stderr stays empty.
301
+ Parsers must accept trailing `key=value` fields.
296
302
  - **What the probe runs:** the same candidate loop as a launch, including marker upkeep
297
303
  and its log lines, stopping before exec.
298
304
  - **Environment:** run it with no `CLAUDE_CONFIG_DIR`, `CLAUDE_ACCOUNT` or
@@ -50,6 +50,14 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
50
50
  before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
51
51
  check every token immediately; `claude-accounts expired` reports token-only accounts
52
52
  as `UNVERIFIED` until that proof exists.
53
+ - **An account reads `BLOCKED` although its subscription is active again** — the park came
54
+ from a real call the org refused ("Your organization has disabled Claude subscription
55
+ access for Claude Code"), e.g. while a lapsed subscription was unpaid. Run
56
+ `claude-accounts verify`: a PASS lifts it, and so does a refusal from the usage limiter
57
+ (`FAIL … — usage-limited, not a login or org problem; lifted its org-blocked park`),
58
+ because the provider answers the org gate before the limiter. Without either, the park
59
+ lapses after its 6-hour `soft_until` and the pool tries the account again. The 404
60
+ "…or ask your admin to enable this model" is about a model, never an org block.
53
61
  - **A session restarted itself, or did not** — that is [auto-resume](AUTORESUME.md).
54
62
  `grep autoresume ~/.claude-accounts/selection.log` shows each step: `hold` means no
55
63
  other account had headroom, and `giveup` gives the reason nothing was relaunched. A
package/lib/audit.py CHANGED
@@ -7,7 +7,9 @@ States:
7
7
  expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
8
8
  token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
9
9
  blocked authenticates, but its organization has disabled Claude Code subscription
10
- access — a re-login cannot fix it, so it needs an admin (or removal)
10
+ access — a re-login cannot fix it, so it needs an admin (or removal); a
11
+ park with a soft_until (every one written now: 6 h) lapses on its own
12
+ and the pool re-tries the account
11
13
  locked the OAuth login is in the macOS Keychain and THIS session cannot open it
12
14
  (ssh/tmux/background) — it works from the Mac's own GUI session, so it is
13
15
  neither dead nor missing; just not usable from here
@@ -73,12 +75,31 @@ def marker_slug(detail):
73
75
  return m.group(1) if m else ''
74
76
 
75
77
 
78
+ def marker_soft_until(detail):
79
+ """The soft_until=<epoch> field of a marker line (None when absent)."""
80
+ m = re.search(r'soft_until=(\d+)', detail or '')
81
+ return int(m.group(1)) if m else None
82
+
83
+
84
+ def org_block_in_force(body, now=None):
85
+ """True when a marker is an org-blocked park that still binds — no soft_until, or
86
+ one still ahead (expired_marked below and bin/claude's reading of the same line).
87
+ The writers use it to keep a weaker reason from overwriting a live org block; a
88
+ block whose soft window has run out no longer outranks anything."""
89
+ now = time.time() if now is None else now
90
+ if marker_slug(body) != 'org-blocked':
91
+ return False
92
+ soft = marker_soft_until(body)
93
+ return soft is None or soft > now
94
+
95
+
76
96
  def expired_marked(d, now=None):
77
97
  """True when <d>/.expired is still in force. Mirrors the shim exactly:
78
98
  a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
79
99
  refresh by another process); a POLICY park (org-blocked) does not, because a token
80
100
  refresh says nothing about whether the org re-enabled Claude Code. Either kind also
81
- expires at its own soft_until when the shim wrote it from a single failed run."""
101
+ expires at its own soft_until when it carries one — every shim park, and every
102
+ org-blocked park (the shim's and verify's alike carry six hours)."""
82
103
  now = time.time() if now is None else now
83
104
  mpath = os.path.join(d, '.expired')
84
105
  if not os.path.isfile(mpath):
@@ -105,8 +126,8 @@ def expired_marked(d, now=None):
105
126
  if not os.path.isfile(os.path.join(d, '.credentials.json')) \
106
127
  and keychain.item_mtime(d) > mt:
107
128
  return False
108
- m = re.search(r'soft_until=(\d+)', detail)
109
- if m and int(m.group(1)) <= now:
129
+ soft = marker_soft_until(detail)
130
+ if soft is not None and soft <= now:
110
131
  return False
111
132
  return True
112
133
 
@@ -288,6 +309,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
288
309
  row['state'] = 'blocked'
289
310
  row['reason'] = ('this account\'s organization has disabled Claude Code '
290
311
  'subscription access')
312
+ soft = marker_soft_until(detail)
313
+ if soft is not None:
314
+ # When the pool gives it another real call on its own (the park's
315
+ # soft_until). A passing verify, one refused only by a usage limit, or
316
+ # a re-login lifts it sooner.
317
+ row['reason'] += time.strftime(
318
+ '; re-tried by the pool after %Y-%m-%d %H:%M UTC', time.gmtime(soft))
291
319
  elif slug == 'setup-token-invalid':
292
320
  login = oauth_login(d, now)
293
321
  if login['state'] == 'ok':
package/lib/autoresume.py CHANGED
@@ -1118,10 +1118,13 @@ def probe_env(environ, provider, avoid):
1118
1118
 
1119
1119
 
1120
1120
  def parse_probe(out):
1121
- """The probe's ``pick=<acct> tier=<eligible|soft|hard|none>`` -> (pick, tier)."""
1121
+ """The probe's ``pick=<acct> tier=<eligible|soft|hard|none>`` -> (pick, tier).
1122
+
1123
+ Trailing ``key=value`` fields (the codex shim's ``resets_at=<epoch>``) are accepted
1124
+ and ignored here."""
1122
1125
  pick, tier = "", ""
1123
1126
  for line in (out or "").split("\n"):
1124
- m = re.match(r"^pick=(\S*) tier=(\S+)\s*$", line.strip())
1127
+ m = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
1125
1128
  if m:
1126
1129
  pick, tier = m.group(1), m.group(2)
1127
1130
  if pick and not ACCT_RE.match(pick):
@@ -69,7 +69,8 @@ def expired_marked(d, now=None):
69
69
  a CREDENTIAL-scoped park clears as soon as a newer auth.json lands (re-login, or a
70
70
  refresh by another process); a POLICY park (org-blocked) does not, because a token
71
71
  refresh says nothing about whether the workspace re-enabled Codex. Either kind also
72
- expires at its own soft_until when the shim wrote it from a single failed run."""
72
+ expires at its own soft_until when it carries one — every shim park, and every
73
+ org-blocked park (the shim's and verify's alike carry six hours)."""
73
74
  now = time.time() if now is None else now
74
75
  mpath = os.path.join(d, '.expired')
75
76
  if not os.path.isfile(mpath):
@@ -199,6 +200,12 @@ def audit_account(root, acct, now=None, machine=None):
199
200
  row['state'] = 'blocked'
200
201
  row['reason'] = ('Codex access is disabled for this account by a '
201
202
  'workspace admin')
203
+ soft = re.search(r'soft_until=(\d+)', detail)
204
+ if soft:
205
+ # When the pool gives it another real call on its own (lib/audit.py).
206
+ row['reason'] += time.strftime(
207
+ '; re-tried by the pool after %Y-%m-%d %H:%M UTC',
208
+ time.gmtime(int(soft.group(1))))
202
209
  else:
203
210
  row['state'] = 'expired'
204
211
  row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.44",
3
+ "version": "2.0.46",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -272,8 +272,21 @@ if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
272
272
  exit 1
273
273
  fi
274
274
  case " $* " in
275
- *" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
275
+ *" -p --output-format text --max-turns 1 "*)
276
+ # verify's real call, refused: `verifysay:<acct>` answers FAKE_VERIFY_TEXT the way
277
+ # the real client prints a refusal in text mode — on STDOUT, exit 1 (health.log
278
+ # 2026-09-28: out="You've hit your weekly limit · resets 1am (Europe/Istanbul)" err='').
279
+ if [ -f "$ctl" ] && grep -qx "verifysay:$acct" "$ctl" 2>/dev/null; then
280
+ printf '%s\n' "${FAKE_VERIFY_TEXT:-}"
281
+ exit 1
282
+ fi
283
+ echo "OK"; exit 0 ;;
276
284
  esac
285
+ if [ -f "$ctl" ] && grep -qx "model404:$acct" "$ctl" 2>/dev/null; then
286
+ # 2.1.283's 404 model_not_found text: about a MODEL, never the organization.
287
+ echo "The model claude-nope is not available on your firstParty deployment. Try switching to claude-opus-5, or ask your admin to enable this model."
288
+ exit 1
289
+ fi
277
290
  if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
278
291
  echo "API Error: 429 rate limit exceeded" >&2
279
292
  exit 1
@@ -1155,6 +1168,17 @@ check "relogin targets org-blocked accounts" "acct-01 login saved" "$out"
1155
1168
  [ ! -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login clears an org block" \
1156
1169
  || t_fail "org-block relogin" "marker survived the re-login"
1157
1170
  rm -f "$ACC/acct-01/.expired"
1171
+ # The client's 404 model_not_found ends "…or ask your admin to enable this model". The org
1172
+ # vocabulary's bare "ask your admin to enable" matched it, parking a healthy account as
1173
+ # org-blocked over a model name; only "…to enable access" is the org refusal.
1174
+ printf '{"fetched_at":%s,"max_percent":1,"weekly_percent":1,"session_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
1175
+ printf '{"fetched_at":%s,"max_percent":50,"weekly_percent":50,"session_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
1176
+ echo "model404:acct-01" > "$FAKE_CTL"
1177
+ out="$(claude -p hello < /dev/null 2>&1)"
1178
+ check "a model 404 reaches the caller as it is" "not available on your firstParty deployment" "$out"
1179
+ [ ! -f "$ACC/acct-01/.expired" ] && t_ok "a model 404 ('ask your admin to enable this model') parks nothing" \
1180
+ || t_fail "model 404 park" "$(tail -1 "$ACC/acct-01/.expired")"
1181
+ rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
1158
1182
 
1159
1183
  # ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
1160
1184
  mkdir -p "$ACC/acct-03"
@@ -3213,6 +3237,14 @@ rm -f "$ACC/acct-05/limits.json"
3213
3237
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
3214
3238
  [ -f "$ACC/acct-05/.expired" ] && t_ok "a usage fetch does not unpark an org-blocked account" \
3215
3239
  || t_fail "org-block unpark" "telemetry cleared an org block it cannot observe"
3240
+ # ...nor one verify wrote with its soft_until (every org park carries one now)
3241
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' "$now" "$(( $(date +%s) + 21600 ))" \
3242
+ > "$ACC/acct-05/.expired"
3243
+ rm -f "$ACC/acct-05/limits.json"
3244
+ out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
3245
+ grep -q 'reason=org-blocked soft_until=' "$ACC/acct-05/.expired" 2>/dev/null \
3246
+ && t_ok "a usage fetch does not unpark a soft-stamped org block" \
3247
+ || t_fail "soft org-block unpark" "telemetry cleared a soft-stamped org block"
3216
3248
  rm -f "$ACC/acct-05/.expired" "$ACC/acct-05/server.token"
3217
3249
 
3218
3250
  # ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
@@ -3770,6 +3802,34 @@ RACER
3770
3802
  grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null \
3771
3803
  && t_ok "a dead refresh grant never overwrites an org-blocked marker" \
3772
3804
  || t_fail "org block downgraded" "marker is now: $(cat "$SD/acct-01/.expired" 2>/dev/null | tail -1)"
3805
+ # The same without the portable token beside it — park_dead_grant never parks an
3806
+ # account its token still serves, so the check above cannot see the guard. A live org
3807
+ # block (no soft_until, or one still ahead) is never overwritten; one whose soft_until
3808
+ # has run out binds nothing and outranks nothing, so the dead grant gets its own park.
3809
+ mv "$SD/acct-01/server.token" "$SD/acct-01/server.token.aside"
3810
+ for soft in none "$(( $(date +%s) + 21600 ))" "$(( $(date +%s) - 10 ))"; do
3811
+ if [ "$soft" = none ]; then
3812
+ printf '%s\nreason=org-blocked marked_at=now detail=test\n' "$now" > "$SD/acct-01/.expired"
3813
+ else
3814
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' \
3815
+ "$((now - 30000))" "$soft" > "$SD/acct-01/.expired"
3816
+ fi
3817
+ rm -f "$SD/acct-01/limits.json"
3818
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
3819
+ claude-accounts limits --force >/dev/null 2>&1
3820
+ grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null && echo kept || echo "$(tail -1 "$SD/acct-01/.expired" 2>/dev/null)"
3821
+ done > "$WORK/lapsed-org.txt"
3822
+ mv "$SD/acct-01/server.token.aside" "$SD/acct-01/server.token"
3823
+ [ "$(sed -n 1p "$WORK/lapsed-org.txt")" = kept ] \
3824
+ && t_ok "a dead refresh grant (no token beside it) never overwrites an org block" \
3825
+ || t_fail "org block downgraded" "$(sed -n 1p "$WORK/lapsed-org.txt")"
3826
+ [ "$(sed -n 2p "$WORK/lapsed-org.txt")" = kept ] \
3827
+ && t_ok "a dead refresh grant never overwrites an org block inside its soft window" \
3828
+ || t_fail "soft org block downgraded" "$(sed -n 2p "$WORK/lapsed-org.txt")"
3829
+ case "$(sed -n 3p "$WORK/lapsed-org.txt")" in
3830
+ reason=refresh-token-expired*) t_ok "a lapsed org block does not hide a dead refresh grant from its park" ;;
3831
+ *) t_fail "lapsed org block shields a dead grant" "$(sed -n 3p "$WORK/lapsed-org.txt")" ;;
3832
+ esac
3773
3833
  rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json"
3774
3834
 
3775
3835
  # ---- the >=90% cutoff keeps the TIGHT window --------------------------------
@@ -4293,6 +4353,171 @@ case "$out" in
4293
4353
  esac
4294
4354
  rm -f "$WORK/ctl-brokencli"
4295
4355
 
4356
+ # ---- 17a3. a usage-limit refusal proves the org lets the account through --------------
4357
+ # acct-15, 2026-09-27: at weekly_all 100% a real call came back with the ORG refusal, and
4358
+ # once the lapsed subscription was paid again the same call answered "You've hit your
4359
+ # weekly limit" — the provider checks the org gate before the usage limiter. verify
4360
+ # failed that answer with no hint and kept the org-blocked park, so a paid account read
4361
+ # BLOCKED on that Mac through every re-check. A limit refusal now lifts a park by the PASS
4362
+ # rule, and the line stays FAIL (the runner counts it).
4363
+ VP="$WORK/verifylimit"
4364
+ vnow="$(date +%s)" # the suite's $now is minutes old by here; soft windows need a fresh one
4365
+ VP_HOME=mac; VP_MACHINE=mac; [ "$(uname -s)" = "Darwin" ] || { VP_HOME=server; VP_MACHINE=linux; }
4366
+ mkdir -p "$VP/tmp" "$VP/acct-01" "$VP/acct-02" "$VP/acct-03"
4367
+ : > "$VP/.limits-kick"
4368
+ cat > "$VP/accounts.json" <<EOF2
4369
+ { "version": 1, "threshold": 90,
4370
+ "accounts": [
4371
+ {"id": "acct-01", "email": "vl1@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
4372
+ {"id": "acct-02", "email": "vl2@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
4373
+ {"id": "acct-03", "email": "vl3@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"}
4374
+ ] }
4375
+ EOF2
4376
+ VP_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-vl","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
4377
+ # acct-01: OAuth login only. acct-02: a live login beside a portable token (verify runs
4378
+ # the LOGIN). acct-03: the portable token only (verify runs the TOKEN).
4379
+ printf '%s' "$VP_LIVE" > "$VP/acct-01/.credentials.json"
4380
+ printf '%s' "$VP_LIVE" > "$VP/acct-02/.credentials.json"
4381
+ printf 'sk-ant-oat01-vl-token-02' > "$VP/acct-02/server.token"
4382
+ printf 'sk-ant-oat01-vl-token-03' > "$VP/acct-03/server.token"
4383
+ printf 'verifysay:acct-01\nverifysay:acct-02\nverifysay:acct-03\n' > "$WORK/ctl-verifysay"
4384
+ vp_verify() { # $1 = what the refused call prints
4385
+ FAKE_CTL="$WORK/ctl-verifysay" FAKE_VERIFY_TEXT="$1" CLAUDE_ACCOUNTS_DIR="$VP" \
4386
+ claude-accounts verify 2>&1
4387
+ }
4388
+ vp_line() { printf '%s\n' "$out" | grep "^$1 " | head -1; }
4389
+ vp_park() { # $1 acct, $2 marker line 2 (reason=… fields)
4390
+ printf '%s\n%s\n' "$((vnow - 3600))" "$2" > "$VP/$1/.expired"
4391
+ }
4392
+ vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=the account's organization has disabled Claude Code access"
4393
+ vp_park acct-02 "reason=setup-token-invalid marked_at=2026-09-27T20:42:42Z detail=portable setup-token failed a real inference"
4394
+ vp_park acct-03 "reason=org-blocked soft_until=$((vnow + 20000)) marked_at=2026-09-27T20:42:42Z detail=x"
4395
+ rm -f "$VP/acct-03/.server-token-verified"
4396
+ out="$(vp_verify "You've hit your weekly limit · resets 1am (Europe/Istanbul)")"
4397
+ rc=$?
4398
+ check "a limit refusal still reads FAIL, in the shape the runner parses" \
4399
+ "acct-01 vl1@test: FAIL rc=1 out=\"You've hit your weekly limit" "$out"
4400
+ check "a limit refusal is called what it is" "usage-limited, not a login or org problem" "$(vp_line acct-01)"
4401
+ check "a limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-01)"
4402
+ [ ! -f "$VP/acct-01/.expired" ] && t_ok "a limit refusal lifts an org-blocked park (the org gate answers first)" \
4403
+ || t_fail "limit lifts org park" "$(tail -1 "$VP/acct-01/.expired")"
4404
+ [ "$rc" = 1 ] && check "limit refusals are still counted as failures" "verified 3 account(s), 3 failure(s)" "$out" \
4405
+ || t_fail "limit refusal exit" "rc=$rc"
4406
+ n="$(printf '%s\n' "$out" | grep -cE '^(acct-[0-9]+)[[:space:]]+([^[:space:]]+):[[:space:]]+(PASS|FAIL|OK|SKIP)([^[:alnum:]_].*)?$')"
4407
+ [ "$n" = 3 ] && t_ok "every account line still matches app-robot's verdict pattern" \
4408
+ || t_fail "verdict pattern" "matched $n of 3: $out"
4409
+ # The LOGIN answered for acct-02: that proves nothing about the portable token a real
4410
+ # call rejected, so its token park stays — the PASS rule, exactly.
4411
+ check "a limit refusal on the login is still called usage-limited" "usage-limited" "$(vp_line acct-02)"
4412
+ case "$(vp_line acct-02)" in
4413
+ *lifted*) t_fail "login limit must not claim a lift" "$(vp_line acct-02)" ;;
4414
+ *) t_ok "a limit refusal that lifted nothing does not claim a lift" ;;
4415
+ esac
4416
+ grep -q 'reason=setup-token-invalid' "$VP/acct-02/.expired" 2>/dev/null \
4417
+ && t_ok "a limit refusal on the OAuth login keeps a setup-token-invalid park" \
4418
+ || t_fail "login limit vs token park" "the token park was lifted by the login's answer"
4419
+ [ ! -f "$VP/acct-02/.server-token-verified" ] && t_ok "the login's limit answer records no token proof" \
4420
+ || t_fail "login limit token proof" ".server-token-verified written for a token that never answered"
4421
+ # The TOKEN answered for acct-03: its park goes and the token is proven, as on a PASS.
4422
+ check "a token limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-03)"
4423
+ [ ! -f "$VP/acct-03/.expired" ] && t_ok "a limit refusal on the token lifts the park" \
4424
+ || t_fail "token limit lift" "$(tail -1 "$VP/acct-03/.expired")"
4425
+ want="$(printf 'sk-ant-oat01-vl-token-03' | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d' ' -f1)"
4426
+ [ "$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)" = "$want" ] \
4427
+ && t_ok "a limit refusal on the token records .server-token-verified for that token" \
4428
+ || t_fail "token limit proof" "got '$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)'"
4429
+ st="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list --json 2>/dev/null \
4430
+ | python3 -c 'import json,sys; print({a["id"]: a["status"] for a in json.load(sys.stdin)["accounts"]}.get("acct-01"))')"
4431
+ [ "$st" != "blocked" ] && t_ok "the lifted account no longer lists as blocked ($st)" \
4432
+ || t_fail "lifted account status" "list --json still says blocked"
4433
+ # A model-scoped limit is the same limiter speaking.
4434
+ vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
4435
+ out="$(vp_verify "You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cl to continue.")"
4436
+ [ ! -f "$VP/acct-01/.expired" ] && check "a model-scoped limit lifts the org park too" "lifted its org-blocked park" "$(vp_line acct-01)" \
4437
+ || t_fail "model limit lift" "$(tail -1 "$VP/acct-01/.expired")"
4438
+ # ...but not the throttle the client itself calls "not your usage limit", nor an overload:
4439
+ # neither proves the org gate was passed, and both contain limit words.
4440
+ for said in "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited" \
4441
+ "API Error: 529 Overloaded. Rate limit exceeded, try again" \
4442
+ "Opus is experiencing high load, please use /model to switch to Sonnet"; do
4443
+ vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
4444
+ out="$(vp_verify "$said")"
4445
+ if grep -q 'reason=org-blocked' "$VP/acct-01/.expired" 2>/dev/null \
4446
+ && case "$(vp_line acct-01)" in *usage-limited*) false ;; *) true ;; esac; then
4447
+ t_ok "no lift on a transient answer: ${said:0:48}"
4448
+ else
4449
+ t_fail "transient answer lifted a park" "${said:0:48} -> $(vp_line acct-01)"
4450
+ fi
4451
+ done
4452
+ rm -f "$VP"/acct-*/.expired
4453
+
4454
+ # The ORG refusal: verify's park carries the shim's 6 h soft_until, and seeing the refusal
4455
+ # again refreshes it (a stale stamp would release a still-blocked account early).
4456
+ ORG_TEXT="Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
4457
+ t0="$(date +%s)"
4458
+ out="$(vp_verify "$ORG_TEXT")"
4459
+ t1="$(date +%s)"
4460
+ check "verify calls out the org block" "ORG BLOCKED" "$(vp_line acct-01)"
4461
+ soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
4462
+ { [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$soft" -le $((t1 + 21600)) ]; } \
4463
+ && t_ok "verify's org park carries soft_until = now + 6 h" \
4464
+ || t_fail "verify org soft_until" "$(tail -1 "$VP/acct-01/.expired" 2>/dev/null)"
4465
+ grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$VP/acct-01/.expired" \
4466
+ && t_ok "verify writes the shim's field order" || t_fail "org park shape" "$(tail -1 "$VP/acct-01/.expired")"
4467
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
4468
+ "$((vnow - 21000))" "$((vnow + 60))" > "$VP/acct-01/.expired"
4469
+ t0="$(date +%s)"
4470
+ out="$(vp_verify "$ORG_TEXT")"
4471
+ soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
4472
+ epoch="$(head -1 "$VP/acct-01/.expired" 2>/dev/null)"
4473
+ { [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$epoch" -ge "$t0" ] \
4474
+ && ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$VP/acct-01/.expired"; } \
4475
+ && t_ok "a re-observed org refusal refreshes the park (epoch, marked_at, soft_until)" \
4476
+ || t_fail "org park refresh" "$(cat "$VP/acct-01/.expired" 2>/dev/null | tr '\n' ' ')"
4477
+ # A weaker reason still never overwrites an org block that binds...
4478
+ out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
4479
+ grep -q 'reason=org-blocked' "$VP/acct-01/.expired" \
4480
+ && t_ok "an auth failure never overwrites an org block in force" \
4481
+ || t_fail "org block outranked" "$(tail -1 "$VP/acct-01/.expired")"
4482
+ # ...while one whose soft window has run out binds nothing and outranks nothing.
4483
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
4484
+ "$((vnow - 30000))" "$((vnow - 10))" > "$VP/acct-01/.expired"
4485
+ out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
4486
+ grep -q 'reason=auth-error' "$VP/acct-01/.expired" \
4487
+ && t_ok "a lapsed org block no longer shields the account from an auth park" \
4488
+ || t_fail "lapsed org block" "$(tail -1 "$VP/acct-01/.expired")"
4489
+ rm -f "$VP"/acct-*/.expired
4490
+ # The 404 model_not_found text is about a model, not the org: no park in verify either.
4491
+ out="$(vp_verify "The model claude-nope is not available on your firstParty deployment. Try switching to claude-opus-5, or ask your admin to enable this model.")"
4492
+ [ ! -f "$VP/acct-01/.expired" ] && t_ok "verify does not park on 'ask your admin to enable this model'" \
4493
+ || t_fail "verify model 404" "$(tail -1 "$VP/acct-01/.expired")"
4494
+ case "$(vp_line acct-01)" in
4495
+ *"ORG BLOCKED"*|*usage-limited*) t_fail "verify model 404 wording" "$(vp_line acct-01)" ;;
4496
+ *) t_ok "a model 404 is neither an org block nor a usage limit" ;;
4497
+ esac
4498
+ # An org park whose soft_until has passed no longer blocks the account anywhere: the
4499
+ # audit (list/expired/status/--json) reads it the way the shim does.
4500
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$((vnow - 30000))" "$((vnow - 10))" \
4501
+ > "$VP/acct-01/.expired"
4502
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$vnow" "$((vnow + 21600))" \
4503
+ > "$VP/acct-02/.expired"
4504
+ rows="$(python3 "$REPO_DIR/lib/audit.py" "$VP" "$VP_MACHINE")"
4505
+ [ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-01"{print $4}')" = "ok" ] \
4506
+ && t_ok "a lapsed org park no longer reads blocked in the audit" \
4507
+ || t_fail "lapsed org park audit" "$(printf '%s\n' "$rows" | grep acct-01)"
4508
+ [ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-02"{print $4}')" = "blocked" ] \
4509
+ && t_ok "an org park inside its soft window still reads blocked" \
4510
+ || t_fail "org park audit" "$(printf '%s\n' "$rows" | grep acct-02)"
4511
+ out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list 2>&1)"
4512
+ case "$(printf '%s\n' "$out" | grep '^acct-01 ')" in
4513
+ *ORG-BLOCKED*) t_fail "lapsed org park list" "$(printf '%s\n' "$out" | grep '^acct-01 ')" ;;
4514
+ *) t_ok "list does not flag a lapsed org park" ;;
4515
+ esac
4516
+ out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts expired 2>&1)"
4517
+ check "expired says when the pool re-tries an org-blocked account" "re-tried by the pool after" "$out"
4518
+ rm -f "$WORK/ctl-verifysay"
4519
+ unset -f vp_verify vp_line vp_park
4520
+
4296
4521
  # ---- 16e. MCP servers for every account ---------------------------------------------
4297
4522
  # Claude Code keeps MCP servers in the CONFIG DIR, so under the pool a stock `claude mcp
4298
4523
  # add` used to land in ONE random account — appinspire was connected in some sessions
@@ -5470,6 +5695,10 @@ cxlj 99 10 99 > "$CX/acct-01/limits.json"
5470
5695
  cxlj 7 100 100 > "$CX/acct-02/limits.json"
5471
5696
  out="$(codex 2>&1)"
5472
5697
  check "codex: a still-serving limited account beats an exhausted one with more headroom" "CFG=acct-01" "$out"
5698
+ out="$(CODEX_MULTIACC_AR_PROBE=1 codex 2>&1)"; rc=$?
5699
+ [ "$rc:$out" = "0:pick=acct-01 tier=soft resets_at=$((now+3600))" ] \
5700
+ && t_ok "codex probe: a soft pick carries its own marker's resets_at" \
5701
+ || t_fail "codex probe soft resets_at" "rc=$rc out=$out"
5473
5702
  # The codex-review case (2026-09-04): the exhausted account is the only one INSIDE the
5474
5703
  # session gate; the gate must not resurrect it over a still-serving account outside it.
5475
5704
  printf '%s\nbucket=7d percent=100 marked_at=x reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
@@ -5488,6 +5717,12 @@ printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+7200))" >
5488
5717
  printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$CX/acct-02/.limited"
5489
5718
  out="$(codex 2>&1)"
5490
5719
  check "codex: all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
5720
+ # The probe is the machine channel for the tty-only notice: it names the soonest reset,
5721
+ # and its stderr stays empty.
5722
+ out="$(CODEX_MULTIACC_AR_PROBE=1 codex 2>"$WORK/cx-probe.err")"; rc=$?
5723
+ [ "$rc:$out" = "0:pick=acct-02 tier=hard resets_at=$((now+600))" ] && [ ! -s "$WORK/cx-probe.err" ] \
5724
+ && t_ok "codex probe: all exhausted answers tier=hard resets_at=<soonest reset>, stderr clean" \
5725
+ || t_fail "codex probe hard resets_at" "rc=$rc out=$out err=$(cat "$WORK/cx-probe.err")"
5491
5726
  grep -q "all-exhausted resets_in=" "$CX/selection.log" \
5492
5727
  && t_ok "codex: the all-exhausted pick is logged with its reset" || t_fail "codex all-exhausted log" "no line"
5493
5728
  rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
@@ -6886,6 +7121,23 @@ out="$(codex-accounts verify 2>&1)"
6886
7121
  check "codex: verify calls out the workspace block" "ORG BLOCKED" "$out"
6887
7122
  grep -q "reason=org-blocked" "$CX/acct-02/.expired" 2>/dev/null \
6888
7123
  && t_ok "codex: verify parks the org-blocked account" || t_fail "codex verify org park" "no marker"
7124
+ # ...with bin/codex's 6 h soft_until, in the shim's field order, refreshed when seen again
7125
+ t0="$(date +%s)"
7126
+ soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
7127
+ { [ -n "$soft" ] && [ "$soft" -gt $((t0 + 21000)) ] && [ "$soft" -le $((t0 + 21600)) ] \
7128
+ && grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$CX/acct-02/.expired"; } \
7129
+ && t_ok "codex: verify's org park carries soft_until = now + 6 h" \
7130
+ || t_fail "codex verify org soft_until" "$(tail -1 "$CX/acct-02/.expired" 2>/dev/null)"
7131
+ printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
7132
+ "$((t0 - 21000))" "$((t0 + 60))" > "$CX/acct-02/.expired"
7133
+ out="$(codex-accounts verify 2>&1)"
7134
+ soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
7135
+ { [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] \
7136
+ && ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$CX/acct-02/.expired"; } \
7137
+ && t_ok "codex: a re-observed workspace block refreshes the park" \
7138
+ || t_fail "codex org park refresh" "$(cat "$CX/acct-02/.expired" 2>/dev/null | tr '\n' ' ')"
7139
+ out="$(codex-accounts expired 2>&1)"
7140
+ check "codex: expired says when the pool re-tries a blocked account" "re-tried by the pool after" "$out"
6889
7141
  rm -f "$FAKE_CTL2" "$CX/acct-02/.expired"
6890
7142
 
6891
7143
  # ---- C16. audit vocabulary + empty pool ---------------------------------------------
@@ -11,6 +11,7 @@ PATH and a fake probe script. Runs under python3.9+.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import calendar
14
15
  import contextlib
15
16
  import importlib.util
16
17
  import json
@@ -128,6 +129,27 @@ def cx_error(ts, info, message="boom", completed_at=None):
128
129
 
129
130
  LIMIT_MSG = ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
130
131
  "to purchase more credits or try again at Sep 26th, 2026 12:39 AM.")
132
+ _MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
133
+ "Dec")
134
+
135
+
136
+ def limit_msg(epoch):
137
+ """LIMIT_MSG with its "try again at" date set to ``epoch``, rendered in UTC the way
138
+ the codex CLI prints it (read it back with TZ=UTC). A walk on the real clock needs this:
139
+ the classifier ignores a "try again at" that is already past, and a fixed date goes stale
140
+ (LIMIT_MSG's Sep 26th, 2026 did on that day, which failed the Publish run)."""
141
+ t = time.gmtime(epoch)
142
+ day = t.tm_mday
143
+ nth = "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
144
+ return ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
145
+ "to purchase more credits or try again at %s %d%s, %d %d:%02d %s."
146
+ % (_MONTH_ABBR[t.tm_mon - 1], day, nth, t.tm_year, t.tm_hour % 12 or 12,
147
+ t.tm_min, "AM" if t.tm_hour < 12 else "PM"))
148
+
149
+
150
+ def future_reset(now):
151
+ """A minute-aligned "try again at" three days after ``now``."""
152
+ return (int(now) // 60 + 3 * 24 * 60) * 60
131
153
 
132
154
 
133
155
  def cx_tokens(ts, primary=None, secondary=None, limit_id="codex", info=None):
@@ -434,6 +456,12 @@ class CodexClassifierTests(unittest.TestCase):
434
456
  1793631600)
435
457
  self.assertEqual(ar.parse_try_again("try again at December 3rd, 2026 9:15 AM"),
436
458
  1796289300)
459
+ self.assertEqual(limit_msg(1790383140), LIMIT_MSG) # the helper renders it
460
+ for stamp in ((2026, 10, 1, 12, 5), (2026, 11, 2, 15, 0), (2026, 12, 3, 9, 15),
461
+ (2026, 9, 11, 0, 0), (2026, 9, 12, 23, 59), (2026, 9, 13, 12, 0),
462
+ (2026, 9, 21, 1, 1), (2026, 9, 22, 13, 30), (2026, 9, 23, 11, 59)):
463
+ when = calendar.timegm(stamp + (0,))
464
+ self.assertEqual(ar.parse_try_again(limit_msg(when)), when, limit_msg(when))
437
465
  if os.path.exists("/usr/share/zoneinfo/America/New_York"):
438
466
  with local_tz("America/New_York"): # EDT, UTC-4
439
467
  self.assertEqual(ar.parse_try_again(LIMIT_MSG), 1790383140 + 4 * 3600)
@@ -669,7 +697,13 @@ class StateTests(unittest.TestCase):
669
697
  self.assertEqual(ar.parse_probe("noise\npick=acct-02 tier=eligible\n"),
670
698
  ("acct-02", "eligible"))
671
699
  self.assertEqual(ar.parse_probe("pick= tier=none\n"), ("", "none"))
672
- for bad in ("", "pick=acct-02", "pick=../x tier=eligible", "pick=acct-02 tier=great"):
700
+ # The codex shim appends resets_at=<epoch> to a limited tier; old lines still parse.
701
+ self.assertEqual(ar.parse_probe("pick=acct-03 tier=hard resets_at=1790000000\n"),
702
+ ("acct-03", "hard"))
703
+ self.assertEqual(ar.parse_probe("pick=acct-03 tier=soft resets_at=1790000000"),
704
+ ("acct-03", "soft"))
705
+ for bad in ("", "pick=acct-02", "pick=../x tier=eligible", "pick=acct-02 tier=great",
706
+ "pick=acct-02 tier=hard resets_at", "pick=acct-02 tier=hard x y"):
673
707
  self.assertEqual(ar.parse_probe(bad), ("", ""), bad)
674
708
  self.assertTrue(ar.probe_allows("quota", "acct-02", "eligible", "acct-01"))
675
709
  self.assertFalse(ar.probe_allows("quota", "acct-01", "eligible", "acct-01"))
@@ -1973,16 +2007,17 @@ class CodexWatcherTests(PoolCase):
1973
2007
  return rc, w, r, path
1974
2008
 
1975
2009
  def test_quota_rotates_with_resume_and_the_message_date(self):
2010
+ reset = future_reset(self.t0) # the message's date, read in the local (UTC) zone
2011
+ msg = limit_msg(reset)
1976
2012
  with local_tz("UTC"):
1977
2013
  rc, w, r, _ = self.run_codex(
1978
2014
  ["--yolo", "resume", CX_SID], [
1979
2015
  cx_meta(self.launched - 7200, cwd=str(self.work)),
1980
- cx_error(self.launched - 7000, "usage_limit_exceeded", LIMIT_MSG),
2016
+ cx_error(self.launched - 7000, "usage_limit_exceeded", msg),
1981
2017
  cx_event(self.t0 - 2, {"type": "task_started"}),
1982
2018
  cx_tokens(self.t0 - 1, limit_id="premium"),
1983
- cx_error(self.t0 - 1, "usage_limit_exceeded", LIMIT_MSG)])
2019
+ cx_error(self.t0 - 1, "usage_limit_exceeded", msg)])
1984
2020
  self.assertEqual(rc, 0)
1985
- reset = 1790383140 # Sep 26th, 2026 12:39 AM, read in the local (UTC) zone
1986
2021
  prompt = ar.resume_prompt("quota")
1987
2022
  probe = r.probes[0]
1988
2023
  self.assertEqual(probe["argv"], [self.self_path, "resume", "--yolo", CX_SID, prompt])
@@ -2234,13 +2269,15 @@ class CliTests(PoolCase):
2234
2269
  self.assertEqual(doc["events"][0]["kind"], "verdict") # no --since: everything counts
2235
2270
 
2236
2271
  def test_classify_codex(self):
2272
+ reset = future_reset(self.t0)
2237
2273
  path = self.tmp / "r.jsonl"
2238
2274
  path.write_text(jl([cx_meta(self.t0), cx_tokens(self.t0, limit_id="premium"),
2239
- cx_error(self.t0, {"usage_limit_exceeded": None}, LIMIT_MSG)]))
2275
+ cx_error(self.t0, {"usage_limit_exceeded": None},
2276
+ limit_msg(reset))]))
2240
2277
  doc = json.loads(self.cli("classify", "--provider", "codex", str(path),
2241
2278
  env={"TZ": "UTC"}).stdout)
2242
2279
  self.assertEqual(doc["pending"]["class"], "quota")
2243
- self.assertEqual(doc["pending"]["reset"], 1790383140)
2280
+ self.assertEqual(doc["pending"]["reset"], reset)
2244
2281
  self.assertNotIn("_message", doc["pending"])
2245
2282
  self.assertNotIn("usage limit", json.dumps(doc))
2246
2283