codebee 0.1.3 → 0.1.5

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.
@@ -29,8 +29,13 @@ def _history_bonus(stats, agent_id, ttype):
29
29
  s = (stats.get(agent_id) or {}).get(ttype) or {}
30
30
  if not s.get("runs"):
31
31
  return 0.0
32
- win_rate = s["wins"] / s["runs"]
33
- return round(18.0 * win_rate + min(6.0, s["runs"]), 1) # 胜率为主,经验为辅
32
+ runs = s["runs"]
33
+ win_rate = s["wins"] / runs
34
+ # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
35
+ # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
36
+ # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
37
+ loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
38
+ return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
34
39
 
35
40
 
36
41
  def score(agent, role, ttype, stats=None):
@@ -48,7 +53,7 @@ def score(agent, role, ttype, stats=None):
48
53
  hb = _history_bonus(stats, agent.get("id"), ttype)
49
54
  total = base + bb + hb
50
55
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
51
- htxt = (",历史 %d/%d 胜(+%s)" % (hs["wins"], hs["runs"], hb)) if hs else ",无历史记录"
56
+ htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
52
57
  quota_txt = ""
53
58
  quota = int(agent.get("quota_tokens_per_hour") or 0)
54
59
  if quota > 0:
@@ -58,6 +58,28 @@ def resolve_command(command):
58
58
  return [path]
59
59
 
60
60
 
61
+ def _npm_shim_bypass(argv):
62
+ """generic 类 CLI 把提示词嵌进 argv;经 cmd /c 重解析时引号/特殊字符会把
63
+ 提示词截烂(kimi 实测:模型只看到 UTF-8 须知、任务本体整段丢失)。识别
64
+ 标准 npm .cmd 垫片(%_prog% + %dp0% 目标脚本),改 node 直启绕开 cmd。
65
+ 识别不出标准形态原样返回。"""
66
+ if len(argv) >= 3 and argv[0] == "cmd" and argv[1] == "/c" \
67
+ and str(argv[2]).lower().endswith((".cmd", ".bat")):
68
+ shim = argv[2]
69
+ try:
70
+ text = open(shim, encoding="utf-8", errors="replace").read()
71
+ except Exception:
72
+ return argv
73
+ m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
74
+ if not m:
75
+ return argv
76
+ script = os.path.normpath(
77
+ os.path.join(os.path.dirname(shim), m.group(1).lstrip("\\")))
78
+ node = os.path.join(os.path.dirname(shim), "node.exe")
79
+ return [node if os.path.isfile(node) else "node", script] + list(argv[3:])
80
+ return argv
81
+
82
+
61
83
  def _kill_tree(pid):
62
84
  try:
63
85
  subprocess.run(
@@ -81,18 +103,21 @@ def _drain_streams(proc, t_out, t_err, *, timeout=10):
81
103
  t.join(timeout=remaining)
82
104
 
83
105
 
84
- def _pipe_reader(stream, chunks, log_fh):
106
+ def _pipe_reader(stream, chunks, log_fh, stamp=None):
85
107
  """持续读子进程管道并实时落盘。
86
108
 
87
109
  必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
88
110
  在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
89
111
  read1() 只要有数据就返回,日志才能真正边跑边看。
112
+ stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
90
113
  """
91
114
  while True:
92
115
  b = stream.read1(65536)
93
116
  if not b:
94
117
  break
95
118
  chunks.append(b)
119
+ if stamp is not None:
120
+ stamp[0] = time.time()
96
121
  if log_fh:
97
122
  try:
98
123
  log_fh.write(b)
@@ -258,16 +283,22 @@ def pretty_cli_log(text, max_event_chars=4000):
258
283
 
259
284
 
260
285
  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):
286
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
287
+ stall_timeout=0):
262
288
  """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
263
289
 
264
- 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out}。
290
+ stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
291
+ 新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
292
+ 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
293
+ 开了会把正常长任务误杀。
294
+
295
+ 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
265
296
  """
