codebee 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/app/__init__.py +0 -0
  4. package/app/core/__init__.py +0 -0
  5. package/app/core/attachments.py +322 -0
  6. package/app/core/automation.py +585 -0
  7. package/app/core/bookmeta.py +296 -0
  8. package/app/core/capability.py +130 -0
  9. package/app/core/catalog.py +319 -0
  10. package/app/core/compaction.py +186 -0
  11. package/app/core/diagnostics.py +115 -0
  12. package/app/core/env_scrub.py +84 -0
  13. package/app/core/error_codes.py +65 -0
  14. package/app/core/flows.py +328 -0
  15. package/app/core/gitmod.py +949 -0
  16. package/app/core/goal_service.py +159 -0
  17. package/app/core/health.py +294 -0
  18. package/app/core/history.py +32 -0
  19. package/app/core/jobs.py +424 -0
  20. package/app/core/manager.py +1415 -0
  21. package/app/core/market.py +299 -0
  22. package/app/core/market_remote.py +896 -0
  23. package/app/core/mocks.py +64 -0
  24. package/app/core/modelhub.py +2750 -0
  25. package/app/core/paths.py +60 -0
  26. package/app/core/pipeline.py +2161 -0
  27. package/app/core/planner.py +493 -0
  28. package/app/core/registry.py +105 -0
  29. package/app/core/remote.py +303 -0
  30. package/app/core/repeat_guard.py +124 -0
  31. package/app/core/router.py +120 -0
  32. package/app/core/runner.py +856 -0
  33. package/app/core/selfupdate.py +170 -0
  34. package/app/core/session_log.py +162 -0
  35. package/app/core/sessions.py +312 -0
  36. package/app/core/settings.py +85 -0
  37. package/app/core/settings_schema.py +250 -0
  38. package/app/core/skillpacks/fanqie-novel.md +80 -0
  39. package/app/core/skillpacks/market/character-bible.md +66 -0
  40. package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
  41. package/app/core/skillpacks/market/git-workflow.md +57 -0
  42. package/app/core/skillpacks/market/release-notes.md +72 -0
  43. package/app/core/skillpacks/market/weekly-report.md +71 -0
  44. package/app/core/skillpacks/market/worldview-consistency.md +70 -0
  45. package/app/core/skillpacks/qimao-signing.md +105 -0
  46. package/app/core/skills.py +649 -0
  47. package/app/core/step_runner.py +61 -0
  48. package/app/core/store.py +1321 -0
  49. package/app/core/token_meter.py +130 -0
  50. package/app/core/usage.py +450 -0
  51. package/app/main.py +1448 -0
  52. package/app/ui/app.js +8021 -0
  53. package/app/ui/i18n.js +1709 -0
  54. package/app/ui/icons/brand-horizontal.png +0 -0
  55. package/app/ui/icons/brand-square.png +0 -0
  56. package/app/ui/icons/icon-192.png +0 -0
  57. package/app/ui/icons/icon-512.png +0 -0
  58. package/app/ui/icons/logo-horizontal.png +0 -0
  59. package/app/ui/icons/logo-mark.png +0 -0
  60. package/app/ui/index.html +864 -0
  61. package/app/ui/manifest.json +16 -0
  62. package/app/ui/qrcode.js +2297 -0
  63. package/app/ui/style.css +2733 -0
  64. package/bin/tutti.js +121 -0
  65. package/package.json +39 -0
