claude-dev-env 8.75.2 → 8.75.4

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.
@@ -1,16 +1,23 @@
1
1
  """Behavioral tests for the artifact template pointer's path."""
2
2
 
3
- from hooks_constants.artifact_template_pointer_constants import (
4
- ARTIFACT_TEMPLATE_PATH,
5
- HTML_PLAN_TEMPLATE_README_PATH,
6
- )
3
+ from hooks_constants import artifact_template_pointer_constants
7
4
 
8
5
 
9
6
  def test_template_path_names_the_packaged_template_file() -> None:
10
- assert ARTIFACT_TEMPLATE_PATH.is_file()
11
- assert ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
7
+ assert artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH.is_file()
8
+ assert artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
12
9
 
13
10
 
14
11
  def test_html_plan_readme_path_names_the_packaged_readme_and_house_stylesheet() -> None:
15
- assert HTML_PLAN_TEMPLATE_README_PATH.is_file()
16
- assert HTML_PLAN_TEMPLATE_README_PATH.parent.joinpath("house.css").is_file()
12
+ assert artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH.is_file()
13
+ assert artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH.parent.joinpath("house.css").is_file()
14
+
15
+
16
+ def test_every_exported_name_resolves_to_a_module_attribute() -> None:
17
+ all_missing_names = [
18
+ each_name
19
+ for each_name in artifact_template_pointer_constants.__all__
20
+ if not hasattr(artifact_template_pointer_constants, each_name)
21
+ ]
22
+
23
+ assert all_missing_names == []
@@ -0,0 +1,61 @@
1
+ """Behavioral tests for the image_read_size_gate header offsets and refusal text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from image_read_size_gate import image_size
8
+
9
+ from hooks_constants.image_read_size_gate_constants import (
10
+ AGENT_COPY_SCRIPT_PATH,
11
+ ALL_GIF_SIGNATURES,
12
+ JPEG_MARKER_PREFIX,
13
+ JPEG_SIGNATURE,
14
+ MAXIMUM_LONG_EDGE_PIXELS,
15
+ OVERSIZED_IMAGE_REASON_TEMPLATE,
16
+ PNG_SIGNATURE,
17
+ )
18
+
19
+ BASELINE_FRAME_MARKER = 0xC0
20
+ SAMPLE_PRECISION = 8
21
+
22
+
23
+ def test_png_offsets_read_the_width_and_height_from_the_ihdr_chunk() -> None:
24
+ ihdr_length = struct.pack(">I", 13)
25
+ png_header = PNG_SIGNATURE + ihdr_length + b"IHDR" + struct.pack(">II", 1440, 2560)
26
+
27
+ assert image_size(png_header) == (1440, 2560)
28
+
29
+
30
+ def test_gif_offsets_read_the_little_endian_logical_screen_size() -> None:
31
+ for each_signature in ALL_GIF_SIGNATURES:
32
+ gif_header = each_signature + struct.pack("<HH", 640, 480)
33
+
34
+ assert image_size(gif_header) == (640, 480)
35
+
36
+
37
+ def test_jpeg_frame_offsets_read_height_then_width_from_a_baseline_frame() -> None:
38
+ frame_segment = bytes([JPEG_MARKER_PREFIX, BASELINE_FRAME_MARKER]) + struct.pack(
39
+ ">HBHH", 17, SAMPLE_PRECISION, 2560, 1440
40
+ )
41
+
42
+ assert image_size(JPEG_SIGNATURE + frame_segment) == (1440, 2560)
43
+
44
+
45
+ def test_agent_copy_script_path_names_the_packaged_script() -> None:
46
+ assert AGENT_COPY_SCRIPT_PATH.is_file()
47
+ assert AGENT_COPY_SCRIPT_PATH.name == "agent_image_copy.py"
48
+
49
+
50
+ def test_reason_template_names_the_size_the_cap_and_the_copy_command() -> None:
51
+ reason = OVERSIZED_IMAGE_REASON_TEMPLATE.format(
52
+ width=1500,
53
+ height=2700,
54
+ maximum_edge=MAXIMUM_LONG_EDGE_PIXELS,
55
+ script_path=AGENT_COPY_SCRIPT_PATH,
56
+ image_path="/tmp/shot.png",
57
+ )
58
+
59
+ assert "1500x2700" in reason
60
+ assert f"{MAXIMUM_LONG_EDGE_PIXELS} pixels" in reason
61
+ assert f'python3 "{AGENT_COPY_SCRIPT_PATH}" "/tmp/shot.png"' in reason
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from unittest.mock import patch
6
7
 
7
8
  import pytest
8
9
 
9
10
  from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
11
+ from hooks_constants.test_stdout_flush_support import FlushRecordingStream
10
12
 
11
13
 
12
14
  def test_should_write_the_post_tool_use_payload_carrying_the_context_text(
@@ -20,3 +22,14 @@ def test_should_write_the_post_tool_use_payload_carrying_the_context_text(
20
22
  "additionalContext": "=== PR DONE CHECKLIST ===",
21
23
  }
22
24
  }
25
+
26
+
27
+ def test_should_flush_the_whole_payload_so_the_dispatcher_reads_it_before_exit() -> None:
28
+ recording_stream = FlushRecordingStream()
29
+ with patch("sys.stdout", recording_stream):
30
+ write_post_tool_use_context_to_stdout('line one\nline "two"')
31
+
32
+ assert recording_stream.flushed_text
33
+ assert json.loads(recording_stream.flushed_text[-1])["hookSpecificOutput"]["additionalContext"] == (
34
+ 'line one\nline "two"'
35
+ )
@@ -7,6 +7,7 @@ from io import StringIO
7
7
  from unittest.mock import patch
8
8
 
9
9
  from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
10
+ from hooks_constants.test_stdout_flush_support import FlushRecordingStream
10
11
 
11
12
 
12
13
  def _emitted_payload(updated_tool_input: dict[str, object]) -> dict[str, object]:
@@ -25,3 +26,13 @@ def test_emitter_writes_an_allow_decision_carrying_the_updated_input() -> None:
25
26
  "updatedInput": {"command": "git status", "description": "check"},
26
27
  }
27
28
  }
29
+
30
+
31
+ def test_emitter_flushes_the_whole_payload_with_nested_input_intact() -> None:
32
+ nested_input = {"command": "echo hi", "options": {"timeout": 5, "flags": ["-n"]}}
33
+ recording_stream = FlushRecordingStream()
34
+ with patch("sys.stdout", recording_stream):
35
+ write_pre_tool_use_allow_to_stdout(nested_input)
36
+
37
+ assert recording_stream.flushed_text
38
+ assert json.loads(recording_stream.flushed_text[-1])["hookSpecificOutput"]["updatedInput"] == nested_input
@@ -0,0 +1,17 @@
1
+ """Shared stdout stand-in for hook emitter tests that check flush behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from io import StringIO
6
+
7
+
8
+ class FlushRecordingStream(StringIO):
9
+ """StringIO that records the full buffer text at each flush call."""
10
+
11
+ def __init__(self) -> None:
12
+ super().__init__()
13
+ self.flushed_text: list[str] = []
14
+
15
+ def flush(self) -> None:
16
+ self.flushed_text.append(self.getvalue())
17
+ super().flush()
@@ -1,3 +1,6 @@
1
+ import json
2
+ import re
3
+
1
4
  from hooks_constants import visual_reply_rules_constants as constants
2
5
 
3
6
 
@@ -13,3 +16,28 @@ def test_tracker_number_pattern_reads_items_issues_and_cards() -> None:
13
16
  assert [
14
17
  each.group(0) for each in constants.TRACKER_NUMBER_PATTERN.finditer("issue 5735, item #6, card 2")
15
18
  ] == ["issue 5735", "item #6", "card 2"]
19
+
20
+
21
+ def test_rule_key_constants_name_rules_in_the_shipped_rule_list() -> None:
22
+ rules_document = json.loads(constants.RULES_FILE_PATH.read_text(encoding=constants.RULES_FILE_ENCODING))
23
+ all_shipped_keys = {each_rule[constants.RULE_KEY_FIELD] for each_rule in rules_document[constants.RULES_LIST_KEY]}
24
+
25
+ assert {
26
+ constants.NO_PICTURE_RULE_KEY,
27
+ constants.ABBREVIATIONS_RULE_KEY,
28
+ constants.ONE_ITEM_RULE_KEY,
29
+ } <= all_shipped_keys
30
+ assert constants.ALLOWED_CAPITALIZED_WORDS_KEY in rules_document
31
+ assert constants.LOWERCASE_ABBREVIATIONS_KEY in rules_document
32
+
33
+
34
+ def test_lowercase_abbreviation_template_matches_the_word_on_its_own_only() -> None:
35
+ pattern = re.compile(constants.LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape("repo")))
36
+
37
+ assert pattern.findall("the repo, my repos, a repo2 tag") == ["repo"]
38
+
39
+
40
+ def test_widget_anchor_pattern_matches_a_link_and_skips_other_tags() -> None:
41
+ assert constants.WIDGET_ANCHOR_PATTERN.search('<A class="x" HREF="https://example.com">')
42
+ assert constants.WIDGET_ANCHOR_PATTERN.search('<abbr title="a">') is None
43
+ assert constants.WIDGET_ANCHOR_PATTERN.search("<a>no link</a>") is None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.75.2",
3
+ "version": "8.75.4",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@ import subprocess
12
12
  import sys
