dsh-conductor 0.3.0__py3-none-any.whl
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.
- conductor/__init__.py +24 -0
- conductor/__main__.py +3 -0
- conductor/agents/__init__.py +1 -0
- conductor/agents/base.py +355 -0
- conductor/agents/claude.py +23 -0
- conductor/agents/codex.py +24 -0
- conductor/cli.py +234 -0
- conductor/dsh.py +460 -0
- conductor/models.py +320 -0
- conductor/progress.py +216 -0
- conductor/prompt.py +209 -0
- conductor/sdk.py +293 -0
- conductor/skills.py +92 -0
- conductor/state.py +167 -0
- conductor/tmux.py +199 -0
- conductor/worker_log.py +204 -0
- dsh_conductor-0.3.0.data/data/share/dsh-conductor/skills/tmux-claude-code/SKILL.md +58 -0
- dsh_conductor-0.3.0.data/data/share/dsh-conductor/skills/tmux-claude-code/scripts/claude_session.py +23 -0
- dsh_conductor-0.3.0.data/data/share/dsh-conductor/skills/tmux-codex/SKILL.md +59 -0
- dsh_conductor-0.3.0.data/data/share/dsh-conductor/skills/tmux-codex/scripts/codex_session.py +23 -0
- dsh_conductor-0.3.0.dist-info/METADATA +123 -0
- dsh_conductor-0.3.0.dist-info/RECORD +26 -0
- dsh_conductor-0.3.0.dist-info/WHEEL +5 -0
- dsh_conductor-0.3.0.dist-info/entry_points.txt +2 -0
- dsh_conductor-0.3.0.dist-info/licenses/LICENSE +21 -0
- dsh_conductor-0.3.0.dist-info/top_level.txt +1 -0
conductor/__init__.py
ADDED
|
@@ -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
|
+
]
|
conductor/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""不同 agent 各自独立的 tmux 协议。"""
|
conductor/agents/base.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Codex 的 tmux 启动与就绪协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .base import AgentAdapter, main as adapter_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# pending_submission 是 Codex 长文本粘贴尚未提交的明确界面事实。
|
|
11
|
+
CODEX = AgentAdapter(
|
|
12
|
+
kind="codex",
|
|
13
|
+
executable="codex",
|
|
14
|
+
arguments=("--dangerously-bypass-approvals-and-sandbox", "--no-alt-screen"),
|
|
15
|
+
cursor_glyphs=("›",),
|
|
16
|
+
menu_hint=re.compile(r"Press enter to continue|to navigate|to select", re.IGNORECASE),
|
|
17
|
+
affirmative=re.compile(r"^(yes|continue|allow|approve)\b", re.IGNORECASE),
|
|
18
|
+
ready=re.compile(r"›|permissions:\s*YOLO", re.IGNORECASE),
|
|
19
|
+
pending_submission=re.compile(r"^› \[Pasted Content \d+ chars\]", re.MULTILINE),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
return adapter_main(CODEX, argv)
|
conductor/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""conductor 命令行入口;任务执行统一委托给 Python SDK。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .dsh import DshError, probe_dsh
|
|
16
|
+
from .models import AgentKind, RecordError, read_json_object
|
|
17
|
+
from .progress import RunEvent
|
|
18
|
+
from .sdk import Conductor, ConductorConfig, ConductorError
|
|
19
|
+
from .skills import (
|
|
20
|
+
dsh_home,
|
|
21
|
+
prepare_workspace_skills,
|
|
22
|
+
source_skill,
|
|
23
|
+
)
|
|
24
|
+
from .state import default_state_root
|
|
25
|
+
|
|
26
|
+
type JsonObject = dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _json(value: object) -> None:
|
|
30
|
+
# stdout 是机器协议通道,进度和诊断统一由 SDK 事件回调写 stderr。
|
|
31
|
+
json.dump(value, sys.stdout, ensure_ascii=False, indent=2)
|
|
32
|
+
sys.stdout.write("\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _event_to_stderr(event: RunEvent) -> None:
|
|
36
|
+
print(event.format(), file=sys.stderr)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_install_skills(args: argparse.Namespace) -> int:
|
|
40
|
+
workspace = args.workspace.expanduser().resolve()
|
|
41
|
+
if not workspace.is_dir():
|
|
42
|
+
raise DshError(f"workspace is not a directory: {workspace}")
|
|
43
|
+
skill_root = prepare_workspace_skills(workspace)
|
|
44
|
+
records: list[JsonObject] = []
|
|
45
|
+
for kind in AgentKind:
|
|
46
|
+
source = source_skill(kind)
|
|
47
|
+
target = skill_root / kind.skill_name
|
|
48
|
+
record: JsonObject = {
|
|
49
|
+
"name": kind.skill_name,
|
|
50
|
+
"status": "overwritten",
|
|
51
|
+
"source": str(source),
|
|
52
|
+
"target": str(target),
|
|
53
|
+
}
|
|
54
|
+
records.append(record)
|
|
55
|
+
_json(
|
|
56
|
+
{
|
|
57
|
+
"schema_version": 1,
|
|
58
|
+
"status": "ok",
|
|
59
|
+
"workspace": str(workspace),
|
|
60
|
+
"skill_dir": str(skill_root),
|
|
61
|
+
"skills": records,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
68
|
+
home = dsh_home(args.dsh_home)
|
|
69
|
+
checks: list[JsonObject] = []
|
|
70
|
+
|
|
71
|
+
def add(name: str, ok: bool, detail: object, *, required: bool = True) -> None:
|
|
72
|
+
checks.append({"name": name, "ok": ok, "required": required, "detail": str(detail)})
|
|
73
|
+
|
|
74
|
+
add("python", sys.version_info >= (3, 13), sys.version.split()[0])
|
|
75
|
+
tmux = shutil.which("tmux")
|
|
76
|
+
add("tmux", tmux is not None, tmux or "not found")
|
|
77
|
+
try:
|
|
78
|
+
add("dsh", True, probe_dsh(args.dsh_bin))
|
|
79
|
+
except DshError as exc:
|
|
80
|
+
add("dsh", False, exc)
|
|
81
|
+
available_workers: list[str] = []
|
|
82
|
+
for kind in AgentKind:
|
|
83
|
+
try:
|
|
84
|
+
add(kind.skill_name, True, source_skill(kind))
|
|
85
|
+
except DshError as exc:
|
|
86
|
+
add(kind.skill_name, False, exc)
|
|
87
|
+
binary = shutil.which(kind.value)
|
|
88
|
+
add(f"worker:{kind.value}", binary is not None, binary or "not found", required=False)
|
|
89
|
+
if binary:
|
|
90
|
+
available_workers.append(kind.value)
|
|
91
|
+
add("worker:any", bool(available_workers), ", ".join(available_workers) or "neither claude nor codex found")
|
|
92
|
+
credentials = home / ".credentials.yaml"
|
|
93
|
+
authenticated = credentials.is_file() or bool(os.environ.get("DEEPSEEK_API_KEY"))
|
|
94
|
+
add("dsh-credentials", authenticated, credentials if credentials.is_file() else "not found")
|
|
95
|
+
ok = all(check["ok"] for check in checks if check["required"])
|
|
96
|
+
_json({"schema_version": 1, "status": "ok" if ok else "error", "checks": checks})
|
|
97
|
+
return 0 if ok else 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
101
|
+
workspace = args.workspace.expanduser().resolve()
|
|
102
|
+
try:
|
|
103
|
+
config = ConductorConfig(
|
|
104
|
+
dsh_bin=args.dsh_bin,
|
|
105
|
+
dsh_home=args.dsh_home,
|
|
106
|
+
state_dir=args.state_dir,
|
|
107
|
+
provider=args.provider,
|
|
108
|
+
model=args.model,
|
|
109
|
+
max_attempts=args.max_attempts,
|
|
110
|
+
attempt_timeout_seconds=args.attempt_timeout_seconds,
|
|
111
|
+
timeout_seconds=args.timeout_seconds,
|
|
112
|
+
keep_session=args.keep_session,
|
|
113
|
+
heartbeat_seconds=args.heartbeat_seconds,
|
|
114
|
+
worker_log=args.worker_log,
|
|
115
|
+
worker_log_interval_seconds=args.worker_log_interval_seconds,
|
|
116
|
+
)
|
|
117
|
+
result = Conductor(workspace, config).run(
|
|
118
|
+
args.prompt,
|
|
119
|
+
on_event=None if args.quiet else _event_to_stderr,
|
|
120
|
+
)
|
|
121
|
+
except (ConductorError, ValueError, OSError) as exc:
|
|
122
|
+
if isinstance(exc, ConductorError):
|
|
123
|
+
_json(exc.to_json())
|
|
124
|
+
else:
|
|
125
|
+
_json({"schema_version": 1, "status": "error", "error": str(exc)})
|
|
126
|
+
return 1
|
|
127
|
+
_json(result.to_json())
|
|
128
|
+
return 0 if result.accepted else 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
132
|
+
state_root = args.state_dir.expanduser().resolve()
|
|
133
|
+
if args.run_id:
|
|
134
|
+
run_root = state_root / "runs" / args.run_id
|
|
135
|
+
else:
|
|
136
|
+
try:
|
|
137
|
+
latest = read_json_object(state_root / "latest.json")
|
|
138
|
+
run_root = Path(str(latest["run_directory"]))
|
|
139
|
+
except (RecordError, KeyError) as exc:
|
|
140
|
+
_json({"schema_version": 1, "status": "error", "error": f"cannot resolve latest run: {exc}"})
|
|
141
|
+
return 1
|
|
142
|
+
try:
|
|
143
|
+
request = read_json_object(run_root / "request.json")
|
|
144
|
+
except RecordError as exc:
|
|
145
|
+
_json({"schema_version": 1, "status": "error", "error": str(exc)})
|
|
146
|
+
return 1
|
|
147
|
+
output: JsonObject = {"schema_version": 1, "run_directory": str(run_root), "request": request}
|
|
148
|
+
for name in ("user-prompt.md", "manager-prompt.md", "plan.json", "verdict.json", "worker-screen.log"):
|
|
149
|
+
path = run_root / name
|
|
150
|
+
if not path.exists():
|
|
151
|
+
continue
|
|
152
|
+
if path.suffix == ".json":
|
|
153
|
+
try:
|
|
154
|
+
output[path.stem] = read_json_object(path)
|
|
155
|
+
except RecordError as exc:
|
|
156
|
+
output[f"{path.stem}_error"] = str(exc)
|
|
157
|
+
elif path.suffix == ".log":
|
|
158
|
+
output["worker_log"] = str(path)
|
|
159
|
+
else:
|
|
160
|
+
output[path.stem.replace("-", "_")] = path.read_text(encoding="utf-8")
|
|
161
|
+
_json(output)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _positive(value: str) -> int:
|
|
166
|
+
parsed = int(value)
|
|
167
|
+
if parsed <= 0:
|
|
168
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
169
|
+
return parsed
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _positive_float(value: str) -> float:
|
|
173
|
+
parsed = float(value)
|
|
174
|
+
if not math.isfinite(parsed) or parsed <= 0:
|
|
175
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
176
|
+
return parsed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _non_empty(value: str) -> str:
|
|
180
|
+
if not value.strip():
|
|
181
|
+
raise argparse.ArgumentTypeError("must not be empty")
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
186
|
+
parser = argparse.ArgumentParser(prog="conductor", description="通过 DSH 管理并验收编码任务。")
|
|
187
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
188
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
189
|
+
run = commands.add_parser("run", help="运行一个包含任务与验收标准的 prompt")
|
|
190
|
+
run.add_argument("--workspace", type=Path, required=True)
|
|
191
|
+
run.add_argument("--prompt", type=_non_empty, required=True)
|
|
192
|
+
run.add_argument("--max-attempts", type=_positive, default=2)
|
|
193
|
+
run.add_argument("--attempt-timeout-seconds", type=_positive, default=1200)
|
|
194
|
+
run.add_argument("--timeout-seconds", type=_positive_float, default=3600.0)
|
|
195
|
+
run.add_argument("--provider", default="deepseek-official")
|
|
196
|
+
run.add_argument("--model", default="deepseek-flash")
|
|
197
|
+
run.add_argument("--dsh-bin")
|
|
198
|
+
run.add_argument("--dsh-home", type=Path)
|
|
199
|
+
run.add_argument("--state-dir", type=Path, default=default_state_root())
|
|
200
|
+
run.add_argument("--keep-session", action="store_true")
|
|
201
|
+
run.add_argument("--quiet", action="store_true")
|
|
202
|
+
run.add_argument("--heartbeat-seconds", type=_positive_float, default=10.0)
|
|
203
|
+
run.add_argument("--worker-log", action=argparse.BooleanOptionalAction, default=True)
|
|
204
|
+
run.add_argument(
|
|
205
|
+
"--worker-log-interval-seconds",
|
|
206
|
+
type=_positive_float,
|
|
207
|
+
default=5.0,
|
|
208
|
+
help="tmux worker 屏幕采样间隔,默认 5 秒",
|
|
209
|
+
)
|
|
210
|
+
run.set_defaults(func=cmd_run)
|
|
211
|
+
install = commands.add_parser("install-skills", help="将两个 skill 复制到指定 workspace")
|
|
212
|
+
install.add_argument("--workspace", type=Path, required=True)
|
|
213
|
+
install.set_defaults(func=cmd_install_skills)
|
|
214
|
+
doctor = commands.add_parser("doctor")
|
|
215
|
+
doctor.add_argument("--dsh-bin")
|
|
216
|
+
doctor.add_argument("--dsh-home", type=Path)
|
|
217
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
218
|
+
show = commands.add_parser("show")
|
|
219
|
+
show.add_argument("--run-id")
|
|
220
|
+
show.add_argument("--state-dir", type=Path, default=default_state_root())
|
|
221
|
+
show.set_defaults(func=cmd_show)
|
|
222
|
+
return parser
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
226
|
+
args = build_parser().parse_args(argv)
|
|
227
|
+
try:
|
|
228
|
+
return args.func(args)
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
_json({"schema_version": 1, "status": "error", "error": "interrupted"})
|
|
231
|
+
return 130
|
|
232
|
+
except (DshError, OSError) as exc:
|
|
233
|
+
_json({"schema_version": 1, "status": "error", "error": str(exc)})
|
|
234
|
+
return 1
|