codebee 0.1.27 → 0.1.29

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.29(2026-09-21)
10
+
11
+ ### ✨ 新功能
12
+
13
+ - 升级后蜜蜂自动换新装:修复「升级后还是旧的丑蜜蜂」——服务重启后旧蜜蜂会自动让位,由新服务拉起带新形象的新蜜蜂(此前旧蜜蜂占着位置永不退场)。
14
+
15
+ ### 🛠 问题修复
16
+
17
+ - 去掉蜜蜂底部的任务进度条(真机截图反馈):窗口收紧贴着蜂体,画面更干净。
18
+
19
+ ## v0.1.28(2026-09-21)
20
+
21
+ ### 🛠 问题修复
22
+
23
+ - 「关于与更新」页的版本信息与更新说明改为左对齐阅读,不再整块居中(加载占位的居中样式误留在了正式内容上)。
24
+
9
25
  ## v0.1.27(2026-09-21)
10
26
 
11
27
  ### ✨ 新功能
package/README.md CHANGED
@@ -23,6 +23,19 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
23
23
  <!-- relnotes:start -->
24
24
  ### ✨ 新功能 · New
25
25
 
26
+ - 升级后蜜蜂自动换新装:服务重启后旧蜜蜂自动让位,新蜜蜂带新形象上线。
27
+ - After upgrading, the desktop bee swaps itself for the new look: the old bee steps aside when the service restarts.
28
+
29
+ ### 🛠 问题修复 · Fixes
30
+
31
+ - 去掉蜜蜂底部的任务进度条,窗口更干净。
32
+ - Removed the task progress bar under the desktop bee for a cleaner look.
33
+
34
+ - 「关于与更新」页的版本信息与更新说明改为左对齐阅读,不再整块居中。
35
+ - The version info and release notes on the About page are now left-aligned for easier reading.
36
+
37
+ ### ✨ 新功能 · New
38
+
26
39
  - 连载任务防穿帮:新增「资源账本」——每章评审自动登记新出现的道具、伤情、承诺与伏笔,写下一章前自动注入提醒核对。
27
40
  - Serial tasks now keep a resource ledger: props, injuries, promises, and plot threads are tracked per chapter and injected before writing the next one.
28
41
 
@@ -103,7 +103,9 @@ def _online_model_bonus(entry, task_type, role):
103
103
  rate = float(metrics.get("success_rate") or 0.0)
104
104
  success_score = max(-6.0, min(6.0, (rate - 0.75) * 18.0))
105
105
  p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
106
- latency_score = -min(4.0, max(0.0, (p95 - 45.0) / 12.0))
106
+ # 模型链也必须让真实延迟影响排序,不能让 P95 数分钟的链靠静态质量分
107
+ # 长期压在十几秒的健康链前面。
108
+ latency_score = -min(12.0, max(0.0, (p95 - 30.0) / 8.0))
107
109
  cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
108
110
  cost_score = -min(3.0, max(0.0, (cost - 0.01) / 0.01))
109
111
  total = round(success_score + latency_score + cost_score, 2)
package/app/core/flows.py CHANGED
@@ -36,7 +36,7 @@ BUILTIN_FLOWS = [
36
36
  "note": "CodeBee 直连模型 API 干活(无 CLI 进程),无可用供应商时回退本机 CLI;无拆解/评审(快)"},
37
37
  {"id": "code", "name": "代码", "icon": "i-code", "engine": "code", "builtin": True,
38
38
  "goal_hint": "要实现/修复什么(一句话)",
39
- "note": "实现 → 验证命令 → 跨厂商评审 → 自动修复/换将"},
39
+ "note": "按难度动态编排:简单任务走实现+验证短链,复杂任务增加规划、评审、修复与换将"},
40
40
  {"id": "novel", "name": "小说", "icon": "i-book-open", "engine": "review", "builtin": True,
41
41
  "manuscript": "manuscript.md",
42
42
  "rubric": ["情节", "人物", "文笔", "节奏", "吸引力"],
package/app/core/jobs.py CHANGED
@@ -309,6 +309,18 @@ def _run_job(job):
309
309
  pass
310
310
  finally:
311
311
  if run_id:
312
+ # 执行线程退出就是生命周期的最后边界。流水线分支若意外提前 return、
313
+ # 或内部吞掉异常却漏写终态,旧逻辑会让 run/task 永久停在 running。
314
+ try:
315
+ from . import store
316
+ latest = store.get_run(run_id)
317
+ if latest and latest.get("status") == "running":
318
+ store.update_run(
319
+ run_id, expected_status="running", status="failed",
320
+ ended_at=_now(),
321
+ error="执行流程已退出但未写入完成状态,已自动标记失败;可重试")
322
+ except Exception:
323
+ pass
312
324
  CANCELS.pop(run_id, None)
313
325
  try:
314
326
  _maybe_auto_resume(run_id)
@@ -31,18 +31,18 @@ _LOCK = threading.RLock()
31
31
  _STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
32
32
 
33
33
  # 能自动写入默认模型的 config.format(其余格式只能手动编辑)
34
- _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
35
- "yaml-line")
36
-
37
-
38
- def _read_text(path, preserve_newlines=False):
39
- """读取文本并及时关闭句柄;改写配置前可保留原始换行符。"""
40
- newline = "" if preserve_newlines else None
41
- with open(path, encoding="utf-8", errors="replace", newline=newline) as fh:
42
- return fh.read()
43
-
44
-
45
- def _expand(p):
34
+ _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
35
+ "yaml-line")
36
+
37
+
38
+ def _read_text(path, preserve_newlines=False):
39
+ """读取文本并及时关闭句柄;改写配置前可保留原始换行符。"""
40
+ newline = "" if preserve_newlines else None
41
+ with open(path, encoding="utf-8", errors="replace", newline=newline) as fh:
42
+ return fh.read()
43
+
44
+
45
+ def _expand(p):
46
46
  return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
47
47
 
48
48
 
@@ -482,7 +482,7 @@ def read_model(entry):
482
482
  if not path or not os.path.isfile(path) or not cfg.get("format"):
483
483
  return None
484
484
  try:
485
- text = _read_text(path)
485
+ text = _read_text(path)
486
486
  except Exception:
487
487
  return None
488
488
  if cfg["format"] == "toml-line":
@@ -554,7 +554,7 @@ def write_model(entry, model):
554
554
  # mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
555
555
  # _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
556
556
  keys = (cfg.get("model_key") or "model").split(".")
557
- text = _read_text(path, preserve_newlines=True)
557
+ text = _read_text(path, preserve_newlines=True)
558
558
  new_text, ok = _jsonc_set(text, tuple(keys),
559
559
  json.dumps(model, ensure_ascii=False))
560
560
  if not ok:
@@ -562,7 +562,7 @@ def write_model(entry, model):
562
562
  "error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
563
563
  Path(path).write_bytes(new_text.encode("utf-8"))
564
564
  elif fmt == "toml-line":
565
- text = _read_text(path)
565
+ text = _read_text(path)
566
566
  new_line = 'model = "%s"' % model
567
567
  if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
568
568
  text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
@@ -570,19 +570,19 @@ def write_model(entry, model):
570
570
  text = text.rstrip("\n") + "\n" + new_line + "\n"
571
571
  Path(path).write_bytes(text.encode("utf-8"))
572
572
  elif fmt == "toml-section":
573
- text = _read_text(path, preserve_newlines=True)
573
+ text = _read_text(path, preserve_newlines=True)
574
574
  text = _toml_write_value(text, *_dotted_key(cfg), value=model)
575
575
  Path(path).write_bytes(text.encode("utf-8"))
576
576
  elif fmt == "yaml-line":
577
577
  # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
578
578
  # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
579
- text = _read_text(path, preserve_newlines=True)
579
+ text = _read_text(path, preserve_newlines=True)
580
580
  text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
581
581
  Path(path).write_bytes(text.encode("utf-8"))
582
582
  elif fmt == "json-path":
583
583
  # openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
584
584
  # 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
585
- text = _read_text(path, preserve_newlines=True)
585
+ text = _read_text(path, preserve_newlines=True)
586
586
  try:
587
587
  data = json.loads(text) if text.strip() else {}
588
588
  except Exception:
@@ -604,7 +604,7 @@ def write_model(entry, model):
604
604
  json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
605
605
  else:
606
606
  try:
607
- data = json.loads(_read_text(path))
607
+ data = json.loads(_read_text(path))
608
608
  except FileNotFoundError:
609
609
  return {"ok": False,
610
610
  "error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
@@ -662,7 +662,7 @@ def _launch_log_path(entry):
662
662
  def _best_url(log_path, port):
663
663
  """从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
664
664
  try:
665
- text = _read_text(str(log_path))
665
+ text = _read_text(str(log_path))
666
666
  except Exception:
667
667
  return None
668
668
  urls = re.findall(r"https?://[^\s\"'<>]+", text)
@@ -762,7 +762,7 @@ def _sync_dsh_settings(entry, model, base_url):
762
762
  return "dsh 配置路径越出用户主目录,已拒绝"
763
763
  try:
764
764
  if os.path.isfile(path):
765
- text = _read_text(path, preserve_newlines=True)
765
+ text = _read_text(path, preserve_newlines=True)
766
766
  else:
767
767
  Path(path).parent.mkdir(parents=True, exist_ok=True)
768
768
  text = ""
@@ -785,7 +785,7 @@ def _dsh_selfcheck_model(entry):
785
785
  if not path or not os.path.isfile(path):
786
786
  return None, ""
787
787
  try:
788
- text = _read_text(path, preserve_newlines=True)
788
+ text = _read_text(path, preserve_newlines=True)
789
789
  except Exception:
790
790
  return None, ""
791
791
  cur = _yaml_read_value(text, "agent-default-model", "model")
@@ -809,7 +809,7 @@ def _dsh_key_present():
809
809
  return True
810
810
  try:
811
811
  envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
812
- return "DEEPSEEK_API_KEY" in _read_text(envfile)
812
+ return "DEEPSEEK_API_KEY" in _read_text(envfile)
813
813
  except Exception:
814
814
  return False
815
815
 
@@ -915,7 +915,7 @@ def _sync_codex_settings(entry, model, cp):
915
915
  ("wire_api", q(cp.get("wire_api", "responses")))]
916
916
  try:
917
917
  if os.path.isfile(path):
918
- text = _read_text(path, preserve_newlines=True)
918
+ text = _read_text(path, preserve_newlines=True)
919
919
  else:
920
920
  Path(path).parent.mkdir(parents=True, exist_ok=True)
921
921
  text = ""
@@ -1122,7 +1122,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
1122
1122
  不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
1123
1123
  try:
1124
1124
  if os.path.isfile(path):
1125
- text = _read_text(path)
1125
+ text = _read_text(path)
1126
1126
  try:
1127
1127
  data = json.loads(text)
1128
1128
  except Exception:
@@ -1242,7 +1242,7 @@ def _sync_opencode_settings(entry, model, prov):
1242
1242
  errs.append("路径越出主目录:%s" % path)
1243
1243
  continue
1244
1244
  if os.path.isfile(path):
1245
- text = _read_text(path, preserve_newlines=True)
1245
+ text = _read_text(path, preserve_newlines=True)
1246
1246
  else:
1247
1247
  Path(path).parent.mkdir(parents=True, exist_ok=True)
1248
1248
  text = ""
@@ -1333,7 +1333,7 @@ def _sync_kimi_settings(entry, model, prov):
1333
1333
  Path(path).parent.mkdir(parents=True, exist_ok=True)
1334
1334
  text = ""
1335
1335
  if os.path.isfile(path):
1336
- text = _read_text(path, preserve_newlines=True)
1336
+ text = _read_text(path, preserve_newlines=True)
1337
1337
  shutil.copyfile(path, path + ".bak")
1338
1338
  # 移除旧托管块与旧顶层键(防重复/防键落进别的表)
1339
1339
  text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
@@ -1410,7 +1410,7 @@ def _sync_agent_injection(entry, binding):
1410
1410
  if entry["id"] in _NO_CHANNEL_HINT:
1411
1411
  if binding.get("env"):
1412
1412
  return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
1413
- return "未指定可用供应商:打开后使用 CLI 自带登录;需要固定注入请到「模型调度(可选)」页指定"
1413
+ return "未指定可用供应商:打开后使用 CLI 自带登录;需要固定注入请到「模型调度(可选)」页指定"
1414
1414
  return None
1415
1415
 
1416
1416
 
@@ -1418,8 +1418,9 @@ def _sync_launch_model(entry, binding):
1418
1418
  """打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
1419
1419
  交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
1420
1420
  「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
1421
- codex 额外落 provider 段(交互 TUI 只认 config.toml,不认编排的 -c 覆盖
1422
- 与 ORCH_API_KEY env)。返回给用户看的同步笔记列表。"""
1421
+ codex 已改为不落盘(2026-09-21 起:全局 model_provider 归 CC Switch/
1422
+ 用户管理,编排任务的绑定走 runner 的 -c 一次性注入)。返回给用户
1423
+ 看的同步笔记列表。"""
1423
1424
  notes = []
1424
1425
  model = (binding.get("model") or "").strip()
1425
1426
  prov = binding.get("provider") or {}
@@ -1427,6 +1428,14 @@ def _sync_launch_model(entry, binding):
1427
1428
  is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1428
1429
  is_codex = entry["id"] in ("codex-cli", "codex")
1429
1430
  cp = binding.get("codex_provider")
1431
+ if is_codex:
1432
+ # codex 全局配置不落盘(2026-09-21 用户决策):全局 model_provider 交还
1433
+ # CC Switch/用户手动管理,一键打开不再顶掉外部选择。编排任务不受影响
1434
+ # ——runner 的 -c 一次性注入仍带绑定供应商;交互 TUI 打开即用外部当前
1435
+ # 配置,想用绑定供应商起编排任务即可。
1436
+ notes.append("codex 配置保持外部管理(绑定 %s 仅用于编排 -c 注入,未写 config.toml)"
1437
+ % (cp.get("name") or "orch"))
1438
+ return notes
1430
1439
  if model and fmt in _WRITABLE_FORMATS:
1431
1440
  w = write_model(entry, model)
1432
1441
  notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
@@ -1530,7 +1539,7 @@ def launch(entry, open_browser=True):
1530
1539
  notes = _sync_launch_model(entry, binding)
1531
1540
  if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
1532
1541
  notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
1533
- "要打开即用,请到「模型调度(可选)」页给 DeepSeek Harness 指定 openai 协议供应商")
1542
+ "要打开即用,请到「模型调度(可选)」页给 DeepSeek Harness 指定 openai 协议供应商")
1534
1543
  name = entry.get("name", entry["id"])
1535
1544
  kind = (launch.get("kind") or "console").lower()
1536
1545
  extra = (";".join(notes)) if notes else ""
@@ -1637,7 +1646,7 @@ def _open_when_ready(port, url, log_path, timeout=30):
1637
1646
  # ---------------------------------------------------------------- 安装/升级
1638
1647
 
1639
1648
  def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
1640
- """执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
1649
+ """执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
1641
1650
  if op == "uninstall":
1642
1651
  cmd = catalog.uninstall_command(entry)
1643
1652
  if not cmd:
@@ -944,14 +944,20 @@ def _run_code(run, task, agents, ev, stats, mode):
944
944
  error="没有可用智能体", ended_at=_now())
945
945
  return
946
946
 
947
- # ---- 规划
948
- if mode == "auto":
947
+ # 项目记忆既给规划器,也给快速路径的实现者;不能因省掉独立规划而漏掉。
948
+ user_context_len = len(task.get("context") or "")
949
+ project_memory = _read_project_memory(workdir)
950
+ if project_memory:
951
+ task = dict(task, context=(task.get("context") or "") + "\n\n" + project_memory)
952
+
953
+ # ---- 规划。低风险短任务直接把目标作为单步计划,省掉一次独立模型调用;
954
+ # 附件或较长的用户背景仍先规划,避免快速路径漏读约束。
955
+ fast_path = (mode == "auto" and difficulty == "easy"
956
+ and not task.get("attachments")
957
+ and user_context_len < 600)
958
+ planned = False
959
+ if mode == "auto" and not fast_path:
949
960
  _wait_gate(run_id, ev)
950
- # 项目记忆注入(借鉴 agentmemory 持久记忆):同工作目录此前代码任务留下的
951
- # 架构事实,让规划器不再对代码库一无所知
952
- _pm = _read_project_memory(workdir)
953
- if _pm:
954
- task = dict(task, context=(task.get("context") or "") + "\n\n" + _pm)
955
961
  plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