13
13
  import tempfile
14
14
  import threading
15
+ import time
15
16
  from contextlib import contextmanager
16
17
  from datetime import datetime, timezone
17
18
  from pathlib import Path, PureWindowsPath
@@ -28,15 +29,23 @@ from claude_account_profile import default_profile_home, validate_profile_name
28
29
  from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
29
30
  from dev_env_scripts_constants.account_broker_constants import (
30
31
  ALL_BATCH_FILE_EXTENSIONS,
32
+ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS,
33
+ ALL_LOGIN_REFRESH_ARGUMENTS,
31
34
  BROKER_STATE_DIRECTORY_NAME,
32
35
  BROKER_STATE_FILE_NAME,
33
36
  BROKER_STATE_LOCK_SUFFIX,
34
37
  BROKER_STATE_TEMP_SUFFIX,
35
38
  Account,
36
39
  BrokerConfigurationError,
40
+ CLAUDE_BINARY_NAME,
37
41
  CMD_SHELL_METACHARACTERS,
38
42
  Decision,
39
43
  JobOutcome,
44
+ LOGIN_REFRESH_TIMEOUT_SECONDS,
45
+ METER_READ_ATTEMPTS,
46
+ METER_READ_DELAY_FACTOR,
47
+ METER_READ_FIRST_DELAY_SECONDS,
48
+ METER_READ_MAX_DELAY_SECONDS,
40
49
  Meters,
41
50
  Product,
42
51
  ProductAdapter,
@@ -203,6 +212,54 @@ def load_codex_accounts() -> tuple[Account, ...]:
203
212
  return tuple(Account(Product.CODEX, name, (profiles_root / name).resolve()) for name in names)
204
213
 
205
214
 
215
+ def resolve_claude_path() -> Path | None:
216
+ """Find the Claude Code CLI.
217
+
218
+ Returns:
219
+ ``claude`` on PATH, else a known install path, else None.
220
+ """
221
+ on_path = shutil.which(CLAUDE_BINARY_NAME)
222
+ if on_path is not None:
223
+ return Path(on_path)
224
+ for each_parts in ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS:
225
+ candidate = Path.home().joinpath(*each_parts)
226
+ if candidate.is_file():
227
+ return candidate
228
+ return None
229
+
230
+
231
+ def refresh_claude_login(account_home: Path) -> bool:
232
+ """Run one small CLI call so the CLI refreshes an expired login.
233
+
234
+ ::
235
+
236
+ ev token expired -> claude -p ok, CLAUDE_CONFIG_DIR=<ev home> -> new token saved
237
+
238
+ The CLI saves a new access token before it sends the request.
239
+
240
+ Args:
241
+ account_home: The account's Claude config folder.
242
+
243
+ Returns:
244
+ True when the call ran, False when no CLI was found or the call could not start.
245
+ """
246
+ claude_path = resolve_claude_path()
247
+ if claude_path is None:
248
+ return False
249
+ refresh_environment = {**os.environ, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
250
+ try:
251
+ subprocess.run(
252
+ [str(claude_path), *ALL_LOGIN_REFRESH_ARGUMENTS],
253
+ env=refresh_environment,
254
+ capture_output=True,
255
+ timeout=LOGIN_REFRESH_TIMEOUT_SECONDS,
256
+ check=False,
257
+ )
258
+ except (OSError, subprocess.SubprocessError):
259
+ return False
260
+ return True
261
+
262
+
206
263
  def read_claude_meters(account: Account) -> Meters | None:
207
264
  """Probe one Claude account.
208
265
 
@@ -210,12 +267,13 @@ def read_claude_meters(account: Account) -> Meters | None:
210
267
  account: Account whose meter is read.
211
268
 
212
269
  Returns:
213
- Its meters, or None when the probe fails.
270
+ Its meters.
271
+
272
+ Raises:
273
+ WeeklyUtilizationProbeError: The usage meters could not be measured.
274
+ OSError: The credentials file could not be read.
214
275
  """
215
- try:
216
- usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
217
- except (WeeklyUtilizationProbeError, OSError):
218
- return None
276
+ usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME, refresh_claude_login)
219
277
  return Meters(
220
278
  FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
221
279
  usage.session_resets_at,
@@ -231,17 +289,14 @@ def read_codex_account_meters(account: Account) -> Meters:
231
289
  account: Account whose meter is read.
232
290
 
233
291
  Returns:
234
- Its meters, read again once when the first read fails.
292
+ Its meters.
235
293
 
236
294
  Raises:
237
- CodexMeterUnreadError: Both reads failed; the message is the second failure.
295
+ CodexMeterUnreadError: The meter read failed.
238
296
  OSError: No Codex executable was found.
239
297
  """
240
298
  codex_path = codex_account_meters.resolve_codex_path(None)
241
- try:
242
- usage = codex_account_meters.read_codex_meters(codex_path, account.home)
243
- except codex_account_meters.CodexMeterUnreadError:
244
- usage = codex_account_meters.read_codex_meters(codex_path, account.home)
299
+ usage = codex_account_meters.read_codex_meters(codex_path, account.home)
245
300
  weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
246
301
  short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
247
302
  weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
@@ -254,6 +309,35 @@ def read_codex_account_meters(account: Account) -> Meters:
254
309
  )
255
310
 
256
311
 
312
+ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], account: Account) -> tuple[Meters | None, str | None]:
313
+ """Read one account's meters, retrying a failed read with exponential backoff.
314
+
315
+ Args:
316
+ read_meters: The product's meter reader.
317
+ account: Account whose meter is read.
318
+
319
+ Returns:
320
+ The meters from the first read that succeeds, or None with the last
321
+ failure text once every attempt failed.
322
+ """
323
+ unread_reason = None
324
+ for each_attempt in range(METER_READ_ATTEMPTS):
325
+ if each_attempt:
326
+ time.sleep(min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (each_attempt - 1), METER_READ_MAX_DELAY_SECONDS))
327
+ try:
328
+ return read_meters(account), None
329
+ except (
330
+ codex_account_meters.CodexMeterUnreadError,
331
+ WeeklyUtilizationProbeError,
332
+ OSError,
333
+ ValueError,
334
+ RuntimeError,
335
+ subprocess.SubprocessError,
336
+ ) as error:
337
+ unread_reason = str(error) or type(error).__name__
338
+ return None, unread_reason
339
+
340
+
257
341
  all_product_adapters = {
258
342
  Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES),
259
343
  Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures()),
@@ -409,12 +493,7 @@ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict
409
493
  if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
410
494
  cached_reason = cached.get("unread_reason")
411
495
  return Reading(account, _meters_from_payload(cached.get("meters")), cached_reason if isinstance(cached_reason, str) else None), False
412
- unread_reason = None
413
- try:
414
- meters = adapter.read_meters(account)
415
- except (codex_account_meters.CodexMeterUnreadError, OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
416
- meters = None
417
- unread_reason = str(error) or type(error).__name__
496
+ meters, unread_reason = read_meters_with_backoff(adapter.read_meters, account)
418
497
  all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters), "unread_reason": unread_reason}
419
498
  return Reading(account, meters, unread_reason), True
420
499
 
@@ -10,6 +10,7 @@ from dataclasses import dataclass
10
10
  from datetime import datetime
11
11
  from pathlib import Path
12
12
  from types import ModuleType
13
+ from typing import Callable
13
14
 
14
15
  from dev_env_scripts_constants.claude_chain_usage_constants import (
15
16
  NO_ACCESS_TOKEN_ERROR_TEMPLATE,
@@ -111,13 +112,17 @@ class AccountUsageMeters:
111
112
 
112
113
 
113
114
  def _read_access_token(
114
- usage_window_resolver: ModuleType, credentials_path: Path, now: datetime
115
+ usage_window_resolver: ModuleType,
116
+ credentials_path: Path,
117
+ now: datetime,
118
+ refresh_login: Callable[[Path], object] | None = None,
115
119
  ) -> str | None:
116
120
  """Return the bearer token that reads one account's meters.
117
121
 
118
122
  ::
119
123
 
120
124
  credential file holds a token -> that token
125
+ token expired, refresh_login saves a new one -> the new token
121
126
  no token, the session's own credential path -> the session ingress token
122
127
  no token, another account's credential path -> None
123
128
 
@@ -128,6 +133,8 @@ def _read_access_token(
128
133
  usage_window_resolver: The loaded usage-pause resolver module.
129
134
  credentials_path: The account's CLI credential file.
130
135
  now: The current time, for the token expiry check.
136
+ refresh_login: Called with the account home when the stored token is
137
+ unusable, so a fresh token can be saved before one more read.
131
138
 
132
139
  Returns:
133
140
  A bearer token, or None when the account has none.
@@ -135,6 +142,11 @@ def _read_access_token(
135
142
  file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
136
143
  if file_token is not None:
137
144
  return file_token
145
+ if refresh_login is not None and credentials_path.is_file():
146
+ refresh_login(credentials_path.parent)
147
+ refreshed_token = usage_window_resolver.read_oauth_access_token(credentials_path, datetime.now().astimezone())
148
+ if refreshed_token is not None:
149
+ return refreshed_token
138
150
  session_credentials_path = usage_window_resolver.default_credentials_path()
139
151
  if credentials_path.resolve() != session_credentials_path.resolve():
140
152
  return None
@@ -142,11 +154,13 @@ def _read_access_token(
142
154
 
143
155
 
144
156
  def _fetch_account_usage_payload(
145
- usage_window_resolver: ModuleType, credentials_path: Path
157
+ usage_window_resolver: ModuleType,
158
+ credentials_path: Path,
159
+ refresh_login: Callable[[Path], object] | None,
146
160
  ) -> dict[str, object]:
147
161
  now = datetime.now().astimezone()
148
162
  try:
149
- access_token = _read_access_token(usage_window_resolver, credentials_path, now)
163
+ access_token = _read_access_token(usage_window_resolver, credentials_path, now, refresh_login)
150
164
  if access_token is None:
151
165
  raise WeeklyUtilizationProbeError(
152
166
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
@@ -166,10 +180,21 @@ def _fetch_account_usage_payload(
166
180
  ) from probe_error
167
181
 
168
182
 
169
- def probe_account_meters(credentials_path: Path) -> AccountUsageMeters:
170
- """Read short and weekly usage meters for one account."""
183
+ def probe_account_meters(
184
+ credentials_path: Path, refresh_login: Callable[[Path], object] | None = None
185
+ ) -> AccountUsageMeters:
186
+ """Read short and weekly usage meters for one account.
187
+
188
+ Args:
189
+ credentials_path: The account's CLI credential file.
190
+ refresh_login: Called with the account home when the stored token is
191
+ unusable, before one more token read.
192
+
193
+ Returns:
194
+ The account's meters.
195
+ """
171
196
  usage_window_resolver = _load_resolve_usage_window_module()
172
- usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path)
197
+ usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path, refresh_login)
173
198
  usage_windows = usage_window_resolver.extract_usage_windows(usage_payload)
174
199
  return AccountUsageMeters(
175
200
  session_utilization=usage_windows.session_utilization,
@@ -48,6 +48,10 @@ ALL_CODEX_FLOORS = {
48
48
  "luna_short_minimum_left": LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
49
49
  }
50
50
 
51
+ METER_READ_ATTEMPTS = 3
52
+ METER_READ_FIRST_DELAY_SECONDS = 1.0
53
+ METER_READ_DELAY_FACTOR = 2.0
54
+ METER_READ_MAX_DELAY_SECONDS = 8.0
51
55
  WAIT_EXIT_CODE = 3
52
56
  COMMAND_MISSING_EXIT_CODE = 127
53
57
  TIMEOUT_EXIT_CODE = 124
@@ -58,6 +62,13 @@ BROKER_STATE_TEMP_SUFFIX = ".json"
58
62
  BROKER_STATE_LOCK_SUFFIX = ".lock"
59
63
  ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
60
64
  CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
65
+ CLAUDE_BINARY_NAME = "claude"
66
+ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
67
+ (".local", "bin", "claude.exe"),
68
+ (".local", "bin", "claude"),
69
+ )
70
+ ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = ("-p", "ok", "--model", "haiku")
71
+ LOGIN_REFRESH_TIMEOUT_SECONDS = 120.0
61
72
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
62
73
  "CLAUDE_CODE_SESSION_ID",
63
74
  "CLAUDE_CODE_REMOTE",
@@ -97,12 +97,22 @@ def test_parse_utc_time_rejects_values_without_a_timezone(timestamp: object) ->
97
97
  assert broker_constants.parse_utc_time(timestamp) is None
98
98
 
99
99
 
100
+ def test_tightest_percent_left_should_take_the_lower_known_window() -> None:
101
+ reset = datetime(2026, 10, 3, tzinfo=timezone.utc)
102
+
103
+ assert broker_constants.Meters(30.0, reset, 70.0, reset).tightest_percent_left == 30.0
104
+ assert broker_constants.Meters(80.0, reset, 20.0, reset).tightest_percent_left == 20.0
105
+ assert broker_constants.Meters(None, None, 45.0, reset).tightest_percent_left == 45.0
106
+ assert broker_constants.Meters(None, None, None, None).tightest_percent_left is None
107
+
108
+
100
109
  def test_parse_utc_time_converts_an_offset_timestamp() -> None:
101
110
  parsed = broker_constants.parse_utc_time("2026-10-03T07:30:00-04:00")
102
111
 
103
112
  assert parsed is not None
104
113
  assert parsed.isoformat() == "2026-10-03T11:30:00+00:00"
105
114
 
115
+
106
116
  def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
107
117
  if sys.platform.startswith("win32"):
108
118
  importlib.import_module("_winapi").CreateJunction(
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  import pytest
11
11
 
12
12
  import account_broker
13
+ import account_broker_support
13
14
  import claude_account_worker
14
15
  from account_broker import (
15
16
  Account,
@@ -75,7 +76,8 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
75
76
  )
76
77
 
77
78
 
78
- def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room() -> None:
79
+ def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room(monkeypatch: pytest.MonkeyPatch) -> None:
80
+ monkeypatch.setattr(account_broker_support.time, "sleep", lambda _: None)
79
81
  codex_3 = _account("codex-3")
80
82
  codex_4 = _account("codex-4")
81
83
 
@@ -1232,3 +1234,28 @@ def test_should_hand_the_live_log_to_the_subprocess_runner(tmp_path: Path, monke
1232
1234
 
1233
1235
  assert outcome.returncode == 0
1234
1236
  assert captured_options["live_log"] == live_log
1237
+
1238
+
1239
+ def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1240
+ account = _account("codex-2")
1241
+ decision = account_broker.Decision("run", account, None, "most room", "normal")
1242
+
1243
+ assert account_broker.decision_payload(decision) == {
1244
+ "action": "run",
1245
+ "account": "codex-2",
1246
+ "home": str(Path("/profiles") / "codex-2"),
1247
+ "reason": "most room",
1248
+ "tier": "normal",
1249
+ "resets_at": None,
1250
+ }
1251
+
1252
+
1253
+ def test_decision_payload_should_leave_the_account_empty_and_give_the_reset_for_a_wait() -> None:
1254
+ reset = datetime(2026, 10, 3, 4, 0, tzinfo=timezone(timedelta(hours=-4)))
1255
+ decision = account_broker.Decision("wait", None, reset, "no account has room", "wait")
1256
+
1257
+ payload = account_broker.decision_payload(decision)
1258
+
1259
+ assert payload["account"] is None
1260
+ assert payload["home"] is None
1261
+ assert payload["resets_at"] == "2026-10-03T08:00:00+00:00"
@@ -114,10 +114,13 @@ def test_should_read_claude_meters_through_injected_probe(
114
114
  weekly_utilization=30.0,
115
115
  weekly_resets_at=None,
116
116
  )
117
- monkeypatch.setattr(support, "probe_account_meters", lambda _: usage)
117
+ all_refresh_helpers: list[object] = []
118
+ monkeypatch.setattr(support, "probe_account_meters", lambda _, refresh_login: all_refresh_helpers.append(refresh_login) or usage)
118
119
 
119
120
  meters = support.read_claude_meters(Account(Product.CLAUDE, "one", tmp_path))
120
121
 
122
+ assert all_refresh_helpers == [support.refresh_claude_login]
123
+
121
124
  assert meters.session_percent_left == 80.0
122
125
  assert meters.weekly_percent_left == 70.0
123
126
 
@@ -154,40 +157,113 @@ def _codex_meter_reads(
154
157
  return all_homes_read
155
158
 
156
159
 
157
- def test_should_read_codex_meters_again_after_one_failed_read(
160
+ def _claude_meter_reads(monkeypatch: pytest.MonkeyPatch, all_outcomes: list[object]) -> list[Path]:
161
+ all_paths_read: list[Path] = []
162
+
163
+ def probe_account_meters(credentials_path: Path, refresh_login: object) -> object:
164
+ all_paths_read.append(credentials_path)
165
+ outcome = all_outcomes.pop(0)
166
+ if isinstance(outcome, Exception):
167
+ raise outcome
168
+ return outcome
169
+
170
+ monkeypatch.setattr(support, "probe_account_meters", probe_account_meters)
171
+ return all_paths_read
172
+
173
+
174
+ def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
175
+ all_delays: list[float] = []
176
+ monkeypatch.setattr(support.time, "sleep", all_delays.append)
177
+ return all_delays
178
+
179
+
180
+ def _first_reading(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, account: Account, read_meters: object) -> object:
181
+ monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
182
+ adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
183
+ state = support._load_state(support.broker_state_path())
184
+ now = datetime(2026, 10, 3, tzinfo=timezone.utc)
185
+ return support.read_accounts(account.product, adapter, all_state=state, now=now)[0], adapter, state, now
186
+
187
+
188
+ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
189
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
190
+ ) -> None:
191
+ usage = SimpleNamespace(session_utilization=20.0, session_resets_at=None, weekly_utilization=30.0, weekly_resets_at=None)
192
+ unread = support.WeeklyUtilizationProbeError("usage endpoint timed out")
193
+ all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
194
+ all_delays = _recorded_sleeps(monkeypatch)
195
+
196
+ reading = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
197
+
198
+ assert reading.meters is not None
199
+ assert reading.meters.weekly_percent_left == 70.0
200
+ assert reading.unread_reason is None
201
+ assert len(all_paths_read) == 3
202
+ assert all_delays == [1.0, 2.0]
203
+
204
+
205
+ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
158
206
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
159
207
  ) -> None:
160
208
  weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
161
209
  usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
162
210
  unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
163
- all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, usage])
211
+ all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
212
+ all_delays = _recorded_sleeps(monkeypatch)
164
213
 
165
- meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
214
+ reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
166
215
 
167
- assert meters is not None
168
- assert meters.weekly_percent_left == 70.0
169
- assert all_homes_read == [tmp_path, tmp_path]
216
+ assert reading.meters is not None
217
+ assert reading.meters.weekly_percent_left == 70.0
218
+ assert all_homes_read == [tmp_path, tmp_path, tmp_path]
219
+ assert all_delays == [1.0, 2.0]
170
220
 
171
221
 
172
- def test_should_keep_the_unread_reason_after_two_failed_codex_reads(
222
+ def test_should_keep_the_unread_reason_after_every_claude_read_fails(
223
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
224
+ ) -> None:
225
+ all_failures = [support.WeeklyUtilizationProbeError(f"usage endpoint failed {each_number}") for each_number in range(3)]
226
+ all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
227
+ all_delays = _recorded_sleeps(monkeypatch)
228
+
229
+ reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
230
+ cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
231
+
232
+ assert reading.meters is None
233
+ assert reading.unread_reason == "usage endpoint failed 2"
234
+ assert cached_reading.unread_reason == "usage endpoint failed 2"
235
+ assert len(all_paths_read) == 3
236
+ assert all_delays == [1.0, 2.0]
237
+
238
+
239
+ def test_should_keep_the_unread_reason_after_every_codex_read_fails(
173
240
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
174
241
  ) -> None:
175
- monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
176
242
  first_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server failed: timed out")
177
- second_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
178
- all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, second_unread])
179
- account = Account(Product.CODEX, "codex-3", tmp_path)
180
- adapter = ProductAdapter(lambda: (account,), support.read_codex_account_meters, "CODEX_HOME", ())
181
- state = support._load_state(support.broker_state_path())
182
- now = datetime(2026, 10, 3, tzinfo=timezone.utc)
243
+ last_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
244
+ all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
245
+ _recorded_sleeps(monkeypatch)
183
246
 
184
- first_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
247
+ reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
185
248
  cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
186
249
 
187
- assert first_reading.meters is None
188
- assert first_reading.unread_reason == "codex app-server sent no rate-limit reply"
250
+ assert reading.meters is None
251
+ assert reading.unread_reason == "codex app-server sent no rate-limit reply"
189
252
  assert cached_reading.unread_reason == "codex app-server sent no rate-limit reply"
190
- assert all_homes_read == [tmp_path, tmp_path]
253
+ assert all_homes_read == [tmp_path, tmp_path, tmp_path]
254
+
255
+
256
+ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
257
+ monkeypatch.setattr(support, "METER_READ_ATTEMPTS", 6)
258
+ all_delays = _recorded_sleeps(monkeypatch)
259
+ all_failures = [support.WeeklyUtilizationProbeError("down") for _ in range(6)]
260
+ _claude_meter_reads(monkeypatch, all_failures)
261
+
262
+ meters, unread_reason = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
263
+
264
+ assert meters is None
265
+ assert unread_reason == "down"
266
+ assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
191
267
 
192
268
 
193
269
  def test_should_cache_account_reading_under_injected_state_path(
@@ -443,3 +519,35 @@ def test_should_match_order_names_to_an_account_named_by_its_windows_launcher_pa
443
519
  ranked = support._with_priorities((launcher_account,), ("claude-org-jon", "claude-editor"))
444
520
 
445
521
  assert ranked[0].priority == 1
522
+
523
+
524
+ def test_should_run_the_cli_against_the_account_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
525
+ all_calls: list[tuple[list[str], str]] = []
526
+
527
+ def fake_run(command: list[str], **keyword_arguments: object) -> subprocess.CompletedProcess[bytes]:
528
+ environment = keyword_arguments["env"]
529
+ assert isinstance(environment, dict)
530
+ all_calls.append((command, environment["CLAUDE_CONFIG_DIR"]))
531
+ return subprocess.CompletedProcess(command, 0)
532
+
533
+ monkeypatch.setattr(support, "resolve_claude_path", lambda: Path("claude"))
534
+ monkeypatch.setattr(support.subprocess, "run", fake_run)
535
+
536
+ assert support.refresh_claude_login(tmp_path) is True
537
+ assert all_calls == [(["claude", "-p", "ok", "--model", "haiku"], str(tmp_path))]
538
+
539
+
540
+ def test_should_skip_the_refresh_without_a_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
541
+ monkeypatch.setattr(support, "resolve_claude_path", lambda: None)
542
+
543
+ assert support.refresh_claude_login(tmp_path) is False
544
+
545
+
546
+ def test_should_find_the_cli_in_the_local_bin_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
547
+ local_claude = tmp_path / ".local" / "bin" / "claude"
548
+ local_claude.parent.mkdir(parents=True)
549
+ local_claude.write_text("", encoding="utf-8")
550
+ monkeypatch.setattr(support.shutil, "which", lambda name: None)
551
+ monkeypatch.setattr(support.Path, "home", classmethod(lambda cls: tmp_path))
552
+
553
+ assert support.resolve_claude_path() == local_claude
@@ -389,3 +389,17 @@ def test_should_create_named_profile_and_launcher_without_changes_on_repeat(
389
389
  def test_should_reject_unsafe_profile_names(profile_name: str) -> None:
390
390
  with pytest.raises(ValueError):
391
391
  profile.default_profile_home(profile_name)
392
+
393
+
394
+ def test_sync_report_payload_should_list_each_entry_under_its_own_key() -> None:
395
+ report = profile.ProfileSyncReport(
396
+ all_linked=("CLAUDE.md", "skills"),
397
+ all_moved_aside=("settings.json",),
398
+ all_unlinked=("rules",),
399
+ )
400
+
401
+ assert profile.sync_report_payload(report) == {
402
+ "linked": ["CLAUDE.md", "skills"],
403
+ "moved_aside": ["settings.json"],
404
+ "unlinked": ["rules"],
405
+ }
@@ -65,3 +65,22 @@ def test_should_pass_the_live_log_to_the_runner_when_one_is_named(tmp_path: Path
65
65
  )
66
66
 
67
67
  assert captured_options["live_log"] == live_log
68
+
69
+
70
+ def test_should_measure_the_time_the_runner_spends_on_the_job(tmp_path: Path) -> None:
71
+ current_seconds = [50.0]
72
+
73
+ def slow_runner(product: Product, argv: list[str], **options: object) -> JobOutcome:
74
+ current_seconds[0] += 42.0
75
+ return JobOutcome(0, "{}", "", "main", (), "served", None, None)
76
+
77
+ _, elapsed_seconds = invoke_worker(
78
+ all_arguments=["claude", "-p"],
79
+ cwd=tmp_path,
80
+ prompt_text="brief",
81
+ timeout_minutes=1,
82
+ runner=slow_runner,
83
+ monotonic_clock=lambda: current_seconds[0],
84
+ )
85
+
86
+ assert elapsed_seconds == 42.0
@@ -191,3 +191,55 @@ def test_usage_module_imports_without_picker() -> None:
191
191
  )
192
192
 
193
193
  assert completed.returncode == 0, completed.stderr
194
+
195
+
196
+ def _expiring_resolver(all_tokens: list[str | None], all_fetched_tokens: list[str]) -> object:
197
+ class Windows:
198
+ session_utilization = 10.0
199
+ session_resets_at = None
200
+ weekly_utilization = 40.0
201
+ weekly_resets_at = None
202
+
203
+ class Resolver:
204
+ def read_oauth_access_token(self, credentials_path: Path, now: datetime) -> str | None:
205
+ return all_tokens.pop(0)
206
+
207
+ def default_credentials_path(self) -> Path:
208
+ return Path("session") / ".credentials.json"
209
+
210
+ def read_session_ingress_token(self) -> None:
211
+ return None
212
+
213
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
214
+ all_fetched_tokens.append(access_token)
215
+ return {}
216
+
217
+ def extract_usage_windows(self, payload: dict[str, object]) -> Windows:
218
+ return Windows()
219
+
220
+ return Resolver()
221
+
222
+
223
+ def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
224
+ account_home = tmp_path / "ev"
225
+ account_home.mkdir()
226
+ credentials_path = account_home / ".credentials.json"
227
+ credentials_path.write_text("{}", encoding="utf-8")
228
+ all_refreshed_homes: list[Path] = []
229
+ all_fetched_tokens: list[str] = []
230
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, "fresh-token"], all_fetched_tokens))
231
+
232
+ meters = usage.probe_account_meters(credentials_path, all_refreshed_homes.append)
233
+
234
+ assert all_refreshed_homes == [account_home]
235
+ assert all_fetched_tokens == ["fresh-token"]
236
+ assert meters.weekly_utilization == 40.0
237
+
238
+
239
+ def test_should_report_no_token_when_the_refresh_saves_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
240
+ credentials_path = tmp_path / ".credentials.json"
241
+ credentials_path.write_text("{}", encoding="utf-8")
242
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, None], []))
243
+
244
+ with pytest.raises(usage.WeeklyUtilizationProbeError, match="bearer token"):
245
+ usage.probe_account_meters(credentials_path, lambda home: True)