claude-dev-env 8.13.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.
- package/.agents/skills/everything-search/SKILL.md +1 -1
- package/.agents/skills/second-account-workers/.claude/CLAUDE.md +1 -0
- package/.agents/skills/second-account-workers/AGENTS.md +1 -0
- package/.agents/skills/second-account-workers/SKILL.md +65 -0
- package/.agents/skills/team-advisor/SKILL.md +1 -1
- package/_shared/advisor/advisor-protocol.md +19 -19
- package/_shared/advisor/reference/advisor-block.md +1 -1
- package/_shared/advisor/reference/astra-rung.md +6 -6
- package/_shared/advisor/reference/identity.md +4 -4
- package/_shared/advisor/reference/lifecycle.md +2 -2
- package/_shared/advisor/reference/spawn-walk-log.md +4 -4
- package/_shared/advisor/reference/third-party-bind.md +6 -6
- package/_shared/advisor/reference/warm-up.md +3 -3
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +2 -2
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -3
- package/_shared/advisor/scripts/model_tier_run_validator.py +8 -9
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +91 -91
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.prompt-context.test.mjs +42 -0
- package/docs/DJANGO_PATTERNS.md +2 -2
- package/docs/TEST_QUALITY.md +1 -13
- package/docs/second-claude-account.md +4 -0
- package/hooks/hooks.json +1 -6
- package/hooks/session/style_reminder_prompt.py +18 -4
- package/hooks/session/test_style_reminder_prompt.py +57 -3
- package/package.json +1 -1
- package/rules/research-mode.md +1 -1
- package/scripts/claude_account_worker.py +347 -0
- package/scripts/claude_account_worker_process.py +169 -0
- package/scripts/claude_account_worker_report.py +170 -0
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +139 -0
- package/scripts/grok_headless_runner.py +2 -5
- package/scripts/subprocess_stream_text.py +28 -0
- package/scripts/task_usage_events.py +387 -0
- package/scripts/task_usage_report.py +391 -0
- package/scripts/test_claude_account_worker.py +331 -0
- package/scripts/test_task_usage_events.py +47 -0
- package/scripts/test_task_usage_report.py +603 -0
|
@@ -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
|
|
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
package/rules/research-mode.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Research Mode (Global)
|
|
2
2
|
|
|
3
|
-
Three anti-hallucination constraints are
|
|
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())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Launch a headless Claude worker process and wait for it to finish."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
14
|
+
DRAIN_ATTEMPT_LIMIT,
|
|
15
|
+
DRAIN_GRACE_TIMEOUT_SECONDS,
|
|
16
|
+
LAUNCH_FAILURE_EXIT_CODE,
|
|
17
|
+
SECONDS_PER_MINUTE,
|
|
18
|
+
TIMEOUT_EXIT_CODE,
|
|
19
|
+
UTF8_DECODE_ERRORS,
|
|
20
|
+
UTF8_ENCODING,
|
|
21
|
+
)
|
|
22
|
+
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
23
|
+
from subprocess_stream_text import decode_stream_text
|
|
24
|
+
from subprocess_window_access import hidden_window_creation_flags
|
|
25
|
+
|
|
26
|
+
_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
|
|
27
|
+
__file__, all_environment=os.environ
|
|
28
|
+
)
|
|
29
|
+
if str(_process_tree_scripts_directory) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(_process_tree_scripts_directory))
|
|
31
|
+
|
|
32
|
+
_process_tree_kill = importlib.import_module("process_tree_kill")
|
|
33
|
+
_should_start_new_session = _process_tree_kill.should_start_new_session
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WorkerProcess(Protocol):
|
|
37
|
+
returncode: int | None
|
|
38
|
+
|
|
39
|
+
pid: int
|
|
40
|
+
|
|
41
|
+
def communicate(
|
|
42
|
+
self,
|
|
43
|
+
input: str | None = None,
|
|
44
|
+
timeout: float | None = None,
|
|
45
|
+
) -> tuple[str | bytes | None, str | bytes | None]: ...
|
|
46
|
+
|
|
47
|
+
def poll(self) -> int | None: ...
|
|
48
|
+
|
|
49
|
+
def kill(self) -> None: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decoded_text(stream_payload: str | bytes | None) -> str:
|
|
53
|
+
return decode_stream_text(
|
|
54
|
+
stream_payload, encoding=UTF8_ENCODING, errors=UTF8_DECODE_ERRORS
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _launch_process(
|
|
59
|
+
process_factory: Callable[..., WorkerProcess],
|
|
60
|
+
all_arguments: list[str],
|
|
61
|
+
cwd: Path,
|
|
62
|
+
all_child_environment_variables: dict[str, str],
|
|
63
|
+
) -> WorkerProcess | None:
|
|
64
|
+
try:
|
|
65
|
+
return process_factory(
|
|
66
|
+
all_arguments,
|
|
67
|
+
cwd=str(cwd),
|
|
68
|
+
env=all_child_environment_variables,
|
|
69
|
+
stdin=subprocess.PIPE,
|
|
70
|
+
stdout=subprocess.PIPE,
|
|
71
|
+
stderr=subprocess.PIPE,
|
|
72
|
+
text=True,
|
|
73
|
+
encoding=UTF8_ENCODING,
|
|
74
|
+
errors=UTF8_DECODE_ERRORS,
|
|
75
|
+
start_new_session=_should_start_new_session(),
|
|
76
|
+
creationflags=hidden_window_creation_flags(),
|
|
77
|
+
)
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _drain_attempt_outcome(process: WorkerProcess) -> tuple[bool, str]:
|
|
83
|
+
try:
|
|
84
|
+
captured_stdout, _ = process.communicate(timeout=DRAIN_GRACE_TIMEOUT_SECONDS)
|
|
85
|
+
except subprocess.TimeoutExpired as timeout_error:
|
|
86
|
+
return False, _decoded_text(timeout_error.output)
|
|
87
|
+
return True, _decoded_text(captured_stdout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _drain_after_termination(
|
|
91
|
+
process: WorkerProcess,
|
|
92
|
+
process_terminator: Callable[[WorkerProcess], None],
|
|
93
|
+
initial_stdout_text: str,
|
|
94
|
+
) -> str:
|
|
95
|
+
latest_stdout_text = initial_stdout_text
|
|
96
|
+
for _ in range(DRAIN_ATTEMPT_LIMIT):
|
|
97
|
+
process_terminator(process)
|
|
98
|
+
did_drain, attempt_stdout_text = _drain_attempt_outcome(process)
|
|
99
|
+
if attempt_stdout_text:
|
|
100
|
+
latest_stdout_text = attempt_stdout_text
|
|
101
|
+
if did_drain:
|
|
102
|
+
return latest_stdout_text
|
|
103
|
+
return latest_stdout_text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ProcessControls(Protocol):
|
|
107
|
+
"""The process factory, terminator, and clock a worker run uses."""
|
|
108
|
+
|
|
109
|
+
process_factory: Callable[..., WorkerProcess]
|
|
110
|
+
process_terminator: Callable[[WorkerProcess], None]
|
|
111
|
+
monotonic_clock: Callable[[], float]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _await_completion(
|
|
115
|
+
process: WorkerProcess | None,
|
|
116
|
+
prompt_text: str,
|
|
117
|
+
timeout_minutes: int,
|
|
118
|
+
process_terminator: Callable[[WorkerProcess], None],
|
|
119
|
+
) -> tuple[int, str]:
|
|
120
|
+
if process is None:
|
|
121
|
+
return LAUNCH_FAILURE_EXIT_CODE, ""
|
|
122
|
+
try:
|
|
123
|
+
captured_stdout, _ = process.communicate(
|
|
124
|
+
input=prompt_text,
|
|
125
|
+
timeout=timeout_minutes * SECONDS_PER_MINUTE,
|
|
126
|
+
)
|
|
127
|
+
except subprocess.TimeoutExpired as timeout_error:
|
|
128
|
+
stdout_text = _drain_after_termination(
|
|
129
|
+
process, process_terminator, _decoded_text(timeout_error.output)
|
|
130
|
+
)
|
|
131
|
+
return TIMEOUT_EXIT_CODE, stdout_text
|
|
132
|
+
process_exit_code = (
|
|
133
|
+
process.returncode
|
|
134
|
+
if process.returncode is not None
|
|
135
|
+
else LAUNCH_FAILURE_EXIT_CODE
|
|
136
|
+
)
|
|
137
|
+
return process_exit_code, _decoded_text(captured_stdout)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def invoke_worker(
|
|
141
|
+
*,
|
|
142
|
+
all_arguments: list[str],
|
|
143
|
+
cwd: Path,
|
|
144
|
+
all_child_environment_variables: dict[str, str],
|
|
145
|
+
prompt_text: str,
|
|
146
|
+
timeout_minutes: int,
|
|
147
|
+
controls: ProcessControls,
|
|
148
|
+
) -> tuple[int, float, str]:
|
|
149
|
+
"""Launch the worker process and return its exit code, duration, and stdout.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
all_arguments: The full command line, executable first.
|
|
153
|
+
cwd: Working directory the child process runs in.
|
|
154
|
+
all_child_environment_variables: Environment passed to the child.
|
|
155
|
+
prompt_text: The prompt piped to the child on stdin.
|
|
156
|
+
timeout_minutes: Minutes to wait before the child is timed out.
|
|
157
|
+
controls: The process factory, terminator, and clock.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The exit code, the duration in seconds, and the decoded stdout.
|
|
161
|
+
"""
|
|
162
|
+
process_start = controls.monotonic_clock()
|
|
163
|
+
process = _launch_process(
|
|
164
|
+
controls.process_factory, all_arguments, cwd, all_child_environment_variables
|
|
165
|
+
)
|
|
166
|
+
exit_code, stdout_text = _await_completion(
|
|
167
|
+
process, prompt_text, timeout_minutes, controls.process_terminator
|
|
168
|
+
)
|
|
169
|
+
return exit_code, controls.monotonic_clock() - process_start, stdout_text
|