claude-dev-env 8.78.3 → 8.80.0

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 (68) hide show
  1. package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
  2. package/bin/install.settings-defaults.test.mjs +27 -2
  3. package/bin/merge_settings_defaults.mjs +39 -21
  4. package/bin/merge_settings_defaults.test.mjs +42 -0
  5. package/docs/account-broker.md +1 -1
  6. package/docs/codex-accounts.md +6 -5
  7. package/hooks/blocking/cloud_graphql_gate.py +213 -0
  8. package/hooks/blocking/headless_claude_broker_gate.py +1 -8
  9. package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
  10. package/hooks/blocking/merge_review_gate.py +65 -3
  11. package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
  12. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  13. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  14. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  15. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  16. package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
  17. package/hooks/blocking/test_merge_review_gate.py +63 -0
  18. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  19. package/hooks/conftest.py +1 -1
  20. package/hooks/features/bash-dispatchers.md +3 -0
  21. package/hooks/features/conduct-gates.md +5 -2
  22. package/hooks/hooks.json +10 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  24. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  25. package/hooks/hooks_constants/cloud_session.py +17 -0
  26. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  27. package/hooks/hooks_constants/gh_command_words.py +52 -0
  28. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  29. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  30. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  31. package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
  32. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  33. package/hooks/hooks_constants/mod_handoff.py +2 -3
  34. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  35. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  36. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  37. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  38. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  39. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  40. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  41. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  42. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  43. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  44. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  45. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  46. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  47. package/hooks/test_conftest.py +1 -1
  48. package/package.json +1 -1
  49. package/scripts/account_broker.py +23 -11
  50. package/scripts/account_broker_support.py +23 -14
  51. package/scripts/codex_account_meters.py +30 -43
  52. package/scripts/conftest.py +25 -0
  53. package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
  54. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  55. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  56. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
  57. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  58. package/scripts/merge_review_receipt.py +56 -12
  59. package/scripts/policy_lint/adapter_configuration.py +5 -0
  60. package/scripts/policy_lint/config/constants.py +1 -0
  61. package/scripts/test_account_broker.py +145 -3
  62. package/scripts/test_account_broker_support.py +88 -6
  63. package/scripts/test_claude_chain_usage.py +3 -11
  64. package/scripts/test_codex_account_meters.py +26 -14
  65. package/scripts/test_merge_review_receipt.py +86 -0
  66. package/scripts/test_shared_tree_paths.py +58 -36
  67. package/scripts/tests/test_adapter_configuration.py +8 -0
  68. package/settings.json +3 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+
5
7
  import pytest
6
8
 
7
9
  from hooks_constants import shell_command_wrappers
@@ -31,6 +33,29 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
31
33
  assert shell_command_wrappers.all_wrapped_command_texts("git push") == ["git push"]
32
34
 
33
35
 
36
+ @pytest.mark.parametrize(
37
+ "command",
38
+ [
39
+ "git push",
40
+ 'bash -c "git add -A && git push"; echo done',
41
+ "bash -c \"pwsh -Command 'git push; git status'\"",
42
+ "sudo bash -c 'gh pr list' | head",
43
+ 'echo "$(git push)"',
44
+ "x=`git push`",
45
+ ],
46
+ )
47
+ def test_should_return_the_segments_of_every_wrapped_command_text(command: str) -> None:
48
+ all_segments_from_texts = [
49
+ each_segment
50
+ for each_text in shell_command_wrappers.all_wrapped_command_texts(command)
51
+ for each_segment, _following_operator in (
52
+ shell_command_wrappers.pipeline_segments_for_command(each_text)
53
+ )
54
+ ]
55
+ all_segments = shell_command_wrappers.all_wrapped_command_segments(command)
56
+ assert sorted(all_segments) == sorted(all_segments_from_texts)
57
+
58
+
34
59
  @pytest.mark.parametrize(
35
60
  ("all_segment_tokens", "expected_program_and_arguments"),
36
61
  [
@@ -50,6 +75,13 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
50
75
  (["time", "sudo", "git", "push"], ("git", ["push"])),
51
76
  (["'/usr/bin/git'", "push"], ("git", ["push"])),
52
77
  (["VAR=x"], ("", [])),
78
+ (["if", "git", "push"], ("git", ["push"])),
79
+ (["then", "git", "push"], ("git", ["push"])),
80
+ (["if", "!", "git", "push"], ("git", ["push"])),
81
+ (["while", "git", "push"], ("git", ["push"])),
82
+ (["command", "git", "push"], ("git", ["push"])),
83
+ (["command", "-p", "git", "push"], ("git", ["push"])),
84
+ (["exec", "-a", "name", "git", "push"], ("git", ["push"])),
53
85
  ],
54
86
  )
