claude-dev-env 8.49.4 → 8.50.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/account-broker.md +5 -1
- package/hooks/blocking/test_code_rules_js_conventions.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +1 -0
- package/scripts/account_broker_support.py +29 -3
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +10 -5
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +3 -0
- package/scripts/test_account_broker.py +53 -0
- package/scripts/test_account_broker_support.py +58 -0
package/docs/account-broker.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
`account_broker.py` reads the Claude and Codex account rosters, checks each account's session and weekly usage, and chooses an account with room. It does not select a model or sleep. A Codex decision names the `normal` or `luna` tier for the caller.
|
|
4
4
|
|
|
5
|
-
Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chain` list; each entry's `credentials_path` field identifies the credentials file whose parent is that account's home folder. The optional `extra-profiles.json` file in the main home lists additional profile names.
|
|
5
|
+
Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chain` list; each entry's `credentials_path` field identifies the credentials file whose parent is that account's home folder. The optional `extra-profiles.json` file in the main home lists additional profile names.
|
|
6
|
+
|
|
7
|
+
The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
|
|
8
|
+
|
|
9
|
+
Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
|
|
6
10
|
|
|
7
11
|
## Commands
|
|
8
12
|
|
|
@@ -12,7 +12,7 @@ from code_rules_js_conventions import ( # noqa: E402
|
|
|
12
12
|
check_js_boolean_naming,
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
CONVERGE_PATH = "
|
|
15
|
+
CONVERGE_PATH = "skill-archive/autoconverge/workflow/converge.mjs"
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def test_flags_unprefixed_boolean_let_declaration() -> None:
|
package/package.json
CHANGED
|
@@ -121,6 +121,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
|
|
|
121
121
|
"name": each_reading.account.name,
|
|
122
122
|
"home": str(each_reading.account.home),
|
|
123
123
|
"is_main": each_reading.account.is_main,
|
|
124
|
+
"priority": each_reading.account.priority,
|
|
124
125
|
"meters": _meter_payload(each_reading.meters),
|
|
125
126
|
}
|
|
126
127
|
for each_reading in all_readings
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
import errno
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
@@ -47,6 +48,8 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
47
48
|
utc_time_text,
|
|
48
49
|
)
|
|
49
50
|
from dev_env_scripts_constants.claude_account_constants import (
|
|
51
|
+
CLAUDE_ACCOUNT_ORDER_FILE_NAME,
|
|
52
|
+
CLAUDE_LAUNCHER_PROGRAM,
|
|
50
53
|
CREDENTIALS_FILE_NAME,
|
|
51
54
|
EXTRA_PROFILES_FILE_NAME,
|
|
52
55
|
FULL_PERCENT,
|
|
@@ -64,7 +67,7 @@ from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_EN
|
|
|
64
67
|
|
|
65
68
|
def _read_list(path: Path) -> object:
|
|
66
69
|
try:
|
|
67
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
68
71
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
69
72
|
raise BrokerConfigurationError(f"cannot read account list {path}: {error}") from error
|
|
70
73
|
|
|
@@ -121,11 +124,34 @@ def _append_claude_account(
|
|
|
121
124
|
all_seen.add(key)
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def _account_order(main_home: Path) -> tuple[str, ...]:
|
|
128
|
+
order_path = main_home / CLAUDE_ACCOUNT_ORDER_FILE_NAME
|
|
129
|
+
if not order_path.exists():
|
|
130
|
+
return ()
|
|
131
|
+
all_names = _read_list(order_path)
|
|
132
|
+
if not isinstance(all_names, list) or any(not isinstance(each_name, str) for each_name in all_names):
|
|
133
|
+
raise BrokerConfigurationError(f"invalid account list {order_path}")
|
|
134
|
+
return tuple(each_name.casefold() for each_name in all_names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _order_names(account: Account) -> frozenset[str]:
|
|
138
|
+
launcher_name = Path(CLAUDE_LAUNCHER_PROGRAM.file_name_template.format(profile_name=account.name)).stem
|
|
139
|
+
return frozenset(each_name.casefold() for each_name in (account.name, account.command, launcher_name) if each_name)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _with_priorities(all_accounts: Sequence[Account], all_ordered_names: Sequence[str]) -> tuple[Account, ...]:
|
|
143
|
+
def priority_of(account: Account) -> int | None:
|
|
144
|
+
all_names = _order_names(account)
|
|
145
|
+
return next((index for index, each_name in enumerate(all_ordered_names) if each_name in all_names), None)
|
|
146
|
+
|
|
147
|
+
return tuple(dataclasses.replace(each_account, priority=priority_of(each_account)) for each_account in all_accounts)
|
|
148
|
+
|
|
149
|
+
|
|
124
150
|
def load_claude_accounts() -> tuple[Account, ...]:
|
|
125
151
|
"""Read the Claude account roster.
|
|
126
152
|
|
|
127
153
|
Returns:
|
|
128
|
-
Main and additional accounts in configured order.
|
|
154
|
+
Main and additional accounts in configured order, each with its place in the account order file.
|
|
129
155
|
|
|
130
156
|
Raises:
|
|
131
157
|
BrokerConfigurationError: An account list is unreadable.
|
|
@@ -138,7 +164,7 @@ def load_claude_accounts() -> tuple[Account, ...]:
|
|
|
138
164
|
for each_home in _extra_homes(main_home):
|
|
139
165
|
home = each_home.resolve()
|
|
140
166
|
_append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
|
|
141
|
-
return
|
|
167
|
+
return _with_priorities(all_accounts, _account_order(main_home))
|
|
142
168
|
|
|
143
169
|
|
|
144
170
|
def load_codex_accounts() -> tuple[Account, ...]:
|
|
@@ -137,6 +137,7 @@ class Account:
|
|
|
137
137
|
home: Path
|
|
138
138
|
is_main: bool = False
|
|
139
139
|
command: str | None = None
|
|
140
|
+
priority: int | None = None
|
|
140
141
|
|
|
141
142
|
|
|
142
143
|
@dataclass(frozen=True)
|
|
@@ -257,9 +258,10 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
257
258
|
return max(all_blocking_resets, default=None)
|
|
258
259
|
|
|
259
260
|
|
|
260
|
-
def _rank_key(reading: Reading) -> float:
|
|
261
|
+
def _rank_key(reading: Reading) -> tuple[bool, int, float]:
|
|
261
262
|
room = reading.meters.tightest_percent_left if reading.meters else None
|
|
262
|
-
|
|
263
|
+
priority = reading.account.priority
|
|
264
|
+
return (priority is None, priority or 0, -(room if room is not None else -1.0))
|
|
263
265
|
|
|
264
266
|
|
|
265
267
|
def _main_has_room(meters: Meters | None) -> bool:
|
|
@@ -284,10 +286,13 @@ def _choose_claude(all_available: Sequence[Reading], preferred_command: str | No
|
|
|
284
286
|
is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
|
|
285
287
|
if preferred_command is not None and is_bound:
|
|
286
288
|
return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
|
|
287
|
-
|
|
288
|
-
if
|
|
289
|
+
chosen = min(all_with_room, key=_rank_key, default=None)
|
|
290
|
+
if chosen is None:
|
|
289
291
|
return None
|
|
290
|
-
|
|
292
|
+
room_text = f"{chosen.account.name} has {chosen.meters.tightest_percent_left:g}% left"
|
|
293
|
+
if chosen.account.priority is not None:
|
|
294
|
+
room_text = f"{room_text}; priority {chosen.account.priority + 1} in the account order"
|
|
295
|
+
return Decision("run", chosen.account, None, room_text, TIER_NORMAL)
|
|
291
296
|
|
|
292
297
|
|
|
293
298
|
def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
|
|
@@ -27,6 +27,9 @@ SECOND_ACCOUNT_PROFILE_NAME: str = "ev"
|
|
|
27
27
|
EXTRA_PROFILES_FILE_NAME: str = "extra-profiles.json"
|
|
28
28
|
"""Local main-home file listing extra profile names in selection order."""
|
|
29
29
|
|
|
30
|
+
CLAUDE_ACCOUNT_ORDER_FILE_NAME: str = "claude-account-order.json"
|
|
31
|
+
"""Optional main-home file listing Claude accounts in pick priority, first choice first."""
|
|
32
|
+
|
|
30
33
|
PROFILE_NAME_PATTERN: str = r"[A-Za-z0-9][A-Za-z0-9_-]*"
|
|
31
34
|
"""Allowed profile names for directories and command launchers."""
|
|
32
35
|
|
|
@@ -302,6 +302,59 @@ def test_should_use_claude_extra_floors_and_wait_for_unread() -> None:
|
|
|
302
302
|
assert decision.account is None
|
|
303
303
|
|
|
304
304
|
|
|
305
|
+
def _ranked_claude_account(name: str, priority: int | None) -> Account:
|
|
306
|
+
return Account(Product.CLAUDE, name, Path("/profiles") / name, False, name, priority)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_should_pick_the_first_claude_account_in_priority_order_with_room() -> None:
|
|
310
|
+
readings = (
|
|
311
|
+
Reading(_ranked_claude_account("roomy", 2), _meters(90, 90)),
|
|
312
|
+
Reading(_ranked_claude_account("spent", 0), _meters(5, 90)),
|
|
313
|
+
Reading(_ranked_claude_account("preferred", 1), _meters(20, 20)),
|
|
314
|
+
Reading(_ranked_claude_account("unranked", None), _meters(99, 99)),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
|
|
318
|
+
|
|
319
|
+
assert decision.account.name == "preferred"
|
|
320
|
+
assert "priority 2" in decision.reason
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_should_fall_back_to_the_roomiest_unranked_claude_account() -> None:
|
|
324
|
+
readings = (
|
|
325
|
+
Reading(_ranked_claude_account("ranked_spent", 0), _meters(5, 5)),
|
|
326
|
+
Reading(_ranked_claude_account("tight", None), _meters(30, 30)),
|
|
327
|
+
Reading(_ranked_claude_account("roomy", None), _meters(60, 60)),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
|
|
331
|
+
|
|
332
|
+
assert decision.account.name == "roomy"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_should_keep_resume_affinity_ahead_of_priority() -> None:
|
|
336
|
+
readings = (
|
|
337
|
+
Reading(_ranked_claude_account("first", 0), _meters(90, 90)),
|
|
338
|
+
Reading(_ranked_claude_account("bound", 1), _meters(40, 40)),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
decision = choose_from_readings(Product.CLAUDE, readings, now=NOW, preferred_command="bound")
|
|
342
|
+
|
|
343
|
+
assert decision.account.name == "bound"
|
|
344
|
+
assert decision.reason == "resume affinity"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_should_wait_when_every_ranked_claude_account_is_spent() -> None:
|
|
348
|
+
readings = (
|
|
349
|
+
Reading(_ranked_claude_account("first", 0), _meters(5, 5)),
|
|
350
|
+
Reading(_ranked_claude_account("second", 1), _meters(0, 50)),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
|
|
354
|
+
|
|
355
|
+
assert decision.action == "wait"
|
|
356
|
+
|
|
357
|
+
|
|
305
358
|
@pytest.mark.parametrize("count", (1, 5))
|
|
306
359
|
def test_should_consider_every_account_in_the_roster(count: int) -> None:
|
|
307
360
|
readings = tuple(
|
|
@@ -34,6 +34,64 @@ def test_should_load_claude_roster_from_both_lists(
|
|
|
34
34
|
assert accounts[1].home == (tmp_path / "alternate").resolve()
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _claude_home_with_extras(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, extras: list[str]) -> Path:
|
|
38
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
39
|
+
monkeypatch.setattr(support, "default_profile_home", lambda name="extra": tmp_path / "profiles" / name)
|
|
40
|
+
main_home = tmp_path / ".claude"
|
|
41
|
+
main_home.mkdir()
|
|
42
|
+
(main_home / "extra-profiles.json").write_text(json.dumps(extras), encoding="utf-8")
|
|
43
|
+
return main_home
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_should_leave_claude_priorities_unset_without_an_order_file(
|
|
47
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
48
|
+
) -> None:
|
|
49
|
+
_claude_home_with_extras(monkeypatch, tmp_path, ["first", "second"])
|
|
50
|
+
|
|
51
|
+
accounts = support.load_claude_accounts()
|
|
52
|
+
|
|
53
|
+
assert [each_account.priority for each_account in accounts] == [None, None, None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_should_assign_claude_priorities_from_the_order_file(
|
|
57
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
58
|
+
) -> None:
|
|
59
|
+
main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first", "second", "unlisted"])
|
|
60
|
+
(main_home / "claude-account-order.json").write_text(
|
|
61
|
+
json.dumps(["claude-second", "missing", "claude", "FIRST"]), encoding="utf-8"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
accounts = support.load_claude_accounts()
|
|
65
|
+
|
|
66
|
+
assert {each_account.name: each_account.priority for each_account in accounts} == {
|
|
67
|
+
"main": 2,
|
|
68
|
+
"first": 3,
|
|
69
|
+
"second": 0,
|
|
70
|
+
"unlisted": None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_read_an_order_file_saved_with_a_byte_order_mark(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
76
|
+
) -> None:
|
|
77
|
+
main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first"])
|
|
78
|
+
(main_home / "claude-account-order.json").write_text('["first"]', encoding="utf-8-sig")
|
|
79
|
+
|
|
80
|
+
accounts = support.load_claude_accounts()
|
|
81
|
+
|
|
82
|
+
assert {each_account.name: each_account.priority for each_account in accounts} == {"main": None, "first": 0}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_reject_an_order_file_that_is_not_a_list_of_names(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
87
|
+
) -> None:
|
|
88
|
+
main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first"])
|
|
89
|
+
(main_home / "claude-account-order.json").write_text('{"order": ["first"]}', encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
with pytest.raises(support.BrokerConfigurationError, match="claude-account-order.json"):
|
|
92
|
+
support.load_claude_accounts()
|
|
93
|
+
|
|
94
|
+
|
|
37
95
|
def test_should_load_codex_roster_without_meter_reads(
|
|
38
96
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
39
97
|
) -> None:
|