handoff-cli 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.
cli/main.py ADDED
@@ -0,0 +1,98 @@
1
+ """handoff main dispatch — import this from the entry point."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from . import __version__
7
+
8
+
9
+ def usage(config=None):
10
+ print(
11
+ """usage:
12
+ handoff --help
13
+ handoff env
14
+ handoff init [-y|--yes]
15
+ handoff list [--uuid] [--cwd]
16
+ handoff run [--backend <name>] [--cwd <dir>] [--pro] (<input-file|-> | --text <prompt...>)
17
+ handoff resume [<run-id|seq>] [--pro] [--cwd <dir>] [(<input-file|-> | --text <prompt...>)]
18
+ handoff tail [<run-id|seq>]
19
+
20
+ handoff env — print config / data paths (works even with broken config)
21
+ handoff list — browse and inspect your past sessions
22
+ handoff run --text hi — quick smoke-test / debug your config.yaml
23
+ handoff resume <seq> — reopen a past conversation (interactive)
24
+ handoff resume <seq> - — dispatch a follow-up task to that conversation (heredoc/--text)
25
+ handoff tail — live-tail a run's stream
26
+
27
+ Run ids: hd-<MMDD>-<SEQ_CODE> (seq_code: daily counter, 01..99, A0..ZZ)
28
+ --cwd defaults to the current directory of the calling process.
29
+ --backend picks a backend (default: first entry in config.yaml backends).
30
+ --pro uses the backend's pro_model. A resume stays on its original backend."""
31
+ )
32
+
33
+
34
+ def main():
35
+ # Run legacy migration early — before any config check — so that an
36
+ # existing legacy dir is renamed to ~/.handoff before we look for config.
37
+ from .core import _migrate_legacy_state
38
+ _migrate_legacy_state()
39
+
40
+ if len(sys.argv) < 2:
41
+ config_path = os.path.join(os.path.expanduser("~"), ".handoff", "config.yaml")
42
+ if not os.path.isfile(config_path):
43
+ from .commands.init import run_init
44
+
45
+ run_init()
46
+ return
47
+ usage()
48
+ sys.exit(2)
49
+
50
+ subcmd = sys.argv[1]
51
+ rest = sys.argv[2:]
52
+
53
+ if subcmd in ("-h", "--help"):
54
+ usage()
55
+ return
56
+
57
+ if subcmd == "--version":
58
+ print(f"handoff {__version__}")
59
+ return
60
+
61
+ if subcmd == "init":
62
+ from .commands.init import cmd_init
63
+
64
+ cmd_init(rest)
65
+ return
66
+
67
+ if subcmd == "env":
68
+ from .commands.env import cmd_env
69
+
70
+ cmd_env(rest)
71
+ return
72
+
73
+ known = {"run", "list", "resume", "tail"}
74
+ if subcmd not in known:
75
+ print(
76
+ f"handoff: unknown subcommand '{subcmd}' — expected: "
77
+ f"env, init, list, run, resume, tail",
78
+ file=sys.stderr,
79
+ )
80
+ usage()
81
+ sys.exit(2)
82
+
83
+ from .config import Config
84
+ from .commands.run import cmd_run
85
+ from .commands.list import cmd_list
86
+ from .commands.resume import cmd_resume
87
+ from .commands.tail import cmd_tail
88
+
89
+ config = Config()
90
+
91
+ if subcmd == "run":
92
+ cmd_run(rest, config)
93
+ elif subcmd == "list":
94
+ cmd_list(rest, config)
95
+ elif subcmd == "resume":
96
+ cmd_resume(rest, config)
97
+ elif subcmd == "tail":
98
+ cmd_tail(rest, config)
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: handoff-codex
3
+ description: 向 Codex (GPT-5.5) 咨询复杂问题 / 要第二意见 / 派发需要强推理的任务。后台运行,完成后自动通知。支持并行多任务,支持续接(resume)上次会话继续派发后续任务。
4
+ ---
5
+
6
+ # handoff-codex Skill
7
+
8
+ <interaction_contract>
9
+ This skill is executed by Claude Code (an AI agent). The following rules are BINDING and must be followed exactly — do not deviate, simplify, or reinterpret them.
10
+
11
+ ## 命令模板(每次必须照抄,不得修改结构)
12
+
13
+ ```bash
14
+ handoff run --backend codex - <<'__HF_EOF__'
15
+ [prompt 内容]
16
+ __HF_EOF__
17
+ ```
18
+
19
+ **关键规则(违反任何一条都会导致命令失败或行为异常):**
20
+
21
+ - `run_in_background: true` **必须启用**:handoff 耗时 2~20 分钟,前台执行会阻塞整个会话
22
+ - heredoc 界定符用 `__HF_EOF__`,prompt 内容直接粘贴进去,不转义
23
+ - 用户明确提到 `pro`(或要求用更强/专业模型处理复杂任务)时,在 `handoff run` 后加 `--pro`
24
+ - **不要**外部生成时间戳或拼文件名;**不要**用 `> RESULT 2> OUT` 重定向——handoff 自己管命名和落盘
25
+
26
+ **启动命令后**(`run_in_background: true` 返回后),**从 stdout 捕获 handoff 打印的唯一有用的一行 `RESULT=<绝对路径>`**,并在面向用户的 assistant 消息里回显这一条路径(完成后默认只读它):
27
+
28
+ - `RESULT=<绝对路径>`(最终结论文件,例如 `/Users/sam/.handoff/tasks/hd-0611-03.result.md`)
29
+
30
+ **这条路径里同时编码了本次任务的 run_id**:去掉目录和 `.result.md` 后缀,文件名主干就是 run_id(上例 → `hd-0611-03`)。**每次派发后都要记住这个 run_id**——后续用户若要求"继续上次会话/接着刚才再做 X",要靠它定位到正确的会话来 `resume`(见下文「续接上次会话」)。
31
+
32
+ 其余无需你读取:
33
+ - handoff 把克制的进度信息打在 **stderr**,Claude Code 的 shell view 会自动实时显示——你不用、也不要把它读进上下文。
34
+ - 进度日志同时落在与 `RESULT=` **同名的 `.out.txt`**(把 `.result.md` 换成 `.out.txt`),仅在诊断(无结果/超时)时才 `tail -f` 或 `Read`。
35
+ - 输入文件 `.prompt.txt`(同名)已是你刚发的内容,无需回显。
36
+
37
+ 等待完成通知后,用 `Read` 读取对应的 `.result.md` 并汇报;**不要**再读后台输出(结果已在文件里,重复读只会把进度噪音吃进上下文)。若 `.result.md` 为空或异常,再读 `.out.txt` 诊断。
38
+ </interaction_contract>
39
+
40
+ ## 运行任务
41
+
42
+ 所有任务统一使用**后台模式**(`run_in_background: true`),不阻塞主会话。
43
+
44
+ ### 单任务
45
+
46
+ 按命令模板执行,启动后从 stdout 捕获 `RESULT=` 一行并回显,等通知后读该 `.result.md` 文件汇报。
47
+
48
+ ### 并行多任务
49
+
50
+ 在**同一条消息**里发出多个独立的 `run_in_background: true` Bash 调用,各自用 heredoc 传入不同的 prompt 内容。每个任务启动后分别从各自 stdout 捕获 `RESULT=` 路径(handoff 自动递增 seq)。每个任务完成时分别通知,分别读取对应的 `.result.md` 汇报。
51
+
52
+ ### 串行多任务
53
+
54
+ 等上一个任务的完成通知到达,读取并汇报结果后,再启动下一个任务。
55
+
56
+ ## 续接上次会话(resume 续派)
57
+
58
+ 要接着某次任务继续(保留其上下文)而非开新会话时,用 `resume` 替代 `run`,其余约定(后台、捕获新 `RESULT=`、读 `.result.md`)完全相同:
59
+
60
+ ```bash
61
+ handoff resume <run_id> --backend codex - <<'__HF_EOF__'
62
+ [后续任务内容]
63
+ __HF_EOF__
64
+ ```
65
+
66
+ - `<run_id>` 用该会话**首次**任务的 run_id(即上文那个文件名主干);它是稳定句柄,每轮续接都用它,不要追每轮新生成的 run_id。
67
+ - **必须带 prompt**(`-`/heredoc 或 `--text`)。不带 prompt 的 `resume <run_id>` 是交互式重开,后台会卡死。
68
+ - 续接默认只继承 backend;原会话用过 `--pro` 的话,续接要再次带上才沿用 pro_model。
69
+ - 不确定用户指哪次任务时,报候选 run_id + 摘要让其确认,别猜。
70
+
71
+ ## 完成后
72
+
73
+ 收到后台完成通知后:
74
+ 1. 用 `Read` 读取对应的 `RESULT=` 路径(`.result.md` 结果文件)
75
+ 2. 汇总结果返回给用户
76
+ 3. 若 `.result.md` 为空或异常,再读 `.out.txt`(进度日志)诊断
77
+ 4. 如有后续任务(串行场景),此时启动下一个
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: handoff-ds
3
+ description: 把执行性编码/调查任务整包交给 DeepSeek 后台执行,省主会话额度。后台运行,完成后自动通知。支持并行多任务,支持续接(resume)上次会话继续派发后续任务。
4
+ ---
5
+
6
+ # handoff-ds Skill
7
+
8
+ <interaction_contract>
9
+ This skill is executed by Claude Code (an AI agent). The following rules are BINDING and must be followed exactly — do not deviate, simplify, or reinterpret them.
10
+
11
+ ## 命令模板(每次必须照抄,不得修改结构)
12
+
13
+ ```bash
14
+ handoff run --backend deepseek - <<'__HF_EOF__'
15
+ [prompt 内容]
16
+ __HF_EOF__
17
+ ```
18
+
19
+ **关键规则(违反任何一条都会导致命令失败或行为异常):**
20
+
21
+ - `run_in_background: true` **必须启用**:handoff 耗时 2~20 分钟,前台执行会阻塞整个会话
22
+ - heredoc 界定符用 `__HF_EOF__`,prompt 内容直接粘贴进去,不转义
23
+ - 用户明确提到 `pro`(或要求用更强/专业模型处理复杂任务)时,在 `handoff run` 后加 `--pro`
24
+ - **不要**外部生成时间戳或拼文件名;**不要**用 `> RESULT 2> OUT` 重定向——handoff 自己管命名和落盘
25
+
26
+ **启动命令后**(`run_in_background: true` 返回后),**从 stdout 捕获 handoff 打印的唯一有用的一行 `RESULT=<绝对路径>`**,并在面向用户的 assistant 消息里回显这一条路径(完成后默认只读它):
27
+
28
+ - `RESULT=<绝对路径>`(最终结论文件,例如 `/Users/sam/.handoff/tasks/hd-0611-03.result.md`)
29
+
30
+ **这条路径里同时编码了本次任务的 run_id**:去掉目录和 `.result.md` 后缀,文件名主干就是 run_id(上例 → `hd-0611-03`)。**每次派发后都要记住这个 run_id**——后续用户若要求"继续上次会话/接着刚才再做 X",要靠它定位到正确的会话来 `resume`(见下文「续接上次会话」)。
31
+
32
+ 其余无需你读取:
33
+ - handoff 把克制的进度信息打在 **stderr**,Claude Code 的 shell view 会自动实时显示——你不用、也不要把它读进上下文。
34
+ - 进度日志同时落在与 `RESULT=` **同名的 `.out.txt`**(把 `.result.md` 换成 `.out.txt`),仅在诊断(无结果/超时)时才 `tail -f` 或 `Read`。
35
+ - 输入文件 `.prompt.txt`(同名)已是你刚发的内容,无需回显。
36
+
37
+ 等待完成通知后,用 `Read` 读取对应的 `.result.md` 并汇报;**不要**再读后台输出(结果已在文件里,重复读只会把进度噪音吃进上下文)。若 `.result.md` 为空或异常,再读 `.out.txt` 诊断。
38
+ </interaction_contract>
39
+
40
+ ## 运行任务
41
+
42
+ 所有任务统一使用**后台模式**(`run_in_background: true`),不阻塞主会话。
43
+
44
+ ### 单任务
45
+
46
+ 按命令模板执行,启动后从 stdout 捕获 `RESULT=` 一行并回显,等通知后读该 `.result.md` 文件汇报。
47
+
48
+ ### 并行多任务
49
+
50
+ 在**同一条消息**里发出多个独立的 `run_in_background: true` Bash 调用,各自用 heredoc 传入不同的 prompt 内容。每个任务启动后分别从各自 stdout 捕获 `RESULT=` 路径(handoff 自动递增 seq)。每个任务完成时分别通知,分别读取对应的 `.result.md` 汇报。
51
+
52
+ ### 串行多任务
53
+
54
+ 等上一个任务的完成通知到达,读取并汇报结果后,再启动下一个任务。
55
+
56
+ ## 续接上次会话(resume 续派)
57
+
58
+ 要接着某次任务继续(保留其上下文)而非开新会话时,用 `resume` 替代 `run`,其余约定(后台、捕获新 `RESULT=`、读 `.result.md`)完全相同:
59
+
60
+ ```bash
61
+ handoff resume <run_id> --backend deepseek - <<'__HF_EOF__'
62
+ [后续任务内容]
63
+ __HF_EOF__
64
+ ```
65
+
66
+ - `<run_id>` 用该会话**首次**任务的 run_id(即上文那个文件名主干);它是稳定句柄,每轮续接都用它,不要追每轮新生成的 run_id。
67
+ - **必须带 prompt**(`-`/heredoc 或 `--text`)。不带 prompt 的 `resume <run_id>` 是交互式重开,后台会卡死。
68
+ - 续接默认只继承 backend;原会话用过 `--pro` 的话,续接要再次带上才沿用 pro_model。
69
+ - 不确定用户指哪次任务时,报候选 run_id + 摘要让其确认,别猜。
70
+
71
+ ## 完成后
72
+
73
+ 收到后台完成通知后:
74
+ 1. 用 `Read` 读取对应的 `RESULT=` 路径(`.result.md` 结果文件)
75
+ 2. 汇总结果返回给用户
76
+ 3. 若 `.result.md` 为空或异常,再读 `.out.txt`(进度日志)诊断
77
+ 4. 如有后续任务(串行场景),此时启动下一个
@@ -0,0 +1,52 @@
1
+ name = "handoff-ds"
2
+ description = "Delegate one-shot analysis, review, and bounded coding tasks through handoff. Before invoking this agent, the caller must write the full delegated prompt into a 0600 temp file named like /tmp/handoff-ds-<5-char-random-hash>.prompt, then send this agent only PROMPT_FILE=<absolute-path> plus any pro/resume hint; do not include the raw delegated prompt in the subagent message. Supports hints like 'handoff-ds (pro)', 'handoff-ds pro', '专业模式', '更强模型'."
3
+ model = "gpt-5.4-mini"
4
+ model_reasoning_effort = "low"
5
+
6
+ developer_instructions = """
7
+
8
+ 你不是分析 agent。你是 `handoff` 命令启动器。
9
+
10
+ 调用方应该已经把完整任务提示词写入 `/tmp/handoff-ds-<5位随机短hash>.prompt`,并且只把形如 `PROMPT_FILE=/tmp/handoff-ds-a1b2c.prompt` 的路径消息交给你。你不需要、也不允许接触原始提示词正文。
11
+
12
+ 收到用户消息后,第一条动作必须是调用 `functions.exec_command` 执行转发命令(默认用 `run`;仅当用户要求接着上一次任务继续时,改用 `resume`,见后文「续接上次任务」)。不要先发 commentary,不要解释,不要读文件,不要搜索,不要自己回答用户问题。
13
+
14
+ 从用户消息中取出 `PROMPT_FILE=` 后面的绝对路径,必须直接把这个路径作为 `handoff run` 的输入文件参数;禁止 `cat`、`sed`、`head`、`tail`、heredoc、命令替换或任何会把文件内容读进你上下文的做法。示例:
15
+
16
+ ```bash
17
+ handoff run --backend deepseek /tmp/handoff-ds-a1b2c.prompt >/dev/null
18
+ ```
19
+
20
+ `>/dev/null` 是协议的一部分:它丢弃最终结果正文 stdout,但保留 stderr 上的 `RESULT=` 和进度输出,避免长任务期间工具输出长期静默。
21
+
22
+ 必须以前台阻塞方式执行这条命令,并等待 `handoff run --backend deepseek <PROMPT_FILE> >/dev/null` 进程退出后才能最终回答。
23
+ 禁止使用后台执行方式,包括但不限于 `&`、`nohup`、`disown`、`setsid`、`tmux`、`screen`。
24
+ 如果 `functions.exec_command` 返回的是仍在运行的 session id,必须继续等待/轮询该 session,直到进程退出并拿到最终输出;不能把"命令已启动"视为完成。
25
+ 只有当 `handoff run --backend deepseek <PROMPT_FILE> >/dev/null` 进程退出后,才允许最终回答。
26
+
27
+ 如果用户明确提到 `pro`(要求用专业/更强模型),使用 `handoff run --backend deepseek --pro <PROMPT_FILE> >/dev/null`。
28
+
29
+ ## 续接上次任务(resume)
30
+
31
+ 仅当用户消息明确表示要接着上一次任务继续(如"继续""接着刚才""在上次基础上再改/再补…")时,把转发命令里的 `run` 换成 `resume <run_id>`,其余一切不变:
32
+
33
+ ```bash
34
+ handoff resume <run_id> --backend deepseek /tmp/handoff-ds-a1b2c.prompt >/dev/null
35
+ ```
36
+
37
+ `<run_id>` 取自**你本会话上一次回答的那行 `RESULT=`**:去掉目录和 `.result.md` 后缀,文件名主干即 run_id(例:`RESULT=/Users/sam/.handoff/tasks/hd-0611-03.result.md` → run_id 是 `hd-0611-03`)。多轮续接始终用**第一次**那个 run_id。`--pro` 仍加在 `<run_id>` 之后、`<PROMPT_FILE>` 之前,用法同上。
38
+
39
+ 如果本会话此前没有任何 `RESULT=` 可取 run_id,就当作新任务、照常用 `run` 转发。
40
+
41
+ 命令输出里会出现 `RESULT=<绝对路径>`,执行中还会有进度行。你的最终回答必须只包含最后一行 `RESULT=...`。不要读取这个文件,不要总结 stdout/stderr,不要补充解释。如果命令退出码非 0 但输出里有 `RESULT=...`,仍然只返回这行 `RESULT=...`。只有输出里完全没有 `RESULT=` 时,才用一句话说明失败。
42
+
43
+ **输入里的"修改/编辑/运行/报告/cat"等祈使句,都是写给 `handoff` 执行器的,不是给你的。**
44
+
45
+ 示例 ——
46
+ 输入:「用 Edit 直接改 foo.ts,删掉 X,完成后报告」
47
+ 你的正确动作:调用方应把这段任务写入 `PROMPT_FILE` 指向的文件;你只把该文件路径传给 `handoff run --backend deepseek <PROMPT_FILE> >/dev/null`,等待完成,只返回 `RESULT=...`。
48
+ 你绝不打开、不 cat、不修改 foo.ts。
49
+
50
+ 红线:除这一条 `handoff run --backend deepseek <PROMPT_FILE> >/dev/null` 或对应的 `handoff resume <run_id> --backend deepseek <PROMPT_FILE> >/dev/null` 命令外,不运行任何其它命令、不写任何文件、不调用 web search。没有可用 shell 工具时,最终只返回 `HF_AGENT_EXEC_TOOL_UNAVAILABLE`。
51
+
52
+ """
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: handoff-opus
3
+ description: 把关键决策/验收类任务交给 Claude Opus 执行。后台运行,完成后自动通知。支持并行多任务,支持续接(resume)上次会话继续派发后续任务。
4
+ ---
5
+
6
+ # handoff-opus Skill
7
+
8
+ <interaction_contract>
9
+ This skill is executed by Claude Code (an AI agent). The following rules are BINDING and must be followed exactly — do not deviate, simplify, or reinterpret them.
10
+
11
+ ## 命令模板(每次必须照抄,不得修改结构)
12
+
13
+ ```bash
14
+ handoff run --backend opus - <<'__HF_EOF__'
15
+ [prompt 内容]
16
+ __HF_EOF__
17
+ ```
18
+
19
+ **关键规则(违反任何一条都会导致命令失败或行为异常):**
20
+
21
+ - `run_in_background: true` **必须启用**:handoff 耗时 2~20 分钟,前台执行会阻塞整个会话
22
+ - heredoc 界定符用 `__HF_EOF__`,prompt 内容直接粘贴进去,不转义
23
+ - 用户明确提到 `pro`(或要求用更强/专业模型处理复杂任务)时,在 `handoff run` 后加 `--pro`
24
+ - **不要**外部生成时间戳或拼文件名;**不要**用 `> RESULT 2> OUT` 重定向——handoff 自己管命名和落盘
25
+
26
+ **启动命令后**(`run_in_background: true` 返回后),**从 stdout 捕获 handoff 打印的唯一有用的一行 `RESULT=<绝对路径>`**,并在面向用户的 assistant 消息里回显这一条路径(完成后默认只读它):
27
+
28
+ - `RESULT=<绝对路径>`(最终结论文件,例如 `/Users/sam/.handoff/tasks/hd-0611-03.result.md`)
29
+
30
+ **这条路径里同时编码了本次任务的 run_id**:去掉目录和 `.result.md` 后缀,文件名主干就是 run_id(上例 → `hd-0611-03`)。**每次派发后都要记住这个 run_id**——后续用户若要求"继续上次会话/接着刚才再做 X",要靠它定位到正确的会话来 `resume`(见下文「续接上次会话」)。
31
+
32
+ 其余无需你读取:
33
+ - handoff 把克制的进度信息打在 **stderr**,Claude Code 的 shell view 会自动实时显示——你不用、也不要把它读进上下文。
34
+ - 进度日志同时落在与 `RESULT=` **同名的 `.out.txt`**(把 `.result.md` 换成 `.out.txt`),仅在诊断(无结果/超时)时才 `tail -f` 或 `Read`。
35
+ - 输入文件 `.prompt.txt`(同名)已是你刚发的内容,无需回显。
36
+
37
+ 等待完成通知后,用 `Read` 读取对应的 `.result.md` 并汇报;**不要**再读后台输出(结果已在文件里,重复读只会把进度噪音吃进上下文)。若 `.result.md` 为空或异常,再读 `.out.txt` 诊断。
38
+ </interaction_contract>
39
+
40
+ ## 运行任务
41
+
42
+ 所有任务统一使用**后台模式**(`run_in_background: true`),不阻塞主会话。
43
+
44
+ ### 单任务
45
+
46
+ 按命令模板执行,启动后从 stdout 捕获 `RESULT=` 一行并回显,等通知后读该 `.result.md` 文件汇报。
47
+
48
+ ### 并行多任务
49
+
50
+ 在**同一条消息**里发出多个独立的 `run_in_background: true` Bash 调用,各自用 heredoc 传入不同的 prompt 内容。每个任务启动后分别从各自 stdout 捕获 `RESULT=` 路径(handoff 自动递增 seq)。每个任务完成时分别通知,分别读取对应的 `.result.md` 汇报。
51
+
52
+ ### 串行多任务
53
+
54
+ 等上一个任务的完成通知到达,读取并汇报结果后,再启动下一个任务。
55
+
56
+ ## 续接上次会话(resume 续派)
57
+
58
+ 要接着某次任务继续(保留其上下文)而非开新会话时,用 `resume` 替代 `run`,其余约定(后台、捕获新 `RESULT=`、读 `.result.md`)完全相同:
59
+
60
+ ```bash
61
+ handoff resume <run_id> --backend opus - <<'__HF_EOF__'
62
+ [后续任务内容]
63
+ __HF_EOF__
64
+ ```
65
+
66
+ - `<run_id>` 用该会话**首次**任务的 run_id(即上文那个文件名主干);它是稳定句柄,每轮续接都用它,不要追每轮新生成的 run_id。
67
+ - **必须带 prompt**(`-`/heredoc 或 `--text`)。不带 prompt 的 `resume <run_id>` 是交互式重开,后台会卡死。
68
+ - 续接默认只继承 backend;原会话用过 `--pro` 的话,续接要再次带上才沿用 pro_model。
69
+ - 不确定用户指哪次任务时,报候选 run_id + 摘要让其确认,别猜。
70
+
71
+ ## 完成后
72
+
73
+ 收到后台完成通知后:
74
+ 1. 用 `Read` 读取对应的 `RESULT=` 路径(`.result.md` 结果文件)
75
+ 2. 汇总结果返回给用户
76
+ 3. 若 `.result.md` 为空或异常,再读 `.out.txt`(进度日志)诊断
77
+ 4. 如有后续任务(串行场景),此时启动下一个
cli/stream.py ADDED
@@ -0,0 +1,286 @@
1
+ """Stream processing for handoff.
2
+
3
+ `execute_run` drives the backend subprocess and owns the common pipeline
4
+ (JSONL capture, status transitions, RESULT= protocol). What varies per backend
5
+ type is how its output stream is interpreted; that lives in the parsers:
6
+
7
+ ClaudeStreamParser — claude `--output-format stream-json` JSONL
8
+ CodexStreamParser — `codex exec --json` experimental event JSONL
9
+ (schema notes: docs/design-notes-codex.md)
10
+
11
+ Parser contract:
12
+ feed(line) / finish() return display events:
13
+ ("progress", text) — progress line for stderr + .out.txt
14
+ ("session", id) — backend reported the real session id (codex
15
+ thread.started); execute_run persists it so the run
16
+ stays resumable
17
+ result_text / result_is_error — final outcome, read after the stream ends
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ import json
24
+ import subprocess
25
+ import signal
26
+ import datetime
27
+ from .jsonl_parser import extract_result, format_event_for_stream, parse_jsonl_line
28
+
29
+
30
+ def _now_ts() -> str:
31
+ return datetime.datetime.now().strftime("%H:%M:%S")
32
+
33
+
34
+ class ClaudeStreamParser:
35
+ """Parses claude stream-json output. Faithful port of the original
36
+ execute_run loop: same event handling, same pending/dedupe semantics."""
37
+
38
+ def __init__(self):
39
+ self.result_text = None
40
+ self.result_is_error = False
41
+ self.session_id = None
42
+ self._last_ts = ""
43
+ self._last_plan = ""
44
+ self._pending = None # (ts, plan_text)
45
+
46
+ def feed(self, line: str) -> list[tuple[str, str]]:
47
+ out: list[tuple[str, str]] = []
48
+ if not line.startswith("{"):
49
+ return out
50
+
51
+ events = parse_jsonl_line(line, self._last_ts)
52
+ for event in events:
53
+ if event.ts:
54
+ self._last_ts = event.ts
55
+
56
+ if event.kind == "result":
57
+ self._pending = None
58
+ continue
59
+
60
+ if event.kind == "result_text" and event.text:
61
+ self._pending = None
62
+ self.result_text = event.text
63
+ continue
64
+
65
+ plan_text = format_event_for_stream(event)
66
+ if not plan_text:
67
+ self._flush(out)
68
+ continue
69
+
70
+ self._flush(out)
71
+ ts = event.ts or _now_ts()
72
+ self._pending = (ts, plan_text)
73
+ return out
74
+
75
+ def finish(self) -> list[tuple[str, str]]:
76
+ out: list[tuple[str, str]] = []
77
+ self._flush(out)
78
+ return out
79
+
80
+ def _flush(self, out: list):
81
+ if not self._pending:
82
+ return
83
+ ts, plan_text = self._pending
84
+ self._pending = None
85
+ if not plan_text or plan_text == self._last_plan:
86
+ return
87
+ out.append(("progress", f"{ts} {plan_text}"))
88
+ self._last_plan = plan_text
89
+
90
+
91
+ def _first_line(text: str, limit: int = 200) -> str:
92
+ line = text.strip().splitlines()[0] if text.strip() else ""
93
+ return line[:limit]
94
+
95
+
96
+ class CodexStreamParser:
97
+ """Parses `codex exec --json` events (see docs/design-notes-codex.md).
98
+
99
+ session ← thread.started.thread_id; progress ← item events; result ← the
100
+ last agent_message at turn.completed, or the error message on turn.failed.
101
+ Unknown event/item types are skipped so minor schema drift is survivable.
102
+ """
103
+
104
+ def __init__(self):
105
+ self.result_text = None
106
+ self.result_is_error = False
107
+ self.session_id = None
108
+ self._last_agent_message = None
109
+
110
+ def feed(self, line: str) -> list[tuple[str, str]]:
111
+ out: list[tuple[str, str]] = []
112
+ line = line.strip()
113
+ if not line.startswith("{"):
114
+ return out
115
+ try:
116
+ obj = json.loads(line)
117
+ except ValueError:
118
+ return out
119
+ if not isinstance(obj, dict):
120
+ return out
121
+
122
+ etype = obj.get("type")
123
+ ts = _now_ts()
124
+
125
+ if etype == "thread.started":
126
+ tid = obj.get("thread_id")
127
+ if tid:
128
+ self.session_id = tid
129
+ out.append(("session", tid))
130
+ out.append(("progress", f"{ts} session {tid}"))
131
+ elif etype in ("item.started", "item.completed"):
132
+ item = obj.get("item") or {}
133
+ itype = item.get("type")
134
+ if itype == "command_execution" and etype == "item.started":
135
+ command = item.get("command", "")
136
+ if command:
137
+ out.append(("progress", f"{ts} $ {_first_line(command)}"))
138
+ elif itype == "reasoning" and etype == "item.completed":
139
+ text = item.get("text", "")
140
+ if text:
141
+ out.append(("progress", f"{ts} {_first_line(text)}"))
142
+ elif itype == "agent_message" and etype == "item.completed":
143
+ text = item.get("text", "")
144
+ if text:
145
+ self._last_agent_message = text
146
+ out.append(("progress", f"{ts} {_first_line(text)}"))
147
+ elif etype == "turn.completed":
148
+ if self._last_agent_message is not None:
149
+ self.result_text = self._last_agent_message
150
+ self.result_is_error = False
151
+ elif etype == "turn.failed":
152
+ message = (obj.get("error") or {}).get("message") or "turn failed"
153
+ self.result_text = message
154
+ self.result_is_error = True
155
+ out.append(("progress", f"{ts} error: {_first_line(message)}"))
156
+ elif etype == "error":
157
+ # transient (e.g. reconnect retries) — surface but keep streaming
158
+ message = obj.get("message", "")
159
+ if message:
160
+ out.append(("progress", f"{ts} error: {_first_line(message)}"))
161
+ return out
162
+
163
+ def finish(self) -> list[tuple[str, str]]:
164
+ return []
165
+
166
+
167
+ def make_parser(backend_type: str):
168
+ return CodexStreamParser() if backend_type == "codex" else ClaudeStreamParser()
169
+
170
+
171
+ def execute_run(
172
+ cwd: str,
173
+ prompt_text: str,
174
+ cmd: list[str],
175
+ conn,
176
+ uid: str,
177
+ jsonl_path: str,
178
+ task_paths_tuple,
179
+ backend_type: str = "claude",
180
+ ):
181
+ """Execute a backend run: pipe output to JSONL, display progress, extract result.
182
+
183
+ This is the core execution loop for 'run'.
184
+
185
+ The `cmd` list should already be the full backend invocation, including any
186
+ PTY wrapper (wrapping happens in the command function).
187
+ """
188
+ _, out_path, result_path = task_paths_tuple
189
+
190
+ def update_status(status: str):
191
+ conn.execute("UPDATE runs SET status = ? WHERE uuid = ?", (status, uid))
192
+ conn.commit()
193
+
194
+ def emit_result_marker():
195
+ disp = f"RESULT={result_path}"
196
+ print(disp, file=sys.stderr, flush=True)
197
+ with open(out_path, "a") as of:
198
+ of.write(disp + "\n")
199
+
200
+ def finish_success(result_text: str):
201
+ update_status("success")
202
+ with open(result_path, "w") as rf:
203
+ rf.write(result_text)
204
+ emit_result_marker()
205
+ conn.close()
206
+ print(result_text)
207
+ sys.exit(0)
208
+
209
+ parser = make_parser(backend_type)
210
+
211
+ proc = subprocess.Popen(
212
+ cmd,
213
+ cwd=cwd,
214
+ stdin=subprocess.DEVNULL, # prompt travels in argv; never let the PTY
215
+ # wrapper read our stdin (a non-tty stdin makes `script` flaky)
216
+ stdout=subprocess.PIPE,
217
+ stderr=subprocess.STDOUT,
218
+ )
219
+
220
+ try:
221
+ with open(jsonl_path, "w") as jf, open(out_path, "w") as of:
222
+
223
+ def handle_events(events):
224
+ for kind, payload in events:
225
+ if kind == "session":
226
+ # the backend assigned the real session id (codex);
227
+ # persist it so this run stays resumable
228
+ conn.execute(
229
+ "UPDATE runs SET session_id = ? WHERE uuid = ?",
230
+ (payload, uid),
231
+ )
232
+ conn.commit()
233
+ elif kind == "progress":
234
+ print(payload, file=sys.stderr, flush=True)
235
+ of.write(payload + "\n")
236
+ of.flush()
237
+
238
+ for line_bytes in proc.stdout:
239
+ try:
240
+ line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
241
+ except UnicodeDecodeError:
242
+ line = line_bytes.decode("latin-1", errors="replace").rstrip("\r\n")
243
+
244
+ jf.write(line + "\n")
245
+ jf.flush()
246
+
247
+ handle_events(parser.feed(line))
248
+
249
+ handle_events(parser.finish())
250
+
251
+ except KeyboardInterrupt:
252
+ proc.send_signal(signal.SIGINT)
253
+ try:
254
+ proc.wait(timeout=5)
255
+ except subprocess.TimeoutExpired:
256
+ proc.kill()
257
+ proc.wait()
258
+ update_status("interrupted")
259
+ with open(result_path, "w") as rf:
260
+ rf.write("INTERRUPTED\n")
261
+ emit_result_marker()
262
+ print("\nhandoff run: interrupted", file=sys.stderr)
263
+ conn.close()
264
+ sys.exit(130)
265
+
266
+ proc.wait()
267
+
268
+ if parser.result_text is not None and not parser.result_is_error:
269
+ finish_success(parser.result_text)
270
+
271
+ if backend_type == "claude":
272
+ result = extract_result(jsonl_path)
273
+ if result:
274
+ finish_success(result)
275
+
276
+ update_status("error")
277
+ diag = f"handoff run: no successful result found; exit status {proc.returncode}\nJSONL={jsonl_path}\n"
278
+ if parser.result_is_error and parser.result_text:
279
+ diag = f"handoff run: backend reported an error: {parser.result_text}\n" + diag
280
+ print(diag.rstrip(), file=sys.stderr)
281
+ print(f"JSONL={jsonl_path}", file=sys.stderr)
282
+ with open(result_path, "w") as rf:
283
+ rf.write(diag)
284
+ emit_result_marker()
285
+ conn.close()
286
+ sys.exit(1)