266
297
  if shell_cmd:
267
298
  argv = ["cmd", "/c", shell_cmd]
268
299
  if argv is None:
269
300
  return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
270
- "duration": 0.0, "cancelled": False, "timed_out": False}
301
+ "duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
271
302
  full_env = scrub_env(os.environ.copy(), mode="drop")
272
303
  if env:
273
304
  # 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
@@ -302,8 +333,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
302
333
  "stderr": "启动失败: %r" % e, "duration": 0.0,
303
334
  "cancelled": False, "timed_out": False}
304
335
  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)
336
+ stamp = [time.time()] # 最后输出时刻(两条管道共同刷新)
337
+ t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
338
+ t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
307
339
  t_out.start()
308
340
  t_err.start()
309
341
  if stdin_text is not None:
@@ -319,7 +351,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
319
351
  pass
320
352
  threading.Thread(target=_feed, daemon=True).start()
321
353
  start = time.time()
322
- cancelled = timed_out = False
354
+ cancelled = timed_out = stalled = False
323
355
  while True:
324
356
  try:
325
357
  proc.wait(timeout=0.4)
@@ -336,6 +368,14 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
336
368
  _kill_tree(proc.pid)
337
369
  _drain_streams(proc, t_out, t_err, timeout=5)
338
370
  break
371
+ if stall_timeout and time.time() - stamp[0] > stall_timeout:
372
+ # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
373
+ # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
374
+ stalled = True
375
+ timed_out = True
376
+ _kill_tree(proc.pid)
377
+ _drain_streams(proc, t_out, t_err, timeout=5)
378
+ break
339
379
  duration = round(time.time() - start, 1)
340
380
  t_out.join(timeout=5)
341
381
  t_err.join(timeout=5)
@@ -350,12 +390,15 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
350
390
  pass
351
391
  if cancelled:
352
392
  stderr += "\n[已被用户取消]"
393
+ elif stalled:
394
+ stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
353
395
  elif timed_out:
354
396
  stderr += "\n[超时 %ss,已终止进程树]" % timeout
355
397
  return {
356
398
  "ok": exit_code == 0 and not cancelled and not timed_out,
357
399
  "exit_code": exit_code, "stdout": stdout, "stderr": stderr,
358
400
  "duration": duration, "cancelled": cancelled, "timed_out": timed_out,
401
+ "stalled": stalled,
359
402
  }
360
403
  finally:
361
404
  if log_fh:
@@ -405,6 +448,28 @@ def _parse_codex_jsonl(stdout):
405
448
  return text, usage, sid
406
449
 
407
450
 
451
+ def _codex_work_events(stdout):
452
+ """统计本轮事件流里真实「动了手」的事件数(命令执行/文件改动/MCP 工具)。
453
+
454
+ 实现步空转闸的客观判据:2026-09-17 mo-so 实现步模型一条命令都没发,纯口头
455
+ 谎报「进程执行被策略拦截」却 exit 0 + 一段像模像样的总结——text 看不出敷衍,
456
+ command_execution 计数为 0 才是硬证据。"""
457
+ n = 0
458
+ for line in stdout.splitlines():
459
+ line = line.strip()
460
+ if not line.startswith("{"):
461
+ continue
462
+ try:
463
+ ev = json.loads(line)
464
+ except Exception:
465
+ continue
466
+ if ev.get("type") == "item.completed" and (
467
+ ev.get("item") or {}).get("type") in (
468
+ "command_execution", "file_change", "mcp_tool_call"):
469
+ n += 1
470
+ return n
471
+
472
+
408
473
  def _codex_fail_msg(stdout):
