dsh-conductor 0.3.0__tar.gz
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.
- dsh_conductor-0.3.0/LICENSE +21 -0
- dsh_conductor-0.3.0/MANIFEST.in +5 -0
- dsh_conductor-0.3.0/PKG-INFO +123 -0
- dsh_conductor-0.3.0/README.md +101 -0
- dsh_conductor-0.3.0/conductor/__init__.py +24 -0
- dsh_conductor-0.3.0/conductor/__main__.py +3 -0
- dsh_conductor-0.3.0/conductor/agents/__init__.py +1 -0
- dsh_conductor-0.3.0/conductor/agents/base.py +355 -0
- dsh_conductor-0.3.0/conductor/agents/claude.py +23 -0
- dsh_conductor-0.3.0/conductor/agents/codex.py +24 -0
- dsh_conductor-0.3.0/conductor/cli.py +234 -0
- dsh_conductor-0.3.0/conductor/dsh.py +460 -0
- dsh_conductor-0.3.0/conductor/models.py +320 -0
- dsh_conductor-0.3.0/conductor/progress.py +216 -0
- dsh_conductor-0.3.0/conductor/prompt.py +209 -0
- dsh_conductor-0.3.0/conductor/sdk.py +293 -0
- dsh_conductor-0.3.0/conductor/skills.py +92 -0
- dsh_conductor-0.3.0/conductor/state.py +167 -0
- dsh_conductor-0.3.0/conductor/tmux.py +199 -0
- dsh_conductor-0.3.0/conductor/worker_log.py +204 -0
- dsh_conductor-0.3.0/docs/architecture.md +65 -0
- dsh_conductor-0.3.0/docs/pypi-publishing.md +57 -0
- dsh_conductor-0.3.0/docs/result-json.md +303 -0
- dsh_conductor-0.3.0/dsh_conductor.egg-info/PKG-INFO +123 -0
- dsh_conductor-0.3.0/dsh_conductor.egg-info/SOURCES.txt +44 -0
- dsh_conductor-0.3.0/dsh_conductor.egg-info/dependency_links.txt +1 -0
- dsh_conductor-0.3.0/dsh_conductor.egg-info/entry_points.txt +2 -0
- dsh_conductor-0.3.0/dsh_conductor.egg-info/top_level.txt +1 -0
- dsh_conductor-0.3.0/examples/quickstart.py +26 -0
- dsh_conductor-0.3.0/pyproject.toml +41 -0
- dsh_conductor-0.3.0/setup.cfg +4 -0
- dsh_conductor-0.3.0/skills/tmux-claude-code/SKILL.md +58 -0
- dsh_conductor-0.3.0/skills/tmux-claude-code/scripts/claude_session.py +23 -0
- dsh_conductor-0.3.0/skills/tmux-codex/SKILL.md +59 -0
- dsh_conductor-0.3.0/skills/tmux-codex/scripts/codex_session.py +23 -0
- dsh_conductor-0.3.0/tests/test_agents.py +122 -0
- dsh_conductor-0.3.0/tests/test_cli.py +68 -0
- dsh_conductor-0.3.0/tests/test_dsh.py +90 -0
- dsh_conductor-0.3.0/tests/test_models.py +144 -0
- dsh_conductor-0.3.0/tests/test_progress.py +24 -0
- dsh_conductor-0.3.0/tests/test_prompt.py +46 -0
- dsh_conductor-0.3.0/tests/test_sdk.py +46 -0
- dsh_conductor-0.3.0/tests/test_skills.py +53 -0
- dsh_conductor-0.3.0/tests/test_state.py +46 -0
- dsh_conductor-0.3.0/tests/test_tmux.py +44 -0
- dsh_conductor-0.3.0/tests/test_worker_log.py +88 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dsh-conductor
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: DSH-managed coding-agent delegation with independent verification
|
|
5
|
+
Author-email: Yu <66neko@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/66neko/dsh-conductor
|
|
8
|
+
Project-URL: Repository, https://github.com/66neko/dsh-conductor
|
|
9
|
+
Project-URL: Issues, https://github.com/66neko/dsh-conductor/issues
|
|
10
|
+
Keywords: dsh,deepseek,tmux,claude-code,codex,orchestration,sdk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# dsh-conductor
|
|
24
|
+
|
|
25
|
+
`dsh-conductor` 是一个零第三方依赖的 Python 3.13 SDK。它把一段自然语言 prompt 交给工作环境中的 DSH:DSH 负责拆解任务、根据 prompt 选择 Claude Code 或 Codex、通过 tmux 监督执行、独立验证产物,并返回结构化验收结果。
|
|
26
|
+
|
|
27
|
+
DSH 由运行环境提供,本项目不会安装或调用模型 API。Claude Code 与 Codex 分别由 `tmux-claude-code` 和 `tmux-codex` 两个独立 skill 驱动。
|
|
28
|
+
|
|
29
|
+
## SDK 使用
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from conductor import Conductor, ConductorConfig
|
|
35
|
+
|
|
36
|
+
client = Conductor(
|
|
37
|
+
workspace=Path("/path/to/project"),
|
|
38
|
+
config=ConductorConfig(
|
|
39
|
+
# dsh_bin=None 时从 DSH_BIN 或 PATH 查找。
|
|
40
|
+
dsh_bin=None,
|
|
41
|
+
max_attempts=2,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
result = client.run(
|
|
46
|
+
"""
|
|
47
|
+
请使用 Codex 完成以下任务:创建 hello.txt,内容为 Hello Conductor 加一个换行。
|
|
48
|
+
|
|
49
|
+
验收标准:hello.txt 必须存在,且 bytes 恰好等于 b'Hello Conductor\\n'。
|
|
50
|
+
""",
|
|
51
|
+
on_event=lambda event: print(event.format()),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
print(result.accepted)
|
|
55
|
+
print(result.plan.task_summary)
|
|
56
|
+
print(result.verdict.summary)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`run()` 的第一个参数是唯一业务输入。prompt 中可以明确写“使用 Claude Code”或“使用 Codex”;没有明确指定时,DSH 根据任务与当前环境选择。调用方不再传 `agent`、`task` 或 `verify` 参数。
|
|
60
|
+
|
|
61
|
+
`on_event` 会在运行中接收 `RunEvent`,事件来源包括 `dsh`、`claude`、`codex` 和 `conductor`。事件只用于显示进度,完成事实仍由 DSH 协议、worker receipt 和最终 verdict 文件决定。`TaskResult` 包含:
|
|
62
|
+
|
|
63
|
+
- `plan`:DSH 生成的 agent、任务摘要、步骤和带 ID 的验收项;
|
|
64
|
+
- `verdict`:DSH 独立检查后的 `accepted`/`rejected`、每项证据、产物和剩余问题;
|
|
65
|
+
- `state_directory`:本轮 request、plan、task、receipt、verdict 与 `worker-screen.log`;
|
|
66
|
+
- `dsh`:协议状态、耗时、事件数量和 DSH 最后文本。
|
|
67
|
+
|
|
68
|
+
被 DSH 拒绝是正常业务结果,`result.accepted` 为 `False`;DSH 启动失败、协议失败或结果文件不合法时抛出 `ConductorError`。
|
|
69
|
+
|
|
70
|
+
完整字段、嵌套对象、错误结果和实时事件格式见 [`docs/result-json.md`](docs/result-json.md)。可运行示例见 [`examples/quickstart.py`](examples/quickstart.py)。
|
|
71
|
+
|
|
72
|
+
## CLI
|
|
73
|
+
|
|
74
|
+
CLI 是 SDK 的薄封装,保留给脚本和人工调用:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python3.13 -m conductor install-skills --workspace /path/to/project
|
|
78
|
+
python3.13 -m conductor doctor
|
|
79
|
+
python3.13 -m conductor run \
|
|
80
|
+
--workspace /path/to/project \
|
|
81
|
+
--prompt '请使用 Claude Code 创建 hello.txt。验收标准:文件存在且内容为 Hello。'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`run` 的 stdout 始终只有一个 JSON 对象;实时事件由 CLI 写入 stderr,因此可以安全地重定向 stdout。每次运行启动 DSH 前,SDK 都会把包内两个 skill 直接覆盖到 `<workspace>/.dsh/skills/`,供 DSH 项目级发现;不会写入 `~/.dsh/skills`,任务结束后也不会删除。`install-skills --workspace` 可提前执行同样的复制操作。`show` 可读取最近一次运行的 request、用户 prompt、manager prompt、plan、verdict 和日志路径。
|
|
85
|
+
|
|
86
|
+
`.dsh/skills/` 是 SDK 生成的运行目录,建议加入项目的 Git 忽略规则。
|
|
87
|
+
|
|
88
|
+
## 日志与状态
|
|
89
|
+
|
|
90
|
+
conductor 会同时轮询两个候选 tmux 会话;DSH 选择哪个 agent 后,只有实际存在的会话产生屏幕日志。默认每 5 秒采样一次,SDK 可通过 `ConductorConfig(worker_log_interval_seconds=...)` 调整,CLI 可通过 `--worker-log-interval-seconds` 调整。变化会通过 `RunEvent(source="claude"/"codex", kind="worker_output")` 回调,并追加到 `worker-screen.log`。屏幕文字不会被当作完成或验收信号。
|
|
91
|
+
|
|
92
|
+
状态目录默认是 `$XDG_STATE_HOME/dsh-conductor`,未设置时为 `~/.local/state/dsh-conductor`:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
runs/<run-id>/
|
|
96
|
+
├── request.json
|
|
97
|
+
├── user-prompt.md
|
|
98
|
+
├── manager-prompt.md
|
|
99
|
+
├── plan.json
|
|
100
|
+
├── verdict.json
|
|
101
|
+
├── worker-screen.log
|
|
102
|
+
└── attempts/
|
|
103
|
+
├── claude/1/{task.md,receipt.json}
|
|
104
|
+
└── codex/1/{task.md,receipt.json}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
每轮运行使用新的 run id 和 receipt token,避免读取旧结论。accepted verdict 必须同时满足 DSH `turn/end` + `session.status=idle`、所有验收项通过、产物路径安全且选定 agent 的最终 receipt 有效。
|
|
108
|
+
|
|
109
|
+
## 开发验证
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python3.13 -m compileall -q conductor skills tests
|
|
113
|
+
python3.13 -m unittest discover -v
|
|
114
|
+
python3.13 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/tmux-claude-code
|
|
115
|
+
python3.13 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/tmux-codex
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## 发布
|
|
119
|
+
|
|
120
|
+
推送 GitHub Release 后,`.github/workflows/publish.yml` 会先在独立 job 中运行测试并构建
|
|
121
|
+
发行包,再用 PyPI Trusted Publishing 上传已验证的 wheel 与 sdist。首次发布前,需要在
|
|
122
|
+
PyPI 为 GitHub Actions 配置 Trusted Publisher,并在 GitHub 创建 `pypi` 环境。完整字段、
|
|
123
|
+
版本标签规则和发布步骤见 [`docs/pypi-publishing.md`](docs/pypi-publishing.md)。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# dsh-conductor
|
|
2
|
+
|
|
3
|
+
`dsh-conductor` 是一个零第三方依赖的 Python 3.13 SDK。它把一段自然语言 prompt 交给工作环境中的 DSH:DSH 负责拆解任务、根据 prompt 选择 Claude Code 或 Codex、通过 tmux 监督执行、独立验证产物,并返回结构化验收结果。
|
|
4
|
+
|
|
5
|
+
DSH 由运行环境提供,本项目不会安装或调用模型 API。Claude Code 与 Codex 分别由 `tmux-claude-code` 和 `tmux-codex` 两个独立 skill 驱动。
|
|
6
|
+
|
|
7
|
+
## SDK 使用
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from conductor import Conductor, ConductorConfig
|
|
13
|
+
|
|
14
|
+
client = Conductor(
|
|
15
|
+
workspace=Path("/path/to/project"),
|
|
16
|
+
config=ConductorConfig(
|
|
17
|
+
# dsh_bin=None 时从 DSH_BIN 或 PATH 查找。
|
|
18
|
+
dsh_bin=None,
|
|
19
|
+
max_attempts=2,
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = client.run(
|
|
24
|
+
"""
|
|
25
|
+
请使用 Codex 完成以下任务:创建 hello.txt,内容为 Hello Conductor 加一个换行。
|
|
26
|
+
|
|
27
|
+
验收标准:hello.txt 必须存在,且 bytes 恰好等于 b'Hello Conductor\\n'。
|
|
28
|
+
""",
|
|
29
|
+
on_event=lambda event: print(event.format()),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
print(result.accepted)
|
|
33
|
+
print(result.plan.task_summary)
|
|
34
|
+
print(result.verdict.summary)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`run()` 的第一个参数是唯一业务输入。prompt 中可以明确写“使用 Claude Code”或“使用 Codex”;没有明确指定时,DSH 根据任务与当前环境选择。调用方不再传 `agent`、`task` 或 `verify` 参数。
|
|
38
|
+
|
|
39
|
+
`on_event` 会在运行中接收 `RunEvent`,事件来源包括 `dsh`、`claude`、`codex` 和 `conductor`。事件只用于显示进度,完成事实仍由 DSH 协议、worker receipt 和最终 verdict 文件决定。`TaskResult` 包含:
|
|
40
|
+
|
|
41
|
+
- `plan`:DSH 生成的 agent、任务摘要、步骤和带 ID 的验收项;
|
|
42
|
+
- `verdict`:DSH 独立检查后的 `accepted`/`rejected`、每项证据、产物和剩余问题;
|
|
43
|
+
- `state_directory`:本轮 request、plan、task、receipt、verdict 与 `worker-screen.log`;
|
|
44
|
+
- `dsh`:协议状态、耗时、事件数量和 DSH 最后文本。
|
|
45
|
+
|
|
46
|
+
被 DSH 拒绝是正常业务结果,`result.accepted` 为 `False`;DSH 启动失败、协议失败或结果文件不合法时抛出 `ConductorError`。
|
|
47
|
+
|
|
48
|
+
完整字段、嵌套对象、错误结果和实时事件格式见 [`docs/result-json.md`](docs/result-json.md)。可运行示例见 [`examples/quickstart.py`](examples/quickstart.py)。
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
CLI 是 SDK 的薄封装,保留给脚本和人工调用:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3.13 -m conductor install-skills --workspace /path/to/project
|
|
56
|
+
python3.13 -m conductor doctor
|
|
57
|
+
python3.13 -m conductor run \
|
|
58
|
+
--workspace /path/to/project \
|
|
59
|
+
--prompt '请使用 Claude Code 创建 hello.txt。验收标准:文件存在且内容为 Hello。'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`run` 的 stdout 始终只有一个 JSON 对象;实时事件由 CLI 写入 stderr,因此可以安全地重定向 stdout。每次运行启动 DSH 前,SDK 都会把包内两个 skill 直接覆盖到 `<workspace>/.dsh/skills/`,供 DSH 项目级发现;不会写入 `~/.dsh/skills`,任务结束后也不会删除。`install-skills --workspace` 可提前执行同样的复制操作。`show` 可读取最近一次运行的 request、用户 prompt、manager prompt、plan、verdict 和日志路径。
|
|
63
|
+
|
|
64
|
+
`.dsh/skills/` 是 SDK 生成的运行目录,建议加入项目的 Git 忽略规则。
|
|
65
|
+
|
|
66
|
+
## 日志与状态
|
|
67
|
+
|
|
68
|
+
conductor 会同时轮询两个候选 tmux 会话;DSH 选择哪个 agent 后,只有实际存在的会话产生屏幕日志。默认每 5 秒采样一次,SDK 可通过 `ConductorConfig(worker_log_interval_seconds=...)` 调整,CLI 可通过 `--worker-log-interval-seconds` 调整。变化会通过 `RunEvent(source="claude"/"codex", kind="worker_output")` 回调,并追加到 `worker-screen.log`。屏幕文字不会被当作完成或验收信号。
|
|
69
|
+
|
|
70
|
+
状态目录默认是 `$XDG_STATE_HOME/dsh-conductor`,未设置时为 `~/.local/state/dsh-conductor`:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
runs/<run-id>/
|
|
74
|
+
├── request.json
|
|
75
|
+
├── user-prompt.md
|
|
76
|
+
├── manager-prompt.md
|
|
77
|
+
├── plan.json
|
|
78
|
+
├── verdict.json
|
|
79
|
+
├── worker-screen.log
|
|
80
|
+
└── attempts/
|
|
81
|
+
├── claude/1/{task.md,receipt.json}
|
|
82
|
+
└── codex/1/{task.md,receipt.json}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
每轮运行使用新的 run id 和 receipt token,避免读取旧结论。accepted verdict 必须同时满足 DSH `turn/end` + `session.status=idle`、所有验收项通过、产物路径安全且选定 agent 的最终 receipt 有效。
|
|
86
|
+
|
|
87
|
+
## 开发验证
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python3.13 -m compileall -q conductor skills tests
|
|
91
|
+
python3.13 -m unittest discover -v
|
|
92
|
+
python3.13 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/tmux-claude-code
|
|
93
|
+
python3.13 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/tmux-codex
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 发布
|
|
97
|
+
|
|
98
|
+
推送 GitHub Release 后,`.github/workflows/publish.yml` 会先在独立 job 中运行测试并构建
|
|
99
|
+
发行包,再用 PyPI Trusted Publishing 上传已验证的 wheel 与 sdist。首次发布前,需要在
|
|
100
|
+
PyPI 为 GitHub Actions 配置 Trusted Publisher,并在 GitHub 创建 `pypi` 环境。完整字段、
|
|
101
|
+
版本标签规则和发布步骤见 [`docs/pypi-publishing.md`](docs/pypi-publishing.md)。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""由 DSH 管理运行在 tmux 中的编码 agent。"""
|
|
2
|
+
|
|
3
|
+
from .dsh import DshClient, DshConfig, DshError, RunResult
|
|
4
|
+
from .models import AgentKind, ExecutionPlan, Verdict
|
|
5
|
+
from .progress import RunEvent
|
|
6
|
+
from .sdk import Conductor, ConductorConfig, ConductorError, TaskResult
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AgentKind",
|
|
12
|
+
"Conductor",
|
|
13
|
+
"ConductorConfig",
|
|
14
|
+
"ConductorError",
|
|
15
|
+
"DshClient",
|
|
16
|
+
"DshConfig",
|
|
17
|
+
"DshError",
|
|
18
|
+
"RunResult",
|
|
19
|
+
"RunEvent",
|
|
20
|
+
"TaskResult",
|
|
21
|
+
"ExecutionPlan",
|
|
22
|
+
"Verdict",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""不同 agent 各自独立的 tmux 协议。"""
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""共享控制器机制;所有 TUI 差异由具体 adapter 负责。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Pattern, Sequence
|
|
16
|
+
|
|
17
|
+
from ..models import RecordError, WorkerReceipt
|
|
18
|
+
from ..state import atomic_write_json
|
|
19
|
+
from ..tmux import TmuxError, TmuxSession
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorkerError(RuntimeError):
|
|
23
|
+
"""worker 会话未能到达可验证的交接点。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AgentAdapter:
|
|
28
|
+
kind: str
|
|
29
|
+
executable: str
|
|
30
|
+
arguments: tuple[str, ...]
|
|
31
|
+
cursor_glyphs: tuple[str, ...]
|
|
32
|
+
menu_hint: Pattern[str]
|
|
33
|
+
affirmative: Pattern[str]
|
|
34
|
+
ready: Pattern[str]
|
|
35
|
+
pending_submission: Pattern[str] | None = None
|
|
36
|
+
|
|
37
|
+
def resolve_command(self, explicit_binary: str | None = None) -> list[str]:
|
|
38
|
+
raw = explicit_binary or shutil.which(self.executable)
|
|
39
|
+
if not raw:
|
|
40
|
+
raise WorkerError(f"cannot find {self.executable} on PATH; pass --binary")
|
|
41
|
+
binary = Path(raw).expanduser().resolve()
|
|
42
|
+
if not binary.is_file():
|
|
43
|
+
raise WorkerError(f"agent executable does not exist: {binary}")
|
|
44
|
+
return [str(binary), *self.arguments]
|
|
45
|
+
|
|
46
|
+
def cursor_label(self, screen: str) -> str | None:
|
|
47
|
+
for line in screen.splitlines():
|
|
48
|
+
for glyph in self.cursor_glyphs:
|
|
49
|
+
if glyph in line:
|
|
50
|
+
label = line.split(glyph, 1)[1].strip()
|
|
51
|
+
return re.sub(r"^\d+[.)]\s*", "", label)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def is_menu(self, screen: str) -> bool:
|
|
55
|
+
return bool(self.menu_hint.search(screen))
|
|
56
|
+
|
|
57
|
+
def is_ready(self, screen: str) -> bool:
|
|
58
|
+
return not self.is_menu(screen) and bool(self.ready.search(screen))
|
|
59
|
+
|
|
60
|
+
def has_pending_submission(self, screen: str) -> bool:
|
|
61
|
+
return self.pending_submission is not None and bool(self.pending_submission.search(screen))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit(value: object) -> None:
|
|
65
|
+
json.dump(value, sys.stdout, ensure_ascii=False)
|
|
66
|
+
sys.stdout.write("\n")
|
|
67
|
+
sys.stdout.flush()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _submission_prompt(*, task_file: Path, script: Path, receipt: Path, token: str) -> str:
|
|
71
|
+
# TUI 只接收短指令;任务正文留在文件中,避免长粘贴在交互式编辑器中被截断。
|
|
72
|
+
command = (
|
|
73
|
+
f"python3.13 {shlex.quote(str(script))} complete "
|
|
74
|
+
f"--receipt {shlex.quote(str(receipt))} --token {shlex.quote(token)} "
|
|
75
|
+
"--status ready_for_verification --summary '简短事实总结'"
|
|
76
|
+
)
|
|
77
|
+
blocked = command.replace("ready_for_verification", "blocked")
|
|
78
|
+
return f"""
|
|
79
|
+
<dsh_conductor_handoff>
|
|
80
|
+
请完整读取任务文件 `{task_file}`,自主完成其中的任务和自检。该文件不是验收结论。
|
|
81
|
+
全部编辑与检查结束后,最后一个工具操作必须运行下列命令,并把 summary 占位文字改为简短事实总结:
|
|
82
|
+
|
|
83
|
+
{command}
|
|
84
|
+
|
|
85
|
+
如果外部阻塞导致无法完成,改用下列命令,并在 summary 中说明阻塞原因:
|
|
86
|
+
|
|
87
|
+
{blocked}
|
|
88
|
+
|
|
89
|
+
写入回执后不要继续工作。回执只表示可以交给 DSH 独立验收,不表示任务已经通过验收。
|
|
90
|
+
</dsh_conductor_handoff>
|
|
91
|
+
""".strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wait_until_ready(
|
|
95
|
+
session: TmuxSession,
|
|
96
|
+
adapter: AgentAdapter,
|
|
97
|
+
*,
|
|
98
|
+
timeout_seconds: float,
|
|
99
|
+
ready_settle_seconds: float = 2.0,
|
|
100
|
+
) -> None:
|
|
101
|
+
deadline = time.monotonic() + timeout_seconds
|
|
102
|
+
menu_steps = 0
|
|
103
|
+
ready_since: float | None = None
|
|
104
|
+
while time.monotonic() < deadline:
|
|
105
|
+
screen = session.capture()
|
|
106
|
+
status = session.status()
|
|
107
|
+
if status.pane_dead:
|
|
108
|
+
raise WorkerError(f"{adapter.kind} exited during startup\n{screen[-2000:]}")
|
|
109
|
+
if adapter.is_menu(screen):
|
|
110
|
+
ready_since = None
|
|
111
|
+
label = adapter.cursor_label(screen)
|
|
112
|
+
if label and adapter.affirmative.search(label):
|
|
113
|
+
session.send_keys("Enter")
|
|
114
|
+
time.sleep(1.0)
|
|
115
|
+
menu_steps = 0
|
|
116
|
+
continue
|
|
117
|
+
if menu_steps >= 8:
|
|
118
|
+
raise WorkerError(f"cannot find an affirmative startup option\n{screen[-2000:]}")
|
|
119
|
+
menu_steps += 1
|
|
120
|
+
session.send_keys("Down")
|
|
121
|
+
# Claude Code 异步重绘菜单;过早读屏会看到旧光标并误判为没有移动。
|
|
122
|
+
time.sleep(0.75)
|
|
123
|
+
continue
|
|
124
|
+
if adapter.is_ready(screen):
|
|
125
|
+
# 启动界面可能先显示输入框,随后才弹出目录信任菜单;稳定后再粘贴任务。
|
|
126
|
+
if ready_since is None:
|
|
127
|
+
ready_since = time.monotonic()
|
|
128
|
+
elif time.monotonic() - ready_since >= ready_settle_seconds:
|
|
129
|
+
return
|
|
130
|
+
else:
|
|
131
|
+
ready_since = None
|
|
132
|
+
time.sleep(0.5)
|
|
133
|
+
raise WorkerError(f"{adapter.kind} did not become ready\n{session.capture()[-2000:]}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def wait_for_receipt(
|
|
137
|
+
session: TmuxSession,
|
|
138
|
+
*,
|
|
139
|
+
adapter: AgentAdapter,
|
|
140
|
+
receipt_file: Path,
|
|
141
|
+
token: str,
|
|
142
|
+
timeout_seconds: float,
|
|
143
|
+
submission: str,
|
|
144
|
+
) -> tuple[WorkerReceipt, float]:
|
|
145
|
+
started = time.monotonic()
|
|
146
|
+
deadline = started + timeout_seconds
|
|
147
|
+
last_error: str | None = None
|
|
148
|
+
menu_steps = 0
|
|
149
|
+
resubmit_after_menu = False
|
|
150
|
+
last_submit = time.monotonic()
|
|
151
|
+
while time.monotonic() < deadline:
|
|
152
|
+
if receipt_file.exists():
|
|
153
|
+
try:
|
|
154
|
+
receipt = WorkerReceipt.load(receipt_file, expected_token=token)
|
|
155
|
+
return receipt, time.monotonic() - started
|
|
156
|
+
except RecordError as exc:
|
|
157
|
+
last_error = str(exc)
|
|
158
|
+
screen = session.capture()
|
|
159
|
+
if adapter.is_menu(screen):
|
|
160
|
+
label = adapter.cursor_label(screen)
|
|
161
|
+
if label and adapter.affirmative.search(label):
|
|
162
|
+
session.send_keys("Enter")
|
|
163
|
+
menu_steps = 0
|
|
164
|
+
resubmit_after_menu = True
|
|
165
|
+
time.sleep(1.0)
|
|
166
|
+
continue
|
|
167
|
+
if menu_steps >= 8:
|
|
168
|
+
raise WorkerError(f"cannot handle worker menu while waiting for receipt\n{screen[-2000:]}")
|
|
169
|
+
session.send_keys("Down")
|
|
170
|
+
menu_steps += 1
|
|
171
|
+
time.sleep(0.75)
|
|
172
|
+
continue
|
|
173
|
+
if resubmit_after_menu and adapter.is_ready(screen):
|
|
174
|
+
# 菜单可能截断首次粘贴;清空残留输入后完整重投,不能只补发 Enter。
|
|
175
|
+
session.send_keys("C-c")
|
|
176
|
+
time.sleep(0.25)
|
|
177
|
+
session.send_text(submission)
|
|
178
|
+
resubmit_after_menu = False
|
|
179
|
+
last_submit = time.monotonic()
|
|
180
|
+
time.sleep(0.5)
|
|
181
|
+
continue
|
|
182
|
+
if adapter.has_pending_submission(screen) and time.monotonic() - last_submit >= 2.0:
|
|
183
|
+
# Codex 可能在 Enter 到达后才完成 bracketed paste。仅当编辑器仍显示
|
|
184
|
+
# pending-paste 标记时节流重发,避免无依据地重复提交任务。
|
|
185
|
+
session.send_keys("Enter")
|
|
186
|
+
last_submit = time.monotonic()
|
|
187
|
+
time.sleep(0.5)
|
|
188
|
+
continue
|
|
189
|
+
if session.status().pane_dead:
|
|
190
|
+
raise WorkerError(f"worker process exited before writing its receipt\n{screen[-2000:]}")
|
|
191
|
+
time.sleep(0.5)
|
|
192
|
+
detail = f"; last receipt error: {last_error}" if last_error else ""
|
|
193
|
+
raise WorkerError(f"timed out waiting for worker receipt {receipt_file}{detail}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def submit(
|
|
197
|
+
*,
|
|
198
|
+
session: TmuxSession,
|
|
199
|
+
adapter: AgentAdapter,
|
|
200
|
+
task_file: Path,
|
|
201
|
+
receipt_file: Path,
|
|
202
|
+
token: str,
|
|
203
|
+
script: Path,
|
|
204
|
+
timeout_seconds: float,
|
|
205
|
+
) -> dict[str, object]:
|
|
206
|
+
if receipt_file.exists():
|
|
207
|
+
raise WorkerError(f"refusing stale receipt path: {receipt_file}")
|
|
208
|
+
try:
|
|
209
|
+
task = task_file.read_text(encoding="utf-8")
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise WorkerError(f"cannot read task file {task_file}: {exc}") from exc
|
|
212
|
+
if not task.strip():
|
|
213
|
+
raise WorkerError(f"task file is empty: {task_file}")
|
|
214
|
+
receipt_file.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
prompt = _submission_prompt(
|
|
216
|
+
task_file=task_file.resolve(),
|
|
217
|
+
script=script,
|
|
218
|
+
receipt=receipt_file.resolve(),
|
|
219
|
+
token=token,
|
|
220
|
+
)
|
|
221
|
+
session.send_text(prompt)
|
|
222
|
+
# 长文本粘贴可能在首个 Enter 后才完成展开;菜单由后续分支处理,其余状态只补交一次。
|
|
223
|
+
time.sleep(2.0)
|
|
224
|
+
if not receipt_file.exists() and not adapter.is_menu(session.capture()):
|
|
225
|
+
session.send_keys("Enter")
|
|
226
|
+
receipt, elapsed = wait_for_receipt(
|
|
227
|
+
session,
|
|
228
|
+
adapter=adapter,
|
|
229
|
+
receipt_file=receipt_file,
|
|
230
|
+
token=token,
|
|
231
|
+
timeout_seconds=timeout_seconds,
|
|
232
|
+
submission=prompt,
|
|
233
|
+
)
|
|
234
|
+
return {
|
|
235
|
+
"schema_version": 1,
|
|
236
|
+
"session": session.name,
|
|
237
|
+
"agent": adapter.kind,
|
|
238
|
+
"status": receipt.status,
|
|
239
|
+
"summary": receipt.summary,
|
|
240
|
+
"receipt_file": str(receipt_file.resolve()),
|
|
241
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
242
|
+
"attach_command": f"tmux attach -t {session.name}",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_parser(adapter: AgentAdapter) -> argparse.ArgumentParser:
|
|
247
|
+
parser = argparse.ArgumentParser(
|
|
248
|
+
prog=f"{adapter.kind}_session.py",
|
|
249
|
+
description=f"Control one {adapter.kind} interactive session through tmux.",
|
|
250
|
+
)
|
|
251
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
252
|
+
|
|
253
|
+
run = commands.add_parser("run", help="start a session, submit a task, and wait for its receipt")
|
|
254
|
+
run.add_argument("--workspace", type=Path, required=True)
|
|
255
|
+
run.add_argument("--session", required=True)
|
|
256
|
+
run.add_argument("--task-file", type=Path, required=True)
|
|
257
|
+
run.add_argument("--receipt", type=Path, required=True)
|
|
258
|
+
run.add_argument("--token", required=True)
|
|
259
|
+
run.add_argument("--binary")
|
|
260
|
+
run.add_argument("--startup-timeout-seconds", type=float, default=120.0)
|
|
261
|
+
run.add_argument("--timeout-seconds", type=float, default=1200.0)
|
|
262
|
+
|
|
263
|
+
send = commands.add_parser("send", help="submit a follow-up to the same session")
|
|
264
|
+
send.add_argument("--session", required=True)
|
|
265
|
+
send.add_argument("--task-file", type=Path, required=True)
|
|
266
|
+
send.add_argument("--receipt", type=Path, required=True)
|
|
267
|
+
send.add_argument("--token", required=True)
|
|
268
|
+
send.add_argument("--timeout-seconds", type=float, default=1200.0)
|
|
269
|
+
|
|
270
|
+
for name in ("status", "capture", "close"):
|
|
271
|
+
command = commands.add_parser(name)
|
|
272
|
+
command.add_argument("--session", required=True)
|
|
273
|
+
capture = commands.choices["capture"]
|
|
274
|
+
capture.add_argument("--history-lines", type=int, default=0)
|
|
275
|
+
|
|
276
|
+
complete = commands.add_parser("complete", help="atomically write a worker receipt")
|
|
277
|
+
complete.add_argument("--receipt", type=Path, required=True)
|
|
278
|
+
complete.add_argument("--token", required=True)
|
|
279
|
+
complete.add_argument(
|
|
280
|
+
"--status",
|
|
281
|
+
choices=("ready_for_verification", "blocked"),
|
|
282
|
+
required=True,
|
|
283
|
+
)
|
|
284
|
+
complete.add_argument("--summary", required=True)
|
|
285
|
+
return parser
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(adapter: AgentAdapter, argv: Sequence[str] | None = None) -> int:
|
|
289
|
+
parser = build_parser(adapter)
|
|
290
|
+
args = parser.parse_args(argv)
|
|
291
|
+
script = Path(sys.argv[0]).resolve()
|
|
292
|
+
try:
|
|
293
|
+
if args.command == "complete":
|
|
294
|
+
if args.receipt.exists():
|
|
295
|
+
raise WorkerError(f"receipt already exists: {args.receipt}")
|
|
296
|
+
atomic_write_json(
|
|
297
|
+
args.receipt.resolve(),
|
|
298
|
+
WorkerReceipt(args.token, args.status, args.summary).to_json(),
|
|
299
|
+
)
|
|
300
|
+
_emit({"schema_version": 1, "status": "receipt_written", "receipt": str(args.receipt)})
|
|
301
|
+
return 0
|
|
302
|
+
if args.command == "run":
|
|
303
|
+
session = TmuxSession.create(
|
|
304
|
+
name=args.session,
|
|
305
|
+
workspace=args.workspace,
|
|
306
|
+
agent=adapter.kind,
|
|
307
|
+
command=adapter.resolve_command(args.binary),
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
wait_until_ready(session, adapter, timeout_seconds=args.startup_timeout_seconds)
|
|
311
|
+
_emit(
|
|
312
|
+
submit(
|
|
313
|
+
session=session,
|
|
314
|
+
adapter=adapter,
|
|
315
|
+
task_file=args.task_file,
|
|
316
|
+
receipt_file=args.receipt,
|
|
317
|
+
token=args.token,
|
|
318
|
+
script=script,
|
|
319
|
+
timeout_seconds=args.timeout_seconds,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
return 0
|
|
323
|
+
except Exception:
|
|
324
|
+
# 失败会话保留给 DSH 或调用方排查,不能在异常路径中销毁现场。
|
|
325
|
+
raise
|
|
326
|
+
session = TmuxSession.attach(name=args.session, expected_agent=adapter.kind)
|
|
327
|
+
if args.command == "send":
|
|
328
|
+
_emit(
|
|
329
|
+
submit(
|
|
330
|
+
session=session,
|
|
331
|
+
adapter=adapter,
|
|
332
|
+
task_file=args.task_file,
|
|
333
|
+
receipt_file=args.receipt,
|
|
334
|
+
token=args.token,
|
|
335
|
+
script=script,
|
|
336
|
+
timeout_seconds=args.timeout_seconds,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
elif args.command == "status":
|
|
340
|
+
_emit({"schema_version": 1, **session.status().to_json()})
|
|
341
|
+
elif args.command == "capture":
|
|
342
|
+
_emit(
|
|
343
|
+
{
|
|
344
|
+
"schema_version": 1,
|
|
345
|
+
**session.status().to_json(),
|
|
346
|
+
"screen": session.capture(history_lines=args.history_lines),
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
elif args.command == "close":
|
|
350
|
+
session.close()
|
|
351
|
+
_emit({"schema_version": 1, "status": "closed", "session": args.session})
|
|
352
|
+
return 0
|
|
353
|
+
except (WorkerError, TmuxError, RecordError, OSError) as exc:
|
|
354
|
+
print(f"{adapter.kind}_session: {exc}", file=sys.stderr)
|
|
355
|
+
return 1
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Claude Code 的 tmux 启动与就绪协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .base import AgentAdapter, main as adapter_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 菜单与就绪表达式只描述 Claude Code,不能与 Codex 的界面规则混用。
|
|
11
|
+
CLAUDE = AgentAdapter(
|
|
12
|
+
kind="claude",
|
|
13
|
+
executable="claude",
|
|
14
|
+
arguments=("--dangerously-skip-permissions",),
|
|
15
|
+
cursor_glyphs=("❯",),
|
|
16
|
+
menu_hint=re.compile(r"Enter to confirm|Esc to cancel|to navigate|to select", re.IGNORECASE),
|
|
17
|
+
affirmative=re.compile(r"^(yes|trust|accept|continue|allow|approve)\b", re.IGNORECASE),
|
|
18
|
+
ready=re.compile(r"❯|bypass permissions on", re.IGNORECASE),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
return adapter_main(CLAUDE, argv)
|