@@ -0,0 +1,856 @@
1
+ # -*- coding: utf-8 -*-
2
+ """运行器:把各家 CLI 的无头调用统一成一个接口。
3
+
4
+ 实测验证过的接入方式(2026-09,本机):
5
+ codex : codex exec --skip-git-repo-check --json -s <sandbox> [prompt从stdin]
6
+ stdout 为 JSONL 事件流;item.completed(type=agent_message)=最终回答,
7
+ turn.completed=token 用量。pnpm 的 .cmd 垫片需 cmd /c 包装。
8
+ claude : claude -p --output-format json [prompt从stdin]
9
+ stdout 为单个 JSON:result / total_cost_usd / usage。
10
+ 需环境变量 CLAUDE_CODE_GIT_BASH_PATH(原生 exe 找不到 bash 会拒绝启动)
11
+ 与 CLAUDE_CODE_MAX_OUTPUT_TOKENS(自定义网关模型常见 32768 上限)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import threading
21
+ import time
22
+
23
+ from .env_scrub import scrub_env
24
+ from .error_codes import ErrorCode
25
+
26
+ CREATE_NO_WINDOW = 0x08000000
27
+ DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
28
+
29
+ _BASH_CANDIDATES = [
30
+ r"D:\Git\usr\bin\bash.exe",
31
+ r"C:\Program Files\Git\usr\bin\bash.exe",
32
+ r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
33
+ ]
34
+ _bash_cache = {"path": None, "done": False}
35
+
36
+
37
+ def find_git_bash():
38
+ if _bash_cache["done"]:
39
+ return _bash_cache["path"]
40
+ _bash_cache["done"] = True
41
+ p = shutil.which("bash.exe") or shutil.which("bash")
42
+ if p:
43
+ _bash_cache["path"] = p
44
+ return p
45
+ for cand in _BASH_CANDIDATES:
46
+ if os.path.isfile(cand):
47
+ _bash_cache["path"] = cand
48
+ return cand
49
+ return None
50
+
51
+
52
+ def resolve_command(command):
53
+ """命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
54
+ path = shutil.which(command) or command
55
+ low = path.lower()
56
+ if low.endswith(".cmd") or low.endswith(".bat"):
57
+ return ["cmd", "/c", path]
58
+ return [path]
59
+
60
+
61
+ def _kill_tree(pid):
62
+ try:
63
+ subprocess.run(
64
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
65
+ capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=15)
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def _drain_streams(proc, t_out, t_err, *, timeout=10):
71
+ """kill_tree 后等管道线程读完剩余字节。
72
+
73
+ daemon=True 线程 join 超时即结束(不会卡死主流程)。
74
+ 设计稿:docs/migration/01-defense-patterns.md §5B。
75
+ 参考 dsh docs/defensive-patterns.zh.md 第 21 行("dispose 必须达到完全停稳")。
76
+ """
77
+ deadline = time.time() + timeout
78
+ for t in (t_out, t_err):
79
+ remaining = max(0.0, deadline - time.time())
80
+ if remaining > 0:
81
+ t.join(timeout=remaining)
82
+
83
+
84
+ def _pipe_reader(stream, chunks, log_fh):
85
+ """持续读子进程管道并实时落盘。
86
+
87
+ 必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
88
+ 在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
89
+ read1() 只要有数据就返回,日志才能真正边跑边看。
90
+ """
91
+ while True:
92
+ b = stream.read1(65536)
93
+ if not b:
94
+ break
95
+ chunks.append(b)
96
+ if log_fh:
97
+ try:
98
+ log_fh.write(b)
99
+ log_fh.flush()
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ def decode_output(data):
105
+ """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。"""
106
+ if not data:
107
+ return ""
108
+ try:
109
+ return data.decode("utf-8")
110
+ except UnicodeDecodeError:
111
+ pass
112
+ try:
113
+ return data.decode("gbk")
114
+ except UnicodeDecodeError:
115
+ return data.decode("utf-8", "replace")
116
+
117
+
118
+ def read_text_any_enc(path):
119
+ """读文本文件,编码纪律同 decode_output(UTF-8 严格 → GBK 回退 → replace 兜底)。
120
+
121
+ 工作区文件由 CLI 子代理落盘,中文 Windows 上 PowerShell Set-Content 缺省写
122
+ GBK;消费侧(章节/diff/故事圣经)统一走本函数,不再因编码混编出 U+FFFD。
123
+ 文件不存在/读失败抛 OSError,由调用方兜底(与 open 语义一致)。"""
124
+ with open(path, "rb") as f:
125
+ data = f.read()
126
+ return decode_output(data)
127
+
128
+
129
+ def tail_decoded(data, tail):
130
+ """按字节取尾部再解码;切片可能落在 UTF-8 多字节字符中间,先丢弃开头的
131
+ continuation 字节(10xxxxxx,至多 3 个)对齐字符边界,否则残缺字节会被
132
+ GBK 回退解码成乱码字符。"""
133
+ if not data:
134
+ return ""
135
+ chunk = data[-tail:] if tail and len(data) > tail else data
136
+ i = 0
137
+ while i < len(chunk) and i < 3 and (chunk[i] & 0xC0) == 0x80:
138
+ i += 1
139
+ return decode_output(chunk[i:])
140
+
141
+
142
+ _TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
143
+
144
+
145
+ def collapse_dup_lines(text, max_group=4):
146
+ """折叠「归一化后重复」的邻近行,保留每种首行原文并标注折叠数。
147
+
148
+ 真实案例:codex_otel 遥测对网关自定义模型名(如 [opencode]xxx,方括号是
149
+ 非法 OTel tag 字符)每个 SSE 事件刷一对 WARN——counter/duration 两种文案
150
+ 交替出现,且每条带微秒时间戳:剥掉时间戳前缀后相邻行仍不相等,必须按
151
+ 「邻近组」折叠——组内至多 max_group 个键,新键只有组满才结算上一组,
152
+ 这样交替刷屏的各个文案都归入同一组(计数各自累计)。空行先结算所在组
153
+ 再原样保留。超过组宽的零散重复(中间隔着足量新行)不受影响。
154
+ """
155
+ if not text:
156
+ return ""
157
+ out = []
158
+ keys, first, cnt = [], {}, {}
159
+
160
+ def _flush():
161
+ for k in keys:
162
+ out.append(first[k])
163
+ if cnt[k] > 1:
164
+ out.append("⋯(上行重复 ×%d 已折叠)" % (cnt[k] - 1))
165
+ keys.clear()
166
+ first.clear()
167
+ cnt.clear()
168
+
169
+ for ln in text.splitlines():
170
+ k = _TS_PREFIX.sub("", ln).strip()
171
+ if not k:
172
+ _flush() # 先结算扣住的组,保证空行前后顺序不失真
173
+ out.append(ln)
174
+ continue
175
+ if k in cnt:
176
+ cnt[k] += 1
177
+ elif len(keys) >= max_group:
178
+ _flush()
179
+ keys.append(k)
180
+ first[k] = ln
181
+ cnt[k] = 1
182
+ else:
183
+ keys.append(k)
184
+ first[k] = ln
185
+ cnt[k] = 1
186
+ _flush()
187
+ if text.endswith("\n"):
188
+ out.append("")
189
+ return "\n".join(out)
190
+
191
+
192
+ def _fmt_codex_item(it, cap):
193
+ t = it.get("type") or ""
194
+ if t == "agent_message":
195
+ return "【消息】" + str(it.get("text") or "")[:cap]
196
+ if t == "reasoning":
197
+ return "【思考】" + str(it.get("text") or "")[:cap]
198
+ if t == "command_execution":
199
+ line = "【命令】%s(退出码 %s)" % (it.get("command") or "?", it.get("exit_code", "?"))
200
+ outp = str(it.get("aggregated_output") or "").strip()
201
+ if outp:
202
+ line += "\n | " + outp[:600].replace("\n", "\n | ")
203
+ return line
204
+ if t == "file_change":
205
+ names = ", ".join(str(c.get("path") or "?") for c in (it.get("changes") or [])
206
+ if isinstance(c, dict))
207
+ return "【文件改动】" + (names or "?")
208
+ if t == "mcp_tool_call":
209
+ return "【工具】%s %s" % (it.get("tool") or "?",
210
+ json.dumps(it.get("arguments") or "", ensure_ascii=False)[:200])
211
+ if t == "web_search":
212
+ return "【搜索】" + str(it.get("query") or "")
213
+ if t == "error":
214
+ return "【错误】" + str(it.get("message") or "")
215
+ return None
216
+
217
+
218
+ def pretty_cli_log(text, max_event_chars=4000):
219
+ """codex --json 的 JSONL 事件流 → 人类可读行;非 JSONL 行原样保留。
220
+
221
+ --json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
222
+ 话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
223
+ 读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
224
+ """
225
+ out = []
226
+ for ln in (text or "").splitlines():
227
+ s = ln.strip()
228
+ ev = None
229
+ if s.startswith("{") and s.endswith("}"):
230
+ try:
231
+ ev = json.loads(s)
232
+ except Exception:
233
+ ev = None
234
+ if not isinstance(ev, dict):
235
+ out.append(ln)
236
+ continue
237
+ typ = ev.get("type") or ""
238
+ if typ in ("thread.started", "turn.started"):
239
+ continue
240
+ if typ == "turn.completed":
241
+ u = ev.get("usage") or {}
242
+ out.append("— 一轮完成(tokens 入 %s / 出 %s)—" % (
243
+ u.get("input_tokens", "?"), u.get("output_tokens", "?")))
244
+ continue
245
+ if typ == "turn.failed":
246
+ e = ev.get("error")
247
+ out.append("— 一轮失败:%s —" % (e.get("message") if isinstance(e, dict) else e))
248
+ continue
249
+ if typ in ("item.completed", "item.started", "item.updated"):
250
+ if typ != "item.completed" or not isinstance(ev.get("item"), dict):
251
+ continue # started/updated 是过程噪音,completed 才有内容
252
+ line = _fmt_codex_item(ev["item"], max_event_chars)
253
+ if line:
254
+ out.append(line)
255
+ continue
256
+ out.append(ln)
257
+ return "\n".join(out)
258
+
259
+
260
+ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
261
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None):
262
+ """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
263
+
264
+ 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out}。
265
+ """
266
+ if shell_cmd:
267
+ argv = ["cmd", "/c", shell_cmd]
268
+ if argv is None:
269
+ return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
270
+ "duration": 0.0, "cancelled": False, "timed_out": False}
271
+ full_env = scrub_env(os.environ.copy(), mode="drop")
272
+ if env:
273
+ # 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
274
+ full_env.update({str(k): str(v) for k, v in env.items()})
275
+ log_fh = open(log_path, "ab") if log_path else None
276
+ # 审计:调用下达前先把「执行的命令 + 发给智能体的指令原文」写进日志,
277
+ # 运行中点开步骤就能看到"编排者下了什么令",不用等结束猜。
278
+ # env 绝不写(含 API key);argv 里只有 base_url/env_key 名,无密钥值。
279
+ # 指令超 12000 字符截断(章节正文可能很长),标注原始长度防误读。
280
+ if log_fh:
281
+ try:
282
+ head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
283
+ "$ " + " ".join(str(a) for a in argv)]
284
+ if stdin_text:
285
+ capped = stdin_text[:12000]
286
+ head.append("--- 指令(%d 字符%s)---" % (
287
+ len(stdin_text), ",已截断" if len(stdin_text) > 12000 else ""))
288
+ head.append(capped)
289
+ log_fh.write(("\n".join(head) + "\n--- 输出 ---\n").encode("utf-8", "replace"))
290
+ log_fh.flush()
291
+ except Exception:
292
+ pass
293
+ try:
294
+ try:
295
+ proc = subprocess.Popen(
296
+ [str(a) for a in argv], cwd=cwd, env=full_env,
297
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
298
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
299
+ creationflags=CREATE_NO_WINDOW)
300
+ except Exception as e:
301
+ return {"ok": False, "exit_code": None, "stdout": "",
302
+ "stderr": "启动失败: %r" % e, "duration": 0.0,
303
+ "cancelled": False, "timed_out": False}
304
+ out_chunks, err_chunks = [], []
305
+ t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh), daemon=True)
306
+ t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh), daemon=True)
307
+ t_out.start()
308
+ t_err.start()
309
+ if stdin_text is not None:
310
+ def _feed():
311
+ try:
312
+ proc.stdin.write(stdin_text.encode("utf-8"))
313
+ except Exception:
314
+ pass
315
+ finally:
316
+ try:
317
+ proc.stdin.close()
318
+ except Exception:
319
+ pass
320
+ threading.Thread(target=_feed, daemon=True).start()
321
+ start = time.time()
322
+ cancelled = timed_out = False
323
+ while True:
324
+ try:
325
+ proc.wait(timeout=0.4)
326
+ break
327
+ except subprocess.TimeoutExpired:
328
+ pass
329
+ if cancel_event is not None and cancel_event.is_set():
330
+ cancelled = True
331
+ _kill_tree(proc.pid)
332
+ _drain_streams(proc, t_out, t_err, timeout=10)
333
+ break
334
+ if time.time() - start > timeout:
335
+ timed_out = True
336
+ _kill_tree(proc.pid)
337
+ _drain_streams(proc, t_out, t_err, timeout=5)
338
+ break
339
+ duration = round(time.time() - start, 1)
340
+ t_out.join(timeout=5)
341
+ t_err.join(timeout=5)
342
+ exit_code = proc.returncode
343
+ stdout = decode_output(b"".join(out_chunks))
344
+ stderr = decode_output(b"".join(err_chunks))
345
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
346
+ try:
347
+ if stream:
348
+ stream.close()
349
+ except Exception:
350
+ pass
351
+ if cancelled:
352
+ stderr += "\n[已被用户取消]"
353
+ elif timed_out:
354
+ stderr += "\n[超时 %ss,已终止进程树]" % timeout
355
+ return {
356
+ "ok": exit_code == 0 and not cancelled and not timed_out,
357
+ "exit_code": exit_code, "stdout": stdout, "stderr": stderr,
358
+ "duration": duration, "cancelled": cancelled, "timed_out": timed_out,
359
+ }
360
+ finally:
361
+ if log_fh:
362
+ try:
363
+ log_fh.close()
364
+ except Exception:
365
+ pass
366
+
367
+
368
+ # ---------------------------------------------------------------- 各家适配
369
+
370
+ def _parse_codex_jsonl(stdout):
371
+ """解析 codex exec JSONL 事件流。返回 (text, usage, sid)。
372
+
373
+ usage 细分来自 turn.completed:input_tokens(含 cached)、cached_input_tokens、
374
+ output_tokens、reasoning_output_tokens;多 turn 累加。
375
+ sid 来自 thread.started 的 thread_id(§07 T1.1:供后续 revise/fix 复用会话,
376
+ 会话内前缀按供应商缓存读计价)。
377
+ """
378
+ text = ""
379
+ sid = ""
380
+ usage = {"input": 0, "output": 0, "cached": 0, "reasoning": 0, "total": 0}
381
+ for line in stdout.splitlines():
382
+ line = line.strip()
383
+ if not line.startswith("{"):
384
+ continue
385
+ try:
386
+ ev = json.loads(line)
387
+ except Exception:
388
+ continue
389
+ if ev.get("type") == "thread.started":
390
+ sid = str(ev.get("thread_id") or sid)
391
+ elif ev.get("type") == "item.completed":
392
+ item = ev.get("item") or {}
393
+ if item.get("type") == "agent_message" and item.get("text"):
394
+ text = item["text"]
395
+ elif ev.get("type") == "turn.completed":
396
+ u = ev.get("usage") or {}
397
+ inp = int(u.get("input_tokens") or 0)
398
+ out = int(u.get("output_tokens") or 0)
399
+ usage["input"] += inp
400
+ usage["output"] += out
401
+ usage["cached"] += int(u.get("cached_input_tokens") or 0)
402
+ usage["reasoning"] += int(u.get("reasoning_output_tokens") or 0)
403
+ usage["total"] += (u.get("total_tokens") if u.get("total_tokens") is not None
404
+ else inp + out)
405
+ return text, usage, sid
406
+
407
+
408
+ def _codex_fail_msg(stdout):
409
+ """从 JSONL 事件流提取终态失败消息;无失败返回 ""。
410
+
411
+ 2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
412
+ 两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
413
+ _quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
414
+ Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取;turn.failed 是
415
+ 终态优先于裸 error(后者取最后一条兜底)。
416
+ """
417
+ terminal, last_err = "", ""
418
+ for line in (stdout or "").splitlines():
419
+ line = line.strip()
420
+ if not line.startswith("{"):
421
+ continue
422
+ try:
423
+ ev = json.loads(line)
424
+ except Exception:
425
+ continue
426
+ t = ev.get("type")
427
+ if t == "turn.failed":
428
+ e = ev.get("error")
429
+ terminal = e.get("message") if isinstance(e, dict) else str(e or "")
430
+ elif t == "error":
431
+ m = str(ev.get("message") or "")
432
+ if m and "reconnecting" not in m.lower():
433
+ last_err = m
434
+ return terminal or last_err
435
+
436
+
437
+ def _parse_claude_json(stdout):
438
+ try:
439
+ data = json.loads(stdout)
440
+ except Exception:
441
+ return None
442
+ if not isinstance(data, dict):
443
+ return None
444
+ u = data.get("usage") or {}
445
+ inp = int(u.get("input_tokens") or 0)
446
+ out = int(u.get("output_tokens") or 0)
447
+ # 缓存读 + 缓存写都计入 cached(读是省钱的部分,写是额外消耗的部分)
448
+ cached = int(u.get("cache_read_input_tokens") or 0) + \
449
+ int(u.get("cache_creation_input_tokens") or 0)
450
+ usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
451
+ "total": inp + out + cached}
452
+ return {
453
+ "text": data.get("result") or "",
454
+ "cost_usd": data.get("total_cost_usd") or 0.0,
455
+ "usage": usage,
456
+ "tokens": usage["total"],
457
+ "is_error": bool(data.get("is_error")),
458
+ # §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
459
+ "sid": str(data.get("session_id") or ""),
460
+ }
461
+
462
+
463
+ def _codex_provider_args(cp):
464
+ """把外部供应商注入为一次性的 codex model_provider(-c 覆盖,不改配置文件)。"""
465
+ name = cp.get("name", "orch")
466
+ return ["-c", 'model_provider="%s"' % name,
467
+ "-c", 'model_providers.%s.name="%s"' % (name, name),
468
+ "-c", 'model_providers.%s.base_url="%s"' % (name, cp.get("base_url", "")),
469
+ "-c", 'model_providers.%s.env_key="%s"' % (name, cp.get("env_key", "ORCH_API_KEY")),
470
+ "-c", 'model_providers.%s.wire_api="%s"' % (name, cp.get("wire_api", "responses"))]
471
+
472
+
473
+ def _model_flag(kind, model):
474
+ if kind in ("codex", "qwen"):
475
+ return ["-m", model]
476
+ return ["--model", model] # claude / opencode / aider
477
+
478
+
479
+ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
480
+ "unavailable", "overloaded", "rate limit", "timeout", "timed out",
481
+ # 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
482
+ # Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
483
+ # 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
484
+ # 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
485
+ "unknown model", "1211", "connection error", "econnrefused",
486
+ "connection aborted", "initialize", "reset by peer",
487
+ "channel is closed", "no route to host")
488
+
489
+
490
+ def _transient_error(err):
491
+ err = (err or "").lower()
492
+ return any(k in err for k in _TRANSIENT)
493
+
494
+
495
+ # 欠费/配额耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
496
+ # 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
497
+ _QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
498
+ "payment required", "402", "欠费", "余额", "额度", "exceeded")
499
+
500
+
501
+ def _quota_error(err):
502
+ err = (err or "").lower()
503
+ return any(k in err for k in _QUOTA)
504
+
505
+
506
+ def _report_key(att, out):
507
+ """把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
508
+
509
+ 回写失败绝不能影响主流程(账本是旁路),所以整体吞异常。
510
+ """
511
+ pid, kid = att.get("provider_id") or "", att.get("key_id") or ""
512
+ if not (pid and kid):
513
+ return
514
+ try:
515
+ from . import modelhub
516
+ if out.get("ok"):
517
+ modelhub.note_key_ok(pid, kid)
518
+ elif out.get("error"):
519
+ modelhub.note_key_error(pid, kid, out["error"])
520
+ except Exception:
521
+ pass
522
+
523
+
524
+ def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_output=False):
525
+ """根据 run_process 结果 + 解析结果,返回 ErrorCode 字符串。
526
+
527
+ 设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
528
+
529
+ Args:
530
+ res: run_process 返回的 dict(含 ok/cancelled/timed_out/exit_code/stdout/stderr)
531
+ parsed: claude 解析后的 dict(或 None);codex 由 caller 处理
532
+ kind: "claude" | "codex" | "generic" | ...
533
+ attempt_done: claude 空响应是否已重试一次(True=已是第二次)
534
+ empty_output: 调用方已确认 stdout 为空(用于 generic/codex 的空响应)
535
+
536
+ Returns:
537
+ ErrorCode 枚举值字符串;成功返回 ""。
538
+ """
539
+ if res.get("cancelled"):
540
+ return ErrorCode.CANCELLED
541
+ if res.get("timed_out"):
542
+ return ErrorCode.TIMEOUT
543
+ # claude 解析失败(进程 ok 但 JSON 不可解析)
544
+ if kind == "claude" and parsed is None:
545
+ return ErrorCode.PARSE_FAIL
546
+ # claude 明确 is_error
547
+ if parsed and parsed.get("is_error"):
548
+ return ErrorCode.VENDOR_REFUSAL
549
+ # claude 两次都空
550
+ if kind == "claude" and attempt_done and not (parsed or {}).get("text", "").strip():
551
+ return ErrorCode.EMPTY
552
+ # generic/codex 空输出(caller 已确认)
553
+ if empty_output and not parsed:
554
+ return ErrorCode.EMPTY
555
+ # 退出码非 0(vendor 内部崩溃)
556
+ ec = res.get("exit_code")
557
+ if ec is not None and ec != 0:
558
+ return ErrorCode.VENDOR_ERROR
559
+ return ""
560
+
561
+
562
+ def _resolve_attempts(agent):
563
+ """把 agent 的模型配置展开为逐次尝试列表。
564
+
565
+ 优先用跨厂商链 call_chain(每条自带 env / codex_provider,来自不同供应商);
566
+ 无链时退回 model + model_fallbacks(同一 CLI 进程内换 -m,沿用 agent 级注入)。
567
+
568
+ 链条目可能带 key_id/provider_id(同一厂商多把 KEY 展开成的多条)——失败时
569
+ 据此把「哪把 KEY 不行」回写冷却,后续解析自动切备用。
570
+ """
571
+ chain = agent.get("call_chain") or []
572
+ if chain:
573
+ return [{"model": (e.get("model") or "").strip() or None,
574
+ "env": dict(e.get("env") or {}),
575
+ "from_chain": True,
576
+ "own_cp": "codex_provider" in e,
577
+ "codex_provider": e.get("codex_provider"),
578
+ "provider_id": e.get("provider_id") or "",
579
+ "key_id": e.get("key_id") or ""} for e in chain]
580
+ base_model = agent.get("model")
581
+ fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
582
+ models_to_try = ([base_model] if base_model else []) + fb
583
+ return [{"model": m or None, "env": {}, "from_chain": False, "own_cp": False,
584
+ "codex_provider": None, "provider_id": "", "key_id": ""}
585
+ for m in (models_to_try or [None])[:3]]
586
+
587
+
588
+ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
589
+ """构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
590
+ images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。"""
591
+ env = {}
592
+ argv = None
593
+ stdin_text = None
594
+ imgs = [str(p) for p in (images or []) if p]
595
+ if kind == "codex":
596
+ cp = agent.get("codex_provider")
597
+ if sid:
598
+ # resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
599
+ # "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
600
+ argv = resolve_command(agent["command"]) + [
601
+ "exec", "resume", sid, "-",
602
+ "--skip-git-repo-check", "--json"]
603
+ if model:
604
+ argv += ["-m", model]
605
+ argv += ["-c", 'sandbox_mode="%s"' %
606
+ ("read-only" if readonly else "workspace-write")]
607
+ if cp:
608
+ argv += _codex_provider_args(cp)
609
+ else:
610
+ argv = resolve_command(agent["command"]) + [
611
+ "exec", "--skip-git-repo-check", "--json",
612
+ "-s", "read-only" if readonly else "workspace-write"]
613
+ if model:
614
+ argv += ["-m", model]
615
+ if cp:
616
+ argv += _codex_provider_args(cp)
617
+ # codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
618
+ # -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
619
+ for p in imgs:
620
+ argv += ["-i", p]
621
+ stdin_text = prompt
622
+ elif kind == "claude":
623
+ argv = resolve_command(agent["command"]) + ["-p", "--output-format", "json"]
624
+ if sid:
625
+ argv += ["--resume", sid]
626
+ if model:
627
+ argv += ["--model", model]
628
+ if readonly:
629
+ # 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
630
+ # 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
631
+ prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
632
+ else:
633
+ argv += ["--permission-mode", "acceptEdits"]
634
+ stdin_text = prompt
635
+ elif kind == "opencode":
636
+ argv = resolve_command(agent["command"]) + ["run"]
637
+ if sid:
638
+ argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
639
+ if model:
640
+ argv += ["--model", model]
641
+ stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
642
+ elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
643
+ argv = resolve_command(agent["command"])
644
+ if sid:
645
+ argv += ["-r", sid] # 恢复指定会话(~/.qwen/projects/*/chats/<sid>.jsonl)
646
+ if model:
647
+ argv += ["-m", model]
648
+ stdin_text = prompt
649
+ elif kind == "aider":
650
+ argv = resolve_command(agent["command"]) + [
651
+ "--yes-always", "--no-auto-commits", "--no-check-update", "--message", prompt]
652
+ if model:
653
+ argv += ["--model", model]
654
+ else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
655
+ tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
656
+ if sid and agent.get("resume_argv_template"):
657
+ tmpl = agent["resume_argv_template"]
658
+ argv = resolve_command(agent["command"]) + [
659
+ str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
660
+ if "{prompt}" not in tmpl:
661
+ stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
662
+ return argv, stdin_text, prompt
663
+
664
+
665
+ def _check_approval(agent):
666
+ """5G:catalog entry sensitive + policy=NEVER → 拒绝(无人值守不静默降级)。
667
+
668
+ Policy 来源:环境变量 TUTTI_APPROVAL_POLICY(默认 "never")。
669
+ sensitive 字段从 catalog orch.sensitive 读取。
670
+
671
+ Returns:
672
+ (True, "") 表示放行;
673
+ (False, reason) 表示拒绝(error_code=ENV_BLOCK)。
674
+ """
675
+ policy = os.environ.get("TUTTI_APPROVAL_POLICY", "never").strip().lower()
676
+ sensitive = bool((agent.get("orch") or {}).get("sensitive"))
677
+ if not sensitive:
678
+ return True, ""
679
+ if policy == "never":
680
+ return False, "sensitive step blocked by policy=never (set TUTTI_APPROVAL_POLICY=ask to require explicit approval)"
681
+ # policy=ask 留 Phase 5 做完整 UI 弹窗流程;当前等价 never
682
+ return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
683
+
684
+
685
+ def run_agent(agent, prompt, workdir=None, readonly=True,
686
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
687
+ images=None):
688
+ """执行一次智能体调用,返回统一结构
689
+ {ok, text, json, cost_usd, tokens, error, error_code, raw}。
690
+ agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
691
+ (codex: exec resume;claude: --resume;opencode/mimo: run -s;qwen: -r;
692
+ generic: catalog orch.resume_argv_template)。
693
+ images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
694
+ 提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
695
+
696
+ 模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
697
+ 主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
698
+
699
+ 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
700
+ """
701
+ # 5E:catalog orch.timeout_ms 优先
702
+ orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
703
+ if orch_timeout_ms:
704
+ timeout = float(orch_timeout_ms) / 1000.0
705
+ kind = agent.get("kind", "generic")
706
+ # 5G:approval NEVER 一线(无人值守不静默降级)
707
+ ok, reason = _check_approval(agent)
708
+ if not ok:
709
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
710
+ "usage": None, "error": reason,
711
+ "error_code": ErrorCode.ENV_BLOCK,
712
+ "raw": None, "kind": kind, "model": None}
713
+ base_env = dict(agent.get("env") or {})
714
+ if kind == "claude":
715
+ bash = find_git_bash()
716
+ if bash:
717
+ base_env.setdefault("CLAUDE_CODE_GIT_BASH_PATH", bash)
718
+ base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16000")
719
+ sid = (resume or "").strip() if agent.get("mode") == "real" else ""
720
+ if sid and kind == "generic" and not agent.get("resume_argv_template"):
721
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
722
+ "usage": None,
723
+ "error": "该 CLI 未配置会话恢复(catalog orch.resume_argv_template),"
724
+ "无法在已有会话上继续",
725
+ "error_code": ErrorCode.VENDOR_ERROR,
726
+ "sid": "", "raw": None, "kind": kind, "model": None}
727
+
728
+ attempts = _resolve_attempts(agent)
729
+ out = None
730
+ for ai, att in enumerate(attempts):
731
+ env = dict(base_env)
732
+ env.update(att["env"])
733
+ if kind == "codex":
734
+ # 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
735
+ # 是非法 OTel tag 值 → codex_otel 每个 SSE 事件刷 2 条 WARN 淹没真输出。
736
+ # 只静音遥测模块(codex 不认 RUST_LOG 也无害),其余 WARN 全保留。
737
+ env.setdefault("RUST_LOG", "codex_otel=off")
738
+ eff_agent = dict(agent)
739
+ eff_agent["env"] = env
740
+ if att["own_cp"]:
741
+ eff_agent["codex_provider"] = att["codex_provider"]
742
+ elif att["from_chain"] and "codex_provider" in eff_agent:
743
+ # 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
744
+ del eff_agent["codex_provider"]
745
+ for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
746
+ argv, stdin_text, prompt_eff = _build_call(eff_agent, kind, sid, readonly,
747
+ att["model"], prompt,
748
+ images=images if kind == "codex" else None)
749
+ res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
750
+ timeout=timeout, cancel_event=cancel_event, log_path=log_path)
751
+ out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
752
+ "tokens": 0, "usage": None, "error": "", "error_code": "",
753
+ "sid": "", "raw": res, "kind": kind, "model": att["model"]}
754
+ if not res["ok"]:
755
+ # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
756
+ # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
757
+ # 里——只取其一会让 _quota_error/_transient_error 判空。
758
+ tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
759
+ out["error"] = (("超时" if res["timed_out"] else "取消" if res["cancelled"]
760
+ else "退出码 %s" % res["exit_code"])
761
+ + (";stderr/stdout: " + tail if tail else ""))
762
+ if kind == "codex":
763
+ fm = _codex_fail_msg(res["stdout"])
764
+ if fm:
765
+ # 事件流终态错误比原始 JSONL 尾部可读,也是链降级判定依据
766
+ out["error"] = "codex: %s(退出码 %s)" % (fm, res["exit_code"])
767
+ # 5D+2D:错误码归一
768
+ if kind == "claude":
769
+ parsed_err = _parse_claude_json(res["stdout"] or "")
770
+ if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
771
+ out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
772
+ out["error_code"] = _classify_failure(res, kind=kind)
773
+ break
774
+ if kind == "codex":
775
+ out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
776
+ out["sid"] = out_sid # §07 T1.1:会话 id 供 revise/fix 复用
777
+ out["tokens"] = out["usage"]["total"]
778
+ # 退出码 0 不代表成功:配额耗尽时 turn.failed 收尾、进程仍正常退出,
779
+ # 不判失败的话编排者会把错误信息当成果往下传
780
+ fm = _codex_fail_msg(res["stdout"])
781
+ if fm:
782
+ out["ok"] = False
783
+ out["error"] = "codex: %s" % fm
784
+ out["error_code"] = ErrorCode.VENDOR_ERROR
785
+ elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
786
+ out["text"] = res["stdout"][-2000:]
787
+ if not out["text"] and out["ok"]:
788
+ out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
789
+ elif kind == "claude":
790
+ parsed = _parse_claude_json(res["stdout"])
791
+ if parsed is None:
792
+ out["ok"] = False
793
+ out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
794
+ out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
795
+ break
796
+ out["text"] = parsed["text"]
797
+ out["cost_usd"] = parsed["cost_usd"]
798
+ out["tokens"] = parsed["tokens"]
799
+ out["usage"] = parsed["usage"]
800
+ out["sid"] = parsed.get("sid") or "" # §07 T1.1
801
+ if parsed["is_error"]:
802
+ out["ok"] = False
803
+ out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
804
+ out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
805
+ break
806
+ if not out["text"] and attempt == 0:
807
+ continue # 空响应,同模型重试
808
+ if not out["text"] and attempt == 1:
809
+ out["error_code"] = _classify_failure(
810
+ res, kind="claude", parsed=parsed, attempt_done=True)
811
+ else:
812
+ out["text"] = res["stdout"].strip()
813
+ if not out["text"]:
814
+ out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
815
+ break
816
+ _report_key(att, out)
817
+ if out["ok"] or ai == len(attempts) - 1:
818
+ return out
819
+ # 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
820
+ # 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。
821
+ if not (_transient_error(out.get("error")) or _quota_error(out.get("error"))):
822
+ return out # 非瞬态(取消/超时/解析失败)不降级
823
+ return out
824
+
825
+
826
+ def extract_json(text):
827
+ """从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
828
+ if not text:
829
+ return None
830
+ text = text.strip()
831
+ try:
832
+ return json.loads(text)
833
+ except Exception:
834
+ pass
835
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
836
+ if m:
837
+ try:
838
+ return json.loads(m.group(1))
839
+ except Exception:
840
+ pass
841
+ start = text.find("{")
842
+ while start != -1:
843
+ depth = 0
844
+ for i in range(start, len(text)):
845
+ c = text[i]
846
+ if c == "{":
847
+ depth += 1
848
+ elif c == "}":
849
+ depth -= 1
850
+ if depth == 0:
851
+ try:
852
+ return json.loads(text[start:i + 1])
853
+ except Exception:
854
+ break
855
+ start = text.find("{", start + 1)
856
+ return None