409
474
  """从 JSONL 事件流提取终态失败消息;无失败返回 ""。
410
475
 
@@ -435,10 +500,21 @@ def _codex_fail_msg(stdout):
435
500
 
436
501
 
437
502
  def _parse_claude_json(stdout):
503
+ data = None
438
504
  try:
439
505
  data = json.loads(stdout)
440
506
  except Exception:
441
- return None
507
+ # stream-json:逐行事件取最后一条 type=="result"(与旧单 JSON 同构)
508
+ for line in stdout.splitlines():
509
+ line = line.strip()
510
+ if not line.startswith("{"):
511
+ continue
512
+ try:
513
+ ev = json.loads(line)
514
+ except Exception:
515
+ continue
516
+ if isinstance(ev, dict) and ev.get("type") == "result":
517
+ data = ev
442
518
  if not isinstance(data, dict):
443
519
  return None
444
520
  u = data.get("usage") or {}
@@ -478,6 +554,7 @@ def _model_flag(kind, model):
478
554
 
479
555
  _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
480
556
  "unavailable", "overloaded", "rate limit", "timeout", "timed out",
557
+ "输出停滞",
481
558
  # 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
482
559
  # Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
483
560
  # 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
@@ -585,7 +662,21 @@ def _resolve_attempts(agent):
585
662
  for m in (models_to_try or [None])[:3]]
586
663
 
587
664
 
588
- def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
665
+ def _codex_sandbox(readonly):
666
+ """codex 沙箱档位。写步默认 danger-full-access(2026-09-17 用户拍板「默认给全部
667
+ 权限」):workspace-write 禁网+禁盘外写,模型偶发还会误判沙箱受限、谎报
668
+ 「进程执行被策略拦截」直接躺平(mo-so BUG#27596 实测,沙箱探针证明命令本可跑)。
669
+ 只读步(评审/规划)仍 read-only——评审者可写会污染 git diff 裁决。
670
+ env TUTTI_CODEX_SANDBOX 可钉死某档(workspace-write / read-only / danger-full-access)。"""
671
+ if readonly:
672
+ return "read-only"
673
+ env = os.environ.get("TUTTI_CODEX_SANDBOX", "").strip().lower()
674
+ if env in ("workspace-write", "read-only", "danger-full-access"):
675
+ return env
676
+ return "danger-full-access"
677
+
678
+
679
+ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
589
680
  """构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
590
681
  images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。"""
591
682
  env = {}
@@ -602,14 +693,13 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
602
693
  "--skip-git-repo-check", "--json"]
603
694
  if model:
604
695
  argv += ["-m", model]
605
- argv += ["-c", 'sandbox_mode="%s"' %
606
- ("read-only" if readonly else "workspace-write")]
696
+ argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
607
697
  if cp:
608
698
  argv += _codex_provider_args(cp)
609
699
  else:
610
700
  argv = resolve_command(agent["command"]) + [
611
701
  "exec", "--skip-git-repo-check", "--json",
612
- "-s", "read-only" if readonly else "workspace-write"]
702
+ "-s", _codex_sandbox(readonly)]
613
703
  if model:
614
704
  argv += ["-m", model]
615
705
  if cp:
@@ -620,7 +710,10 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
620
710
  argv += ["-i", p]
621
711
  stdin_text = prompt
622
712
  elif kind == "claude":
623
- argv = resolve_command(agent["command"]) + ["-p", "--output-format", "json"]
713
+ # stream-json(2026-09-17 起):逐行事件边跑边吐——停滞看门狗有得看,
714
+ # 步骤日志实时可读;result 末行与旧单 JSON 同构,解析器双形态兼容
715
+ argv = resolve_command(agent["command"]) + [
716
+ "-p", "--output-format", "stream-json", "--verbose"]
624
717
  if sid:
625
718
  argv += ["--resume", sid]
626
719
  if model:
@@ -629,15 +722,27 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
629
722
  # 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
630
723
  # 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
631
724
  prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
632
- else:
725
+ elif os.environ.get("TUTTI_CLAUDE_PERMS", "").strip() == "acceptEdits":
633
726
  argv += ["--permission-mode", "acceptEdits"]
727
+ else:
728
+ # 无人值守 -p 下 acceptEdits 只自动放行改文件,Bash 一律权限拒绝 →
729
+ # 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
730
+ # 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
731
+ argv += ["--dangerously-skip-permissions"]
634
732
  stdin_text = prompt
635
733
  elif kind == "opencode":
636
734
  argv = resolve_command(agent["command"]) + ["run"]
735
+ if workdir:
736
+ # opencode 的 shell 不总跟进程 cwd 走(服务端复用时会落在旧项目根,
737
+ # 2026-09-17 mo-so 实测 agent 在 Temp 里全盘找代码)——显式钉死
738
+ argv += ["--dir", str(workdir)]
637
739
  if sid:
638
740
  argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
639
741
  if model:
640
- argv += ["--model", model]
742
+ # opencode 只认 provider/model 全名(裸名直接 UnknownError,实测);
743
+ # CodeBee 同步进其配置的 provider id 固定为 orch,故裸名补前缀。
744
+ # 绑定里已写全名(含 /)的尊重原值。
745
+ argv += ["--model", model if "/" in model else "orch/" + model]
641
746
  stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
642
747
  elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
643
748
  argv = resolve_command(agent["command"])
@@ -657,6 +762,8 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
657
762
  tmpl = agent["resume_argv_template"]
658
763
  argv = resolve_command(agent["command"]) + [
659
764
  str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
765
+ # 提示词在 argv 里 → 必须绕开 cmd 重解析(见 _npm_shim_bypass)
766
+ argv = _npm_shim_bypass(argv)
660
767
  if "{prompt}" not in tmpl:
661
768
  stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
662
769
  return argv, stdin_text, prompt
@@ -682,9 +789,20 @@ def _check_approval(agent):
682
789
  return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
683
790
 
684
791
 
792
+ def _stall_timeout(env_name, default):
793
+ """停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
794
+ 错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
795
+ (codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
796
+ 输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
797
+ try:
798
+ return max(0, int(os.environ.get(env_name, str(default))))
799
+ except Exception:
800
+ return default
801
+
802
+
685
803
  def run_agent(agent, prompt, workdir=None, readonly=True,
686
804
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
687
- images=None):
805
+ images=None, require_tools=False):
688
806
  """执行一次智能体调用,返回统一结构
689
807
  {ok, text, json, cost_usd, tokens, error, error_code, raw}。
690
808
  agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
@@ -692,6 +810,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
692
810
  generic: catalog orch.resume_argv_template)。
693
811
  images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
694
812
  提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
813
+ require_tools:实现步空转闸(仅 codex 事件流可判)——True 时本轮零
814
+ command_execution/file_change/mcp_tool_call 事件即判 VENDOR_REFUSAL,
815
+ 防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
695
816
 
696
817
  模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
697
818
  主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
@@ -703,6 +824,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
703
824
  if orch_timeout_ms:
704
825
  timeout = float(orch_timeout_ms) / 1000.0
705
826
  kind = agent.get("kind", "generic")
827
+ stall_t = (_stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600) if kind == "codex"
828
+ else _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
829
+ if kind == "claude" else 0)
706
830
  # 5G:approval NEVER 一线(无人值守不静默降级)
707
831
  ok, reason = _check_approval(agent)
708
832
  if not ok:
@@ -745,9 +869,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
745
869
  for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
746
870
  argv, stdin_text, prompt_eff = _build_call(eff_agent, kind, sid, readonly,
747
871
  att["model"], prompt,
748
- images=images if kind == "codex" else None)
872
+ images=images if kind == "codex" else None,
873
+ workdir=workdir)
749
874
  res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
750
- timeout=timeout, cancel_event=cancel_event, log_path=log_path)
875
+ timeout=timeout, cancel_event=cancel_event, log_path=log_path,
876
+ stall_timeout=stall_t)
751
877
  out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
752
878
  "tokens": 0, "usage": None, "error": "", "error_code": "",
753
879
  "sid": "", "raw": res, "kind": kind, "model": att["model"]}
@@ -756,9 +882,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
756
882
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
757
883
  # 里——只取其一会让 _quota_error/_transient_error 判空。
758
884
  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 ""))
885
+ head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
886
+ if res.get("stalled")
887
+ else "超时" if res["timed_out"]
888
+ else "取消" if res["cancelled"]
889
+ else "退出码 %s" % res["exit_code"])
890
+ out["error"] = head + (";stderr/stdout: " + tail if tail else "")
762
891
  if kind == "codex":
763
892
  fm = _codex_fail_msg(res["stdout"])
764
893
  if fm:
@@ -769,6 +898,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
769
898
  parsed_err = _parse_claude_json(res["stdout"] or "")
770
899
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
771
900
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
901
+ if kind == "codex" and att.get("provider_id"):
902
+ el = out["error"].lower()
903
+ if (("wire_api" in el and "no longer supported" in el)
904
+ or "no route matched" in el):
905
+ # codex 撞 chat-only 供应商(0.154 只讲 responses):
906
+ # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
907
+ # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
908
+ try:
909
+ from . import modelhub as _mh
910
+ _mh.note_codex_wire_dead(att["provider_id"])
911
+ except Exception:
912
+ pass
913
+ elif "no model configured" in out["error"].lower():
914
+ # CLI 本体没配置(kimi 实测)→ 运行期自愈:把绑定注入其
915
+ # 自家配置,本次换将之后的下一轮即可用
916
+ try:
917
+ from . import manager as _mg
918
+ note = _mg.sync_cli_config_now(agent.get("id", ""))
919
+ if note and log_path:
920
+ try:
921
+ with open(log_path, "a", encoding="utf-8") as fh:
922
+ fh.write("\n[自愈] %s\n" % note)
923
+ except Exception:
924
+ pass
925
+ except Exception:
926
+ pass
772
927
  out["error_code"] = _classify_failure(res, kind=kind)
773
928
  break
774
929
  if kind == "codex":
@@ -784,6 +939,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
784
939
  out["error_code"] = ErrorCode.VENDOR_ERROR
785
940
  elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
786
941
  out["text"] = res["stdout"][-2000:]
942
+ if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
943
+ # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
944
+ # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
945
+ # 仍可换别的 CLI 再试)。原文尾段留进错误供人核对。
946
+ out["ok"] = False
947
+ out["error"] = ("实现步零工具调用:模型未执行任何命令/文件改动,"
948
+ "仅口头汇报(典型如谎报环境受限)。回答尾段: %s"
949
+ % (out["text"] or "")[-300:])
950
+ out["error_code"] = ErrorCode.VENDOR_REFUSAL
787
951
  if not out["text"] and out["ok"]:
788
952
  out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
789
953
  elif kind == "claude":
@@ -62,23 +62,65 @@ def _ver_tuple(s):
62
62
  return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
63
63
 
64
64
 
65
- def _npm_latest():
66
- """npm view codebee version;返回 (latest, err)
67
- 用 _PKG_NAME 变量而非行内字面量:发布名改过一次(tutti-orchestrator→codebee),
68
- 硬编码两处容易漏改;Mimosa 安全基线要求 argv 可执行文件为字面量,参数用变量不违反。"""
65
+ _RELNOTES_RE = re.compile(
66
+ r"<!--\s*relnotes:start\s*-->(.*?)<!--\s*relnotes:end\s*-->", re.S)
67
+
68
+
69
+ def _relnotes(readme):
70
+ """README 里 relnotes 标记段的内容(发布前手工更新,见 CHANGELOG.md 头部说明)。"""
71
+ m = _RELNOTES_RE.search(str(readme or ""))
72
+ return m.group(1).strip() if m else ""
73
+
74
+
75
+ def _npm_meta():
76
+ """npm view <pkg> --json;返回 (latest, relnotes, err)。
77
+
78
+ relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
79
+ GitHub 连通性),展示「新版本更新内容」用。"""
69
80
  r = runner.run_process(
70
- argv=["cmd", "/c", "npm", "view", _PKG_NAME, "version"], timeout=60)
81
+ argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
71
82
  if not r["ok"]:
72
- return "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
73
- m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
74
- return (m.group(0) if m else ""), ("" if m else "npm 输出无法解析")
83
+ return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
84
+ ver, notes = "", ""
85
+ try:
86
+ meta = json.loads(r["stdout"] or "{}")
87
+ if isinstance(meta, dict):
88
+ ver = str(meta.get("version") or "")
89
+ notes = _relnotes(meta.get("readme") or "")
90
+ except Exception:
91
+ pass
92
+ if not ver: # 旧 npm --json 失败时退回纯文本解析
93
+ m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
94
+ ver = m.group(0) if m else ""
95
+ return ver, notes, ("" if ver else "npm 输出无法解析")
96
+
97
+
98
+ def changelog_section(version):
99
+ """本地 CHANGELOG.md 中 version 对应小节(升级重启后「本次更新内容」用)。"""
100
+ v = re.escape(str(version or "").lstrip("v"))
101
+ m = re.search(r"(?ms)^## v?%s\b[^\n]*$(.*?)(?=^## |\Z)" % v,
102
+ _read_changelog())
103
+ if not m:
104
+ return ""
105
+ body = m.group(0).strip()
106
+ return body
107
+
108
+
109
+ def _read_changelog():
110
+ try:
111
+ return (paths.ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
112
+ except Exception:
113
+ return ""
75
114
 
76
115
 
77
116
  def check(force=False):
78
- """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note}。"""
117
+ """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note,
118
+ notes, whatsnew}。notes=远端新版本的更新内容(npm readme relnotes 段);
119
+ whatsnew=当前版本在本地 CHANGELOG.md 的小节(升级重启后弹「本次更新内容」)。"""
79
120
  mode = install_mode()
80
121
  cur = package_version()
81
- out = {"mode": mode, "current": cur, "latest": "", "has_update": False, "note": ""}
122
+ out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
123
+ "note": "", "notes": "", "whatsnew": changelog_section(cur)}
82
124
  if mode != "npm":
83
125
  out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
84
126
  if mode == "repo" else "非 npm 安装,无法自动更新")
@@ -87,8 +129,9 @@ def check(force=False):
87
129
  if not force and _CHECK_CACHE["result"] and \
88
130
  time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
89
131
  return dict(_CHECK_CACHE["result"])
90
- latest, err = _npm_latest()
132
+ latest, notes, err = _npm_meta()
91
133
  out["latest"] = latest
134
+ out["notes"] = notes
92
135
  if err:
93
136
  out["note"] = "查询新版本失败:" + err
94
137
  elif latest and cur:
package/app/core/store.py CHANGED
@@ -244,8 +244,9 @@ def _valid_id(task_id):
244
244
  def set_book_meta(task_id, platform, entry):
245
245
  """写任务的作品信息状态(book_meta[platform] = {status, data?, error?, at})。
246
246
 
247
- 一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度(SSE 推送)。
248
- 任务不存在返回 False。"""
247
+ 一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度。这里必须
248
+ bump_state:SSE 存活时前端不主动拉状态,不推送则卡片停在旧状态(点生成
249
+ 不翻「生成中」、跑完不翻「已生成」)。任务不存在返回 False。"""
249
250
  if not _valid_id(task_id):
250
251
  return False
251
252
  with LOCK:
@@ -254,6 +255,7 @@ def set_book_meta(task_id, platform, entry):
254
255
  return False
255
256
  task.setdefault("book_meta", {})[platform] = entry
256
257
  _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
258
+ bump_state()
257
259
  return True
258
260
 
259
261