956
962
  note=route.get("implementer", ""))
957
963
  plan = planner.make_code_plan(_steered_task(run_id, task),
@@ -967,21 +973,39 @@ def _run_code(run, task, agents, ev, stats, mode):
967
973
  plan["source"], difficulty,
968
974
  ";".join(s["title"] for s in plan["steps"])[:160]),
969
975
  duration_s=0.1 if impl.get("mode") == "mock" else None)
976
+ planned = True
977
+ elif fast_path:
978
+ plan = {
979
+ "source": "dynamic-fast-path",
980
+ "difficulty": "easy",
981
+ "steps": [{"title": "直接实现并自检", "detail": task["goal"]}],
982
+ "workflow": {
983
+ "review_required": not bool(task.get("verify_command")),
984
+ "max_repair_rounds": 1,
985
+ "allow_switch": False,
986
+ },
987
+ }
970
988
  else:
971
989
  plan = {"source": "manual", "steps": [{"title": "实现任务", "detail": task["goal"]}]}
972
- store.update_run(run_id, plan=plan, difficulty=difficulty)
990
+ workflow = task_compile.code_workflow(
991
+ task, difficulty, plan=plan, mode=mode, planned=planned)
992
+ store.update_run(run_id, plan=plan, difficulty=difficulty, workflow=workflow)
973
993
  subtasks = plan["steps"]
974
994
  # 计划落盘(planning-with-files):磁盘上的计划与 spec/evidence 同居任务档案
975
995
  _write_task_plan(task, workdir, plan)
976
996
 
977
997
  # ---- 评审者(有会话延续时评审者仍用新鲜上下文,避免偏见)
978
- if mode == "auto":
998
+ if not workflow["review_required"]:
999
+ reviewer = None
1000
+ route["reviewer"] = workflow["reason"]
1001
+ elif mode == "auto":
979
1002
  reviewer, route["reviewer"] = router.pick_reviewer(agents, impl, "code", stats)
980
1003
  else:
981
1004
  reviewer, route["reviewer"] = _pick_reviewer_legacy(agents, impl)
982
1005
  _record_actual_route(run_id, task, agents, stats, impl, reviewer=reviewer,
983
1006
  implement_reason=route.get("implementer", ""),
984
- review_reason=route.get("reviewer", ""))
1007
+ review_reason=route.get("reviewer", ""),
1008
+ direct=not workflow["review_required"])
985
1009
  store.update_run(run_id, route=route)
986
1010
 
987
1011
  def implement_all(impl_agent, prefix_note):
@@ -1078,7 +1102,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1078
1102
  run_id, task, agents, stats, other,
1079
1103
  implementers=[other], reviewer=reviewer,
1080
1104
  implement_reason=note,
1081
- review_reason=route.get("reviewer", ""))
1105
+ review_reason=route.get("reviewer", ""),
1106
+ direct=not workflow["review_required"])
1082
1107
  return True
1083
1108
  res_err = "%s;换将后仍失败:%s" % (note, (res.get("error") or "")[:200])
1084
1109
  else:
@@ -1091,6 +1116,17 @@ def _run_code(run, task, agents, ev, stats, mode):
1091
1116
 
1092
1117
  def review_and_score():
1093
1118
  verify_pass, verify_ran = _run_verify(run_id, task, workdir, ev)
1119
+ if not workflow["review_required"]:
1120
+ return {
1121
+ "pass": verify_pass,
1122
+ "scores": {},
1123
+ "issues": ([] if verify_pass else [{
1124
+ "severity": "high", "title": "验证命令未通过",
1125
+ "detail": "动态短链省略模型评审,先修复确定性验证失败。",
1126
+ }]),
1127
+ "summary": "低风险短链:确定性验证%s,模型评审已省略。" %
1128
+ ("通过" if verify_pass else "未通过"),
1129
+ }, verify_pass, verify_ran
1094
1130
  # gate 验证(借鉴 pi-subagents 的 gate:"npm test"):verify 失败时先跳过
1095
1131
  # 模型评审直接进修复轮——评审此时只能复述「验证没过」,白烧一次调用。
1096
1132
  # 连续失败 >=2 轮后恢复评审参与诊断(模型能看出编译错误之外的病灶)。
@@ -1138,7 +1174,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1138
1174
  implementers=[impl], reviewer=reviewer,
1139
1175
  implement_reason="修复轮由 %s 完成" %
1140
1176
  (impl.get("label") or impl.get("id")),
1141
- review_reason=route.get("reviewer", ""))
1177
+ review_reason=route.get("reviewer", ""),
1178
+ direct=not workflow["review_required"])
1142
1179
  if impl.get("mode") == "mock" and res["ok"]:
1143
1180
  pass # mock 不产生真实变更
1144
1181
  review_json, verify_pass, verify_ran = review_and_score()
@@ -1149,10 +1186,10 @@ def _run_code(run, task, agents, ev, stats, mode):
1149
1186
  "passed": passed})
1150
1187
  if passed:
1151
1188
  break
1152
- if round_no < router.MAX_REPAIR_ROUNDS and not switched:
1189
+ if round_no < workflow["max_repair_rounds"] and not switched:
1153
1190
  round_no += 1
1154
1191
  continue
1155
- if mode == "auto" and not switched:
1192
+ if mode == "auto" and workflow["allow_switch"] and not switched:
1156
1193
  ex = (impl["id"],)
1157
1194
  if impl.get("mode") == "real":
1158
1195
  ex += ("mock-a", "mock-b") # 真实实现者失败时不降级到 mock
@@ -1166,7 +1203,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1166
1203
  run_id, task, agents, stats, impl,
1167
1204
  implementers=[impl], reviewer=reviewer,
1168
1205
  implement_reason=note,
1169
- review_reason=route.get("reviewer", ""))
1206
+ review_reason=route.get("reviewer", ""),
1207
+ direct=not workflow["review_required"])
1170
1208
  switched = True
1171
1209
  round_no += 1
1172
1210
  continue
@@ -1178,18 +1216,27 @@ def _run_code(run, task, agents, ev, stats, mode):
1178
1216
  verdict = {
1179
1217
  "type": "code", "engine": "code", "pass": overall_pass, "mode": mode,
1180
1218
  "verify_ran": verify_ran, "verify_pass": verify_pass,
1181
- "review_pass": bool(review_json.get("pass")),
1219
+ "review_pass": (bool(review_json.get("pass"))
1220
+ if workflow["review_required"] else None),
1221
+ "review_skipped": not workflow["review_required"],
1182
1222
  "scores": scores, "overall_score": overall_score,
1183
1223
  "issues": review_json.get("issues") or [],
1184
- "reviewer": reviewer["id"],
1224
+ "reviewer": reviewer["id"] if reviewer else "",
1185
1225
  "route": route, "repairs": repairs, "switched": switched,
1226
+ "workflow": workflow,
1186
1227
  }
1187
1228
 
1188
1229
  lines = [
1189
1230
  "# 代码任务报告:%s" % task["title"], "",
1190
1231
  "- 结论:**%s**" % ("✅ 通过" if overall_pass else "❌ 未通过"),
1191
1232
  "- 编排模式:%s 实现者:%s 评审者:%s" % (
1192
- "智能" if mode == "auto" else "手动", impl.get("label"), reviewer.get("label")),
1233
+ "智能" if mode == "auto" else "手动", impl.get("label"),
1234
+ reviewer.get("label") if reviewer else "按验证结果省略"),
1235
+ "- 动态步骤:规划 %s;实现 %d 步;验证 %s;评审 %d 人;最多修复 %d 轮;换将 %s" % (
1236
+ "独立执行" if workflow["planning"] == "llm" else "并入实现",
1237
+ workflow["implementation_steps"], "有" if workflow["verification"] else "无",
1238
+ workflow["reviewers"], workflow["max_repair_rounds"],
1239
+ "允许" if workflow["allow_switch"] else "关闭"),
1193
1240
  "- 验证命令:%s → %s" % (task.get("verify_command") or "(未配置)",
1194
1241
  "通过" if verify_pass else "未通过"),
1195
1242
  "- 综合评分:%s 修复/换将:%s" % (
@@ -1229,7 +1276,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1229
1276
  summary="代码任务%s(验证%s / 评审%s%s)" % (
1230
1277
  "通过" if overall_pass else "未通过",
1231
1278
  "通过" if verify_pass else "未通过",
1232
- "通过" if review_json.get("pass") else "未通过",
1279
+ (("通过" if review_json.get("pass") else "未通过")
1280
+ if workflow["review_required"] else "按策略省略"),
1233
1281
  ",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else ""),
1234
1282
  ended_at=_now())
1235
1283
 
@@ -114,11 +114,13 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
114
114
  except Exception:
115
115
  pass
116
116
 
117
- CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
- 并判定任务难度。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
- {"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
- 难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
121
- 子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
117
+ CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
+ 并判定任务难度和后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
+ {"difficulty": "easy 或 hard", "workflow": {"review_required": true, "max_repair_rounds": 0, "allow_switch": false}, "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
+ 难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
121
+ 质量步骤判定:涉及安全、数据迁移、并发、权限、公共 API 或跨模块改动时必须评审;
122
+ 有明确自动验收的低风险小改可不做模型评审。max_repair_rounds 取 0-2,只有复杂任务才建议换将。
123
+ 子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
122
124
 
123
125
  ## 先探索再计划(重要,借鉴 OpenSpec explore)
124
126
  拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
@@ -135,9 +137,12 @@ __CONTEXT__
135
137
  ## 验收命令(最终必须通过)
136
138
  __VERIFY__"""
137
139
 
138
- REVIEW_OUTLINE_PROMPT = """你是内容主编。请为下面的创作任务拟一份写作大纲(要点列表,3-8 条),
139
- 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
140
- {"outline": ["要点1", "要点2", ...]}
140
+ REVIEW_OUTLINE_PROMPT = """你是内容主编。请为下面的创作任务拟一份写作大纲(要点列表,3-8 条),
141
+ 并按内容风险建议后续质量步骤。
142
+ 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
143
+ {"outline": ["要点1", "要点2", ...], "workflow": {"reviewers": 1, "review_rounds": 1}}
144
+ reviewers 取 1-2,review_rounds 取 1-3;研究报告、技术方案、长文和高发布门槛应增加,
145
+ 普通邮件、短翻译、工作汇报等低风险短内容保持 1。
141
146
 
142
147
  ## 创作任务
143
148
  __GOAL__
@@ -403,9 +408,27 @@ def _norm_subtasks(data):
403
408
  return steps or None
404
409
 
405
410
 
406
- def _plan_difficulty(data):
411
+ def _plan_difficulty(data):
407
412
  d = str((data or {}).get("difficulty") or "").lower()
408
- return d if d in ("easy", "hard") else None
413
+ return d if d in ("easy", "hard") else None
414
+
415
+
416
+ def _plan_workflow(data):
417
+ """规范化规划器给出的后续步骤建议;最终安全下限由任务编译器裁决。"""
418
+ raw = (data or {}).get("workflow")
419
+ if not isinstance(raw, dict):
420
+ return None
421
+ out = {}
422
+ if isinstance(raw.get("review_required"), bool):
423
+ out["review_required"] = raw["review_required"]
424
+ try:
425
+ if "max_repair_rounds" in raw:
426
+ out["max_repair_rounds"] = max(0, min(2, int(raw["max_repair_rounds"])))
427
+ except (TypeError, ValueError):
428
+ pass
429
+ if isinstance(raw.get("allow_switch"), bool):
430
+ out["allow_switch"] = raw["allow_switch"]
431
+ return out or None
409
432
 
410
433
 
411
434
  def _orchestrator():
@@ -437,9 +460,10 @@ def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=
437
460
  _log_usage("plan", "plan", task, res, agent=planner_agent)
438
461
  data = runner.extract_json(res.get("text") or "")
439
462
  steps = _norm_subtasks(data)
440
- if steps:
441
- return {"source": "llm(%s)" % planner_agent["id"], "steps": steps,
442
- "difficulty": _plan_difficulty(data)}
463
+ if steps:
464
+ return {"source": "llm(%s)" % planner_agent["id"], "steps": steps,
465
+ "difficulty": _plan_difficulty(data),
466
+ "workflow": _plan_workflow(data)}
443
467
  return _fallback_code_plan(task, "LLM 计划解析失败,退化为单步模板")
444
468
 
445
469
 
@@ -468,8 +492,9 @@ def _orch_code_plan(task, prov, model, log_path=None):
468
492
  if not steps:
469
493
  _append_log(log_path, "编排者计划返回内容无法解析为子任务,回退 CLI")
470
494
  return None
471
- return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
472
- "steps": steps, "difficulty": _plan_difficulty(data)}
495
+ return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
496
+ "steps": steps, "difficulty": _plan_difficulty(data),
497
+ "workflow": _plan_workflow(data)}
473
498
 
474
499
 
475
500
  def make_review_outline(task):
@@ -492,8 +517,20 @@ def make_review_outline(task):
492
517
  if not isinstance(outline, list):
493
518
  return None
494
519
  items = [str(x).strip()[:120] for x in outline if str(x).strip()][:8]
495
- return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
496
- "items": items} if items else None
520
+ if not items:
521
+ return None
522
+ workflow = data.get("workflow") if isinstance(data.get("workflow"), dict) else {}
523
+ try:
524
+ reviewers = max(1, min(2, int(workflow.get("reviewers") or 1)))
525
+ except (TypeError, ValueError):
526
+ reviewers = 1
527
+ try:
528
+ review_rounds = max(1, min(3, int(workflow.get("review_rounds") or 1)))
529
+ except (TypeError, ValueError):
530
+ review_rounds = 1
531
+ return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
532
+ "items": items,
533
+ "workflow": {"reviewers": reviewers, "review_rounds": review_rounds}}
497
534
 
498
535
 
499
536
  def make_novel_plan(task, author, critics):
@@ -11,6 +11,7 @@ CAPABILITY = {
11
11
  }
12
12
 
13
13
  MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
+ AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
14
15
 
15
16
 
16
17
  def _task_type(ttype):
@@ -62,7 +63,9 @@ def _online_bonus(agent, role, ttype):
62
63
  rate = float(metrics.get("success_rate") or 0.0)
63
64
  success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
64
65
  p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
65
- latency_score = -min(6.0, max(0.0, (p95 - 45.0) / 10.0))
66
+ # 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
67
+ # 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
68
+ latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
66
69
  cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
67
70
  cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
68
71
  total = round(success_score + latency_score + cost_score, 1)
@@ -199,14 +202,18 @@ def pick_reviewer(agents, impl, ttype, stats=None):
199
202
  return impl, "(自评:仅有实现者一个智能体可用)"
200
203
 
201
204
 
202
- def pick_critics(agents, ttype, stats=None, impl=None):
203
- """小说评审组:全部真实智能体(排除作者本人——自评是最典型的同族盲区);
204
- 面板与作者同厂商时在路由依据里明示,供用户决定是否补充其他厂商。
205
- 没有任何真实智能体时用内置 mock。"""
206
- stats = stats or history.agent_stats()
207
- real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
208
- if real:
209
- note = "全部真实智能体参与(按历史表现自动选择)"
205
+ def pick_critics(agents, ttype, stats=None, impl=None):
206
+ """自动内容评审组选综合分前两名(排除作者本人)。
207
+
208
+ 旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
209
+ 任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
210
+ """
211
+ stats = stats or history.agent_stats()
212
+ real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
213
+ if real:
214
+ ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
215
+ real = ranked[:AUTO_CRITIC_LIMIT]
216
+ note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
210
217
  if impl and impl.get("mode") == "real" \
211
218
  and all(a.get("kind") == impl.get("kind") for a in real):
212
219
  note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
@@ -188,16 +188,21 @@ def apply_upgrade(port=None):
188
188
  return {"error": "升级任务创建失败,请稍后重试"}
189
189
  try:
190
190
  jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
191
- except Exception:
192
- # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
193
- log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
194
- try:
195
- store.update_run(run["id"], status="failed",
196
- error="升级任务启动失败,本次未排队,请稍后重试",
197
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
191
+ except Exception as exc:
192
+ # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
193
+ log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
194
+ if isinstance(exc, jobs.JobsBusyError):
195
+ message = "升级未启动:%s" % str(exc)
196
+ else:
197
+ # 仅并发保护类错误可直接展示;其他内部异常继续隐藏实现细节。
198
+ message = "升级任务启动失败,本次未排队,请稍后重试"
199
+ try:
200
+ store.update_run(run["id"], status="failed",
201
+ error=message,
202
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
198
203
  except Exception:
199
204
  log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
200
- return {"error": "升级任务启动失败,本次未排队,请稍后重试", "run_id": run["id"]}
205
+ return {"error": message, "run_id": run["id"]}
201
206
  return {"run_id": run["id"]}
202
207
 
203
208
 
@@ -85,3 +85,93 @@ def summary(spec):
85
85
  spec.get("type") or "direct", spec.get("engine") or "direct",
86
86
  spec.get("difficulty") or "default",
87
87
  "、".join(spec.get("capabilities") or []) or "通用")
88
+
89
+
90
+ def code_workflow(task, difficulty, plan=None, mode="auto", planned=True):
91
+ """把难度、验收能力和规划建议收敛成代码任务的实际步骤策略。
92
+
93
+ 自动模式允许低风险任务走短链;手动模式与高风险任务保留完整质量门禁。
94
+ 规划器只能在安全边界内减少步骤,不能让无确定性验证的任务跳过评审。
95
+ """
96
+ task = task if isinstance(task, Mapping) else {}
97
+ rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
98
+ rec = rec if isinstance(rec, Mapping) else {}
99
+ has_verify = bool(str(task.get("verify_command") or "").strip())
100
+ easy_auto = mode == "auto" and difficulty == "easy"
101
+
102
+ review_required = True
103
+ max_repairs = 2
104
+ allow_switch = True
105
+ reason = "复杂或手动任务使用完整质量门禁"
106
+ if easy_auto:
107
+ review_required = not has_verify
108
+ max_repairs = 1
109
+ allow_switch = False
110
+ reason = ("低风险任务以确定性验证代替模型评审"
111
+ if has_verify else "低风险任务无验证命令,保留一次模型评审")
112
+ if isinstance(rec.get("review_required"), bool):
113
+ review_required = rec["review_required"] or not has_verify
114
+ try:
115
+ if "max_repair_rounds" in rec:
116
+ max_repairs = max(0, min(1, int(rec["max_repair_rounds"])))
117
+ except (TypeError, ValueError):
118
+ pass
119
+ elif mode == "auto" and difficulty == "hard":
120
+ try:
121
+ if "max_repair_rounds" in rec:
122
+ max_repairs = max(1, min(2, int(rec["max_repair_rounds"])))
123
+ except (TypeError, ValueError):
124
+ pass
125
+ if isinstance(rec.get("allow_switch"), bool):
126
+ allow_switch = rec["allow_switch"]
127
+ reason = "复杂任务保留规划、验证与模型评审"
128
+
129
+ return {
130
+ "planning": "llm" if planned else "inline",
131
+ "implementation_steps": len((plan or {}).get("steps") or []) or 1,
132
+ "verification": has_verify,
133
+ "review_required": review_required,
134
+ "reviewers": 1 if review_required else 0,
135
+ "max_repair_rounds": max_repairs,
136
+ "allow_switch": allow_switch,
137
+ "reason": reason,
138
+ }
139
+
140
+
141
+ def content_workflow(task, difficulty, plan=None, mode="auto"):
142
+ """为非连载内容任务生成动态大纲/评审/修订策略。"""
143
+ task = task if isinstance(task, Mapping) else {}
144
+ ttype = str(task.get("type") or "doc")
145
+ requested_rounds = max(1, min(5, int(task.get("rounds") or 2)))
146
+ rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
147
+ rec = rec if isinstance(rec, Mapping) else {}
148
+ light_types = {"email", "weekly_report", "translation"}
149
+ deep_types = {"novel", "research", "tech_proposal"}
150
+
151
+ if mode != "auto":
152
+ return {"outline": True, "reviewers": 0,
153
+ "review_rounds": requested_rounds,
154
+ "reason": "手动模式尊重用户指定的评审组与轮数"}
155
+
156
+ if ttype in light_types and difficulty != "hard":
157
+ return {"outline": False, "reviewers": 1, "review_rounds": 1,
158
+ "reason": "轻量内容省略独立大纲,只做一次快速评审"}
159
+
160
+ outline = ttype in deep_types or difficulty == "hard"
161
+ reviewers = 2 if outline else 1
162
+ review_rounds = min(requested_rounds, 2 if outline else 1)
163
+ try:
164
+ if "reviewers" in rec:
165
+ reviewers = max(1, min(2, int(rec["reviewers"])))
166
+ if "review_rounds" in rec:
167
+ review_rounds = max(1, min(requested_rounds, 3,
168
+ int(rec["review_rounds"])))
169
+ except (TypeError, ValueError):
170
+ pass
171
+ # 深度类型至少保留一轮评审,但允许规划器把双评审降成一名;高门槛强制双评审。
172
+ if float(task.get("threshold") or 7.0) >= 8.5:
173
+ reviewers = 2
174
+ return {"outline": outline, "reviewers": reviewers,
175
+ "review_rounds": review_rounds,
176
+ "reason": ("深度内容保留大纲和多轮质量门禁" if outline
177
+ else "标准内容使用单评审短链")}
package/app/main.py CHANGED
@@ -52,6 +52,7 @@ def _utf8_bytes(data):
52
52
  return data.decode("utf-8", "replace").encode("utf-8")
53
53
 
54
54
  PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
55
+ _BOOT_TS = time.time() # 服务进程启动时间戳:/api/pet_state 下发,蜜蜂据此识别「服务换人了」并让位
55
56
 
56
57
  # 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
57
58
  # 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
@@ -1468,6 +1469,7 @@ class Handler(BaseHTTPRequestHandler):
1468
1469
  「刚完工/刚出事」。设置随响应回传,蜜蜂轮询到 pet_enabled=false 自行
1469
1470
  退出——关闭桌宠不需要专门的信号通道。
1470
1471
  """
1472
+ global _BOOT_TS
1471
1473
  st = settings.load()
1472
1474
  rows = []
1473
1475
  n_run = n_q = 0
@@ -1500,6 +1502,10 @@ class Handler(BaseHTTPRequestHandler):
1500
1502
  dg = {"unseen": 0, "latest": None}
1501
1503
  return self._json(200, {
1502
1504
  "ok": True, "ts": int(time.time()), "port": PORT,
1505
+ # boot:本服务进程的启动时间戳。蜜蜂记住首次见到的值,值变了说明
1506
+ # 服务换了个进程(升级重启)——旧蜜蜂自行退场让新服务 spawn 新蜜蜂
1507
+ # (旧形象常驻的根因:同端口轮询永远正常,旧进程从不自离)。
1508
+ "boot": _BOOT_TS,
1503
1509
  "settings": {"pet_enabled": bool(st.get("pet_enabled", True)),
1504
1510
  "pet_mode": str(st.get("pet_mode") or "always"),
1505
1511
  "pet_skin": str(st.get("pet_skin") or "plush")},
package/app/pet.py CHANGED
@@ -82,6 +82,8 @@ def parse_snapshot(raw):
82
82
  "error": str(t.get("error") or ""),
83
83
  })
84
84
  return {
85
+ # boot:服务进程启动标识(值变化=服务换人,本宠让位给新服务的蜜蜂)
86
+ "boot": raw.get("boot") or None,
85
87
  "settings": {
86
88
  "pet_enabled": bool(st.get("pet_enabled", True)),
87
89
  "pet_mode": str(st.get("pet_mode") or "always"),
@@ -309,7 +311,6 @@ KEY_RGB = (1, 2, 3)
309
311
  WIN_W, WIN_H = 170, 150 # 手绘回落模式的窗口尺寸
310
312
  SPRITE_DIR = Path(__file__).resolve().parent
311
313
  SPRITE_DISP_H = 112 # 精灵显示高度(宽等比)
312
- PROG_H = 16 # 底部进度条预留高度(有进度时才显示)
313
314
 
314
315
 
315
316
  def _pid_alive(pid):
@@ -371,6 +372,9 @@ _BROWSER_EXES = {
371
372
  }
372
373
 
373
374
 
375
+ petWindow = None
376
+
377
+
374
378
  def _window_process_name(hwnd):
375
379
  """取顶层窗口所属进程的可执行文件名(小写);失败返回空串。"""
376
380
  try:
@@ -438,6 +442,38 @@ def _focus_codebee_window(ignore_hwnd=None):
438
442
  return False
439
443
 
440
444
 
445
+ def _show_pet_window(ignore_hwnd=None):
446
+ """复用桌宠拉起的 CodeBee 浏览器窗口;窗口已关闭时返回 False。"""
447
+ global petWindow
448
+ try:
449
+ if os.name != "nt":
450
+ return False
451
+ import ctypes
452
+ user32 = ctypes.windll.user32
453
+ if petWindow and user32.IsWindow(petWindow):
454
+ if user32.IsIconic(petWindow):
455
+ user32.ShowWindow(petWindow, 9) # SW_RESTORE
456
+ user32.SetForegroundWindow(petWindow)
457
+ return True
458
+ petWindow = None
459
+ hwnd = _find_codebee_window(ignore_hwnd)
460
+ if not hwnd:
461
+ return False
462
+ petWindow = hwnd
463
+ if user32.IsIconic(hwnd):
464
+ user32.ShowWindow(hwnd, 9)
465
+ user32.SetForegroundWindow(hwnd)
466
+ return True
467
+ except Exception:
468
+ return False
469
+
470
+
471
+ def openPetWindow(port, ignore_hwnd=None):
472
+ if os.name == "nt" and _show_pet_window(ignore_hwnd):
473
+ return
474
+ webbrowser.open("http://127.0.0.1:%d" % port)
475
+
476
+
441
477
  class PetApp:
442
478
  """蜜蜂窗口:蜜蜂本体(精灵/手绘)+ 状态机动画 + 悬停任务清单 + 右键菜单。"""
443
479
 
@@ -485,6 +521,7 @@ class PetApp:
485
521
  # 运行态
486
522
  self.snap = None
487
523
  self.miss = 0
524
+ self.boot = None # 首次见到的服务 boot 标识;变化=服务换进程,本宠让位
488
525
  self.prev_active = set()
489
526
  self.mood_kind, self.mood_until = "", 0.0
490
527
  self.last_busy = time.time()
@@ -638,18 +675,19 @@ class PetApp:
638
675
  def _size_for(self, frames):
639
676
  fw = max(f.width() for lst in frames.values() for f in lst)
640
677
  fh = max(f.height() for lst in frames.values() for f in lst)
641
- return int(fw) + 8, int(fh) + PROG_H + 4
678
+ return int(fw) + 8, int(fh) + 4
642
679
 
643
680
  # ---- 蜜蜂绘制(精灵模式:贴图 + 状态点缀画件)----
644
- # 用户拍板去掉:右上徽章、左侧速度线、底部影子(真机截图圈删)——
645
- # 窗口贴着蜂体开,点缀只留睡觉 Zzz / 庆祝星光 / 底部进度条。
681
+ # 用户拍板去掉:右上徽章、左侧速度线、底部影子(真机截图圈删)、
682
+ # 底部任务进度条(2026-09-21 真机截图圈删)——
683
+ # 窗口贴着蜂体开,点缀只留睡觉 Zzz / 庆祝星光。
646
684
  def _build_sprite(self):
647
685
  cv = self.cv
648
- cx, cy = self.win_w // 2, (self.win_h - PROG_H) // 2 + 2
686
+ cx, cy = self.win_w // 2, self.win_h // 2 + 2
649
687
  self._spr_c = (cx, cy)
650
688
  self.spr = cv.create_image(cx, cy, image=self.frames["alert"][0],
651
689
  anchor="center")
652
- hw, hh = self.win_w / 2, (self.win_h - PROG_H) / 2
690
+ hw, hh = self.win_w / 2, self.win_h / 2
653
691
  self.zzz = [cv.create_text(cx + hw - 12, cy - hh + 14, text="z",
654
692
  fill="#8A93A6",
655
693
  font=("Segoe UI", 11, "italic bold")),
@@ -671,15 +709,6 @@ class PetApp:
671
709
  for grp in (self.zzz, self.spark):
672
710
  for i in grp:
673
711
  cv.itemconfigure(i, state="hidden")
674
- # 底部进度条(工作且有步骤进度才出现)
675
- self.pbar_bg = cv.create_rectangle(cx - 34, self.win_h - 7,
676
- cx + 34, self.win_h - 2,
677
- fill="#202A36", outline="#3A4654")
678
- self.pbar_fg = cv.create_rectangle(cx - 33, self.win_h - 6,
679
- cx - 32, self.win_h - 3,
680
- fill="#7ED07E", outline="")
681
- cv.itemconfigure(self.pbar_bg, state="hidden")
682
- cv.itemconfigure(self.pbar_fg, state="hidden")
683
712
  self.ox = self.oy = 0.0
684
713
  self._t0 = time.time()
685
714
 
@@ -867,14 +896,6 @@ class PetApp:
867
896
  self._palette = {i: cv.itemcget(i, "fill")
868
897
  for i in (self.body, self.belly)}
869
898
 
870
- # —— 贴地件:进度条(用户拍板去掉影子)——
871
- self.pbar_bg = cv.create_rectangle(46, 139, 128, 147, fill="#202A36",
872
- outline="#3A4654")
873
- self.pbar_fg = cv.create_rectangle(47, 140, 48, 146, fill="#7ED07E",
874
- outline="")
875
- cv.itemconfigure(self.pbar_bg, state="hidden")
876
- cv.itemconfigure(self.pbar_fg, state="hidden")
877
-
878
899
  # 动画游标
879
900
  self.ox = self.oy = 0.0
880
901
  self._t0 = time.time()
@@ -923,26 +944,9 @@ class PetApp:
923
944
  cv.itemconfigure(self.body, fill=self._palette[self.body])
924
945
  cv.itemconfigure(self.belly, fill=self._palette[self.belly])
925
946
  cv.itemconfigure(self.gloss, state="normal")
926
- # —— 共同:Zzz / 星光 / 进度条(徽章与速度线已按用户要求移除)——
947
+ # —— 共同:Zzz / 星光(徽章、速度线、进度条均已按用户要求移除)——
927
948
  self._show(self.zzz, False)
928
949
  self._show(self.spark, False)
929
- prog = []
930
- if st == "work" and self.snap:
931
- prog = [t for t in self.snap["tasks"]
932
- if t["run_status"] == "running" and t["steps_total"]]
933
- if prog:
934
- done = sum(t["steps_done"] for t in prog)
935
- total = sum(t["steps_total"] for t in prog)
936
- ratio = max(0.0, min(1.0, done / float(total)))
937
- x0 = (self.win_w // 2) - 34
938
- cv.itemconfigure(self.pbar_bg, state="normal")
939
- cv.itemconfigure(self.pbar_fg, state="normal")
940
- cv.coords(self.pbar_bg, x0, self.win_h - 7, x0 + 68,
941
- self.win_h - 2)
942
- cv.coords(self.pbar_fg, x0 + 1, self.win_h - 6,
943
- x0 + 1 + 66 * ratio, self.win_h - 3)
944
- else:
945
- self._show([self.pbar_bg, self.pbar_fg], False)
946
950
 
947
951
  def _post_settings(self, body):
948
952
  """合并并串行发送设置,保证快速连点时最后一次选择最终生效。"""
@@ -1029,6 +1033,15 @@ class PetApp:
1029
1033
  else:
1030
1034
  self.miss = 0
1031
1035
  if snap is not None:
1036
+ # 服务换人检测(升级重启同端口场景):boot 与首次见到的不一致,
1037
+ # 说明响应来自新进程——本进程是旧代码的遗老,立即让位,新服务的
1038
+ # 看护会 spawn 带新形象的新蜜蜂(旧形象常驻的根因修复)。
1039
+ boot = snap.get("boot")
1040
+ if boot:
1041
+ if self.boot is None:
1042
+ self.boot = boot
1043
+ elif boot != self.boot:
1044
+ return self._bye()
1032
1045
  self._reconcile_desired_settings(snap, confirmed_at_poll_start)
1033
1046
  if not snap["settings"]["pet_enabled"]:
1034
1047
  return self._bye()
@@ -1375,12 +1388,7 @@ class PetApp:
1375
1388
 
1376
1389
  def _open_ui(self):
1377
1390
  try:
1378
- url = "http://127.0.0.1:%d" % self.port
1379
- # 已开过就不新开:Windows 上先找现有 CodeBee 浏览器窗口置前,
1380
- # 找不到(没开/标签不在前台)才回退 webbrowser.open 新开。
1381
- if os.name == "nt" and _focus_codebee_window(self.root.winfo_id()):
1382
- return
1383
- webbrowser.open(url)
1391
+ openPetWindow(self.port, self.root.winfo_id())
1384
1392
  except Exception:
1385
1393
  pass
1386
1394
 
package/app/ui/app.js CHANGED
@@ -8513,16 +8513,29 @@ function ztHarvestProfiles() {
8513
8513
  });
8514
8514
  }
8515
8515
 
8516
- function ztProfAdd() {
8516
+ async function ztProfAdd() {
8517
8517
  S.ztProfiles = ztHarvestProfiles();
8518
- S.ztProfiles.push({ product: 0, assigned_to: "", severity_cap: 0, our_sides: ["backend"],
8518
+ // 新档案必须来自禅道产品清单。先拉清单,再自动选第一个尚未配置的产品,
8519
+ // 避免生成 product=0 的空卡,用户随后保存才看到“缺产品 ID”。
8520
+ if (!(S.ztProducts || []).length) {
8521
+ if (!($("zt-base-url") && $("zt-base-url").value.trim())) {
8522
+ toast(t("请先测试并保存禅道连接,再添加产品"), true);
8523
+ return;
8524
+ }
8525
+ await ztFetchCatalog(false);
8526
+ S.ztProfiles = ztHarvestProfiles();
8527
+ }
8528
+ const used = new Set(S.ztProfiles.map((p) => String(p.product || "")));
8529
+ const next = (S.ztProducts || []).find((p) => p.id && !used.has(String(p.id)));
8530
+ if (!next) {
8531
+ toast(t((S.ztProducts || []).length ? "禅道产品均已添加" : "未拉到可用产品,请先测试连接"), true);
8532
+ return;
8533
+ }
8534
+ S.ztProfiles.push({ product: Number(next.id), assigned_to: "", severity_cap: 0, our_sides: ["backend"],
8519
8535
  repos: { backend: {}, frontend: {} }, repo_hints: { backend: "", frontend: "" },
8520
8536
  owners: { backend: "", frontend: "", not_ours: "" }, module_routes: [] });
8521
8537
  renderZentaoProfiles();
8522
- // 首次添加时自动拉清单,避免用户只能盲填产品 ID;拉不到仍保留手填回退。
8523
- if (!(S.ztProducts || []).length && $("zt-base-url") && $("zt-base-url").value.trim()) {
8524
- ztFetchCatalog(false);
8525
- }
8538
+ ztFetchMods(S.ztProfiles.length - 1, true);
8526
8539
  }
8527
8540
 
8528
8541
  function ztProfDel(i) {
@@ -8672,6 +8685,13 @@ function ztFormPayload() {
8672
8685
 
8673
8686
  async function saveZentao() {
8674
8687
  const payload = ztFormPayload();
8688
+ if ((payload.product_profiles || []).some((p) => !p.product)) {
8689
+ toast(t("请选择禅道产品后再保存"), true);
8690
+ const empty = Array.from(document.querySelectorAll("#zt-profiles .zt-p-product"))
8691
+ .find((el) => !parseInt(el.value || "", 10));
8692
+ if (empty) empty.focus();
8693
+ return;
8694
+ }
8675
8695
  try {
8676
8696
  await api("/api/zentao/config", { method: "POST", body: JSON.stringify(payload) });
8677
8697
  } catch (e) { toast(t("保存失败:") + e.message, true); return; }
@@ -9459,6 +9479,9 @@ function renderSu() {
9459
9479
  const info = $("su-info");
9460
9480
  if (!info) return;
9461
9481
  if (!SU) { info.textContent = t("无法获取版本信息(服务未连接)"); return; }
9482
+ // 真实内容就绪后摘掉「空状态」皮肤:.empty 是居中+虚线框的加载占位样式,
9483
+ // 版本信息与更新说明都应该左对齐阅读(用户反馈「能不能左对齐」)
9484
+ info.classList.remove("empty");
9462
9485
  const modeTxt = t(SU_MODE_TXT[SU.mode] || "未知安装方式");
9463
9486
  const cur = SU.current ? "v" + SU.current : t("(未同步版本号)");
9464
9487
  let html = "<p class=\"hint\">" + t("当前版本") + t(":") + "<b>" + cur + "</b>" + t(" · ") + t("安装方式") + t(":") + modeTxt + "</p>";
@@ -10598,6 +10621,11 @@ function collapseDrawerIfMobile() {
10598
10621
  function switchTab(name) {
10599
10622
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
10600
10623
  if (name === "__guide") { welcomeOpen(); return; } // 帮助中心是弹层,不切页(设置导航「软件」组)
10624
+ if (name === "__wxdigest") { // 群聊汇报沿用全局蜜蜂面板
10625
+ beeRefresh().finally(() => beeToggle(true));
10626
+ collapseDrawerIfMobile();
10627
+ return;
10628
+ }
10601
10629
  // 导航收进「设置」:进设置后左栏整体换成设置导航,内容铺满
10602
10630
  S.tab = name;
10603
10631
  if (SET_TABS.has(name)) localStorage.setItem("orch.setTab", name);
@@ -11311,7 +11339,7 @@ function bindCmdK() {
11311
11339
 
11312
11340
  /* ------------------------------------------------ 群摘要蜜蜂坞(core/wxdigest.py)
11313
11341
  * 右下角悬浮蜜蜂:未读摘要徽章+气泡;面板看各群摘要、配置监控文件夹。
11314
- * 桌面蜜蜂 pet.py 的网页侧搭档;未启用且无数据时整坞隐藏(不占视线)。
11342
+ * 桌面蜜蜂 pet.py 的网页侧搭档;入口始终可见,未配置时也能直接打开设置。
11315
11343
  * 60s 轮询 /api/wxdigest;打开面板即 POST seen 清零未读。 */
11316
11344
  const Bee = { view: null, lastUnseen: 0, bubT: 0, selGroups: null };
11317
11345
 
@@ -11322,9 +11350,7 @@ async function beeRefresh() {
11322
11350
  let v;
11323
11351
  try { v = await api("/api/wxdigest"); } catch (e) { return; } // 静默:不打扰主流程
11324
11352
  Bee.view = v;
11325
- const on = !!(v.config && v.config.enabled) ||
11326
- (v.digests && v.digests.length) || v.unseen > 0;
11327
- beeShow($("bee-dock"), on);
11353
+ beeShow($("bee-dock"), true);
11328
11354
  beeShow($("bee-badge"), v.unseen > 0);
11329
11355
  if (v.unseen > 0) $("bee-badge").textContent = v.unseen > 99 ? "99+" : String(v.unseen);
11330
11356
  if (v.unseen > Bee.lastUnseen && !beePanelOpen()) { // 新摘要到:气泡 6s
package/app/ui/i18n.js CHANGED
@@ -1963,6 +1963,7 @@
1963
1963
  "产品档案": "Product Profiles",
1964
1964
  "+ 添加产品": "+ Add product",
1965
1965
  "+ 从禅道添加产品": "+ Add from ZenTao",
1966
+ "测试并保存连接": "Test & save connection",
1966
1967
  "刷新产品列表": "Refresh products",
1967
1968
  "重新从禅道拉取产品与账号": "Reload products and accounts from ZenTao",
1968
1969
  "先测试连接,再点「从禅道添加产品」直接选择产品;无需自己查 ID。每个产品只需配置我方仓库,负责人和模块路由均可稍后补充。": "Test the connection, then choose Add from ZenTao — no ID lookup needed. Configure only your repository first; owners and module routes can be added later.",
@@ -2004,6 +2005,10 @@
2004
2005
  "还没有认领过 Bug——配置好连接与产品档案后点「立即扫描」。": "No bugs claimed yet — set up connection and product profiles, then click \"Scan Now\".",
2005
2006
  "还没有产品档案——点「+ 添加产品」,填产品 ID 与两端仓库。": "No product profiles yet — click \"+ Add product\" and fill the product ID and repos.",
2006
2007
  "还没有产品档案——先测试连接,再从禅道选择产品,无需自己查 ID。": "No product profiles yet — test the connection, then select a product from ZenTao; no ID lookup needed.",
2008
+ "请先测试并保存禅道连接,再添加产品": "Test and save the ZenTao connection before adding a product.",
2009
+ "禅道产品均已添加": "All ZenTao products have already been added.",
2010
+ "未拉到可用产品,请先测试连接": "No available products were found. Test the connection first.",
2011
+ "请选择禅道产品后再保存": "Select a ZenTao product before saving.",
2007
2012
  "先填产品 ID 再拉模块清单": "Fill the product ID before fetching modules",
2008
2013
  "先选产品再拉模块清单": "Pick a product before fetching modules",
2009
2014
  "拉取失败": "Fetch failed",
@@ -2199,6 +2204,7 @@
2199
2204
 
2200
2205
  // —— 群摘要蜜蜂坞(wxdigest)——
2201
2206
  "群摘要": "Group Digests",
2207
+ "微信群聊汇报": "WeChat Group Reports",
2202
2208
  "启用定时扫描": "Enable scheduled scanning",
2203
2209
  "监控文件夹": "Watched folder",
2204
2210
  "间隔(分钟)": "Interval (min)",
package/app/ui/index.html CHANGED
@@ -178,6 +178,7 @@
178
178
  <button class="set-item active" data-sub="tasks" data-i18n="任务"><svg class="ico" aria-hidden="true"><use href="#i-tasks"></use></svg>任务</button>
179
179
  <button class="set-item" data-sub="runs" data-i18n="运行记录"><svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg>运行记录</button>
180
180
  <button class="set-item" data-sub="automation" data-i18n="自动化"><svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg>自动化</button>
181
+ <button class="set-item" data-sub="__wxdigest" data-i18n="微信群聊汇报"><svg class="ico" aria-hidden="true"><use href="#i-bee"></use></svg>微信群聊汇报</button>
181
182
  <button class="set-item" data-sub="zentao" data-i18n="禅道"><svg class="ico" aria-hidden="true"><use href="#i-search"></use></svg>禅道</button>
182
183
  <button class="set-item" data-sub="usage" data-i18n="用量统计"><svg class="ico" aria-hidden="true"><use href="#i-chart"></use></svg>用量统计</button>
183
184
  <div data-i18n="配置" class="set-group">配置</div>
@@ -557,12 +558,12 @@
557
558
  <label data-i18n="密码">密码</label><input id="zt-password" type="password" placeholder="" autocomplete="new-password">
558
559
  </div>
559
560
  <h3 data-i18n="自动化" class="sec-title">自动化</h3>
560
- <div class="zt-grid zt-grid-toggles">
561
+ <div class="zt-grid zt-grid-toggles zt-auto-options">
561
562
  <label class="toggle" title="模块路由没命中时,用 AI 读 Bug 描述判端(前端/后端/双端/非我方);关掉则未命中模块的 Bug 留人工" data-i18n-title="模块路由没命中时,用 AI 读 Bug 描述判端(前端/后端/双端/非我方);关掉则未命中模块的 Bug 留人工"><input id="zt-triageai" type="checkbox"><span data-i18n="AI 排查兜底">AI 排查兜底</span></label>
562
563
  <label class="toggle" title="修复任务评审通过后,自动到禅道 resolve(fixed)并回评论" data-i18n-title="修复任务评审通过后,自动到禅道 resolve(fixed)并回评论"><input id="zt-autoresolve" type="checkbox"><span data-i18n="自动 resolve Bug">自动 resolve Bug</span></label>
563
564
  <label class="toggle" title="把任务分支自动合并回基线分支(需配置基线分支;合并失败不 resolve 不转派,留人工)" data-i18n-title="把任务分支自动合并回基线分支(需配置基线分支;合并失败不 resolve 不转派,留人工)"><input id="zt-automerge" type="checkbox"><span data-i18n="自动合并代码">自动合并代码</span></label>
564
565
  <label class="toggle" title="按下面的间隔定时扫描;关掉后仍可手动「立即扫描」" data-i18n-title="按下面的间隔定时扫描;关掉后仍可手动「立即扫描」"><input id="zt-poll" type="checkbox"><span data-i18n="定时扫描">定时扫描</span></label>
565
- <label data-i18n="扫描间隔(小时)">扫描间隔(小时)</label><input id="zt-interval" type="number" min="1" max="168" step="1">
566
+ <label class="zt-interval-field"><span data-i18n="扫描间隔(小时)">扫描间隔(小时)</span><input id="zt-interval" type="number" min="1" max="168" step="1"></label>
566
567
  </div>
567
568
  <div class="ops"><button id="btn-zentao-save" class="primary small"><span data-i18n="保存配置">保存配置</span></button></div>
568
569
  <h3 data-i18n="产品档案" class="sec-title">产品档案</h3>
@@ -1107,10 +1108,10 @@
1107
1108
 
1108
1109
  <!-- 群摘要蜜蜂坞(右下角悬浮蜜蜂):桌面蜜蜂 pet.py 的网页侧搭档。
1109
1110
  未读摘要时徽章+气泡提醒,点开面板看各群摘要/配置监控文件夹;
1110
- 未启用且无数据时整坞隐藏(不占视线)。后端 core/wxdigest.py。 -->
1111
- <div id="bee-dock" class="hidden">
1111
+ 未配置时也保持入口可见,避免功能存在却无处开启。后端 core/wxdigest.py。 -->
1112
+ <div id="bee-dock">
1112
1113
  <div id="bee-bubble" class="hidden"></div>
1113
- <button id="bee-fab" aria-label="群摘要" data-i18n-aria="群摘要" data-i18n-title="群摘要" title="群摘要">
1114
+ <button id="bee-fab" aria-label="微信群聊汇报" data-i18n-aria="微信群聊汇报" data-i18n-title="微信群聊汇报" title="微信群聊汇报">
1114
1115
  <svg class="ico ico-lg" aria-hidden="true"><use href="#i-bee"></use></svg>
1115
1116
  <span id="bee-badge" class="hidden"></span>
1116
1117
  </button>
package/app/ui/style.css CHANGED
@@ -5508,3 +5508,47 @@ button.request-busy > * { opacity: .18; }
5508
5508
  @media (prefers-reduced-motion: reduce) {
5509
5509
  .op-pill.running .ico, .op-row.running .op-state { animation: none; }
5510
5510
  }
5511
+
5512
+ /* 关于与更新:版本信息面板常态样式(加载中才挂 .empty 居中虚线皮肤;
5513
+ renderSu 填充内容后移除 empty,改用这里的左对齐阅读样式——用户反馈) */
5514
+ #su-info {
5515
+ padding: 12px 14px;
5516
+ border: 1px solid var(--border, rgba(128, 128, 128, .25));
5517
+ border-radius: 8px;
5518
+ background: var(--bg-soft, rgba(128, 128, 128, .06));
5519
+ text-align: left;
5520
+ font-size: 13px;
5521
+ line-height: 1.6;
5522
+ }
5523
+ #su-info.empty { text-align: center; }
5524
+
5525
+ /* ---------------- 禅道·添加产品与自动化布局(追加于文件尾) ---------------- */
5526
+ .zt-prof .head .name {
5527
+ display: flex; flex: 1 1 280px; min-width: 0; align-items: center; gap: 8px;
5528
+ overflow: visible; white-space: normal;
5529
+ }
5530
+ .zt-prof .head .zt-p-product {
5531
+ width: min(360px, 100%); min-width: 180px; flex: 1 1 240px;
5532
+ }
5533
+ .zt-auto-options {
5534
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
5535
+ gap: 12px 18px; align-items: start;
5536
+ }
5537
+ @media (min-width: 901px) {
5538
+ .zt-auto-options { grid-template-columns: repeat(5, minmax(0, 1fr)); }
5539
+ }
5540
+ .zt-auto-options > .toggle {
5541
+ min-height: 38px; margin: 0; align-self: end;
5542
+ }
5543
+ .zt-auto-options .zt-interval-field {
5544
+ display: grid; grid-template-columns: 1fr; gap: 6px; min-width: 0;
5545
+ color: var(--muted); font-size: 12.5px;
5546
+ }
5547
+ .zt-auto-options .zt-interval-field input {
5548
+ width: 100%; max-width: none; box-sizing: border-box;
5549
+ }
5550
+ @media (max-width: 640px) {
5551
+ .zt-auto-options { grid-template-columns: 1fr 1fr; }
5552
+ .zt-auto-options .zt-interval-field { grid-column: 1 / -1; }
5553
+ .zt-prof .head .zt-p-product { min-width: 0; }
5554
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {