claude-dev-env 8.45.1 → 8.46.1

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.
@@ -27,6 +27,7 @@ The broker stores cached meters, spent marks, and Claude session bindings in one
27
27
  | `0` | `choose` or `check` found an account, or the job exited successfully. |
28
28
  | `3` | No account clears its floor. The decision includes `resets_at`. |
29
29
  | `4` | A Claude job stopped with `advisor_blocked`. |
30
+ | `127` | A Codex job timed out, or its command could not start while no Codex roster is configured. |
30
31
  | Other command code | `run` returns the command's exit code after a served response. |
31
32
 
32
33
  `run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.45.1",
3
+ "version": "8.46.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Mapping, Sequence
15
15
 
16
16
  import account_broker_support as support
17
+ import codex_account_meters
17
18
  from account_broker_support import (
18
19
  all_product_adapters,
19
20
  Account,
@@ -262,7 +263,8 @@ def _attempt_once(context: _RunContext, decision: Decision) -> JobOutcome | None
262
263
  _record_spent_attempt(context, account, status, COMMAND_MISSING_EXIT_CODE)
263
264
  if isinstance(error, subprocess.TimeoutExpired) or not context.all_readings:
264
265
  context.report.final_decision = decision
265
- return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", str(error), account.name, tuple(context.all_attempts), "advisor_blocked", None, None)
266
+ final_status = "advisor_blocked" if context.product is Product.CLAUDE else status
267
+ return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", str(error), account.name, tuple(context.all_attempts), final_status, None, None)
266
268
  return None
267
269
  combined = f"{completion.stdout}{completion.stderr}".casefold()
268
270
  is_limited = completion.returncode != 0 and any(
@@ -310,11 +312,14 @@ def run_job(
310
312
  def _parser() -> argparse.ArgumentParser:
311
313
  parser = argparse.ArgumentParser(description=__doc__)
312
314
  commands = parser.add_subparsers(dest="action", required=True)
313
- for each_action in ("choose", "check", "accounts", "run"):
315
+ for each_action in ("choose", "check", "accounts", "limits", "run"):
314
316
  command = commands.add_parser(each_action)
315
317
  command.add_argument("--product", choices=[each_product.value for each_product in Product], required=True)
316
318
  if each_action == "choose":
317
319
  command.add_argument("--spent", action="append", default=[])
320
+ if each_action == "limits":
321
+ command.add_argument("--home", type=Path, required=True)
322
+ command.add_argument("--codex", type=Path)
318
323
  if each_action == "run":
319
324
  command.add_argument("--report", type=Path, required=True)
320
325
  command.add_argument("command", nargs=argparse.REMAINDER)
@@ -344,6 +349,19 @@ def _accounts_cli(product: Product) -> int:
344
349
  return 0
345
350
 
346
351
 
352
+ def _limits_cli(product: Product, parsed: argparse.Namespace) -> int:
353
+ if product is not Product.CODEX:
354
+ raise BrokerConfigurationError("limits reads Codex accounts only")
355
+ try:
356
+ rate_limit_records = codex_account_meters.read_rate_limit_records(
357
+ codex_account_meters.resolve_codex_path(parsed.codex), parsed.home
358
+ )
359
+ except (codex_account_meters.CodexMeterUnreadError, OSError) as error:
360
+ raise BrokerConfigurationError(str(error)) from error
361
+ print(json.dumps({"home": str(parsed.home), "rate_limits": rate_limit_records}))
362
+ return 0
363
+
364
+
347
365
  def _run_cli(product: Product, parsed: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
348
366
  command = list(parsed.command)
349
367
  if command and command[0] == "--":
@@ -387,6 +405,8 @@ def main(all_arguments: Sequence[str]) -> int:
387
405
  try:
388
406
  if parsed.action == "accounts":
389
407
  return _accounts_cli(product)
408
+ if parsed.action == "limits":
409
+ return _limits_cli(product, parsed)
390
410
  if parsed.action == "run":
391
411
  return _run_cli(product, parsed, parser)
392
412
  return _choose_cli(product, parsed)
@@ -161,7 +161,7 @@ def _parse_window(raw_window: object) -> UsageWindow | None:
161
161
  )
162
162
 
163
163
 
164
- def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
164
+ def _reply_body(reply_text: str) -> Mapping[str, object]:
165
165
  try:
166
166
  reply = json.loads(reply_text)
167
167
  except json.JSONDecodeError as error:
@@ -171,7 +171,13 @@ def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
171
171
  if reply.get("error") is not None:
172
172
  raise CodexMeterUnreadError(f"rate-limit read failed: {reply['error']}")
173
173
  body = reply.get("result")
174
- rate_limits = body.get("rateLimits") if isinstance(body, dict) else None
174
+ if not isinstance(body, dict):
175
+ raise CodexMeterUnreadError("rate-limit reply has no result")
176
+ return body
177
+
178
+
179
+ def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
180
+ rate_limits = _reply_body(reply_text).get("rateLimits")
175
181
  if not isinstance(rate_limits, dict):
176
182
  raise CodexMeterUnreadError("rate-limit reply names no rateLimits")
177
183
  return rate_limits
@@ -338,13 +344,45 @@ def read_codex_meters(
338
344
  Raises:
339
345
  CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
340
346
  """
347
+ return parse_rate_limits_reply(_rate_limits_reply_line(codex_path, codex_home, exchange))
348
+
349
+
350
+ def read_rate_limit_records(
351
+ codex_path: Path,
352
+ codex_home: Path,
353
+ exchange: ServerExchange = exchange_with_app_server,
354
+ ) -> Mapping[str, object]:
355
+ """Read one account's whole rate-limit reply through the app server.
356
+
357
+ ::
358
+
359
+ {"id": 2, "result": {"rateLimits": {...}, "rateLimitsByLimitId": {...}}}
360
+ -> {"rateLimits": {...}, "rateLimitsByLimitId": {...}}
361
+
362
+ Args:
363
+ codex_path: The Codex executable.
364
+ codex_home: The account's Codex home.
365
+ exchange: Sends messages and returns output lines; tests pass a fake.
366
+
367
+ Returns:
368
+ The reply's ``result`` object, with every limit record it carries.
369
+
370
+ Raises:
371
+ CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
372
+ """
373
+ return _reply_body(_rate_limits_reply_line(codex_path, codex_home, exchange))
374
+
375
+
376
+ def _rate_limits_reply_line(
377
+ codex_path: Path, codex_home: Path, exchange: ServerExchange
378
+ ) -> str:
341
379
  try:
342
380
  all_lines = exchange(codex_path, codex_home, request_messages())
343
381
  except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as error:
344
382
  raise CodexMeterUnreadError(f"codex app-server failed: {error}") from error
345
383
  for each_line in all_lines:
346
384
  if _is_rate_limits_reply(each_line):
347
- return parse_rate_limits_reply(each_line)
385
+ return each_line
348
386
  raise CodexMeterUnreadError("codex app-server sent no rate-limit reply")
349
387
 
350
388
 
@@ -435,13 +435,44 @@ def test_should_stop_after_timeout_without_running_job_again(
435
435
 
436
436
  assert invoked_homes == ["first"]
437
437
  assert outcome.attempts == (("first", "timeout"),)
438
- assert outcome.status == "advisor_blocked"
438
+ assert outcome.status == "timeout"
439
439
  assert outcome.account_name == "first"
440
440
  assert report.final_decision.account.name == "first"
441
441
  assert account_broker.main(("choose", "--product", "codex")) == 0
442
442
  assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
443
443
 
444
444
 
445
+ @pytest.mark.parametrize(
446
+ ("product", "roster_names", "start_error", "expected_code"),
447
+ (
448
+ (Product.CLAUDE, ("first",), subprocess.TimeoutExpired("job", 1), 4),
449
+ (Product.CODEX, ("first",), subprocess.TimeoutExpired("job", 1), 127),
450
+ (Product.CODEX, (), OSError("missing command"), 127),
451
+ ),
452
+ )
453
+ def test_should_exit_four_only_for_a_blocked_claude_job(
454
+ monkeypatch: pytest.MonkeyPatch,
455
+ tmp_path: Path,
456
+ product: Product,
457
+ roster_names: tuple[str, ...],
458
+ start_error: Exception,
459
+ expected_code: int,
460
+ ) -> None:
461
+ accounts = tuple(_account(each_name, product) for each_name in roster_names)
462
+ monkeypatch.setitem(account_broker.all_product_adapters, product, _adapter(accounts, {
463
+ each_name: _meters(80, 80) for each_name in roster_names
464
+ }))
465
+ monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
466
+
467
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
468
+ raise start_error
469
+
470
+ with account_broker.override_subprocess_runner(runner):
471
+ code = account_broker.main(("run", "--product", product.value, "--report", str(tmp_path / "report.json"), "--", "job"))
472
+
473
+ assert code == expected_code
474
+
475
+
445
476
  def test_should_keep_usage_limited_account_spent_for_later_choices(
446
477
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
447
478
  ) -> None:
@@ -663,3 +694,41 @@ def test_should_run_job_through_override(
663
694
  assert outcome.status == "served"
664
695
  assert outcome.attempts == (("only", "served"),)
665
696
  assert calls == [b"input"]
697
+
698
+
699
+
700
+ def test_should_print_the_whole_rate_limit_result_for_one_home(
701
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
702
+ ) -> None:
703
+ rate_limit_records = {"rateLimitsByLimitId": {"codex": {"primary": {"usedPercent": 20}}}}
704
+ observed: list[tuple[Path, Path]] = []
705
+
706
+ def read_result(codex_path: Path, codex_home: Path) -> dict[str, object]:
707
+ observed.append((codex_path, codex_home))
708
+ return rate_limit_records
709
+
710
+ monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
711
+ home = tmp_path / "codex-2"
712
+
713
+ assert account_broker.main(("limits", "--product", "codex", "--home", str(home), "--codex", "codex-bin")) == 0
714
+ assert json.loads(capsys.readouterr().out) == {"home": str(home), "rate_limits": rate_limit_records}
715
+ assert observed == [(Path("codex-bin"), home)]
716
+
717
+
718
+ def test_should_exit_two_when_the_limits_read_fails(
719
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
720
+ ) -> None:
721
+ def read_result(_codex_path: Path, _codex_home: Path) -> dict[str, object]:
722
+ raise account_broker.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
723
+
724
+ monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
725
+
726
+ assert account_broker.main(("limits", "--product", "codex", "--home", str(tmp_path), "--codex", "codex-bin")) == 2
727
+ captured = capsys.readouterr()
728
+ assert captured.out == ""
729
+ assert "no rate-limit reply" in captured.err
730
+
731
+
732
+ def test_should_refuse_limits_for_claude(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
733
+ assert account_broker.main(("limits", "--product", "claude", "--home", str(tmp_path))) == 2
734
+ assert "Codex accounts only" in capsys.readouterr().err
@@ -15,6 +15,7 @@ from codex_account_meters import (
15
15
  exchange_with_app_server,
16
16
  parse_rate_limits_reply,
17
17
  read_codex_meters,
18
+ read_rate_limit_records,
18
19
  request_messages,
19
20
  resolve_codex_path,
20
21
  )
@@ -163,6 +164,38 @@ class TestReadCodexMeters:
163
164
  read_codex_meters(Path("codex"), Path("/h"), broken_exchange)
164
165
 
165
166
 
167
+
168
+ class TestReadRateLimitRecords:
169
+ def should_return_every_limit_record_the_reply_carries(self) -> None:
170
+ rate_limit_records = {
171
+ "rateLimits": {"limitId": "codex", "primary": {"usedPercent": 20}},
172
+ "rateLimitsByLimitId": {
173
+ "codex": {"limitId": "codex", "planType": "pro"},
174
+ "codex_other": {"limitId": "codex_other"},
175
+ },
176
+ }
177
+
178
+ assert (
179
+ read_rate_limit_records(
180
+ Path("codex"),
181
+ Path("/h"),
182
+ lambda *_: [json.dumps({"id": 1}), json.dumps({"id": 2, "result": rate_limit_records})],
183
+ )
184
+ == rate_limit_records
185
+ )
186
+
187
+ def should_raise_unread_on_an_error_reply(self) -> None:
188
+ with pytest.raises(CodexMeterUnreadError, match="read failed"):
189
+ read_rate_limit_records(
190
+ Path("codex"),
191
+ Path("/h"),
192
+ lambda *_: [json.dumps({"id": 2, "error": {"code": 1}})],
193
+ )
194
+
195
+ def should_raise_unread_when_the_server_never_answers(self) -> None:
196
+ with pytest.raises(CodexMeterUnreadError, match="no rate-limit reply"):
197
+ read_rate_limit_records(Path("codex"), Path("/h"), lambda *_: [])
198
+
166
199
  FAKE_APP_SERVER = """#!/usr/bin/env python3
167
200
  import json, os, sys
168
201
  for line in sys.stdin: