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.
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- 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
|
package/hooks/test_conftest.py
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
390
|
-
for
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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(
|
|
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
|
|
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``.
|
|
13
|
-
|
|
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
|
|
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
|
|
89
|
-
"""
|
|
90
|
-
|
|
91
|
-
return max(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
397
|
+
raise CodexExecutableNotFoundError("no codex executable on PATH or at its install path")
|
package/scripts/conftest.py
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"]
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|