claude-dev-env 8.14.0 → 8.15.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.
@@ -42,7 +42,7 @@ Path and operator searches run when the registry file is missing. Malformed regi
42
42
  **Path (WSL):** `/mnt/c/Program\ Files/Everything/es.exe`
43
43
  **Path (Windows):** `"C:/Program Files/Everything/es.exe"`
44
44
 
45
- **CRITICAL:**
45
+ Syntax:
46
46
  - Search terms are SPACE-SEPARATED, not quoted together
47
47
  - When searching a path, use backslashes inside quotes: `"Y:\\path\\to\\folder"`
48
48
 
@@ -0,0 +1 @@
1
+ @../AGENTS.md
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: second-account-workers
3
+ description: >-
4
+ Run local headless Claude workers through the second-account picker.
5
+ Triggers: spawn a worker on the second account, offload a worker,
6
+ claude-ev worker, save main account usage.
7
+ ---
8
+
9
+ # Second Account Workers
10
+
11
+ Use this skill when a task needs a local Claude worker and the session should
12
+ save main account usage.
13
+
14
+ ## Choose a worker
15
+
16
+ Keep the Agent tool for small, fast lookups. For any worker that edits code,
17
+ researches at length, or runs long, give it its own Git worktree, write a
18
+ standalone brief to a file, and launch the account worker in the background.
19
+
20
+ ```bash
21
+ python "$HOME/.claude/scripts/claude_account_worker.py" \
22
+ --prompt-file "<brief>" \
23
+ --cwd "<worktree>" \
24
+ --report-file "<report>" \
25
+ >"<worker-log>" 2>&1 &
26
+ worker_pid=$!
27
+ wait "$worker_pid"
28
+ worker_exit=$?
29
+ cat "<report>"
30
+ ```
31
+
32
+ Read the JSON report and worker log after the process ends. The JSON fields are
33
+ `account`, `reason`, `exit_code`, `duration_seconds`, `result`, and `is_error`.
34
+ The runner writes one summary line with the selected account, exit code, and
35
+ report path.
36
+
37
+ When the command exits 3 and the report has `"account": "wait"`, both accounts
38
+ are out of room. Report the picker's reason and reset time. Do not fall back to
39
+ the Agent tool. Claude can also return exit 3 after a worker starts. When the
40
+ report account is `main` or `second`, report the child's exit code and result.
41
+
42
+ Workers never commit, push, or call `gh`. The calling session reviews each
43
+ worktree diff and owns every Git step.
44
+
45
+ ## Write a standalone brief
46
+
47
+ When pstack is installed, the first brief line is `/pstack:poteto-mode`.
48
+ Otherwise, begin with the worker role. Include every detail needed to finish
49
+ without the calling session's conversation.
50
+
51
+ ```text
52
+ /pstack:poteto-mode
53
+ Role: <one worker duty>
54
+ Owned files: <exact paths this worker may edit>
55
+ Acceptance checks:
56
+ - <observable behavior>
57
+ - <commands to run and expected result>
58
+ Report:
59
+ Changed: <files and reason>
60
+ Proof: <checks run and last result lines>
61
+ Blocked: <open issue, or none>
62
+ ```
63
+
64
+ Keep the owned file list closed. Name the worktree path with `--cwd` and use a
65
+ separate brief and report path for each worker.
@@ -90,6 +90,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
90
90
  'reviewer-gates',
91
91
  'reviews',
92
92
  'run-claude-dev-env',
93
+ 'second-account-workers',
93
94
  'session-continuity',
94
95
  'session-log',
95
96
  'session-tidy',