55
87
  def test_should_return_the_program_past_its_wrappers(
@@ -59,3 +91,96 @@ def test_should_return_the_program_past_its_wrappers(
59
91
  shell_command_wrappers.segment_program_and_arguments(all_segment_tokens)
60
92
  == expected_program_and_arguments
61
93
  )
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ ("command", "all_expected_texts"),
98
+ [
99
+ ('echo "$(gh pr list)"', ["gh pr list"]),
100
+ ("x=`git push`", ["git push"]),
101
+ ("echo $(echo $(git push))", ["echo $(git push)"]),
102
+ ("echo $(cat <(ls))", ["cat <(ls)"]),
103
+ ("echo \"$(printf ')'; git push)\"", ["printf ')'; git push"]),
104
+ ('echo $(echo "$(git push)")', ['echo "$(git push)"']),
105
+ ("x=`echo \\`git push\\``", ["echo `git push`"]),
106
+ ("echo '$(gh pr list)'", []),
107
+ ("echo \\$(gh pr list)", []),
108
+ ("git push", []),
109
+ ],
110
+ )
111
+ def test_should_return_each_command_substitution_body_outside_single_quotes(
112
+ command: str, all_expected_texts: list[str]
113
+ ) -> None:
114
+ assert shell_command_wrappers.all_command_substitution_texts(command) == all_expected_texts
115
+
116
+
117
+ def test_should_unwrap_a_command_substitution_in_turn() -> None:
118
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
119
+ "echo \"$(bash -c 'git push')\""
120
+ )
121
+ assert "bash -c 'git push'" in all_command_texts
122
+ assert "git push" in all_command_texts
123
+
124
+
125
+ _ENCODED_GIT_PUSH = base64.b64encode("git push".encode("utf-16-le")).decode()
126
+
127
+
128
+ @pytest.mark.parametrize(
129
+ ("all_segment_tokens", "expected_inner_command"),
130
+ [
131
+ (["eval", "git push"], "git push"),
132
+ (["eval", "git", "push"], "git push"),
133
+ (["Invoke-Expression", "'git push'"], "git push"),
134
+ (["iex", '"git push"'], "git push"),
135
+ (["find", ".", "-exec", "git", "push", ";"], "git push"),
136
+ (["find", ".", "-execdir", "git", "push", "{}", "+"], "git push {}"),
137
+ (["pwsh", "-EncodedCommand", _ENCODED_GIT_PUSH], "git push"),
138
+ (["powershell.exe", "-enc", _ENCODED_GIT_PUSH], "git push"),
139
+ (["pwsh", "-e", _ENCODED_GIT_PUSH], "git push"),
140
+ (["pwsh", "-ec", _ENCODED_GIT_PUSH], "git push"),
141
+ (["pwsh", "-e", "not-base64!"], None),
142
+ (["find", ".", "-exec", "true", ";", "-exec", "git", "push", ";"], "true ; git push"),
143
+ (["-exec", "git", "push", "\\"], "git push \\"),
144
+ (["pwsh", "-e", f"'{_ENCODED_GIT_PUSH[:8]} {_ENCODED_GIT_PUSH[8:]}'"], "git push"),
145
+ (["Start-Process", "git", "-ArgumentList", "'push'"], "git push"),
146
+ (
147
+ ["Start-Process", "-FilePath", "git", "-ArgumentList", "push,origin", "-Wait"],
148
+ "git push origin",
149
+ ),
150
+ (["saps", "git", "-WorkingDirectory", "C:/repo", "-ArgumentList", "push"], "git push"),
151
+ (["Start-Process", "-FilePath:git", "-ArgumentList:'push'"], "git push"),
152
+ (["Start-Process", "git", "-WorkingDirectory:C:/repo", "-ArgumentList:push"], "git push"),
153
+ (["find", ".", "-name", "x"], None),
154
+ (["git", "push"], None),
155
+ ],
156
+ )
157
+ def test_should_return_the_command_an_evaluating_program_runs(
158
+ all_segment_tokens: list[str], expected_inner_command: str | None
159
+ ) -> None:
160
+ assert (
161
+ shell_command_wrappers.evaluated_inner_command(all_segment_tokens) == expected_inner_command
162
+ )
163
+
164
+
165
+ def test_should_unwrap_an_evaluated_command_in_turn() -> None:
166
+ assert "git push" in shell_command_wrappers.all_wrapped_command_texts('eval "git push"')
167
+
168
+
169
+ def test_should_read_a_quoted_value_that_spans_lines() -> None:
170
+ command = f'pwsh -EncodedCommand "{_ENCODED_GIT_PUSH[:8]}\n{_ENCODED_GIT_PUSH[8:]}"'
171
+ assert "git push" in shell_command_wrappers.all_wrapped_command_texts(command)
172
+
173
+
174
+ def test_should_stop_unwrapping_past_the_depth_limit_without_error() -> None:
175
+ nesting_depth = 1000
176
+ command = "echo " + "$(" * nesting_depth + "git push" + ")" * nesting_depth
177
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(command)
178
+ assert all_command_texts[0] == command
179
+ assert len(all_command_texts) <= nesting_depth
180
+
181
+
182
+ def test_should_parse_a_repeated_inner_command_once() -> None:
183
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
184
+ 'bash -c "git push"; bash -c "git push"'
185
+ )
186
+ assert all_command_texts.count("git push") == 1
@@ -9,7 +9,7 @@ import sys
9
9
  from hooks_constants.harness_scratchpad_constants import (
10
10
  CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME,
11
11
  )
