claude-dev-env 8.48.7 → 8.49.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.
@@ -205,6 +205,9 @@ The recovery command restores the named account and deletes only that record.
205
205
  `--confirm-inactive` before recovery.
206
206
  - Normal pull request actions never call `gh auth switch`. If that command
207
207
  appears outside explicit recovery, stop.
208
+ - `gh auth switch`, `login`, and `logout` change the account for every session
209
+ on the machine, and a PreToolUse hook denies them. Scope another account to
210
+ one command: `GH_TOKEN=$(gh auth token -u <account>) gh <command>`.
208
211
  - GitHub CLI output can contain account data. The command captures account
209
212
  lookup and recovery output and prints only generic errors.
210
213
 
@@ -22,7 +22,8 @@ JSON list of profile names, with the first choice first. Names use letters,
22
22
  digits, hyphens, and underscores. The worker uses the existing second profile
23
23
  when the file is absent. When the file exists, its list sets the full order.
24
24
  Keep `main` and `wait` out of the list because they name picker decisions.
25
- The picker tries each profile after checking the main account's expiring usage.
25
+ The picker ranks main and every extra profile by room left and picks the one
26
+ with the most.
26
27
  For direct picker calls, `--second-config-dir` sets the first extra profile and
27
28
  each `--extra-config-dir` adds another in the order given.
28
29
  Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
@@ -36,17 +36,19 @@ Claude refreshes it on each run.
36
36
 
37
37
  ## Which account a job uses
38
38
 
39
- The main account belongs to the person who works on it, so a job borrows it only
40
- to spend leftover usage that expires soon.
39
+ The broker ranks every Claude account that has room and picks the one with the
40
+ most room left. An account's room is the smaller of its 5-hour and weekly
41
+ percent left. A resumed session stays on its account while that account has
42
+ room.
41
43
 
42
- | Condition | Account |
44
+ | Account | Has room while |
43
45
  |---|---|
44
- | Main week resets within 24 hours, main under 90% of its week, main under 50% of its 5-hour window | main |
45
- | Otherwise, second under 95% of its week and under 90% of its 5-hour window | second |
46
- | Second meter unreadable | wait until the meter can be read |
47
- | Otherwise | wait, with the next reset time |
46
+ | main | under 90% of its week and under 50% of its 5-hour window |
47
+ | second and each extra profile | under 95% of its week and under 90% of its 5-hour window |
48
48
 
49
- An unreadable main meter never picks main.
49
+ When no account has room, the job waits until the next account reset, or for one
50
+ hour when no reset is known. An unreadable meter never picks its account, and
51
+ the wait reason names each account whose meter could not be read.
50
52
 
51
53
  ```
52
54
  python packages/claude-dev-env/scripts/account_broker.py choose --product claude
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: keep agents from changing the global GitHub CLI account.
3
+
4
+ ``gh auth switch``, ``gh auth login``, and ``gh auth logout`` write the shared
5
+ gh configuration, so they change the active account for every session on the
6
+ machine. A command segment whose program is ``gh`` and whose arguments start
7
+ with one of those subcommands is denied, and the deny reason names the
8
+ per-command ``GH_TOKEN`` form to run instead. The program is read past wrappers
9
+ and chained commands, so ``cd x && gh auth switch`` and ``bash -c "gh auth
10
+ logout"`` are denied too. ``gh auth status``, ``gh auth token``, and quoted text
11
+ that only mentions the words pass.
12
+
13
+ Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
14
+ PowerShell tools.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ try:
24
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
25
+ if _hooks_root_directory not in sys.path:
26
+ sys.path.insert(0, _hooks_root_directory)
27
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
28
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
29
+ DENY_DECISION,
30
+ HOOK_EVENT_NAME,
31
+ )
32
+ from hooks_constants.gh_global_account_switch_gate_constants import (
33
+ ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS,
34
+ GATE_HOOK_NAME,
35
+ GH_COMMAND_PATH_LENGTH,
36
+ GH_PROGRAM_NAME,
37
+ GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
38
+ )
39
+ from hooks_constants.hook_block_logger import log_hook_block
40
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
41
+ from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
42
+ from hooks_constants.shell_command_wrappers import (
43
+ all_wrapped_command_texts,
44
+ segment_program_and_arguments,
45
+ )
46
+ except ImportError as import_error:
47
+ raise ImportError(
48
+ "The gh global account switch gate cannot import its dependencies; "
49
+ "ensure the hooks directory is importable."
50
+ ) from import_error
51
+
52
+
53
+ def _changes_global_gh_account(all_segment_tokens: list[str]) -> bool:
54
+ """Return True when a segment runs gh auth switch, login, or logout.
55
+
56
+ Args:
57
+ all_segment_tokens: One command segment's shell tokens.
58
+ """
59
+ program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
60
+ command_path = tuple(all_arguments[:GH_COMMAND_PATH_LENGTH])
61
+ return program_name.lower() == GH_PROGRAM_NAME and command_path in ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS
62
+
63
+
64
+ def changes_global_gh_account(command: str) -> bool:
65
+ """Return True when any command segment changes the machine-wide gh account.
66
+
67
+ ::
68
+
69
+ gh auth switch -u <account> -> True
70
+ git fetch && gh auth login -> True
71
+ GH_TOKEN=$(gh auth token -u <account>) gh repo list -> False
72
+ git commit -m "never run gh auth switch" -> False
73
+
74
+ Args:
75
+ command: The shell command text the agent is about to run.
76
+ """
77
+ return any(
78
+ _changes_global_gh_account(each_segment)
79
+ for each_text in all_wrapped_command_texts(command)
80
+ for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
81
+ )
82
+
83
+
84
+ def main() -> None:
85
+ """Deny a command that changes the global gh account, or stay quiet."""
86
+ hook_payload = read_hook_input_dictionary_from_stdin()
87
+ if hook_payload is None:
88
+ return
89
+ tool_name = hook_payload.get("tool_name")
90
+ if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
91
+ return
92
+ tool_input = hook_payload.get("tool_input")
93
+ if not isinstance(tool_input, dict):
94
+ return
95
+ command = tool_input.get("command", "")
96
+ if not isinstance(command, str) or not changes_global_gh_account(command):
97
+ return
98
+ log_hook_block(
99
+ GATE_HOOK_NAME, HOOK_EVENT_NAME, GLOBAL_ACCOUNT_CHANGE_DENY_REASON, str(tool_name), command
100
+ )
101
+ sys.stdout.write(
102
+ json.dumps(
103
+ {
104
+ "hookSpecificOutput": {
105
+ "hookEventName": HOOK_EVENT_NAME,
106
+ "permissionDecision": DENY_DECISION,
107
+ "permissionDecisionReason": GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
108
+ }
109
+ }
110
+ )
111
+ )
112
+ sys.stdout.flush()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -2,8 +2,9 @@
2
2
 
3
3
  A Bash tool call starts one interpreter, the dispatcher's own. Each roster
4
4
  entry then runs in that process through runpy, so the roster's exact content is
5
- the chain's cost after that single start. The roster holds the MSYS rewriter and
6
- the headless Claude broker gate; any other hook added here fails this test.
5
+ the chain's cost after that single start. The roster holds the MSYS rewriter,
6
+ the headless Claude broker gate, and the gh global account gate; any other hook
7
+ added here fails this test.
7
8
  """
8
9
 
9
10
  import sys
@@ -21,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
21
22
  )
22
23
 
23
24
 
24
- def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None:
25
+ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
25
26
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
26
27
  BashHostedHookEntry(
27
28
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -31,4 +32,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None
31
32
  script_relative_path="blocking/headless_claude_broker_gate.py",
32
33
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
33
34
  ),
35
+ BashHostedHookEntry(
36
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
37
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
38
+ ),
34
39
  )
@@ -0,0 +1,100 @@
1
+ """Tests for gh_global_account_switch_gate, which keeps the global gh account fixed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ import gh_global_account_switch_gate as gate
12
+
13
+
14
+ def _stdout_from_main(payload: dict[str, object]) -> str:
15
+ """Return what main() wrote to stdout for one hook payload."""
16
+ captured_stdout = StringIO()
17
+ with (
18
+ patch("sys.stdin", StringIO(json.dumps(payload))),
19
+ patch("sys.stdout", captured_stdout),
20
+ patch.object(gate, "log_hook_block"),
21
+ ):
22
+ gate.main()
23
+ return captured_stdout.getvalue()
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ "command",
28
+ [
29
+ "gh auth switch",
30
+ "gh auth switch -u account-a",
31
+ "gh auth login",
32
+ "gh auth login --with-token",
33
+ "gh auth logout -u account-b",
34
+ "gh.exe auth switch -u account-b",
35
+ "cd repo && gh auth switch -u account-a",
36
+ "gh auth status; gh auth switch -u account-a",
37
+ "gh auth status || gh auth login",
38
+ 'bash -c "gh auth switch -u account-a"',
39
+ 'pwsh -Command "gh auth logout"',
40
+ "& gh auth switch",
41
+ "sudo gh auth switch --user account-b",
42
+ ],
43
+ )
44
+ def test_global_account_change_is_detected(command: str) -> None:
45
+ assert gate.changes_global_gh_account(command) is True
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "command",
50
+ [
51
+ "gh auth status",
52
+ "gh auth status --active",
53
+ "gh auth token -u account-a",
54
+ "GH_TOKEN=$(gh auth token -u account-a) gh repo list",
55
+ "$env:GH_TOKEN = gh auth token -u account-a; gh repo list; Remove-Item Env:GH_TOKEN",
56
+ "gh pr view 12",
57
+ "gh api user",
58
+ "gh",
59
+ 'git commit -m "never call gh auth switch"',
60
+ 'echo "gh auth switch"',
61
+ 'grep -rn "gh auth login" docs',
62
+ "git switch main",
63
+ ],
64
+ )
65
+ def test_read_only_and_unrelated_commands_pass(command: str) -> None:
66
+ assert gate.changes_global_gh_account(command) is False
67
+
68
+
69
+ def test_bash_payload_with_switch_is_denied_with_the_scoped_token_forms() -> None:
70
+ decision = json.loads(
71
+ _stdout_from_main(
72
+ {"tool_name": "Bash", "tool_input": {"command": "gh auth switch -u account-a"}}
73
+ )
74
+ )
75
+ specific_output = decision["hookSpecificOutput"]
76
+ assert specific_output["permissionDecision"] == "deny"
77
+ reason = specific_output["permissionDecisionReason"]
78
+ assert "GH_TOKEN=$(gh auth token -u <account>) gh <command>" in reason
79
+ assert (
80
+ "$env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN"
81
+ in reason
82
+ )
83
+
84
+
85
+ def test_powershell_payload_with_logout_is_denied() -> None:
86
+ decision = json.loads(
87
+ _stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": "gh auth logout"}})
88
+ )
89
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
90
+
91
+
92
+ def test_scoped_token_payload_emits_nothing() -> None:
93
+ scoped_command = "GH_TOKEN=$(gh auth token -u account-b) gh repo list"
94
+ assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": scoped_command}}) == ""
95
+
96
+
97
+ def test_other_tool_payload_emits_nothing() -> None:
98
+ assert (
99
+ _stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh auth switch"}}) == ""
100
+ )
@@ -63,4 +63,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
63
63
  script_relative_path="blocking/headless_claude_broker_gate.py",
64
64
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
65
65
  ),
66
+ BashHostedHookEntry(
67
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
68
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
69
+ ),
66
70
  )
@@ -0,0 +1,33 @@
1
+ """Constants for the gate that keeps agents from changing the global gh account.
2
+
3
+ Holds the GitHub CLI program name, the ``gh auth`` command paths that change the
4
+ account every session on the machine uses and their word count, the hook name
5
+ the block log records, and the deny message that names the per-command token
6
+ form to run instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "GH_PROGRAM_NAME",
13
+ "GH_COMMAND_PATH_LENGTH",
14
+ "ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS",
15
+ "GATE_HOOK_NAME",
16
+ "GLOBAL_ACCOUNT_CHANGE_DENY_REASON",
17
+ ]
18
+
19
+ GH_PROGRAM_NAME: str = "gh"
20
+ GH_COMMAND_PATH_LENGTH: int = 2
21
+ ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS: frozenset[tuple[str, ...]] = frozenset(
22
+ {("auth", "switch"), ("auth", "login"), ("auth", "logout")}
23
+ )
24
+ GATE_HOOK_NAME: str = "gh_global_account_switch_gate.py"
25
+ GLOBAL_ACCOUNT_CHANGE_DENY_REASON: str = (
26
+ "BLOCKED: gh auth switch, login, and logout change the active GitHub CLI account "
27
+ "for every session on this machine, so other running agents lose access. "
28
+ "Scope the other signed-in account to one command instead. "
29
+ "Bash: GH_TOKEN=$(gh auth token -u <account>) gh <command>. "
30
+ "PowerShell: $env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN. "
31
+ "Run gh auth status to list the signed-in accounts. "
32
+ "Signing an account in or out is a step for the user to run."
33
+ )
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
12
12
  BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
13
13
 
14
14
 
15
- def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
15
+ def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate() -> None:
16
16
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
17
17
  BashHostedHookEntry(
18
18
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -22,4 +22,8 @@ def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
22
22
  script_relative_path="blocking/headless_claude_broker_gate.py",
23
23
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
24
24
  ),
25
+ BashHostedHookEntry(
26
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
27
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
28
+ ),
25
29
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.48.7",
3
+ "version": "8.49.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,7 +65,6 @@ def choose_from_readings(
65
65
  now: datetime,
66
66
  all_spent_accounts: frozenset[Account] = frozenset(),
67
67
  preferred_command: str | None = None,
68
- adapter: ProductAdapter | None = None,
69
68
  all_spent_resets: Mapping[Account, datetime] | None = None,
70
69
  ) -> Decision:
71
70
  """Choose an account or a wait.
@@ -80,10 +79,9 @@ def choose_from_readings(
80
79
  if product is Product.CODEX and not all_readings:
81
80
  home = Path(os.environ.get(CODEX_HOME_ENVIRONMENT_VARIABLE) or Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME).resolve()
82
81
  return Decision("run", Account(product, "default", home, True), None, "no roster is configured", TIER_NORMAL)
83
- active = adapter or all_product_adapters[product]
84
82
  all_available = [each_reading for each_reading in all_readings if each_reading.account not in all_spent_accounts]
85
83
  selected = (
86
- _choose_claude(all_available, now, preferred_command, active.main_guard)
84
+ _choose_claude(all_available, preferred_command)
87
85
  if product is Product.CLAUDE
88
86
  else _choose_codex(all_available)
89
87
  )
@@ -238,7 +236,7 @@ def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
238
236
  if all_attempt_statuses == {"start_failed"}:
239
237
  return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", "", None, tuple(context.all_attempts), "start_failed", None, None)
240
238
  status = "exhausted" if context.all_attempts else "wait"
241
- return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
239
+ return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at, decision.reason)
242
240
 
243
241
 
244
242
  def _record_spent_attempt(context: _RunContext, account: Account, status: str, returncode: int | None) -> None:
@@ -314,7 +312,7 @@ def _execute(
314
312
  ) -> tuple[JobOutcome, Report]:
315
313
  context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
316
314
  while True:
317
- picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command, adapter=context.active, all_spent_resets=context.all_spent_resets)
315
+ picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command, all_spent_resets=context.all_spent_resets)
318
316
  decision = _with_outside_spent_marks(picked, _outside_roster_resets(product, context.all_readings, context.all_state, now))
319
317
  context.report.events.append({"type": "pick", "decision": decision_payload(decision)})
320
318
  if decision.account is None:
@@ -212,8 +212,8 @@ def read_codex_account_meters(account: Account) -> Meters | None:
212
212
 
213
213
 
214
214
  all_product_adapters = {
215
- Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES, True),
216
- Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures(), False),
215
+ Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES),
216
+ Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures()),
217
217
  }
218
218
 
219
219
 
@@ -70,9 +70,9 @@ def _invocation(*, model: str | None, permission_mode: str) -> list[str]:
70
70
 
71
71
 
72
72
  def _wait_reason(outcome: JobOutcome) -> str:
73
- if outcome.wait_reset_at is None:
73
+ if outcome.wait_reason is None:
74
74
  return "no account has room"
75
- return f"no account has room; next reset at {outcome.wait_reset_at.isoformat()}"
75
+ return outcome.wait_reason
76
76
 
77
77
 
78
78
  def run_worker(
@@ -15,7 +15,6 @@ from typing import Callable, Mapping, Sequence
15
15
  from dev_env_scripts_constants.claude_account_constants import (
16
16
  FULL_PERCENT,
17
17
  MAIN_SESSION_USED_CEILING_PERCENT,
18
- MAIN_SPEND_WINDOW,
19
18
  MAIN_WEEKLY_USED_CEILING_PERCENT,
20
19
  SECOND_SESSION_USED_CEILING_PERCENT,
21
20
  SECOND_WEEKLY_USED_CEILING_PERCENT,
@@ -39,7 +38,6 @@ from shared_tree_paths import resolve_shared_scripts_directory
39
38
  ALL_CLAUDE_FLOORS = {
40
39
  "main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
41
40
  "main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
42
- "main_spend_window": MAIN_SPEND_WINDOW,
43
41
  "extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
44
42
  "extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
45
43
  }
@@ -57,7 +55,6 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
57
55
  BROKER_STATE_FILE_NAME = "state.json"
58
56
  BROKER_STATE_TEMP_SUFFIX = ".json"
59
57
  BROKER_STATE_LOCK_SUFFIX = ".lock"
60
- SECONDS_PER_HOUR = 3600
61
58
  ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
62
59
  CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
63
60
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
@@ -180,6 +177,7 @@ class JobOutcome:
180
177
  status: str
181
178
  session_id: str | None
182
179
  wait_reset_at: datetime | None
180
+ wait_reason: str | None = None
183
181
 
184
182
 
185
183
  @dataclass
@@ -201,22 +199,6 @@ class ProductAdapter:
201
199
  read_meters: MeterReader
202
200
  environment_variable: str
203
201
  usage_limit_signatures: tuple[str, ...]
204
- main_guard: bool
205
-
206
-
207
-
208
- def _main_reason(meters: Meters | None, now: datetime) -> str | None:
209
- if meters is None or meters.session_percent_left is None or meters.weekly_percent_left is None or meters.weekly_resets_at is None:
210
- return None
211
- until_reset = meters.weekly_resets_at - now
212
- if (
213
- until_reset > ALL_CLAUDE_FLOORS["main_spend_window"]
214
- or meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
215
- or meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
216
- ):
217
- return None
218
- hours = int(until_reset.total_seconds() // SECONDS_PER_HOUR)
219
- return f"main week resets in {hours} hours with {meters.weekly_percent_left:.0f}% left"
220
202
 
221
203
 
222
204
  def _claude_extra_tier(meters: Meters | None) -> str | None:
@@ -272,8 +254,6 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
272
254
  (meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
273
255
  )
274
256
  all_blocking_resets = [reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None]
275
- if is_claude_main and meters.weekly_resets_at is not None:
276
- all_blocking_resets.append(meters.weekly_resets_at - ALL_CLAUDE_FLOORS["main_spend_window"])
277
257
  return max(all_blocking_resets, default=None)
278
258
 
279
259
 
@@ -292,23 +272,22 @@ def _main_has_room(meters: Meters | None) -> bool:
292
272
  )
293
273
 
294
274
 
295
- def _choose_claude(
296
- all_available: Sequence[Reading], now: datetime, preferred_command: str | None, has_main_guard: bool
297
- ) -> Decision | None:
298
- for each_reading in all_available:
275
+ def _claude_has_room(reading: Reading) -> bool:
276
+ if reading.account.is_main:
277
+ return _main_has_room(reading.meters)
278
+ return _claude_extra_tier(reading.meters) is not None
279
+
280
+
281
+ def _choose_claude(all_available: Sequence[Reading], preferred_command: str | None) -> Decision | None:
282
+ all_with_room = [each_reading for each_reading in all_available if _claude_has_room(each_reading)]
283
+ for each_reading in all_with_room:
299
284
  is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
300
- has_room = _main_has_room(each_reading.meters) if each_reading.account.is_main else bool(_claude_extra_tier(each_reading.meters))
301
- if preferred_command is not None and is_bound and has_room:
285
+ if preferred_command is not None and is_bound:
302
286
  return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
303
- main = next((each_reading for each_reading in all_available if each_reading.account.is_main), None)
304
- main_reason = _main_reason(main.meters, now) if main and has_main_guard else None
305
- if main is not None and main_reason is not None:
306
- return Decision("run", main.account, None, main_reason, TIER_NORMAL)
307
- extras = sorted((each_reading for each_reading in all_available if not each_reading.account.is_main), key=_rank_key)
308
- for each_reading in extras:
309
- if _claude_extra_tier(each_reading.meters):
310
- return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", TIER_NORMAL)
311
- return None
287
+ roomiest = min(all_with_room, key=_rank_key, default=None)
288
+ if roomiest is None:
289
+ return None
290
+ return Decision("run", roomiest.account, None, f"{roomiest.account.name} has {roomiest.meters.tightest_percent_left:g}% left", TIER_NORMAL)
312
291
 
313
292
 
314
293
  def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
@@ -327,14 +306,38 @@ def _choose_codex_tier(all_candidates: Sequence[Reading], tier: str) -> Decision
327
306
  return None
328
307
 
329
308
 
309
+ @dataclass(frozen=True)
310
+ class _RetryTime:
311
+ at: datetime
312
+ is_known_reset: bool
313
+
314
+
315
+ def _wait_reason(all_unreadable_names: Sequence[str], is_every_account_unreadable: bool, soonest: _RetryTime) -> str:
316
+ next_event = "reset" if soonest.is_known_reset else "check"
317
+ next_text = f"next {next_event} at {utc_time_text(soonest.at)}"
318
+ if not all_unreadable_names:
319
+ return f"no account has room; {next_text}"
320
+ unreadable_text = ", ".join(all_unreadable_names)
321
+ if is_every_account_unreadable:
322
+ return f"no account meter could be read ({unreadable_text}); {next_text}"
323
+ return f"no readable account has room; meter unreadable for {unreadable_text}; {next_text}"
324
+
325
+
330
326
  def _wait_decision(
331
327
  all_readings: Sequence[Reading], now: datetime, all_spent_accounts: frozenset[Account], all_spent_resets: Mapping[Account, datetime]
332
328
  ) -> Decision:
333
- all_resets = []
329
+ next_check = _RetryTime(now + timedelta(hours=1), is_known_reset=False)
330
+ all_retry_times = []
334
331
  for each_reading in all_readings:
335
332
  is_spent = each_reading.account in all_spent_accounts
336
333
  reset = all_spent_resets.get(each_reading.account) if is_spent else None
337
334
  reset = reset or _account_reset(each_reading, is_spent)
338
- all_resets.append(reset if reset is not None and reset > now else now + timedelta(hours=1))
339
- soonest = min(all_resets, default=now + timedelta(hours=1))
340
- return Decision("wait", None, soonest, f"no account has room; next reset at {utc_time_text(soonest)}", "wait")
335
+ all_retry_times.append(_RetryTime(reset, is_known_reset=True) if reset is not None and reset > now else next_check)
336
+ soonest = min(all_retry_times, key=lambda each_retry_time: each_retry_time.at, default=next_check)
337
+ all_unreadable_names = [
338
+ each_reading.account.name
339
+ for each_reading in all_readings
340
+ if each_reading.meters is None and each_reading.account not in all_spent_accounts
341
+ ]
342
+ is_every_account_unreadable = len(all_unreadable_names) == len(all_readings)
343
+ return Decision("wait", None, soonest.at, _wait_reason(all_unreadable_names, is_every_account_unreadable, soonest), "wait")
@@ -9,7 +9,6 @@ main account so its owner never runs out.
9
9
  from __future__ import annotations
10
10
 
11
11
  from dataclasses import dataclass
12
- from datetime import timedelta
13
12
 
14
13
  from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
15
14
 
@@ -128,9 +127,6 @@ WINDOWS_OS_NAME: str = "nt"
128
127
  TEXT_ENCODING: str = "utf-8"
129
128
  """Encoding for the launcher file and the JSON reports."""
130
129
 
131
- MAIN_SPEND_WINDOW: timedelta = timedelta(hours=24)
132
- """Main takes jobs only when its weekly window resets within this span."""
133
-
134
130
  MAIN_WEEKLY_USED_CEILING_PERCENT: float = 90.0
135
131
  """Main takes jobs only while its weekly use is under this percent."""
136
132
 
@@ -146,9 +142,6 @@ SECOND_SESSION_USED_CEILING_PERCENT: float = 90.0
146
142
  FULL_PERCENT: float = 100.0
147
143
  """Percent scale ceiling, so remaining is this minus used."""
148
144
 
149
- SECONDS_PER_HOUR: int = 3600
150
- """Seconds in one hour, for reason text that names hours until a reset."""
151
-
152
145
  CHOICE_MAIN: str = "main"
153
146
  """Picker answer: run the job on the main account."""
154
147
 
@@ -158,11 +151,6 @@ CHOICE_SECOND: str = "second"
158
151
  CHOICE_WAIT: str = "wait"
159
152
  """Picker answer: neither account has room, so the job waits."""
160
153
 
161
- REASON_MAIN_EXPIRING_TEMPLATE: str = (
162
- "main week resets in {hours_until_reset} hours with {remaining_percent:.0f}% left"
163
- )
164
- """Reason when main spends leftover usage that expires soon."""
165
-
166
154
  REASON_SECOND_HAS_ROOM_TEMPLATE: str = (
167
155
  "second account has {weekly_remaining_percent:.0f}% of its week"
168
156
  " and {session_remaining_percent:.0f}% of its 5-hour window left"
@@ -23,7 +23,6 @@ from dev_env_scripts_constants.account_broker_constants import (
23
23
  )
24
24
  from dev_env_scripts_constants.claude_account_constants import (
25
25
  MAIN_SESSION_USED_CEILING_PERCENT,
26
- MAIN_SPEND_WINDOW,
27
26
  MAIN_WEEKLY_USED_CEILING_PERCENT,
28
27
  SECOND_SESSION_USED_CEILING_PERCENT,
29
28
  SECOND_WEEKLY_USED_CEILING_PERCENT,
@@ -41,7 +40,6 @@ def test_should_keep_claude_floors_tied_to_the_account_ceilings() -> None:
41
40
  assert ALL_CLAUDE_FLOORS == {
42
41
  "main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
43
42
  "main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
44
- "main_spend_window": MAIN_SPEND_WINDOW,
45
43
  "extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
46
44
  "extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
47
45
  }
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  import pytest
11
11
 
12
12
  import account_broker
13
+ import claude_account_worker
13
14
  from account_broker import (
14
15
  Account,
15
16
  Meters,
@@ -71,7 +72,6 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
71
72
  read_meters=lambda account: meters[account.name],
72
73
  environment_variable="CODEX_HOME",
73
74
  usage_limit_signatures=("rate limit",),
74
- main_guard=False,
75
75
  )
76
76
 
77
77
 
@@ -112,7 +112,6 @@ def test_should_keep_list_order_when_tighter_windows_tie() -> None:
112
112
  @pytest.mark.parametrize(
113
113
  ("weekly_reset", "weekly_left", "short_left"),
114
114
  (
115
- (NOW + timedelta(days=3), 20, 80),
116
115
  (NOW + timedelta(hours=4), 10, 80),
117
116
  (NOW + timedelta(hours=4), 20, 50),
118
117
  ),
@@ -131,24 +130,80 @@ def test_should_guard_main_at_each_limit(
131
130
  assert decision.account == extra.account
132
131
 
133
132
 
134
- def test_should_prioritize_main_when_its_guard_passes() -> None:
133
+ def test_should_pick_main_when_it_has_more_room_than_every_extra() -> None:
135
134
  main = Reading(
136
135
  _account("main", Product.CLAUDE, main=True),
137
- _meters(80, 20, weekly_reset=NOW + timedelta(hours=4)),
136
+ _meters(80, 80, weekly_reset=NOW + timedelta(days=5)),
137
+ )
138
+ extras = (
139
+ Reading(_account("second", Product.CLAUDE), _meters(60, 60)),
140
+ Reading(_account("third", Product.CLAUDE), _meters(40, 40)),
141
+ )
142
+
143
+ decision = choose_from_readings(Product.CLAUDE, (*extras, main), now=NOW)
144
+
145
+ assert decision.action == "run"
146
+ assert decision.account == main.account
147
+
148
+
149
+ def test_should_pick_an_extra_with_more_room_than_main() -> None:
150
+ main = Reading(
151
+ _account("main", Product.CLAUDE, main=True),
152
+ _meters(60, 60, weekly_reset=NOW + timedelta(hours=4)),
138
153
  )
139
154
  extra = Reading(_account("extra", Product.CLAUDE), _meters(90, 90))
140
155
 
141
156
  decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
142
157
 
158
+ assert decision.account == extra.account
159
+
160
+
161
+ @pytest.mark.parametrize(("main_short_left", "expected_name"), ((51, "main"), (50, "extra")))
162
+ def test_should_pick_main_only_while_under_its_5_hour_ceiling(
163
+ main_short_left: float, expected_name: str
164
+ ) -> None:
165
+ main = Reading(
166
+ _account("main", Product.CLAUDE, main=True),
167
+ _meters(main_short_left, 95, weekly_reset=NOW + timedelta(days=5)),
168
+ )
169
+ extra = Reading(_account("extra", Product.CLAUDE), _meters(20, 20))
170
+
171
+ decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
172
+
173
+ assert decision.account.name == expected_name
174
+
175
+
176
+ def test_should_pick_main_when_it_is_the_only_readable_account() -> None:
177
+ main = Reading(
178
+ _account("main", Product.CLAUDE, main=True),
179
+ _meters(80, 80, weekly_reset=NOW + timedelta(days=5)),
180
+ )
181
+ unread = Reading(_account("extra", Product.CLAUDE), None)
182
+
183
+ decision = choose_from_readings(Product.CLAUDE, (unread, main), now=NOW)
184
+
185
+ assert decision.action == "run"
143
186
  assert decision.account == main.account
144
187
 
145
188
 
189
+ def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() -> None:
190
+ short_reset = NOW + timedelta(hours=2)
191
+ main = Reading(
192
+ _account("main", Product.CLAUDE, main=True),
193
+ _meters(40, 50, short_reset=short_reset, weekly_reset=NOW + timedelta(days=3)),
194
+ )
195
+
196
+ decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
197
+
198
+ assert decision.action == "wait"
199
+ assert decision.resets_at == short_reset
200
+
201
+
146
202
  @pytest.mark.parametrize(
147
203
  ("short_left", "weekly_left", "weekly_reset", "expected_reset"),
148
204
  (
149
205
  (40, 20, NOW + timedelta(hours=4), NOW + timedelta(hours=2)),
150
206
  (80, 5, NOW + timedelta(hours=4), NOW + timedelta(hours=4)),
151
- (80, 50, NOW + timedelta(days=3), NOW + timedelta(days=2)),
152
207
  ),
153
208
  )
154
209
  def test_should_wait_for_the_meter_or_window_that_blocks_main(
@@ -339,6 +394,104 @@ def test_should_wait_when_all_meter_reads_fail() -> None:
339
394
  assert decision.resets_at == NOW + timedelta(hours=1)
340
395
 
341
396
 
397
+ _MAIN_CLAUDE = _account("main", Product.CLAUDE, main=True)
398
+ _EV_CLAUDE = _account("ev", Product.CLAUDE)
399
+
400
+
401
+ @pytest.mark.parametrize(
402
+ ("readings", "spent_resets", "expected_reason", "expected_resets_at"),
403
+ (
404
+ pytest.param(
405
+ (Reading(_MAIN_CLAUDE, None), Reading(_EV_CLAUDE, None)),
406
+ {},
407
+ "no account meter could be read (main, ev); next check at 2026-10-03T01:00:00+00:00",
408
+ NOW + timedelta(hours=1),
409
+ id="every-meter-unreadable",
410
+ ),
411
+ pytest.param(
412
+ (Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(minutes=30))), Reading(_EV_CLAUDE, None)),
413
+ {},
414
+ "no readable account has room; meter unreadable for ev; next reset at 2026-10-03T00:30:00+00:00",
415
+ NOW + timedelta(minutes=30),
416
+ id="unreadable-beside-a-reset-before-the-check",
417
+ ),
418
+ pytest.param(
419
+ (Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(hours=2))), Reading(_EV_CLAUDE, None)),
420
+ {},
421
+ "no readable account has room; meter unreadable for ev; next check at 2026-10-03T01:00:00+00:00",
422
+ NOW + timedelta(hours=1),
423
+ id="unreadable-beside-a-reset-after-the-check",
424
+ ),
425
+ pytest.param(
426
+ (Reading(_MAIN_CLAUDE, _meters(80, 80)), Reading(_EV_CLAUDE, None)),
427
+ {_MAIN_CLAUDE: NOW + timedelta(minutes=30)},
428
+ "no readable account has room; meter unreadable for ev; next reset at 2026-10-03T00:30:00+00:00",
429
+ NOW + timedelta(minutes=30),
430
+ id="unreadable-beside-a-spent-account",
431
+ ),
432
+ pytest.param(
433
+ (
434
+ Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(hours=2))),
435
+ Reading(_EV_CLAUDE, _meters(5, 80, short_reset=NOW + timedelta(hours=3))),
436
+ ),
437
+ {},
438
+ "no account has room; next reset at 2026-10-03T02:00:00+00:00",
439
+ NOW + timedelta(hours=2),
440
+ id="every-meter-read-with-known-resets",
441
+ ),
442
+ pytest.param(
443
+ (Reading(_MAIN_CLAUDE, Meters(40, None, 50, None)),),
444
+ {},
445
+ "no account has room; next check at 2026-10-03T01:00:00+00:00",
446
+ NOW + timedelta(hours=1),
447
+ id="every-meter-read-without-a-known-reset",
448
+ ),
449
+ ),
450
+ )
451
+ def test_should_name_what_the_broker_knows_in_the_wait_reason(
452
+ readings: tuple[Reading, ...],
453
+ spent_resets: dict[Account, datetime],
454
+ expected_reason: str,
455
+ expected_resets_at: datetime,
456
+ ) -> None:
457
+ decision = choose_from_readings(
458
+ Product.CLAUDE,
459
+ readings,
460
+ now=NOW,
461
+ all_spent_accounts=frozenset(spent_resets),
462
+ all_spent_resets=spent_resets,
463
+ )
464
+
465
+ assert decision.action == "wait"
466
+ assert decision.reason == expected_reason
467
+ assert decision.resets_at == expected_resets_at
468
+
469
+
470
+ def test_should_carry_the_unreadable_meter_reason_into_the_worker_report(
471
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
472
+ ) -> None:
473
+ adapter = _adapter((_MAIN_CLAUDE, _EV_CLAUDE), {"main": None, "ev": None})
474
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, adapter)
475
+ prompt_file = tmp_path / "brief.md"
476
+ prompt_file.write_text("standalone brief", encoding="utf-8")
477
+ report_file = tmp_path / "report.json"
478
+
479
+ with account_broker.override_subprocess_runner(lambda command, **options: pytest.fail("a waiting job ran")):
480
+ exit_code = claude_account_worker.run_worker(
481
+ prompt_file=prompt_file,
482
+ cwd=tmp_path,
483
+ report_file=report_file,
484
+ model=None,
485
+ permission_mode="auto",
486
+ timeout_minutes=60,
487
+ )
488
+
489
+ report = json.loads(report_file.read_text(encoding="utf-8"))
490
+ assert exit_code == WAIT_EXIT_CODE
491
+ assert report["account"] == "wait"
492
+ assert report["reason"].startswith("no account meter could be read")
493
+
494
+
342
495
  def test_should_replay_stdin_bytes_and_only_print_served_stdout(
343
496
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
344
497
  ) -> None:
@@ -542,7 +695,7 @@ def test_should_list_accounts_without_reading_meters(
542
695
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
543
696
  ) -> None:
544
697
  account = _account("listed")
545
- adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", (), False)
698
+ adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", ())
546
699
  monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
547
700
 
548
701
  assert account_broker.main(("accounts", "--product", "codex")) == 0
@@ -573,7 +726,7 @@ def test_should_reuse_meter_cache_for_60_seconds() -> None:
573
726
  calls.append(selected.name)
574
727
  return _meters(80, 80)
575
728
 
576
- adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", (), False)
729
+ adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", ())
577
730
  state = account_broker._load_state(account_broker.broker_state_path())
578
731
 
579
732
  account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
@@ -88,7 +88,7 @@ def test_should_cache_account_reading_under_injected_state_path(
88
88
  seen.append(selected.name)
89
89
  return Meters(80.0, None, 80.0, None)
90
90
 
91
- adapter = ProductAdapter(lambda: (account,), reader, "CODEX_HOME", (), False)
91
+ adapter = ProductAdapter(lambda: (account,), reader, "CODEX_HOME", ())
92
92
  state = support._load_state(support.broker_state_path())
93
93
  now = datetime(2026, 10, 3, tzinfo=timezone.utc)
94
94
 
@@ -23,6 +23,7 @@ def _outcome(
23
23
  account_name: str | None = "extra_2",
24
24
  status: str = "served",
25
25
  wait_reset_at: datetime | None = None,
26
+ wait_reason: str | None = None,
26
27
  ) -> JobOutcome:
27
28
  return JobOutcome(
28
29
  returncode,
@@ -33,6 +34,7 @@ def _outcome(
33
34
  status,
34
35
  None,
35
36
  wait_reset_at,
37
+ wait_reason,
36
38
  )
37
39
 
38
40
 
@@ -86,20 +88,28 @@ def test_should_send_prompt_and_flags_to_broker(
86
88
  assert captured["timeout_seconds"] == 3600
87
89
 
88
90
 
89
- def test_should_report_wait_with_reset_and_exit_three(
91
+ def test_should_report_the_broker_wait_reason_and_exit_three(
90
92
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
91
93
  ) -> None:
92
94
  reset_at = datetime(2026, 10, 4, 12, tzinfo=timezone.utc)
95
+ broker_reason = f"no readable account has room; meter unreadable for ev; next check at {reset_at.isoformat()}"
93
96
  exit_code, report, captured = _run_worker(
94
97
  monkeypatch,
95
98
  tmp_path,
96
- _outcome(stdout="", returncode=WAIT_EXIT_CODE, account_name=None, status="wait", wait_reset_at=reset_at),
99
+ _outcome(
100
+ stdout="",
101
+ returncode=WAIT_EXIT_CODE,
102
+ account_name=None,
103
+ status="wait",
104
+ wait_reset_at=reset_at,
105
+ wait_reason=broker_reason,
106
+ ),
97
107
  )
98
108
 
99
109
  assert exit_code == WAIT_EXIT_CODE
100
110
  assert report["account"] == "wait"
101
111
  assert report["wait_reset_at"] == reset_at.isoformat()
102
- assert report["reason"] == f"no account has room; next reset at {reset_at.isoformat()}"
112
+ assert report["reason"] == broker_reason
103
113
  assert report["is_error"] is False
104
114
  assert captured["product"] is Product.CLAUDE
105
115
 
@@ -1118,7 +1118,6 @@ def test_usage_limit_fallover_delivers_full_prompt_to_each_account(
1118
1118
  lambda account: Meters(80, reset_at, 80, reset_at),
1119
1119
  "CLAUDE_CONFIG_DIR",
1120
1120
  ("usage limit",),
1121
- False,
1122
1121
  )
1123
1122
  observed: list[tuple[str, bytes]] = []
1124
1123