multicc 0.1.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.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.1
2
+ Name: multicc
3
+ Version: 0.1.0
4
+ Summary: 多智能体任务编排器:把 prompt 拆给 Claude Code 与 Codex 执行,实时输出思维链/工具调用/交互/审批,支持中途注入
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ License: MIT
8
+
9
+ # tasker — 多智能体任务编排器(claude code × codex)
10
+
11
+ 输入一个目标,由 LLM 把它拆成**有依赖关系的子任务**,分派给 **Claude Code** 和 **Codex** 并行/串行执行;在 CLI 里**实时流式输出两者的完整思维链、工具调用、交互与审批请求**,并且你可以随时打字**继续提要求、中途修改、批准/拒绝**。
12
+
13
+ > 主目标平台:**macOS**。Windows/Linux 也可运行(headless 采集相同),但 pty 交互 attach 仅 macOS/Linux 原生支持。
14
+
15
+ ---
16
+
17
+ ## 特性
18
+
19
+ | 能力 | 说明 |
20
+ |---|---|
21
+ | 🧠 完整思维链 | Claude 的 `thinking` 块、Codex 的 `reasoning` 逐条实时打印(`--think full/head/off`) |
22
+ | 🔧 工具调用 | `tool_use`(工具名 + 参数 JSON)与 `tool_result`(stdout/stderr/是否报错) |
23
+ | 🔁 交互事件 | 子代理启动/进度/通知、任务轮次 |
24
+ | 🛡️ 审批请求 | 权限拒绝、`permission_request`/`permission_result` 实时浮现;`approval` 策略可 auto/log/ask |
25
+ | 💬 中途修改 | 运行中 `@claude/@codex/@all <消息>` 实时注入(claude 为真·多轮注入;codex 见下方限制) |
26
+ | 🎯 依赖编排 | LLM 拆出依赖图 → 分层并发;上游输出自动作为下游上下文 |
27
+ | 🧪 零依赖 | 纯 Python 标准库;`--mock` 无需任何 CLI/API key 即可演示全流程 |
28
+ | 📋 报告 | 可选 `--report` 落一份完整 Markdown 轨迹 |
29
+
30
+ ---
31
+
32
+ ## 安装
33
+
34
+ ```bash
35
+ # 1) 准备两个执行器(macOS)
36
+ claude # Claude Code CLI(本工具直接调用)
37
+ npm install -g @openai/codex # Codex CLI(可选,未装时 codex 任务会失败/可先用 --mock)
38
+
39
+ # 2) 安装 tasker(命令名 tasker,PyPI 包名 multicc)
40
+ pip install multicc # 从 PyPI(需联网)
41
+ # 或离线:源码机 python scripts/build_wheel.py 得到 .whl 后 pip install <whl>
42
+ # 或源码直接跑:python tasker_cli.py
43
+
44
+ tasker verify-config # 检查环境
45
+
46
+ # 3) 配置(可选项,见下方"配置")
47
+ tasker init && $EDITOR config.json
48
+ ```
49
+
50
+ Python ≥ 3.9,无第三方依赖。完整打包 / 发布说明见 [RELEASE.md](RELEASE.md)。
51
+
52
+ ---
53
+
54
+ ## 快速开始
55
+
56
+ ```bash
57
+ # 真实运行(LLM 拆分 → claude/codex 执行 → CLI 实时输出)
58
+ python -m tasker run "写一个 Python CLI 工具,带子命令,并补上单元测试"
59
+
60
+ # 规则拆分(不用 LLM 也能跑,适合快速试)
61
+ python -m tasker run "实现一个斐波那契脚本" --plan-rules
62
+
63
+ # 无任何 CLI / API key 的纯演示
64
+ python -m tasker run "写一个 python 脚本并测试" --mock --think head
65
+
66
+ # 只打印计划
67
+ python -m tasker plan "重构这个项目" --json
68
+
69
+ # 附加到交互 TUI(macOS 原生,直接看真实审批弹窗)
70
+ python -m tasker attach claude "帮我初始化一个 Go 项目"
71
+ ```
72
+
73
+ ### 运行中随时输入
74
+
75
+ ```
76
+ @all <消息> 发给所有运行中的 agent
77
+ @claude <消息> 只发给 claude 执行的任务
78
+ @codex <消息> 只发给 codex 执行的任务
79
+ @t2 <消息> 发给指定任务
80
+ <裸文本> 等同 @all <文本>
81
+ :allow [<id>] 批准最近/指定审批请求
82
+ :deny [<id>] 拒绝最近/指定审批请求
83
+ :done [<taskid>] 手动收尾任务(关闭其 stdin 让进程退出)
84
+ :status 各任务进度(思考/工具/审批/注入计数)
85
+ :plan 重打计划
86
+ :quit 终止全部并退出
87
+ ```
88
+
89
+ > claude 子任务是**真·实时多轮**:注入的消息写入其 `stream-json` stdin,下一轮即被采纳(已实测:任务执行到一半注入"第二行改成 xxx",最终产物按修改后内容产出)。
90
+
91
+ ---
92
+
93
+ ## 架构
94
+
95
+ ```
96
+ prompt
97
+ │ LLM API(anthropic / openai 兼容)
98
+
99
+ planner ──► Plan {tasks[], depends_on[], executor: claude|codex}
100
+ │ │ (规则拆分 plan_with_rules 作为无 key 回退)
101
+
102
+ scheduler(按依赖分层,max_parallel 并发)
103
+ ├─► ClaudeRunner ─► claude -p --output-format stream-json --verbose --input-format stream-json
104
+ │ │ stdin 保持打开 → @all/@claude 实时注入
105
+ │ ▼
106
+ │ 事件流: thinking / tool_use / tool_result / task_* / result / permission_denials
107
+ ├─► CodexRunner ─► codex exec --json --full-trace
108
+ │ │ approval_request / tool_call / reasoning / completed
109
+ │ ▼
110
+ └─► 每个事件 → LiveTui 实时打印 + 写入 workspaces/<run>/<task>.events.jsonl
111
+
112
+
113
+ 用户在终端输入 → 输入线程 → 路由给对应 runner
114
+ ```
115
+
116
+ 事件模型(`tasker/models.py`):
117
+ `thinking` `text` `tool_use` `tool_result` `permission_request` `permission_result` `user_message` `interaction` `system` `error` `result` `raw`
118
+
119
+ 文件结构:
120
+
121
+ ```
122
+ tasker/
123
+ main.py CLI 入口(run / plan / attach / verify-config / init)
124
+ scheduler.py 依赖分层、并发、用户输入路由、审批策略接线
125
+ planner.py LLM 拆分 + 规则回退 + 依赖校验/环检测
126
+ claude_runner.py Claude 事件采集 + stdin 实时注入 + 完成判定
127
+ codex_runner.py Codex 事件采集
128
+ live.py LiveTui:实时打印 + 后台输入线程 + 指令解析
129
+ approvals.py 审批策略(auto / log / ask_console)
130
+ ptty.py pty attach(macOS/Linux,真实审批交互)
131
+ llm.py / config.py / spawn.py / models.py / console.py / report.py / mock_runner.py
132
+ ```
133
+
134
+ ---
135
+
136
+ ## 配置(config.json)
137
+
138
+ | 段 | 键 | 默认 | 说明 |
139
+ |---|---|---|---|
140
+ | `llm` | `provider` | `anthropic` | 拆分用 LLM;`openai` 可接任意 OpenAI 兼容接口(DeepSeek/Ollama/网关) |
141
+ | `llm` | `base_url` | `""` | 兼容接口地址,如 `https://api.deepseek.com/v1` |
142
+ | `llm` | `api_key_env` | `ANTHROPIC_API_KEY` | 从该环境变量取 key |
143
+ | `llm` | `model` | `claude-sonnet-5` | 拆分模型 |
144
+ | `claude` | `permission_mode` | `acceptEdits` | headless 下自动允许工作区内编辑;要全自主(含 Bash)改 `bypassPermissions` |
145
+ | `claude` | `allowed_tools` | `[]` | 预授权工具白名单 |
146
+ | `claude` | `completion_idle` | `5.0` | 最终 result 后无活动秒数 → 关闭 stdin 收尾 |
147
+ | `codex` | `sandbox` | `workspace-write` | `read-only` / `workspace-write` / `danger-full-access` |
148
+ | `codex` | `auto_approve` | `false` | 传 `--auto-approve` 给 codex exec |
149
+ | `approval` | `mode` | `auto` | `auto` / `log` / `ask_console` |
150
+ | `approval` | `default_allow` | `true` | auto 模式的默认决定 |
151
+ | — | `max_parallel` | `2` | 同层并发数 |
152
+ | — | `timeout_per_task` | `900` | 单任务超时 |
153
+
154
+ ---
155
+
156
+ ## macOS 行为说明(重点)
157
+
158
+ - **进程启动**:macOS 上 `claude`/`codex` 是 POSIX 二进制或 `#!/bin/sh` shim,`spawn.py` 直接用 `shutil.which` 解析后 `Popen`,无需 `.cmd` 兼容层(Windows 的 `.cmd` 已额外处理)。
159
+ - **交互 attach**:`python -m tasker attach claude|codex` 用 `os.forkpty` 起交互式 TUI 并原样转发到你终端 —— 你能看到**真实的审批弹窗**并直接输入 `y/n` 回答。这是 headless 拿不到的那部分交互(claude -p / codex exec 无 TTY 不弹窗)。
160
+ - **编码**:全程 UTF-8;macOS 终端默认 UTF-8,中文/emoji 无碍。
161
+ - **权限**:headless 下 claude 的写文件/命令会走策略判定,拒绝会以 `tool_result` 拒由 + `result.permission_denials` 呈现(已实测);需要"不问就干"请设 `permission_mode=bypassPermissions`(风险自负)。
162
+ - **SIGINT**:`Ctrl-C` 触发优雅中断(`KeyboardInterrupt` 路径)。
163
+
164
+ ## Codex 的已知限制
165
+
166
+ - `codex exec` 是**单轮非交互**模式,**不支持**像 claude 那样中途注入消息。`@codex` 注入会记录到交互日志,并在调度**依赖它的下一个子任务**时作为上下文携带("下一轮注入")。
167
+ - `codex exec --json` 会发 `approval_request` 事件(本工具会实时打印),但非交互下无法经 stdin 回答;真正的审批对话请用 `attach codex`。
168
+ - 未安装 codex 时,含 codex 的任务会失败并给出清晰报错;可用 `--mock` 或 `--plan-rules` 先体验。
169
+
170
+ ---
171
+
172
+ ## 常见问题
173
+
174
+ **任务拆分 LLM 报 401 / 网络错?**
175
+ `llm` 段用独立 API key。若你的 `ANTHROPIC_API_KEY` 是 Claude Code 网关专用 key,无法直接访问 api.anthropic.com —— 设 `llm.base_url` 指向你的网关,或换 `provider=openai` + DeepSeek/Ollama。任何失败都会自动回退到规则拆分。
176
+
177
+ **headless 下"审批请求"不弹窗?**
178
+ 这是 CLI 行为限制:权限弹窗只在交互 TTY 出现。headless 会直接给出工具执行/拒绝事件。要看并回答真实审批,用 `attach` 模式。
179
+
180
+ **运行卡住不结束?**
181
+ claude 发完最终结果不会自己退出(stdin 仍开)。完成判定:最终 `result` 后 `completion_idle` 秒无活动 → 关闭 stdin 收尾;可 `:done` 手动收尾。
@@ -0,0 +1,21 @@
1
+ tasker/__init__.py,sha256=VmIfCHtuPFKC0uBl4F5N4os-N1g639pL0JkVhThMrt0,280
2
+ tasker/__main__.py,sha256=jKsDFN_oplUp1WjHZjL0oT1jzsRE_0icG-x1GDCZqdk,113
3
+ tasker/approvals.py,sha256=9dMeI3lBi7ggpuJXV2Y_TXi4_gsiz4HM1zNz_WLH6ak,2796
4
+ tasker/claude_runner.py,sha256=Saeo3t7Lt0hqjg3gvxyuV4vjP_SgoR2Cm_7_Kwecglg,12091
5
+ tasker/codex_runner.py,sha256=aTApGY7D_XwODGRyJEMPjbycih80NK8bA5J5W7O9kwA,7712
6
+ tasker/config.py,sha256=yHkS7MU9YQVUtLL6753CSEXBOdldYYIfjhCzBlAjb_U,6645
7
+ tasker/console.py,sha256=sXyZdK0dPH1rycRSaKq3t9vaqCTbt1QhCB8bDRgqPkM,2666
8
+ tasker/live.py,sha256=XbLPxo69g_O1g4oWB94KM8qL4CdOf3J6ifmrAfJwYGk,8232
9
+ tasker/llm.py,sha256=DXPG-kDf4D2FNraHhovBfl-7SpGso2z5jTdze1meW0c,3182
10
+ tasker/main.py,sha256=BXX7CvlMsjf22m9sE3vjQsYyGeVg_p2IM-R-mjkHUrU,8510
11
+ tasker/mock_runner.py,sha256=SkcWDjBen6ut71it-3XucebIQacqpaLtK5c4woAi47w,4349
12
+ tasker/models.py,sha256=QeFdzQnNF8c-WufEnYpLmkpc8qDtQdq4MKFKhZFxL0E,4360
13
+ tasker/planner.py,sha256=lzSrqNzVAfFBBPNTwm6e-1lwiL2pN2d-xkInUtU5wOY,7232
14
+ tasker/ptty.py,sha256=VmHYly-Az7kUHW4WWWWP5PsreKyCY81O_8N3l57lxAE,2916
15
+ tasker/report.py,sha256=FaGWU1JTAgk9BEPQ0XmAgjVHe2ryycqKsAx0xm7VrWc,2268
16
+ tasker/scheduler.py,sha256=sxr4_TJ2pvZuROerYxhvIgpdHobS5cq3ZgxgipN8uhU,11616
17
+ tasker/spawn.py,sha256=D92VJQTxELCjVfOZSFnAkdaZwRyZfrX0_MJLFz4mWio,3768
18
+ multicc-0.1.0.dist-info/METADATA,sha256=AvtPrWIymi2gLqA_26F9mlD4nJhwZHnE4IizDmkT6zQ,9335
19
+ multicc-0.1.0.dist-info/WHEEL,sha256=K0kLJFydSWiP_0Rtow9wws1Z25Dj53aafJlcYqdHAsw,103
20
+ multicc-0.1.0.dist-info/entry_points.txt,sha256=Owhe3dNFjk-NT9rh4ynoLhzUmgDFBc0Vwt-YU_-shjU,44
21
+ multicc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: tasker.build_wheel (stdlib-only)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tasker = tasker.main:main
tasker/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """tasker —— 多智能体任务编排器。
2
+
3
+ 通过 LLM API 把用户 prompt 拆分为子任务,分派给 Claude Code 与 Codex 执行,
4
+ 并完整采集两者的"思维链 / 工具调用 / 交互 / 审批请求"事件,输出 Markdown 报告。
5
+ """
6
+
7
+ __version__ = "0.1.0"
tasker/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m tasker 入口。"""
2
+ from .main import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
tasker/approvals.py ADDED
@@ -0,0 +1,65 @@
1
+ """审批请求处理策略。
2
+
3
+ headless 模式下各 CLI 对审批的表现不同:
4
+ - claude -p:权限弹窗只在交互 TTY 出现;headless 下工具按 permission_mode 自动允许/拒绝,
5
+ 我们把拒绝事件(tool_result / result.permission_denials)采集上报,必要时回注一条用户消息
6
+ 告知模型"用户已批准,请继续"。
7
+ - codex exec --json:会在 stdout 发 approval_request 事件,但非交互下无法经 stdin 回答;
8
+ auto_approve 由 --auto-approve 开关控制。
9
+ 真正的"人点批准/拒绝"交互请用 ptty attach 模式(macOS 原生支持)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+
15
+ from .config import ApprovalConfig
16
+ from .models import Event, TaskRun
17
+
18
+ MODES = ("auto", "log", "ask_console")
19
+
20
+
21
+ class ApprovalPolicy:
22
+ def __init__(self, cfg: ApprovalConfig, on_decision=None):
23
+ self.cfg = cfg
24
+ self.on_decision = on_decision
25
+ self.pending: dict[str, dict] = {} # id -> {"run": TaskRun, "event": Event}
26
+
27
+ def handle(self, run: TaskRun, event: Event, emit, send_msg):
28
+ """处理一条 permission_request 事件。emit: (run, Event) 回调;send_msg: 回注消息函数。"""
29
+ req_id = str(event.data.get("id") or event.data.get("tool_use_id") or time.time())
30
+ mode = self.cfg.mode
31
+ self.pending[req_id] = {"run": run, "event": event, "ts": time.time()}
32
+
33
+ if mode == "auto":
34
+ allowed = self.cfg.default_allow
35
+ self._decide(req_id, allowed, run, event, emit, send_msg)
36
+ elif mode == "ask_console":
37
+ # 在 CLI 里已实时展示,等待用户 :allow / :deny
38
+ return
39
+ else: # log
40
+ return
41
+
42
+ def decide(self, req_id: str, allowed: bool, emit, send_msg) -> bool:
43
+ """由用户指令 :allow/:deny 触发。"""
44
+ item = self.pending.get(req_id)
45
+ if item is None:
46
+ # 没有登记的请求:允许对所有运行中 runner 注入"用户决定"消息
47
+ return False
48
+ self._decide(req_id, allowed, item["run"], item["event"], emit, send_msg)
49
+ return True
50
+
51
+ def _decide(self, req_id, allowed, run, event, emit, send_msg) -> None:
52
+ note = "批准" if allowed else "拒绝"
53
+ emit(
54
+ run,
55
+ Event(
56
+ kind="permission_result",
57
+ source=run.task.executor,
58
+ text=f"{note}请求 {req_id}",
59
+ data={"allowed": allowed, "id": req_id},
60
+ ),
61
+ )
62
+ if send_msg:
63
+ guidance = "用户已批准该工具请求,如适用请继续执行。" if allowed else "用户已拒绝该请求,请换一种安全方式继续。"
64
+ send_msg(f"[审批反馈] {guidance}({event.text})")
65
+ self.pending.pop(req_id, None)
@@ -0,0 +1,271 @@
1
+ """Claude Code 采集器。
2
+
3
+ headless 方式:claude -p --output-format stream-json --verbose --input-format stream-json
4
+ - 从 stdout 逐行解析事件:thinking(思维链)、tool_use、tool_result、task 事件、result。
5
+ - stdin 保持打开:send_message() 以 stream-json 用户消息实时注入,实现"中途修改/继续要求"。
6
+ - 权限相关:headless 下权限弹窗不触发(交互 TTY 才有),工具执行/拒绝会体现为
7
+ tool_result 与 result.permission_denials,一并采集。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ import time
15
+
16
+ from .config import Config
17
+ from .models import Event, TaskRun
18
+ from .spawn import ProcChannel, resolve_binary, start_process
19
+
20
+
21
+ class ClaudeRunner:
22
+ source = "claude"
23
+
24
+ def __init__(self, cfg: Config, run: TaskRun, workdir: str, on_event, prompt: str):
25
+ self.cfg = cfg
26
+ self.run = run
27
+ self.workdir = workdir
28
+ self.on_event = on_event
29
+ self.prompt = prompt
30
+ self.channel: ProcChannel | None = None
31
+ self._tool_names: dict[str, str] = {}
32
+ self._stop = threading.Event()
33
+ self._thread: threading.Thread | None = None
34
+ self._interactions: list[str] = []
35
+ # claude 发出 result 后不会自己退出(stdin 仍开),需要 settle 后关闭 stdin
36
+ self._result_ts: float | None = None
37
+ self._injection_ts: float | None = None
38
+ self._last_event_ts: float = time.time()
39
+ self._settle = float(getattr(cfg.claude, "completion_idle", 5.0))
40
+
41
+ # ---------- 完成判定 ----------
42
+ def is_done(self) -> bool:
43
+ if not self.is_alive():
44
+ return True
45
+ if self._result_ts is None:
46
+ return False
47
+ ref = max(self._result_ts, self._injection_ts or 0)
48
+ now = time.time()
49
+ # 结果之后:既无更新的注入,也无任何 claude 活动,持续 settle 秒 → 认为子任务完成
50
+ return (now - ref) > self._settle and (now - self._last_event_ts) > self._settle
51
+
52
+ def finalize(self) -> None:
53
+ """收到最终 result 后:关闭 stdin 让 claude 自然退出,等待一小段,必要时终止。"""
54
+ if not self.channel or not self.channel.is_alive():
55
+ return
56
+ self.channel.close_stdin()
57
+ try:
58
+ self.channel.proc.wait(timeout=8)
59
+ except Exception:
60
+ self.channel.stop()
61
+
62
+ # ---------- 生命周期 ----------
63
+ def build_args(self) -> list[str]:
64
+ c = self.cfg.claude
65
+ args = [resolve_binary(c.binary), "-p", "--output-format", "stream-json", "--verbose", "--input-format", "stream-json"]
66
+ if c.model:
67
+ args += ["--model", c.model]
68
+ if c.permission_mode and c.permission_mode != "default":
69
+ args += ["--permission-mode", c.permission_mode]
70
+ if c.allowed_tools:
71
+ args += ["--allowedTools", ",".join(c.allowed_tools)]
72
+ if c.disallowed_tools:
73
+ args += ["--disallowedTools", ",".join(c.disallowed_tools)]
74
+ args += c.extra_args
75
+ return args
76
+
77
+ def _sys_note(self) -> str:
78
+ return (
79
+ "你是 tasker 多智能体编排中的一个子任务 worker。当前工作目录即任务目录。\n"
80
+ "协作提示:编排器可能在运行中向你注入后续要求(用户中途修改/追问),收到后请按新要求调整再继续。"
81
+ )
82
+
83
+ def start(self) -> ProcChannel:
84
+ self.run.started_at = time.time()
85
+ args = self.build_args()
86
+ extra = ["--append-system-prompt", self._sys_note()]
87
+ self.channel = start_process(args + extra, workdir=self.workdir, name=f"claude-{self.run.task.id}")
88
+ # 初始用户消息(stream-json)
89
+ first = self._user_msg_json(self.prompt)
90
+ self.channel.write(first + "\n")
91
+ self._thread = threading.Thread(target=self._pump, daemon=True, name=f"claude-{self.run.task.id}")
92
+ self._thread.start()
93
+ return self.channel
94
+
95
+ # ---------- 实时注入 ----------
96
+ def send_message(self, text: str) -> bool:
97
+ if not self.channel or not self.channel.is_alive():
98
+ return False
99
+ ok = self.channel.write(self._user_msg_json(text) + "\n")
100
+ if ok:
101
+ self._interactions.append(text)
102
+ self._injection_ts = time.time()
103
+ self._emit(Event(kind="user_message", source=self.source, text=text))
104
+ return ok
105
+
106
+ def stop(self) -> None:
107
+ self._stop.set()
108
+ if self.channel:
109
+ self.channel.stop()
110
+
111
+ def is_alive(self) -> bool:
112
+ return bool(self.channel and self.channel.is_alive())
113
+
114
+ @staticmethod
115
+ def _user_msg_json(text: str) -> str:
116
+ return json.dumps(
117
+ {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": text}]}},
118
+ ensure_ascii=False,
119
+ )
120
+
121
+ # ---------- 事件泵 ----------
122
+ def _emit(self, event: Event) -> None:
123
+ self._last_event_ts = time.time()
124
+ self.run.events.append(event)
125
+ self.on_event(self.run, event)
126
+
127
+ def _pump(self) -> None:
128
+ ch = self.channel
129
+ if not ch:
130
+ return
131
+ try:
132
+ while not self._stop.is_set():
133
+ line = ch.next_line(timeout=0.2)
134
+ if line is None:
135
+ if not ch.is_alive():
136
+ break
137
+ continue
138
+ line = line.strip()
139
+ if not line:
140
+ continue
141
+ try:
142
+ data = json.loads(line)
143
+ except json.JSONDecodeError:
144
+ self._emit(Event(kind="raw", source=self.source, text=line[:2000]))
145
+ continue
146
+ try:
147
+ self._handle(data)
148
+ except Exception as e: # noqa: BLE001 —— 单条事件解析失败不打断整个泵线程
149
+ self._emit(Event(kind="error", source=self.source, text=f"事件解析失败: {e}"))
150
+ finally:
151
+ self.run.ended_at = time.time()
152
+
153
+ def _handle(self, data: dict) -> None:
154
+ t = data.get("type")
155
+ if t == "system":
156
+ sub = data.get("subtype", "")
157
+ if sub == "init":
158
+ self.run.exit_code = 0
159
+ self._emit(
160
+ Event(
161
+ kind="system",
162
+ source=self.source,
163
+ text=f"会话初始化 session_id={data.get('session_id','')[:8]}… cwd={data.get('cwd','')}",
164
+ data={"session_id": data.get("session_id")},
165
+ )
166
+ )
167
+ elif sub == "thinking_tokens":
168
+ pass # token 计数噪音,忽略
169
+ elif sub in ("task_started", "task_progress", "task_notification", "interrupt_*"):
170
+ self._emit(
171
+ Event(
172
+ kind="interaction",
173
+ source=self.source,
174
+ text=f"{sub}: {data.get('description','')}",
175
+ data={"task_id": data.get("task_id"), "tool_use_id": data.get("tool_use_id"), "subtype": sub},
176
+ )
177
+ )
178
+ else:
179
+ self._emit(Event(kind="system", source=self.source, text=f"{sub} {data.get('description','')}", data=data))
180
+ elif t == "assistant":
181
+ msg = data.get("message", {})
182
+ for block in msg.get("content", []):
183
+ btype = block.get("type")
184
+ if btype == "thinking":
185
+ self._emit(
186
+ Event(
187
+ kind="thinking",
188
+ source=self.source,
189
+ text=str(block.get("thinking", "")),
190
+ data={"signature": block.get("signature")},
191
+ )
192
+ )
193
+ elif btype == "redacted_thinking":
194
+ self._emit(Event(kind="thinking", source=self.source, text="[redacted thinking]"))
195
+ elif btype == "tool_use":
196
+ name = block.get("name", "?")
197
+ tid = block.get("id")
198
+ if tid:
199
+ self._tool_names[tid] = name
200
+ self._emit(
201
+ Event(
202
+ kind="tool_use",
203
+ source=self.source,
204
+ text=name,
205
+ data={"tool": name, "input": block.get("input", {}), "id": tid},
206
+ )
207
+ )
208
+ elif btype == "text":
209
+ self._emit(Event(kind="text", source=self.source, text=str(block.get("text", ""))))
210
+ elif t == "user":
211
+ # 可能是 tool_result 回灌,或子代理消息
212
+ msg = data.get("message", {})
213
+ content = msg.get("content", [])
214
+ for block in content if isinstance(content, list) else [content]:
215
+ if not isinstance(block, dict):
216
+ continue
217
+ if block.get("type") == "tool_result":
218
+ name = self._tool_names.get(str(block.get("tool_use_id")), "?")
219
+ result_text = block.get("content", "")
220
+ if not isinstance(result_text, str):
221
+ result_text = json.dumps(result_text, ensure_ascii=False, default=str)
222
+ raw_extra = data.get("tool_use_result", {})
223
+ extra = raw_extra if isinstance(raw_extra, dict) else {}
224
+ self._emit(
225
+ Event(
226
+ kind="tool_result",
227
+ source=self.source,
228
+ text=result_text,
229
+ data={
230
+ "tool": name,
231
+ "is_error": bool(block.get("is_error", extra.get("is_error", False))),
232
+ "stdout": extra.get("stdout", "") if isinstance(extra.get("stdout"), str) else json.dumps(extra.get("stdout", ""), ensure_ascii=False, default=str),
233
+ "stderr": extra.get("stderr", "") if isinstance(extra.get("stderr"), str) else json.dumps(extra.get("stderr", ""), ensure_ascii=False, default=str),
234
+ "interrupted": bool(extra.get("interrupted", False)),
235
+ "tool_use_id": block.get("tool_use_id"),
236
+ },
237
+ )
238
+ )
239
+ elif block.get("type") == "text":
240
+ self._emit(Event(kind="text", source=self.source, text=str(block.get("text", ""))))
241
+ elif t == "result":
242
+ self._result_ts = time.time()
243
+ self.run.output = str(data.get("result", ""))
244
+ self.run.exit_code = 0 if not data.get("is_error") else data.get("error")
245
+ self.run.cost_usd = float(data.get("total_cost_usd") or 0.0)
246
+ denials = data.get("permission_denials") or []
247
+ if denials:
248
+ for d in denials:
249
+ self._emit(
250
+ Event(
251
+ kind="permission_result",
252
+ source=self.source,
253
+ text=str(d.get("message", "") or d),
254
+ data={"allowed": False, "detail": d},
255
+ )
256
+ )
257
+ self._emit(
258
+ Event(
259
+ kind="result",
260
+ source=self.source,
261
+ text=self.run.output,
262
+ data={
263
+ "stop_reason": data.get("stop_reason"),
264
+ "terminal_reason": data.get("terminal_reason"),
265
+ "cost_usd": self.run.cost_usd,
266
+ "permission_denials": len(denials),
267
+ },
268
+ )
269
+ )
270
+ else:
271
+ self._emit(Event(kind="raw", source=self.source, text=json.dumps(data, ensure_ascii=False)[:2000]))