12
- from hooks_constants.headless_claude_broker_gate_constants import CLOUD_SESSION_ENV_VAR
12
+ from hooks_constants.cloud_session_constants import CLOUD_SESSION_ENV_VAR
13
13
 
14
14
 
15
15
  def test_live_session_id_is_absent_inside_each_test() -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.78.3",
3
+ "version": "8.80.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@ from dev_env_scripts_constants.account_broker_constants import (
47
47
  _wait_decision,
48
48
  ALL_PARENT_CLAUDE_SESSION_VARIABLES,
49
49
  COMMAND_MISSING_EXIT_CODE,
50
+ PLACEHOLDER_NAME_SLOT,
50
51
  REPORT_INDENT_SPACES,
51
52
  TIMEOUT_EXIT_CODE,
52
53
  WAIT_EXIT_CODE,
@@ -102,6 +103,7 @@ def decision_payload(decision: Decision) -> dict[str, object]:
102
103
  return {
103
104
  "action": decision.action,
104
105
  "account": decision.account.name if decision.account else None,
106
+ "profile": decision.account.profile if decision.account else None,
105
107
  "home": str(decision.account.home) if decision.account else None,
106
108
  "reason": decision.reason,
107
109
  "tier": decision.tier,
@@ -121,6 +123,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
121
123
  return [
122
124
  {
123
125
  "name": each_reading.account.name,
126
+ "profile": each_reading.account.profile,
124
127
  "home": str(each_reading.account.home),
125
128
  "is_main": each_reading.account.is_main,
126
129
  "priority": each_reading.account.priority,
@@ -150,11 +153,16 @@ def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, ob
150
153
  return frozenset(spent), spent
151
154
 
152
155
 
153
- def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
156
+ def _record_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
154
157
  until = reset or _account_reset(reading, is_spent=True) or now + timedelta(hours=1)
155
158
  if until <= now:
156
159
  until = now + timedelta(hours=1)
157
160
  all_state["spent"][_state_key(reading.account)] = until.timestamp()
161
+ return until
162
+
163
+
164
+ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime) -> datetime:
165
+ until = _record_spent(all_state, reading, now)
158
166
  _save_state(broker_state_path(), all_state)
159
167
  return until
160
168
 
@@ -162,8 +170,7 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, r
162
170
  def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
163
171
  marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
164
172
  all_roster_names = {each_reading.account.name for each_reading in all_readings}
165
- prefix = f"{product.value}:"
166
- placeholder_suffix = f":{Path()}"
173
+ prefix, _, placeholder_suffix = _state_key(Account(product, PLACEHOLDER_NAME_SLOT, Path(), False)).partition(PLACEHOLDER_NAME_SLOT)
167
174
  all_resets_by_name = {}
168
175
  for each_key, each_raw in marks.items():
169
176
  if not (isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix)):
@@ -376,25 +383,30 @@ def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, pr
376
383
  try:
377
384
  reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
378
385
  except (ValueError, OSError, OverflowError) as error:
379
- raise BrokerConfigurationError(f"invalid reset for account {name}") from error
386
+ raise BrokerConfigurationError(f"invalid reset in spent mark {mark!r}") from error
380
387
  reading = all_accounts_by_name.get(name)
381
388
  if reading is None:
382
- if all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME:
383
- print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
384
389
  return Reading(Account(product, name, Path(), False), None), reset
385
390
  return reading, reset
386
391
 
387
392
 
388
393
  def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime, product: Product) -> None:
389
- all_accounts_by_name = {each_reading.account.name: each_reading for each_reading in all_readings}
390
- for each_mark in all_marks:
391
- reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark, product)
392
- _mark_spent(all_state, reading, now, reset)
394
+ all_accounts_by_profile = {each_reading.account.profile: each_reading for each_reading in all_readings if each_reading.account.profile}
395
+ all_accounts_by_name = all_accounts_by_profile | {each_reading.account.name: each_reading for each_reading in all_readings}
396
+ all_parsed_marks = [_parse_spent_mark(all_accounts_by_name, each_mark, product) for each_mark in all_marks]
397
+ for each_reading, each_reset in all_parsed_marks:
398
+ _record_spent(all_state, each_reading, now, each_reset)
399
+ if all_parsed_marks:
400
+ _save_state(broker_state_path(), all_state)
401
+ for each_reading, _ in all_parsed_marks:
402
+ name = each_reading.account.name
403
+ if name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
404
+ print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
393
405
 
394
406
 
395
407
  def _accounts_cli(product: Product) -> int:
396
408
  roster = all_product_adapters[product].load_accounts()
397
- print(json.dumps({"accounts": [{"name": each_account.name, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
409
+ print(json.dumps({"accounts": [{"name": each_account.name, "profile": each_account.profile, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
398
410
  return 0
399
411
 
400
412
 
@@ -43,9 +43,6 @@ from dev_env_scripts_constants.account_broker_constants import (
43
43
  JobOutcome,
44
44
  LOGIN_REFRESH_TIMEOUT_SECONDS,
45
45
  METER_READ_ATTEMPTS,
46
- METER_READ_DELAY_FACTOR,
47
- METER_READ_FIRST_DELAY_SECONDS,
48
- METER_READ_MAX_DELAY_SECONDS,
49
46
  Meters,
50
47
  Product,
51
48
  ProductAdapter,
@@ -54,6 +51,7 @@ from dev_env_scripts_constants.account_broker_constants import (
54
51
  REPORT_INDENT_SPACES,
55
52
  SubprocessRunner,
56
53
  codex_usage_limit_signatures,
54
+ meter_read_delay_seconds,
57
55
  parse_utc_time,
58
56
  utc_time_text,
59
57
  )
@@ -70,7 +68,6 @@ from dev_env_scripts_constants.codex_account_constants import (
70
68
  CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
71
69
  CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
72
70
  CODEX_HOME_ENVIRONMENT_VARIABLE,
73
- WEEKLY_WINDOW_MINUTES,
74
71
  )
75
72
  from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
76
73
  from shared_tree_paths import resolve_shared_process_tree_scripts_directory
@@ -105,7 +102,7 @@ def _chain_entry(raw_entry: object, main_home: Path, chain_path: Path) -> Accoun
105
102
  if not isinstance(extra_args, list) or any(not isinstance(each_arg, str) for each_arg in extra_args):
106
103
  raise BrokerConfigurationError(f"invalid account list {chain_path}")
107
104
  home = Path(credential).expanduser().parent.resolve() if credential else main_home
108
- return Account(Product.CLAUDE, command, home, command=command)
105
+ return Account(Product.CLAUDE, command, home, command=command, profile=_home_profile(home))
109
106
 
110
107
 
111
108
  def _chain_entries(chain_path: Path, main_home: Path) -> tuple[Account, ...]:
@@ -169,11 +166,20 @@ def _with_priorities(all_accounts: Sequence[Account], all_ordered_names: Sequenc
169
166
  return tuple(dataclasses.replace(each_account, priority=priority_of(each_account)) for each_account in all_accounts)
170
167
 
171
168
 
169
+ def _home_profile(home: Path) -> str | None:
170
+ try:
171
+ profile_home = default_profile_home(home.name).resolve()
172
+ except ValueError:
173
+ return None
174
+ return home.name if profile_home == home else None
175
+
176
+
172
177
  def load_claude_accounts() -> tuple[Account, ...]:
173
178
  """Read the Claude account roster.
174
179
 
175
180
  Returns:
176
- Main and additional accounts in configured order, each with its place in the account order file.
181
+ Main and additional accounts in configured order, each with its place in the account order file
182
+ and, for a home under the profiles root, its profile name.
177
183
 
178
184
  Raises:
179
185
  BrokerConfigurationError: An account list is unreadable.
@@ -185,7 +191,8 @@ def load_claude_accounts() -> tuple[Account, ...]:
185
191
  _append_claude_account(all_accounts, all_seen, each_entry, main_home)
186
192
  for each_home in _extra_homes(main_home):
187
193
  home = each_home.resolve()
188
- _append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
194
+ extra_account = Account(Product.CLAUDE, home.name, home, command=home.name, profile=_home_profile(home))
195
+ _append_claude_account(all_accounts, all_seen, extra_account, main_home)
189
196
  return _with_priorities(all_accounts, _account_order(main_home))
190
197
 
191
198
 
@@ -293,14 +300,13 @@ def read_codex_account_meters(account: Account) -> Meters:
293
300
 
294
301
  Raises:
295
302
  CodexMeterUnreadError: The meter read failed.
296
- OSError: No Codex executable was found.
303
+ CodexExecutableNotFoundError: No Codex executable was found, so
304
+ read_meters_with_backoff stops without a retry.
297
305
  """
298
306
  codex_path = codex_account_meters.resolve_codex_path(None)
299
307
  usage = codex_account_meters.read_codex_meters(codex_path, account.home)
300
- weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
301
- short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
302
- weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
303
- short_window = max(short, key=lambda window: window.used_percent) if short else None
308
+ short_window = usage.short_window
309
+ weekly_window = usage.weekly_window
304
310
  return Meters(
305
311
  usage.short_window_percent_left,
306
312
  short_window.resets_at if short_window else None,
@@ -318,14 +324,17 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
318
324
 
319
325
  Returns:
320
326
  The meters from the first read that succeeds, or None with the last
321
- failure text once every attempt failed.
327
+ failure text once every attempt failed. A missing Codex executable
328
+ fails at once, without a retry.
322
329
  """
323
330
  unread_reason = None
324
331
  for each_attempt in range(METER_READ_ATTEMPTS):
325
332
  if each_attempt:
326
- time.sleep(min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (each_attempt - 1), METER_READ_MAX_DELAY_SECONDS))
333
+ time.sleep(meter_read_delay_seconds(each_attempt))
327
334
  try:
328
335
  return read_meters(account), None
336
+ except codex_account_meters.CodexExecutableNotFoundError as error:
337
+ return None, str(error)
329
338
  except (
330
339
  codex_account_meters.CodexMeterUnreadError,
331
340
  WeeklyUtilizationProbeError,
@@ -3,14 +3,17 @@
3
3
 
4
4
  Codex reports usage through ``codex app-server`` and the JSON-RPC method
5
5
  ``account/rateLimits/read``. The reply names up to two windows, a short one
6
- (5 hours) and a weekly one. The account's room is the smaller of the two::
6
+ (5 hours) and a weekly one. The account broker counts the account's room as
7
+ the smaller of the two::
7
8
 
8
9
  5-hour window 30% used, week 85% used -> 15% left
9
10
  week window only, 40% used -> 60% left
10
11
  no window, error reply, no reply -> CodexMeterUnreadError
11
12
 
12
- Every failure raises ``CodexMeterUnreadError``. The caller decides what an
13
- unread account means; this module never guesses a number.
13
+ Every read failure raises ``CodexMeterUnreadError``. A missing Codex
14
+ executable raises ``CodexExecutableNotFoundError`` from ``resolve_codex_path``.
15
+ The caller decides what an unread account means; this module never guesses a
16
+ number.
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -69,6 +72,10 @@ class CodexMeterUnreadError(Exception):
69
72
  """Raised when one account's rate-limit windows cannot be read."""
70
73
 
71
74
 
75
+ class CodexExecutableNotFoundError(FileNotFoundError):
76
+ """Raised when no Codex executable is on PATH or at its install path."""
77
+
78
+
72
79
  @dataclass(frozen=True)
73
80
  class UsageWindow:
74
81
  """One rate-limit window: its length, the percent used, and when it resets."""
@@ -77,56 +84,36 @@ class UsageWindow:
77
84
  used_percent: float
78
85
  resets_at: datetime | None
79
86
 
87
+ @property
88
+ def is_short(self) -> bool:
89
+ """Whether the window reports a length shorter than a week. A window with no length counts as weekly."""
90
+ return self.duration_minutes is not None and self.duration_minutes < WEEKLY_WINDOW_MINUTES
91
+
80
92
 
81
93
  @dataclass(frozen=True)
82
94
  class CodexAccountMeters:
83
- """One account's windows, with percent_left, short_window_percent_left and resets_before_room."""
95
+ """One account's windows, with its tightest short window and its tightest weekly window."""
84
96
 
85
97
  all_windows: tuple[UsageWindow, ...]
86
98
 
87
99
  @property
88
- def percent_left(self) -> float:
89
- """Room left in the tightest window, from 0 to 100."""
90
- most_used = max(each_window.used_percent for each_window in self.all_windows)
91
- return max(0.0, FULL_PERCENT - most_used)
100
+ def short_window(self) -> UsageWindow | None:
101
+ """The most used window shorter than a week, or None when the account has none."""
102
+ all_short_windows = [each_window for each_window in self.all_windows if each_window.is_short]
103
+ return max(all_short_windows, key=lambda each_window: each_window.used_percent, default=None)
104
+
105
+ @property
106
+ def weekly_window(self) -> UsageWindow | None:
107
+ """The most used window of a week or of no reported length, or None when the account has none."""
108
+ all_weekly_windows = [each_window for each_window in self.all_windows if not each_window.is_short]
109
+ return max(all_weekly_windows, key=lambda each_window: each_window.used_percent, default=None)
92
110
 
93
111
  @property
94
112
  def short_window_percent_left(self) -> float | None:
95
113
  """Room left in the windows shorter than a week, or None when the account has none."""
96
- all_short_used = [
97
- each_window.used_percent
98
- for each_window in self.all_windows
99
- if each_window.duration_minutes is not None
100
- and each_window.duration_minutes < WEEKLY_WINDOW_MINUTES
101
- ]
102
- if not all_short_used:
103
- return None
104
- return max(0.0, FULL_PERCENT - max(all_short_used))
105
-
106
- def resets_before_room(self, minimum_percent_left: float) -> datetime | None:
107
- """When every window that holds the account at or under a bar has reset.
108
-
109
- Args:
110
- minimum_percent_left: The percent left the account must pass.
111
-
112
- Returns:
113
- The latest reset among the blocking windows, or None when one
114
- carries no reset time or none blocks.
115
- """
116
- all_blocking = [
117
- each_window
118
- for each_window in self.all_windows
119
- if FULL_PERCENT - each_window.used_percent <= minimum_percent_left
120
- ]
121
- if not all_blocking or any(
122
- each_window.resets_at is None for each_window in all_blocking
123
- ):
114
+ if self.short_window is None:
124
115
  return None
125
- return max(
126
- each_window.resets_at
127
- for each_window in all_blocking
128
- if each_window.resets_at is not None
129
- )
116
+ return FULL_PERCENT - self.short_window.used_percent
130
117
 
131
118
 
132
119
  def _finite_number(raw_number: object) -> float | None:
@@ -396,7 +383,7 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
396
383
  The named path, else ``codex`` on PATH, else a known install path.
397
384
 
398
385
  Raises:
399
- FileNotFoundError: No Codex executable was found.
386
+ CodexExecutableNotFoundError: No Codex executable was found.
400
387
  """
401
388
  if explicit_path is not None:
402
389
  return explicit_path
@@ -407,4 +394,4 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
407
394
  candidate = Path.home().joinpath(*each_parts)
408
395
  if candidate.is_file():
409
396
  return candidate
410
- raise FileNotFoundError("no codex executable on PATH or at its install path")
397
+ raise CodexExecutableNotFoundError("no codex executable on PATH or at its install path")
@@ -18,7 +18,9 @@ is in place by the time any test module runs its top-level imports.
18
18
  from __future__ import annotations
19
19
 
20
20
  import importlib
21
+ import subprocess
21
22
  import sys
23
+ from collections.abc import Callable
22
24
  from pathlib import Path
23
25
 
24
26
  import pytest
@@ -52,3 +54,26 @@ def isolated_broker_state_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
52
54
  if getattr(each_module, "broker_state_path", None) is _live_broker_state_resolver:
53
55
  monkeypatch.setattr(each_module, "broker_state_path", lambda: isolated_path)
54
56
  return isolated_path
57
+
58
+
59
+ def _link_directory(*, from_link: Path, to_target: Path) -> None:
60
+ try:
61
+ importlib.import_module("claude_account_profile")._link_directory(to_target, from_link)
62
+ except subprocess.CalledProcessError as error:
63
+ link_output = (error.stderr or error.stdout or b"").decode(errors="replace").strip()
64
+ pytest.fail(f"directory link {from_link} -> {to_target} failed: {link_output}")
65
+ except OSError as error:
66
+ pytest.fail(f"directory link {from_link} -> {to_target} failed: {error}")
67
+
68
+
69
+ @pytest.fixture
70
+ def create_directory_link() -> Callable[..., None]:
71
+ """Return a helper that points a new directory link at a target folder.
72
+
73
+ Windows gets a junction, which needs no extra rights. Other systems get a
74
+ symlink. A link that cannot be made fails the test on either system.
75
+
76
+ Returns:
77
+ The helper, called as ``create_directory_link(from_link=..., to_target=...)``.
78
+ """
79
+ return _link_directory
@@ -56,6 +56,7 @@ WAIT_EXIT_CODE = 3
56
56
  COMMAND_MISSING_EXIT_CODE = 127
57
57
  TIMEOUT_EXIT_CODE = 124
58
58
  REPORT_INDENT_SPACES = 2
59
+ PLACEHOLDER_NAME_SLOT = "{name}"
59
60
  BROKER_STATE_DIRECTORY_NAME = "account-broker"
60
61
  BROKER_STATE_FILE_NAME = "state.json"
61
62
  BROKER_STATE_TEMP_SUFFIX = ".json"
@@ -111,6 +112,11 @@ def codex_usage_limit_signatures() -> tuple[str, ...]:
111
112
  return module.ALL_USAGE_LIMIT_MARKERS
112
113
 
113
114
 
115
+ def meter_read_delay_seconds(attempt_index: int) -> float:
116
+ """Seconds to wait before a meter read retry, where the first retry has index 1."""
117
+ return min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (attempt_index - 1), METER_READ_MAX_DELAY_SECONDS)
118
+
119
+
114
120
  def utc_time_text(moment: datetime | None) -> str | None:
115
121
  return moment.astimezone(timezone.utc).isoformat() if moment is not None else None
116
122
 
@@ -150,6 +156,7 @@ class Account:
150
156
  is_main: bool = False
151
157
  command: str | None = None
152
158
  priority: int | None = None
159
+ profile: str | None = field(default=None, compare=False)
153
160
 
154
161
 
155
162
  @dataclass(frozen=True)
@@ -215,16 +222,26 @@ class ProductAdapter:
215
222
  usage_limit_signatures: tuple[str, ...]
216
223
 
217
224
 
225
+ def _is_at_or_under(room: float | None, floor: float) -> bool:
226
+ return room is not None and room <= floor
227
+
228
+
229
+ def _claude_blocking_flags(meters: Meters, is_main: bool) -> tuple[bool, bool]:
230
+ session_key, weekly_key = (
231
+ ("main_session_used_ceiling", "main_weekly_used_ceiling")
232
+ if is_main
233
+ else ("extra_session_used_ceiling", "extra_weekly_used_ceiling")
234
+ )
235
+ return (
236
+ _is_at_or_under(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[session_key]),
237
+ _is_at_or_under(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[weekly_key]),
238
+ )
239
+
240
+
218
241
  def _claude_extra_tier(meters: Meters | None) -> str | None:
219
242
  if meters is None or meters.tightest_percent_left is None:
220
243
  return None
221
- if (
222
- meters.weekly_percent_left is not None
223
- and meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"]
224
- ) or (
225
- meters.session_percent_left is not None
226
- and meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"]
227
- ):
244
+ if any(_claude_blocking_flags(meters, is_main=False)):
228
245
  return None
229
246
  return "normal"
230
247
 
@@ -251,24 +268,19 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
251
268
  return None
252
269
  if is_spent:
253
270
  return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
254
- is_claude_main = reading.account.product is Product.CLAUDE and reading.account.is_main
255
- if is_claude_main:
256
- blocking = (
257
- (meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"], meters.session_resets_at),
258
- (meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"], meters.weekly_resets_at),
259
- )
260
- elif reading.account.product is Product.CLAUDE:
261
- blocking = (
262
- (meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
263
- (meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
264
- )
271
+ if reading.account.product is Product.CLAUDE:
272
+ all_blocking_flags = _claude_blocking_flags(meters, reading.account.is_main)
265
273
  else:
266
- blocking = (
267
- (meters.session_percent_left, ALL_CODEX_FLOORS["luna_short_minimum_left"], meters.session_resets_at),
268
- (meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
274
+ all_blocking_flags = (
275
+ meters.session_percent_left is not None and meters.session_percent_left < ALL_CODEX_FLOORS["luna_short_minimum_left"],
276
+ _is_at_or_under(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"]),
269
277
  )
270
- all_blocking_resets = [reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None]
271
- return max(all_blocking_resets, default=None)
278
+ all_resets = (meters.session_resets_at, meters.weekly_resets_at)
279
+ all_blocking_resets = [reset for is_blocking, reset in zip(all_blocking_flags, all_resets) if is_blocking]
280
+ all_known_resets = [reset for reset in all_blocking_resets if reset is not None]
281
+ if len(all_known_resets) < len(all_blocking_resets):
282
+ return None
283
+ return max(all_known_resets, default=None)
272
284
 
273
285
 
274
286
  def _rank_key(reading: Reading) -> tuple[bool, int, float]:
@@ -282,8 +294,7 @@ def _main_has_room(meters: Meters | None) -> bool:
282
294
  meters is not None
283
295
  and meters.session_percent_left is not None
284
296
  and meters.weekly_percent_left is not None
285
- and meters.session_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
286
- and meters.weekly_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
297
+ and not any(_claude_blocking_flags(meters, is_main=True))
287
298
  )
288
299
 
289
300
 
@@ -181,15 +181,6 @@ REASON_ALL_EXTRAS_WAIT_TEMPLATE: str = (
181
181
  )
182
182
  """Reason when every configured extra profile is blocked."""
183
183
 
184
- REASON_WAIT_TEMPLATE: str = (
185
- "second account is at {weekly_used_percent:.0f}% of its week"
186
- " and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
187
- )
188
- """Reason when neither account has room."""
189
-
190
- UNKNOWN_RESET_TEXT: str = "unknown"
191
- """Reset text when the blocking meter carries no reset time."""
192
-
193
184
  JSON_ACCOUNT_KEY: str = "account"
194
185
  """Picker JSON key naming the chosen account."""
195
186