claude-dev-env 8.37.3 → 8.38.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.
@@ -0,0 +1,32 @@
1
+ # Account broker
2
+
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
+
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. Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
6
+
7
+ ## Commands
8
+
9
+ ```text
10
+ python scripts/account_broker.py accounts --product claude
11
+ python scripts/account_broker.py choose --product codex
12
+ python scripts/account_broker.py choose --product codex --spent account-name:1790000000
13
+ python scripts/account_broker.py check --product codex
14
+ python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p "prompt"
15
+ ```
16
+
17
+ `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. Meter reads younger than 60 seconds are reused.
18
+
19
+ `check` prints nothing and exits 3 while every account is below its floor. `run` sets only `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
20
+
21
+ The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. State writes use a sibling temporary file and `os.replace`.
22
+
23
+ ## Exit codes and report
24
+
25
+ | Code | Meaning |
26
+ | --- | --- |
27
+ | `0` | `choose` or `check` found an account, or the job exited successfully. |
28
+ | `3` | No account clears its floor. The decision includes `resets_at`. |
29
+ | `4` | A Claude job stopped with `advisor_blocked`. |
30
+ | Other command code | `run` returns the command's exit code after a served response. |
31
+
32
+ `run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.37.3",
3
+ "version": "8.38.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ """Choose and run Claude or Codex jobs through one account roster."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Mapping, Sequence
15
+
16
+ import account_broker_support as support
17
+ from account_broker_support import (
18
+ all_product_adapters,
19
+ Account,
20
+ BrokerConfigurationError,
21
+ Decision,
22
+ JobOutcome,
23
+ Meters,
24
+ Product,
25
+ ProductAdapter,
26
+ Reading,
27
+ Report,
28
+ _load_state,
29
+ _meter_payload,
30
+ _resume_id,
31
+ _save_state,
32
+ _state_key,
33
+ broker_state_path,
34
+ extract_session_id_from_stdout,
35
+ load_claude_accounts,
36
+ load_codex_accounts,
37
+ override_subprocess_runner,
38
+ read_accounts,
39
+ read_claude_meters,
40
+ read_codex_account_meters,
41
+ )
42
+ from dev_env_scripts_constants.account_broker_constants import (
43
+ _account_reset,
44
+ _choose_claude,
45
+ _choose_codex,
46
+ _wait_decision,
47
+ COMMAND_MISSING_EXIT_CODE,
48
+ REPORT_INDENT_SPACES,
49
+ WAIT_EXIT_CODE,
50
+ utc_time_text as _time_text,
51
+ )
52
+ from dev_env_scripts_constants.codex_account_constants import (
53
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
54
+ MAIN_CODEX_HOME_DIRECTORY_NAME,
55
+ TIER_NORMAL,
56
+ )
57
+
58
+
59
+ def choose_from_readings(
60
+ product: Product,
61
+ all_readings: Sequence[Reading],
62
+ *,
63
+ now: datetime,
64
+ all_spent_accounts: frozenset[Account] = frozenset(),
65
+ preferred_command: str | None = None,
66
+ adapter: ProductAdapter | None = None,
67
+ all_spent_resets: Mapping[Account, datetime] | None = None,
68
+ ) -> Decision:
69
+ """Choose an account or a wait.
70
+
71
+ Args:
72
+ product: Account product.
73
+ all_readings: Roster and meter readings.
74
+
75
+ Returns:
76
+ A run or wait decision.
77
+ """
78
+ if product is Product.CODEX and not all_readings:
79
+ home = Path(os.environ.get(CODEX_HOME_ENVIRONMENT_VARIABLE) or Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME).resolve()
80
+ return Decision("run", Account(product, "default", home, True), None, "no roster is configured", TIER_NORMAL)
81
+ active = adapter or all_product_adapters[product]
82
+ all_available = [each_reading for each_reading in all_readings if each_reading.account not in all_spent_accounts]
83
+ selected = (
84
+ _choose_claude(all_available, now, preferred_command, active.main_guard)
85
+ if product is Product.CLAUDE
86
+ else _choose_codex(all_available)
87
+ )
88
+ return selected or _wait_decision(all_readings, now, all_spent_accounts, all_spent_resets or {})
89
+
90
+
91
+ def decision_payload(decision: Decision) -> dict[str, object]:
92
+ """Serialize a choice.
93
+
94
+ Args:
95
+ decision: Account choice.
96
+
97
+ Returns:
98
+ JSON-compatible decision fields.
99
+ """
100
+ return {
101
+ "action": decision.action,
102
+ "account": decision.account.name if decision.account else None,
103
+ "home": str(decision.account.home) if decision.account else None,
104
+ "reason": decision.reason,
105
+ "tier": decision.tier,
106
+ "resets_at": _time_text(decision.resets_at),
107
+ }
108
+
109
+
110
+ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]:
111
+ """Serialize account readings.
112
+
113
+ Args:
114
+ all_readings: Roster and meters.
115
+
116
+ Returns:
117
+ JSON-compatible account entries.
118
+ """
119
+ return [
120
+ {
121
+ "name": each_reading.account.name,
122
+ "home": str(each_reading.account.home),
123
+ "is_main": each_reading.account.is_main,
124
+ "meters": _meter_payload(each_reading.meters),
125
+ }
126
+ for each_reading in all_readings
127
+ ]
128
+
129
+
130
+
131
+ def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> tuple[frozenset[Account], dict[Account, datetime]]:
132
+ marks = all_state["spent"]
133
+ spent = {}
134
+ for each_reading in all_readings:
135
+ raw = marks.get(_state_key(each_reading.account))
136
+ if isinstance(raw, (int, float)) and raw > now.timestamp():
137
+ spent[each_reading.account] = datetime.fromtimestamp(raw, timezone.utc)
138
+ return frozenset(spent), spent
139
+
140
+
141
+ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
142
+ until = reset or _account_reset(reading, is_spent=True) or now + timedelta(hours=1)
143
+ if until <= now:
144
+ until = now + timedelta(hours=1)
145
+ all_state["spent"][_state_key(reading.account)] = until.timestamp()
146
+ _save_state(broker_state_path(), all_state)
147
+ return until
148
+
149
+
150
+ def _write_report(path: Path, report: Report) -> None:
151
+ payload = {
152
+ "product": report.product.value,
153
+ "command": report.command,
154
+ "events": report.events,
155
+ "final_decision": decision_payload(report.final_decision) if report.final_decision else None,
156
+ }
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ path.write_text(json.dumps(payload, indent=REPORT_INDENT_SPACES) + "\n", encoding="utf-8", newline="\n")
159
+
160
+
161
+ @dataclass
162
+ class _RunContext:
163
+ product: Product
164
+ all_argv: list[str]
165
+ active: ProductAdapter
166
+ now: datetime
167
+ all_state: dict[str, object]
168
+ all_readings: tuple[Reading, ...]
169
+ report: Report
170
+ stdin_bytes: bytes | None
171
+ timeout_seconds: float | None
172
+ cwd: str | Path | None
173
+ encoding: str
174
+ errors: str
175
+ preferred_command: str | None
176
+ all_attempts: list[tuple[str, str]]
177
+ all_spent_accounts: set[Account]
178
+ all_spent_resets: dict[Account, datetime]
179
+
180
+
181
+ def _prepare_run(
182
+ product: Product, all_argv: Sequence[str], now: datetime, timeout_seconds: float | None,
183
+ stdin_text: str | bytes | None, cwd: str | Path | None, encoding: str, errors: str
184
+ ) -> _RunContext:
185
+ all_state = _load_state(broker_state_path())
186
+ active = all_product_adapters[product]
187
+ all_readings = read_accounts(product, active, all_state=all_state, now=now)
188
+ session_id = _resume_id(all_argv) if product is Product.CLAUDE else None
189
+ preferred = all_state["affinity"].get(session_id) if session_id else None
190
+ spent, all_spent_resets = _spent_for_readings(all_readings, all_state, now)
191
+ stdin_bytes = stdin_text.encode(encoding, errors) if isinstance(stdin_text, str) else stdin_text
192
+ return _RunContext(
193
+ product, list(all_argv), active, now, all_state, all_readings, Report(product, list(all_argv)),
194
+ stdin_bytes, timeout_seconds, cwd, encoding, errors, preferred, [], set(spent), all_spent_resets
195
+ )
196
+
197
+
198
+ def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
199
+ context.report.final_decision = decision
200
+ status = "exhausted" if context.all_attempts else "wait"
201
+ return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
202
+
203
+
204
+ def _record_spent_attempt(context: _RunContext, account: Account, status: str, returncode: int | None) -> None:
205
+ context.all_attempts.append((account.name, status))
206
+ context.report.events.append({"type": "attempt", "account": account.name, "status": status, "exit_code": returncode})
207
+ if context.all_readings:
208
+ reading = next(each_reading for each_reading in context.all_readings if each_reading.account == account)
209
+ context.all_spent_accounts.add(account)
210
+ if status == "usage_limited":
211
+ context.all_spent_resets[account] = _mark_spent(context.all_state, reading, context.now)
212
+
213
+
214
+ def _invoke(context: _RunContext, account: Account) -> subprocess.CompletedProcess[str]:
215
+ environment = {**os.environ, context.active.environment_variable: str(account.home)}
216
+ return support.subprocess_runner(
217
+ context.all_argv,
218
+ env=environment,
219
+ input=context.stdin_bytes,
220
+ timeout=context.timeout_seconds,
221
+ cwd=str(context.cwd) if context.cwd is not None else None,
222
+ encoding=context.encoding,
223
+ errors=context.errors,
224
+ )
225
+
226
+
227
+ def _finish_attempt(context: _RunContext, decision: Decision, completion: subprocess.CompletedProcess[str]) -> JobOutcome:
228
+ account = decision.account
229
+ status = "advisor_blocked" if context.product is Product.CLAUDE and completion.returncode != 0 else "served"
230
+ context.all_attempts.append((account.name, status))
231
+ context.report.events.append({"type": "attempt", "account": account.name, "status": status, "exit_code": completion.returncode})
232
+ context.report.final_decision = decision
233
+ session_id = extract_session_id_from_stdout(completion.stdout) if completion.returncode == 0 else None
234
+ if context.product is Product.CLAUDE and session_id:
235
+ context.all_state["affinity"][session_id] = account.command or account.name
236
+ _save_state(broker_state_path(), context.all_state)
237
+ return JobOutcome(completion.returncode, completion.stdout, completion.stderr, account.name, tuple(context.all_attempts), status, session_id, None)
238
+
239
+
240
+ def _attempt_once(context: _RunContext, decision: Decision) -> JobOutcome | None:
241
+ account = decision.account
242
+ try:
243
+ completion = _invoke(context, account)
244
+ except (OSError, subprocess.TimeoutExpired) as error:
245
+ status = "timeout" if isinstance(error, subprocess.TimeoutExpired) else "start_failed"
246
+ _record_spent_attempt(context, account, status, COMMAND_MISSING_EXIT_CODE)
247
+ if not context.all_readings:
248
+ context.report.final_decision = decision
249
+ return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", str(error), account.name, tuple(context.all_attempts), "advisor_blocked", None, None)
250
+ return None
251
+ combined = f"{completion.stdout}{completion.stderr}".casefold()
252
+ is_limited = completion.returncode != 0 and any(
253
+ signature.casefold() in combined for signature in context.active.usage_limit_signatures
254
+ )
255
+ if is_limited:
256
+ _record_spent_attempt(context, account, "usage_limited", completion.returncode)
257
+ if not context.all_readings:
258
+ waiting = Decision("wait", None, context.now + timedelta(hours=1), "no account has room", "wait")
259
+ return _wait_outcome(context, waiting)
260
+ return None
261
+ return _finish_attempt(context, decision, completion)
262
+
263
+
264
+ def _execute(
265
+ product: Product, all_argv: Sequence[str], *, now: datetime,
266
+ timeout_seconds: float | None = None, stdin_text: str | bytes | None = None,
267
+ cwd: str | Path | None = None, encoding: str = "utf-8", errors: str = "replace"
268
+ ) -> tuple[JobOutcome, Report]:
269
+ context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
270
+ while True:
271
+ decision = choose_from_readings(
272
+ product, context.all_readings, now=now,
273
+ all_spent_accounts=frozenset(context.all_spent_accounts),
274
+ preferred_command=context.preferred_command, adapter=context.active,
275
+ all_spent_resets=context.all_spent_resets
276
+ )
277
+ context.report.events.append({"type": "pick", "decision": decision_payload(decision)})
278
+ if decision.account is None:
279
+ return _wait_outcome(context, decision), context.report
280
+ outcome = _attempt_once(context, decision)
281
+ if outcome is not None:
282
+ return outcome, context.report
283
+
284
+
285
+ def run_job(
286
+ product: Product,
287
+ all_argv: Sequence[str],
288
+ *,
289
+ timeout_seconds: float | None = None,
290
+ stdin_text: str | bytes | None = None,
291
+ cwd: str | Path | None = None,
292
+ encoding: str = "utf-8",
293
+ errors: str = "replace",
294
+ ) -> JobOutcome:
295
+ return _execute(product, all_argv, now=datetime.now(timezone.utc), timeout_seconds=timeout_seconds, stdin_text=stdin_text, cwd=cwd, encoding=encoding, errors=errors)[0]
296
+
297
+
298
+ def _parser() -> argparse.ArgumentParser:
299
+ parser = argparse.ArgumentParser(description=__doc__)
300
+ commands = parser.add_subparsers(dest="action", required=True)
301
+ for each_action in ("choose", "check", "accounts", "run"):
302
+ command = commands.add_parser(each_action)
303
+ command.add_argument("--product", choices=[each_product.value for each_product in Product], required=True)
304
+ if each_action == "choose":
305
+ command.add_argument("--spent", action="append", default=[])
306
+ if each_action == "run":
307
+ command.add_argument("--report", type=Path, required=True)
308
+ command.add_argument("command", nargs=argparse.REMAINDER)
309
+ return parser
310
+
311
+
312
+ def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str) -> tuple[Reading, datetime | None]:
313
+ name, separator, reset_text = mark.partition(":")
314
+ if name not in all_accounts_by_name:
315
+ raise BrokerConfigurationError(f"unknown account {name}")
316
+ try:
317
+ reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
318
+ except ValueError as error:
319
+ raise BrokerConfigurationError(f"invalid reset for account {name}") from error
320
+ return all_accounts_by_name[name], reset
321
+
322
+
323
+ def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> None:
324
+ all_accounts_by_name = {each_reading.account.name: each_reading for each_reading in all_readings}
325
+ for each_mark in all_marks:
326
+ reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark)
327
+ _mark_spent(all_state, reading, now, reset)
328
+
329
+
330
+ def _accounts_cli(product: Product) -> int:
331
+ roster = all_product_adapters[product].load_accounts()
332
+ print(json.dumps({"accounts": [{"name": each_account.name, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
333
+ return 0
334
+
335
+
336
+ def _run_cli(product: Product, parsed: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
337
+ command = list(parsed.command)
338
+ if command and command[0] == "--":
339
+ command.pop(0)
340
+ if not command:
341
+ parser.error("run needs a command after --")
342
+ stdin_bytes = sys.stdin.buffer.read()
343
+ outcome, report = _execute(product, command, stdin_text=stdin_bytes, now=datetime.now(timezone.utc))
344
+ _write_report(parsed.report, report)
345
+ sys.stdout.write(outcome.stdout)
346
+ sys.stderr.write(outcome.stderr)
347
+ return 4 if outcome.status == "advisor_blocked" else outcome.returncode
348
+
349
+
350
+ def _choose_cli(product: Product, parsed: argparse.Namespace) -> int:
351
+ now = datetime.now(timezone.utc)
352
+ all_state = _load_state(broker_state_path())
353
+ all_readings = read_accounts(product, all_state=all_state, now=now)
354
+ if parsed.action == "choose" and parsed.spent:
355
+ _save_spent_arguments(parsed.spent, all_readings, all_state, now)
356
+ spent, resets = _spent_for_readings(all_readings, all_state, now)
357
+ decision = choose_from_readings(product, all_readings, now=now, all_spent_accounts=spent, all_spent_resets=resets)
358
+ if parsed.action == "choose":
359
+ print(json.dumps({"decision": decision_payload(decision), "accounts": readings_payload(all_readings), "state_path": str(broker_state_path())}))
360
+ return WAIT_EXIT_CODE if decision.action == "wait" else 0
361
+
362
+
363
+ def main(all_arguments: Sequence[str]) -> int:
364
+ """Execute a broker command.
365
+
366
+ Args:
367
+ all_arguments: Command-line tokens.
368
+
369
+ Returns:
370
+ Process exit code.
371
+ """
372
+ parser = _parser()
373
+ parsed = parser.parse_args(all_arguments)
374
+ product = Product(parsed.product)
375
+ try:
376
+ if parsed.action == "accounts":
377
+ return _accounts_cli(product)
378
+ if parsed.action == "run":
379
+ return _run_cli(product, parsed, parser)
380
+ return _choose_cli(product, parsed)
381
+ except BrokerConfigurationError as error:
382
+ print(str(error), file=sys.stderr)
383
+ return 2
384
+
385
+
386
+ if __name__ == "__main__":
387
+ raise SystemExit(main(sys.argv[1:]))