@@ -0,0 +1,42 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ import { mergeHooksIntoSettings } from './install.mjs';
6
+
7
+ test('reinstall replaces both prompt hooks with one ordered context hook', () => {
8
+ const hooksConfig = JSON.parse(readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8'));
9
+ const userCommand = 'python C:/Users/example/hooks/user_hook.py';
10
+ const settings = {
11
+ hooks: {
12
+ UserPromptSubmit: [
13
+ {
14
+ matcher: '',
15
+ hooks: [
16
+ { type: 'command', command: userCommand },
17
+ {
18
+ type: 'command',
19
+ command: 'python C:/Users/example/.claude/hooks/session/style_reminder_prompt.py',
20
+ },
21
+ {
22
+ type: 'command',
23
+ command: 'python C:/Users/example/.claude/hooks/session/skill_loaded_reminder.py',
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ };
30
+
31
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
32
+
33
+ const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
34
+ assert.equal(userPromptHooks.length, 2);
35
+ assert.equal(userPromptHooks[0].command, userCommand);
36
+ assert.match(userPromptHooks[1].command, /style_reminder_prompt\.py --include-skill-reminder$/);
37
+ assert.doesNotMatch(userPromptHooks[1].command, /skill_loaded_reminder/);
38
+
39
+ const firstMerge = JSON.stringify(settings);
40
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
41
+ assert.equal(JSON.stringify(settings), firstMerge);
42
+ });
@@ -35,7 +35,7 @@
35
35
 
36
36
  ## Production Migrations (Post-Launch ONLY)
37
37
 
38
- **CRITICAL:** After launch, model changes MUST be backwards-compatible.
38
+ After launch, keep model changes backwards-compatible.
39
39
 
40
40
  **Why:** Manual migration takes 2+ minutes. New code runs against old schema during this window.
41
41
 
@@ -59,7 +59,7 @@
59
59
 
60
60
  ## API Contract Changes (Post-Launch)
61
61
 
62
- **CRITICAL:** After launch, API contract changes MUST be backwards-compatible.
62
+ After launch, keep API contract changes backwards-compatible.
63
63
 
64
64
  **Why:** Users may have old client code cached. Old clients must work with new API.
65
65
 
@@ -4,19 +4,7 @@
4
4
 
5
5
  ## Test Infrastructure Anti-Patterns
6
6
 
7
- **CRITICAL: Test helpers get over-engineered MORE than any other code.**
8
-
9
- **ALWAYS:**
10
- - Single file
11
- - Simple functions
12
- - Minimal abstractions
13
- - Pragmatic approach
14
-
15
- **NEVER:**
16
- - Multi-file packages
17
- - Cache classes
18
- - Abstractions "for future"
19
- - Rigid constraints
7
+ Test helpers drift toward over-engineering faster than production code, because nothing reviews them for size. Keep a helper to one file of plain functions with the fewest abstractions the current tests need. Add a package, a cache class, or an abstraction only when a second test needs it today.
20
8
 
21
9
  **Pre-check (MANDATORY):**
22
10
  - [ ] ONE file?
@@ -10,6 +10,10 @@ keeps its own sign-in and history.
10
10
  |---|---|
11
11
  | `scripts/claude_account_profile.py` | Links each shared entry of `~/.claude` into the profile `~/.claude-profiles/ev`, moves stale copies into `.replaced/<time>/`, and writes the `claude-ev.cmd` launcher into `~/.local/bin` |
12
12
  | `scripts/claude_account_choice.py` | Reads both accounts' 5-hour and weekly meters and prints which account a job runs on |
13
+ | `scripts/claude_account_worker.py` | Runs one headless Claude worker using the account chosen by the picker |
14
+ | `scripts/claude_account_worker_process.py` | Launches the worker process, feeds it the brief, and kills it on timeout |
15
+ | `scripts/claude_account_worker_report.py` | Builds and writes the JSON report a worker leaves behind |
16
+ | `scripts/dev_env_scripts_constants/claude_account_worker_constants.py` | The worker's flag names, defaults, exit codes, and report keys |
13
17
  | `scripts/dev_env_scripts_constants/claude_account_constants.py` | The profile name, the entries that stay per account, and the picker thresholds |
14
18
 
15
19
  The launcher sets `CLAUDE_CONFIG_DIR` to the profile and passes every argument to
package/hooks/hooks.json CHANGED
@@ -69,12 +69,7 @@
69
69
  "hooks": [
70
70
  {
71
71
  "type": "command",
72
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/style_reminder_prompt.py",
73
- "timeout": 10
74
- },
75
- {
76
- "type": "command",
77
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
72
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/style_reminder_prompt.py --include-skill-reminder",
78
73
  "timeout": 10
79
74
  }
80
75
  ]
@@ -12,8 +12,8 @@
12
12
  the model sees the reminder this turn, and each turn after
13
13
 
14
14
  Claude Code and Codex CLI both read the same ``additionalContext`` shape on
15
- their ``UserPromptSubmit`` event. One script serves both. The hook prints
16
- text and stops.
15
+ their ``UserPromptSubmit`` event. The Claude hook includes the skill reminder
16
+ when its flag is present. The unflagged Codex hook prints style alone.
17
17
  """
18
18
 
19
19
  from __future__ import annotations
@@ -23,15 +23,29 @@ import sys
23
23
 
24
24
  import _path_setup # noqa: F401
25
25
 
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
26
27
  from hooks_constants.style_reminder_prompt_constants import STYLE_REMINDER_PROMPT
28
+ from skill_loaded_reminder import reminder_for
29
+
30
+
31
+ def _additional_context_for_prompt() -> str:
32
+ if "--include-skill-reminder" not in sys.argv[1:]:
33
+ return STYLE_REMINDER_PROMPT
34
+ hook_input = read_hook_input_dictionary_from_stdin()
35
+ if hook_input is None:
36
+ return STYLE_REMINDER_PROMPT
37
+ skill_reminder = reminder_for(hook_input)
38
+ if skill_reminder is None:
39
+ return STYLE_REMINDER_PROMPT
40
+ return STYLE_REMINDER_PROMPT + "\n" + skill_reminder
27
41
 
28
42
 
29
43
  def main() -> None:
30
- """Print the style reminder as UserPromptSubmit additionalContext."""
44
+ """Print ordered UserPromptSubmit additionalContext."""
31
45
  payload = {
32
46
  "hookSpecificOutput": {
33
47
  "hookEventName": "UserPromptSubmit",
34
- "additionalContext": STYLE_REMINDER_PROMPT,
48
+ "additionalContext": _additional_context_for_prompt(),
35
49
  }
36
50
  }
37
51
  sys.stdout.write(json.dumps(payload) + "\n")
@@ -13,20 +13,74 @@ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
13
13
  sys.path.insert(0, each_sys_path_entry)
14
14
 
15
15
  import style_reminder_prompt as reminder
16
+ from hooks_constants.skill_loaded_reminder_constants import NOT_LOADED_REMINDER
16
17
  from hooks_constants.style_reminder_prompt_constants import STYLE_REMINDER_PROMPT
17
18
 
18
19
 
19
- def _run_main() -> str:
20
+ def _run_main(all_arguments: list[str], hook_input: str) -> str:
20
21
  """Return the text main() prints."""
21
22
  captured_stdout = StringIO()
22
- with patch("sys.stdout", captured_stdout):
23
+ with (
24
+ patch("sys.argv", ["style_reminder_prompt.py", *all_arguments]),
25
+ patch("sys.stdin", StringIO(hook_input)),
26
+ patch("sys.stdout", captured_stdout),
27
+ ):
23
28
  reminder.main()
24
29
  return captured_stdout.getvalue()
25
30
 
26
31
 
27
32
  class TestStyleReminderPrompt:
28
33
  def test_main_emits_user_prompt_submit_hook_specific_output(self) -> None:
29
- emitted = json.loads(_run_main())
34
+ emitted = json.loads(_run_main([], ""))
30
35
  hook_output = emitted["hookSpecificOutput"]
31
36
  assert hook_output["hookEventName"] == "UserPromptSubmit"
32
37
  assert hook_output["additionalContext"] == STYLE_REMINDER_PROMPT
38
+
39
+ def test_unflagged_invocation_keeps_style_only_with_hook_input(self) -> None:
40
+ emitted = json.loads(_run_main([], json.dumps({"hook_event_name": "UserPromptSubmit"})))
41
+ assert emitted["hookSpecificOutput"]["additionalContext"] == STYLE_REMINDER_PROMPT
42
+
43
+ def test_flagged_invocation_combines_style_and_missing_skill_reminder(self) -> None:
44
+ emitted = json.loads(
45
+ _run_main(
46
+ ["--include-skill-reminder"],
47
+ json.dumps({"hook_event_name": "UserPromptSubmit"}),
48
+ )
49
+ )
50
+ assert emitted["hookSpecificOutput"]["additionalContext"] == (
51
+ STYLE_REMINDER_PROMPT + "\n" + NOT_LOADED_REMINDER
52
+ )
53
+
54
+ def test_flagged_invocation_keeps_style_only_after_skill_load(self, tmp_path: Path) -> None:
55
+ transcript_path = tmp_path / "session.jsonl"
56
+ transcript_path.write_text(
57
+ json.dumps(
58
+ {
59
+ "type": "assistant",
60
+ "message": {
61
+ "content": [
62
+ {
63
+ "type": "tool_use",
64
+ "name": "Skill",
65
+ "input": {"skill": "pstack:poteto-mode"},
66
+ }
67
+ ]
68
+ },
69
+ }
70
+ )
71
+ + "\n",
72
+ encoding="utf-8",
73
+ )
74
+ emitted = json.loads(
75
+ _run_main(
76
+ ["--include-skill-reminder"],
77
+ json.dumps(
78
+ {"hook_event_name": "UserPromptSubmit", "transcript_path": str(transcript_path)}
79
+ ),
80
+ )
81
+ )
82
+ assert emitted["hookSpecificOutput"]["additionalContext"] == STYLE_REMINDER_PROMPT
83
+
84
+ def test_flagged_invocation_without_hook_input_keeps_style_only(self) -> None:
85
+ emitted = json.loads(_run_main(["--include-skill-reminder"], ""))
86
+ assert emitted["hookSpecificOutput"]["additionalContext"] == STYLE_REMINDER_PROMPT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.14.0",
3
+ "version": "8.15.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # Research Mode (Global)
2
2
 
3
- Three anti-hallucination constraints are ALWAYS active.
3
+ Three anti-hallucination constraints are always active.
4
4
 
5
5
  Source: [Anthropic - Reduce Hallucinations](https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/reduce-hallucinations)
6
6
 
@@ -0,0 +1,347 @@
1
+ """Run a headless Claude worker on the account chosen by the usage picker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from claude_account_choice import choose_account, decision_payload, read_account_meters
18
+ from claude_account_profile import default_profile_home
19
+ from claude_account_worker_process import WorkerProcess, invoke_worker
20
+ from claude_account_worker_report import (
21
+ WorkerReport,
22
+ finalize_report,
23
+ make_report,
24
+ pre_launch_failure_report,
25
+ wait_report,
26
+ )
27
+ from dev_env_scripts_constants.claude_account_worker_constants import (
28
+ CHOICE_MAIN,
29
+ CHOICE_SECOND,
30
+ CHOICE_WAIT,
31
+ CLAUDE_BINARY_NAME,
32
+ CLAUDE_CONFIG_DIR_ENV_VAR,
33
+ CLI_DESCRIPTION,
34
+ CREDENTIALS_FILE_NAME,
35
+ CWD_FLAG,
36
+ DEFAULT_PERMISSION_MODE,
37
+ DEFAULT_TIMEOUT_MINUTES,
38
+ INVALID_TIMEOUT_MESSAGE,
39
+ JSON_ACCOUNT_KEY,
40
+ JSON_CONFIG_DIRECTORY_KEY,
41
+ JSON_REASON_KEY,
42
+ LAUNCH_FAILURE_EXIT_CODE,
43
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
44
+ MISSING_BINARY_EXIT_CODE,
45
+ MINIMUM_TIMEOUT_MINUTES,
46
+ MODEL_FLAG,
47
+ OUTPUT_FORMAT_FLAG,
48
+ OUTPUT_FORMAT_JSON,
49
+ PERMISSION_MODE_FLAG,
50
+ PROMPT_FILE_FLAG,
51
+ REPORT_FILE_FLAG,
52
+ SINGLE_PROMPT_FLAG,
53
+ TIMEOUT_MINUTES_FLAG,
54
+ UTF8_ENCODING,
55
+ )
56
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
57
+
58
+ _process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
59
+ __file__, all_environment=os.environ
60
+ )
61
+ if str(_process_tree_scripts_directory) not in sys.path:
62
+ sys.path.insert(0, str(_process_tree_scripts_directory))
63
+
64
+ _process_tree_kill = importlib.import_module("process_tree_kill")
65
+ _terminate_process_tree = _process_tree_kill.terminate_process_tree
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class AccountSelection:
70
+ account: str
71
+ config_dir: Path | None
72
+ reason: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WorkerDependencies:
77
+ process_factory: Callable[..., WorkerProcess]
78
+ all_parent_environment_variables: Mapping[str, str]
79
+ binary_resolver: Callable[[str], str | None]
80
+ process_terminator: Callable[[WorkerProcess], None]
81
+ monotonic_clock: Callable[[], float]
82
+
83
+
84
+ def _default_dependencies() -> WorkerDependencies:
85
+ return WorkerDependencies(
86
+ process_factory=subprocess.Popen,
87
+ all_parent_environment_variables=os.environ,
88
+ binary_resolver=shutil.which,
89
+ process_terminator=_terminate_process_tree,
90
+ monotonic_clock=time.monotonic,
91
+ )
92
+
93
+
94
+ def _selection_from_payload(all_selection_payload: Mapping[str, str | None]) -> AccountSelection:
95
+ account = all_selection_payload[JSON_ACCOUNT_KEY]
96
+ reason = all_selection_payload[JSON_REASON_KEY]
97
+ config_dir_text = all_selection_payload[JSON_CONFIG_DIRECTORY_KEY]
98
+ if account is None or reason is None:
99
+ raise ValueError("account picker returned an incomplete decision")
100
+ return AccountSelection(
101
+ account=account,
102
+ config_dir=Path(config_dir_text) if config_dir_text is not None else None,
103
+ reason=reason,
104
+ )
105
+
106
+
107
+ def select_account() -> AccountSelection:
108
+ """Pick the account this worker runs on and its Claude home directory.
109
+
110
+ Returns:
111
+ The chosen account, its Claude config directory, and the reason.
112
+
113
+ Raises:
114
+ ValueError: When the account picker returns an incomplete decision.
115
+ """
116
+ main_config_dir = Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
117
+ second_config_dir = default_profile_home()
118
+ config_directory_by_account = {
119
+ CHOICE_MAIN: main_config_dir,
120
+ CHOICE_SECOND: second_config_dir,
121
+ }
122
+ main_meters = read_account_meters(main_config_dir / CREDENTIALS_FILE_NAME)
123
+ second_meters = read_account_meters(second_config_dir / CREDENTIALS_FILE_NAME)
124
+ decision = choose_account(
125
+ main_meters=main_meters, second_meters=second_meters, now=datetime.now().astimezone()
126
+ )
127
+ selected_config_dir = config_directory_by_account.get(decision.account)
128
+ payload = decision_payload(decision, config_directory=selected_config_dir)
129
+ return _selection_from_payload(payload)
130
+
131
+
132
+ def _positive_timeout_minutes(candidate_text: str) -> int:
133
+ timeout_minutes = int(candidate_text)
134
+ if timeout_minutes < MINIMUM_TIMEOUT_MINUTES:
135
+ raise argparse.ArgumentTypeError(INVALID_TIMEOUT_MESSAGE)
136
+ return timeout_minutes
137
+
138
+
139
+ def build_argument_parser() -> argparse.ArgumentParser:
140
+ """Build the CLI parser for one headless worker invocation.
141
+
142
+ Returns:
143
+ The configured argument parser.
144
+ """
145
+ parser = argparse.ArgumentParser(description=CLI_DESCRIPTION)
146
+ parser.add_argument(PROMPT_FILE_FLAG, type=Path, required=True)
147
+ parser.add_argument(CWD_FLAG, type=Path, default=Path.cwd())
148
+ parser.add_argument(REPORT_FILE_FLAG, type=Path, required=True)
149
+ parser.add_argument(MODEL_FLAG)
150
+ parser.add_argument(
151
+ PERMISSION_MODE_FLAG,
152
+ default=DEFAULT_PERMISSION_MODE,
153
+ )
154
+ parser.add_argument(
155
+ TIMEOUT_MINUTES_FLAG,
156
+ type=_positive_timeout_minutes,
157
+ default=DEFAULT_TIMEOUT_MINUTES,
158
+ )
159
+ return parser
160
+
161
+
162
+ def _child_environment(
163
+ selection: AccountSelection,
164
+ all_environment_variables: Mapping[str, str],
165
+ ) -> dict[str, str]:
166
+ if selection.account not in (CHOICE_MAIN, CHOICE_SECOND):
167
+ raise ValueError(f"unsupported account selection: {selection.account}")
168
+ all_child_environment_variables = dict(all_environment_variables)
169
+ if selection.account == CHOICE_MAIN:
170
+ all_child_environment_variables.pop(CLAUDE_CONFIG_DIR_ENV_VAR, None)
171
+ return all_child_environment_variables
172
+ if selection.config_dir is None:
173
+ raise ValueError("second account selection needs a config directory")
174
+ all_child_environment_variables[CLAUDE_CONFIG_DIR_ENV_VAR] = str(selection.config_dir)
175
+ return all_child_environment_variables
176
+
177
+
178
+ def _invocation(
179
+ executable: str,
180
+ *,
181
+ model: str | None,
182
+ permission_mode: str,
183
+ ) -> list[str]:
184
+ all_arguments = [
185
+ executable,
186
+ SINGLE_PROMPT_FLAG,
187
+ OUTPUT_FORMAT_FLAG,
188
+ OUTPUT_FORMAT_JSON,
189
+ PERMISSION_MODE_FLAG,
190
+ permission_mode,
191
+ ]
192
+ if model is not None:
193
+ all_arguments.extend([MODEL_FLAG, model])
194
+ return all_arguments
195
+
196
+
197
+ def _prepared_launch(
198
+ selection: AccountSelection,
199
+ prompt_file: Path,
200
+ dependencies: WorkerDependencies,
201
+ ) -> tuple[dict[str, str], str, str] | WorkerReport:
202
+ try:
203
+ all_child_environment_variables = _child_environment(
204
+ selection, dependencies.all_parent_environment_variables
205
+ )
206
+ prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
207
+ except (OSError, ValueError):
208
+ return pre_launch_failure_report(
209
+ selection.account, selection.reason, LAUNCH_FAILURE_EXIT_CODE
210
+ )
211
+ claude_binary = dependencies.binary_resolver(CLAUDE_BINARY_NAME)
212
+ if claude_binary is None:
213
+ return pre_launch_failure_report(
214
+ selection.account, selection.reason, MISSING_BINARY_EXIT_CODE
215
+ )
216
+ return all_child_environment_variables, prompt_text, claude_binary
217
+
218
+
219
+ def _invoked_report(
220
+ selection: AccountSelection,
221
+ *,
222
+ all_arguments: list[str],
223
+ cwd: Path,
224
+ all_child_environment_variables: dict[str, str],
225
+ prompt_text: str,
226
+ timeout_minutes: int,
227
+ dependencies: WorkerDependencies,
228
+ ) -> WorkerReport:
229
+ exit_code, duration_seconds, stdout_text = invoke_worker(
230
+ all_arguments=all_arguments,
231
+ cwd=cwd,
232
+ all_child_environment_variables=all_child_environment_variables,
233
+ prompt_text=prompt_text,
234
+ timeout_minutes=timeout_minutes,
235
+ controls=dependencies,
236
+ )
237
+ return make_report(
238
+ selection.account,
239
+ selection.reason,
240
+ exit_code=exit_code,
241
+ duration_seconds=duration_seconds,
242
+ stdout_text=stdout_text,
243
+ )
244
+
245
+
246
+ def _worker_report(
247
+ selection: AccountSelection,
248
+ *,
249
+ prompt_file: Path,
250
+ cwd: Path,
251
+ model: str | None,
252
+ permission_mode: str,
253
+ timeout_minutes: int,
254
+ dependencies: WorkerDependencies,
255
+ ) -> WorkerReport:
256
+ prepared = _prepared_launch(selection, prompt_file, dependencies)
257
+ if isinstance(prepared, WorkerReport):
258
+ return prepared
259
+ all_child_environment_variables, prompt_text, claude_binary = prepared
260
+ all_arguments = _invocation(claude_binary, model=model, permission_mode=permission_mode)
261
+ return _invoked_report(
262
+ selection,
263
+ all_arguments=all_arguments,
264
+ cwd=cwd,
265
+ all_child_environment_variables=all_child_environment_variables,
266
+ prompt_text=prompt_text,
267
+ timeout_minutes=timeout_minutes,
268
+ dependencies=dependencies,
269
+ )
270
+
271
+
272
+ def _report_for_selection(
273
+ selection: AccountSelection,
274
+ *,
275
+ prompt_file: Path,
276
+ cwd: Path,
277
+ model: str | None,
278
+ permission_mode: str,
279
+ timeout_minutes: int,
280
+ dependencies: WorkerDependencies,
281
+ ) -> WorkerReport:
282
+ if selection.account == CHOICE_WAIT:
283
+ return wait_report(selection.account, selection.reason)
284
+ return _worker_report(
285
+ selection,
286
+ prompt_file=prompt_file,
287
+ cwd=cwd,
288
+ model=model,
289
+ permission_mode=permission_mode,
290
+ timeout_minutes=timeout_minutes,
291
+ dependencies=dependencies,
292
+ )
293
+
294
+
295
+ def run_worker(
296
+ *,
297
+ prompt_file: Path,
298
+ cwd: Path,
299
+ report_file: Path,
300
+ model: str | None,
301
+ permission_mode: str,
302
+ timeout_minutes: int,
303
+ decision_selector: Callable[[], AccountSelection],
304
+ dependencies: WorkerDependencies,
305
+ ) -> int:
306
+ """Pick an account and run one headless Claude worker on it.
307
+
308
+ Args:
309
+ prompt_file, report_file: The prompt and report file paths.
310
+ decision_selector, dependencies: The picker and process controls.
311
+ Returns:
312
+ The exit code: 3 on wait, 124 on timeout, 127 on a missing binary,
313
+ or the child's own exit code.
314
+ """
315
+ report = _report_for_selection(
316
+ decision_selector(),
317
+ prompt_file=prompt_file,
318
+ cwd=cwd,
319
+ model=model,
320
+ permission_mode=permission_mode,
321
+ timeout_minutes=timeout_minutes,
322
+ dependencies=dependencies,
323
+ )
324
+ return finalize_report(report_file, report)
325
+
326
+
327
+ def main() -> int:
328
+ """Parse CLI arguments and run one headless worker.
329
+
330
+ Returns:
331
+ The worker's exit code.
332
+ """
333
+ arguments = build_argument_parser().parse_args()
334
+ return run_worker(
335
+ prompt_file=arguments.prompt_file,
336
+ cwd=arguments.cwd,
337
+ report_file=arguments.report_file,
338
+ model=arguments.model,
339
+ permission_mode=arguments.permission_mode,
340
+ timeout_minutes=arguments.timeout_minutes,
341
+ decision_selector=select_account,
342
+ dependencies=_default_dependencies(),
343
+ )
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(main())