claude-dev-env 8.68.2 → 8.68.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.
- package/docs/codex-accounts.md +2 -1
- package/docs/second-claude-account.md +2 -1
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +4 -4
- package/package.json +1 -1
- package/scripts/account_broker.py +1 -0
- package/scripts/account_broker_support.py +16 -9
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +8 -1
- package/scripts/test_account_broker.py +25 -0
- package/scripts/test_account_broker_support.py +53 -0
package/docs/codex-accounts.md
CHANGED
|
@@ -94,7 +94,8 @@ Room is the smaller of an account's two windows: the 5-hour window and the week.
|
|
|
94
94
|
| No account over 10%, one or more over 1% | `luna` on the account with the most room |
|
|
95
95
|
| An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
|
|
96
96
|
| No account over 1% | `wait`, naming the next reset when known |
|
|
97
|
-
| A meter
|
|
97
|
+
| A meter read fails | The broker reads it once more |
|
|
98
|
+
| A meter is unread after both reads | The account has `meters: null` and the failure text in `unread_reason`; a `wait` reason names the account and that text |
|
|
98
99
|
|
|
99
100
|
```
|
|
100
101
|
python packages/claude-dev-env/scripts/account_broker.py choose --product codex
|
|
@@ -55,6 +55,7 @@ python packages/claude-dev-env/scripts/account_broker.py choose --product claude
|
|
|
55
55
|
|
|
56
56
|
The JSON output has a `decision` object and an `accounts` list. Each account's
|
|
57
57
|
`meters` holds its remaining percent and reset time for the 5-hour window and
|
|
58
|
-
the week. An unreadable meter appears as `null
|
|
58
|
+
the week. An unreadable meter appears as `null`, and the account's
|
|
59
|
+
`unread_reason` holds the failure text when the reader raised one. A wait decision has
|
|
59
60
|
`action: "wait"` and a `resets_at` timestamp. The broker exits with code 3 on
|
|
60
61
|
wait. The worker writes the same reset time to its report.
|
|
@@ -13,6 +13,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
13
13
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
14
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
15
15
|
- `blocking/artifact_look_gate.py` denies an Artifact publish of an HTML page until an image was opened with Read after the page file was last written, so the agent looks at its page before it ships.
|
|
16
|
+
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
16
17
|
|
|
17
18
|
## When it fires
|
|
18
19
|
|
|
@@ -25,6 +26,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
25
26
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
26
27
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
|
|
27
28
|
- `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
|
|
29
|
+
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
28
30
|
|
|
29
31
|
## Proving it
|
|
30
32
|
|
|
@@ -40,6 +42,7 @@ Preconditions:
|
|
|
40
42
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
41
43
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
42
44
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
45
|
+
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
43
46
|
|
|
44
47
|
## Gotchas
|
|
45
48
|
|
package/hooks/hooks.json
CHANGED
|
@@ -152,9 +152,8 @@
|
|
|
152
152
|
"hooks": [
|
|
153
153
|
{
|
|
154
154
|
"type": "command",
|
|
155
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/
|
|
156
|
-
"timeout":
|
|
157
|
-
"async": true
|
|
155
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_dark_mode_gate.py",
|
|
156
|
+
"timeout": 60
|
|
158
157
|
}
|
|
159
158
|
]
|
|
160
159
|
},
|
|
@@ -278,7 +277,8 @@
|
|
|
278
277
|
{
|
|
279
278
|
"type": "command",
|
|
280
279
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
281
|
-
"timeout": 1800
|
|
280
|
+
"timeout": 1800,
|
|
281
|
+
"async": true
|
|
282
282
|
}
|
|
283
283
|
]
|
|
284
284
|
},
|
package/package.json
CHANGED
|
@@ -125,6 +125,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
|
|
|
125
125
|
"is_main": each_reading.account.is_main,
|
|
126
126
|
"priority": each_reading.account.priority,
|
|
127
127
|
"meters": _meter_payload(each_reading.meters),
|
|
128
|
+
"unread_reason": each_reading.unread_reason,
|
|
128
129
|
}
|
|
129
130
|
for each_reading in all_readings
|
|
130
131
|
]
|
|
@@ -222,20 +222,24 @@ def read_claude_meters(account: Account) -> Meters | None:
|
|
|
222
222
|
)
|
|
223
223
|
|
|
224
224
|
|
|
225
|
-
def read_codex_account_meters(account: Account) -> Meters
|
|
225
|
+
def read_codex_account_meters(account: Account) -> Meters:
|
|
226
226
|
"""Probe one Codex account.
|
|
227
227
|
|
|
228
228
|
Args:
|
|
229
229
|
account: Account whose meter is read.
|
|
230
230
|
|
|
231
231
|
Returns:
|
|
232
|
-
Its meters,
|
|
232
|
+
Its meters, read again once when the first read fails.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
CodexMeterUnreadError: Both reads failed; the message is the second failure.
|
|
236
|
+
OSError: No Codex executable was found.
|
|
233
237
|
"""
|
|
238
|
+
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
234
239
|
try:
|
|
235
|
-
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
236
240
|
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
237
|
-
except
|
|
238
|
-
|
|
241
|
+
except codex_account_meters.CodexMeterUnreadError:
|
|
242
|
+
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
239
243
|
weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
|
|
240
244
|
short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
|
|
241
245
|
weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
|
|
@@ -401,13 +405,16 @@ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict
|
|
|
401
405
|
key = _state_key(account)
|
|
402
406
|
cached = all_cache.get(key)
|
|
403
407
|
if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
|
|
404
|
-
|
|
408
|
+
cached_reason = cached.get("unread_reason")
|
|
409
|
+
return Reading(account, _meters_from_payload(cached.get("meters")), cached_reason if isinstance(cached_reason, str) else None), False
|
|
410
|
+
unread_reason = None
|
|
405
411
|
try:
|
|
406
412
|
meters = adapter.read_meters(account)
|
|
407
|
-
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
|
413
|
+
except (codex_account_meters.CodexMeterUnreadError, OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
408
414
|
meters = None
|
|
409
|
-
|
|
410
|
-
|
|
415
|
+
unread_reason = str(error) or type(error).__name__
|
|
416
|
+
all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters), "unread_reason": unread_reason}
|
|
417
|
+
return Reading(account, meters, unread_reason), True
|
|
411
418
|
|
|
412
419
|
|
|
413
420
|
def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
|
|
@@ -158,6 +158,7 @@ class Meters:
|
|
|
158
158
|
class Reading:
|
|
159
159
|
account: Account
|
|
160
160
|
meters: Meters | None
|
|
161
|
+
unread_reason: str | None = None
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
@dataclass(frozen=True)
|
|
@@ -318,6 +319,12 @@ class _RetryTime:
|
|
|
318
319
|
is_known_reset: bool
|
|
319
320
|
|
|
320
321
|
|
|
322
|
+
def _unreadable_text(reading: Reading) -> str:
|
|
323
|
+
if reading.unread_reason is None:
|
|
324
|
+
return reading.account.name
|
|
325
|
+
return f"{reading.account.name} ({reading.unread_reason})"
|
|
326
|
+
|
|
327
|
+
|
|
321
328
|
def _wait_reason(all_unreadable_names: Sequence[str], is_every_account_unreadable: bool, soonest: _RetryTime) -> str:
|
|
322
329
|
next_event = "reset" if soonest.is_known_reset else "check"
|
|
323
330
|
next_text = f"next {next_event} at {utc_time_text(soonest.at)}"
|
|
@@ -341,7 +348,7 @@ def _wait_decision(
|
|
|
341
348
|
all_retry_times.append(_RetryTime(reset, is_known_reset=True) if reset is not None and reset > now else next_check)
|
|
342
349
|
soonest = min(all_retry_times, key=lambda each_retry_time: each_retry_time.at, default=next_check)
|
|
343
350
|
all_unreadable_names = [
|
|
344
|
-
each_reading
|
|
351
|
+
_unreadable_text(each_reading)
|
|
345
352
|
for each_reading in all_readings
|
|
346
353
|
if each_reading.meters is None and each_reading.account not in all_spent_accounts
|
|
347
354
|
]
|
|
@@ -75,6 +75,31 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
|
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room() -> None:
|
|
79
|
+
codex_3 = _account("codex-3")
|
|
80
|
+
codex_4 = _account("codex-4")
|
|
81
|
+
|
|
82
|
+
def read_meter(account: Account) -> Meters:
|
|
83
|
+
if account is codex_3:
|
|
84
|
+
raise OSError("codex app-server failed: timed out")
|
|
85
|
+
return _meters(0, 40, short_reset=NOW + timedelta(hours=2))
|
|
86
|
+
|
|
87
|
+
adapter = ProductAdapter(lambda: (codex_3, codex_4), read_meter, "CODEX_HOME", ())
|
|
88
|
+
state = account_broker._load_state(account_broker.broker_state_path())
|
|
89
|
+
readings = account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
|
|
90
|
+
|
|
91
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
92
|
+
payload = account_broker.readings_payload(readings)
|
|
93
|
+
|
|
94
|
+
assert decision.action == "wait"
|
|
95
|
+
assert decision.reason == (
|
|
96
|
+
"no readable account has room; meter unreadable for codex-3 (codex app-server failed: timed out); "
|
|
97
|
+
"next check at 2026-10-03T01:00:00+00:00"
|
|
98
|
+
)
|
|
99
|
+
assert payload[0]["unread_reason"] == "codex app-server failed: timed out"
|
|
100
|
+
assert payload[1]["unread_reason"] is None
|
|
101
|
+
|
|
102
|
+
|
|
78
103
|
def test_should_rank_by_room_in_the_tighter_window() -> None:
|
|
79
104
|
readings = (
|
|
80
105
|
Reading(_account("first"), _meters(12, 80)),
|
|
@@ -137,6 +137,59 @@ def test_should_read_codex_meters_through_injected_reader(
|
|
|
137
137
|
assert meters.weekly_percent_left == 70.0
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _codex_meter_reads(
|
|
141
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, all_outcomes: list[object]
|
|
142
|
+
) -> list[Path]:
|
|
143
|
+
all_homes_read: list[Path] = []
|
|
144
|
+
|
|
145
|
+
def read_codex_meters(_codex_path: Path, codex_home: Path) -> object:
|
|
146
|
+
all_homes_read.append(codex_home)
|
|
147
|
+
outcome = all_outcomes.pop(0)
|
|
148
|
+
if isinstance(outcome, Exception):
|
|
149
|
+
raise outcome
|
|
150
|
+
return outcome
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
|
|
153
|
+
monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", read_codex_meters)
|
|
154
|
+
return all_homes_read
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_read_codex_meters_again_after_one_failed_read(
|
|
158
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
|
|
161
|
+
usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
|
|
162
|
+
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])
|
|
164
|
+
|
|
165
|
+
meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
|
|
166
|
+
|
|
167
|
+
assert meters is not None
|
|
168
|
+
assert meters.weekly_percent_left == 70.0
|
|
169
|
+
assert all_homes_read == [tmp_path, tmp_path]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_should_keep_the_unread_reason_after_two_failed_codex_reads(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
174
|
+
) -> None:
|
|
175
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
176
|
+
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)
|
|
183
|
+
|
|
184
|
+
first_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
185
|
+
cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
186
|
+
|
|
187
|
+
assert first_reading.meters is None
|
|
188
|
+
assert first_reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
189
|
+
assert cached_reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
190
|
+
assert all_homes_read == [tmp_path, tmp_path]
|
|
191
|
+
|
|
192
|
+
|
|
140
193
|
def test_should_cache_account_reading_under_injected_state_path(
|
|
141
194
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
142
195
|
) -> None:
|