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.
- 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/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
|
@@ -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
|
-
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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.
|
|
@@ -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
|
+
});
|
package/docs/DJANGO_PATTERNS.md
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
## Production Migrations (Post-Launch ONLY)
|
|
37
37
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/TEST_QUALITY.md
CHANGED
|
@@ -4,19 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Test Infrastructure Anti-Patterns
|
|
6
6
|
|
|
7
|
-
|
|
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.
|
|
16
|
-
|
|
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
|
|
44
|
+
"""Print ordered UserPromptSubmit additionalContext."""
|
|
31
45
|
payload = {
|
|
32
46
|
"hookSpecificOutput": {
|
|
33
47
|
"hookEventName": "UserPromptSubmit",
|
|
34
|
-
"additionalContext":
|
|
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
|
|
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())
|