codebee 0.1.69 → 0.1.70

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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
@@ -452,8 +452,9 @@ def create_book_async(task_id, plat, auto_submit=False):
452
452
  metadata={"platform": plat, "action": "create_book"})
453
453
  ledger.record(plat, "create_book", task_id=task_id, title=book_name,
454
454
  book_id=book_id, ok=bool(book_id),
455
- error=("\n".join(logs)[:2000] if book_id else
456
- "建书结果未知:未取得远端 book_id;请先对账"),
455
+ error=((("" if book_id else
456
+ "建书结果未知:未取得远端 book_id;请先对账\n")
457
+ + "\n".join(logs))[:2000] or None),
457
458
  shot=str(ledger.shot_path(plat, task_id, "")),
458
459
  operation_id=operation_id, operation_status=operation_status,
459
460
  remote_receipt=book_id)
@@ -489,6 +490,13 @@ def register_book(task_id, plat, title, book_id=""):
489
490
  title = (title or "").strip()
490
491
  if not title:
491
492
  return False, "作品名必填(发章按作品名在平台找书)"
493
+ # 换绑另一本书(book_id/title 变了)时旧书的校准数作废,防张冠李戴
494
+ old = ledger.book_for(task_id, plat) or {}
495
+ if old and (old.get("book_id") != str(book_id or "").strip()
496
+ or old.get("title") != title[:120]):
497
+ ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
498
+ remote_review=None, remote_rejected=None,
499
+ remote_synced_at=None, remote_error=None)
492
500
  ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
493
501
  "title": title[:120]})
494
502
  return True, ""
@@ -684,6 +692,130 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
684
692
  return True, ""
685
693
 
686
694
 
695
+ # ------------------------------------------------ 已发章数校准(平台实况对账)
696
+ # 台账只记得 CodeBee 自己发成功的章:用户在平台窗口里手工补交/平台驳回/
697
+ # 台账重复记录都会让「已发 N」漂移(2026-09-28 实案:番茄平台 8 章台账 7)。
698
+ # 校准 = 打开章节管理页数行,按状态关键词分桶后写回 books.json 的 remote_*。
699
+
700
+ _STATUS_PUB = "已发布"
701
+ _STATUS_REVIEW = ("待审核", "审核中", "排队")
702
+ _STATUS_BAD = ("未通过", "驳回")
703
+
704
+
705
+ def bucket_chapter_rows(rows):
706
+ """章节管理页的行文本 → {total, published, review, rejected}。
707
+
708
+ 行里含状态关键词才计入对应桶;total=识别出的章节数据行数。"""
709
+ out = {"total": 0, "published": 0, "review": 0, "rejected": 0}
710
+ for t in rows or []:
711
+ t = str(t)
712
+ if not t:
713
+ continue
714
+ if "章节名称" in t: # 表头兜底(正常到不了这)
715
+ continue
716
+ out["total"] += 1
717
+ if any(k in t for k in _STATUS_BAD):
718
+ out["rejected"] += 1
719
+ elif _STATUS_PUB in t:
720
+ out["published"] += 1
721
+ elif any(k in t for k in _STATUS_REVIEW):
722
+ out["review"] += 1
723
+ return out
724
+
725
+
726
+ def sync_published(task_id, plat, manual=False):
727
+ """同步执行一次校准(manager.sync_published_async 的线程体)。
728
+
729
+ manual=False(打开作品页自动触发)只 attach 在跑的浏览器实例,绝不
730
+ 静默 launch 新窗口吓人;manual=True(用户点「校准」按钮)才允许
731
+ attach-or-launch。识别不到章节行(改版/未登录/分页)时只记 note
732
+ 不覆写 remote_*——宁可显示旧数也不把 0 当真相。"""
733
+ from .browser import Browser
734
+ book = ledger.book_for(task_id, plat)
735
+ if not book:
736
+ return False, "该任务未在此平台登记作品"
737
+ if _st(plat).get("status") == "busy":
738
+ return False, "该平台有操作正在进行中"
739
+ mod = PLATFORMS[plat]
740
+ js = mod.CONFIG.get("count_rows_js")
741
+ if not js:
742
+ return False, "该平台未配置章节计数"
743
+ try:
744
+ if manual:
745
+ b, page = _open_page(plat)
746
+ else:
747
+ port = _st(plat).get("port") or 0
748
+ b = Browser.attach(int(port)) # 实例已死 → 异常 → 只记原因
749
+ page = b.first_page(create=True)
750
+ except Exception as e:
751
+ return False, "浏览器未连接,点「连接平台」后再校准"
752
+ try:
753
+ page.navigate(mod.chapter_manage_url(book), timeout=40)
754
+ time.sleep(4.0) # SPA 表格慢渲染
755
+ url = str(page.url() or "")
756
+ if any(m in url for m in mod.CONFIG["login_url_marks"]) \
757
+ or url.startswith("chrome-error://"):
758
+ return False, "平台登录态已失效,请重连后校准"
759
+ rows = page.call(js) or []
760
+ st = bucket_chapter_rows(rows)
761
+ if st["total"] <= 0:
762
+ return False, "章节管理页未识别到章节列表(可能改版)"
763
+ ledger.update_book(task_id, plat,
764
+ remote_total=st["total"],
765
+ remote_published=st["published"],
766
+ remote_review=st["review"],
767
+ remote_rejected=st["rejected"],
768
+ remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
769
+ return True, ""
770
+ except Exception as e:
771
+ return False, "校准失败:%s" % str(e)[:160]
772
+
773
+
774
+ def _sync_preflight(task_id, plat, manual):
775
+ """校准前置检查(同步、不碰浏览器页面):登记在案/没在忙/平台支持;
776
+ 自动触发(manual=False)时还要求浏览器实例活着——绝不静默 launch。"""
777
+ from .browser import Browser
778
+ if plat not in PLATFORMS:
779
+ return False, "未知平台"
780
+ if not ledger.book_for(task_id, plat):
781
+ return False, "该任务未在此平台登记作品"
782
+ if _st(plat).get("status") == "busy":
783
+ return False, "该平台有操作正在进行中"
784
+ if not PLATFORMS[plat].CONFIG.get("count_rows_js"):
785
+ return False, "该平台未配置章节计数"
786
+ if not manual:
787
+ port = _st(plat).get("port") or 0
788
+ try:
789
+ Browser.attach(int(port))
790
+ except Exception:
791
+ return False, "浏览器未连接,点「连接平台」后再校准"
792
+ return True, ""
793
+
794
+
795
+ def sync_published_async(task_id, plat, manual=False):
796
+ """校准进后台线程(浏览器操作绝不能堵 HTTP 线程)。
797
+
798
+ 返回 (ok, err):预检失败时线程都不起,接口即时回报 skipped 原因。"""
799
+ ok, err = _sync_preflight(task_id, plat, manual)
800
+ if not ok:
801
+ return False, err
802
+
803
+ def run():
804
+ ok2, err2 = sync_published(task_id, plat, manual=manual)
805
+ if not ok2:
806
+ ledger.update_book(task_id, plat, remote_error=err2,
807
+ remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
808
+
809
+ threading.Thread(target=run, daemon=True,
810
+ name="pub-sync-%s" % plat).start()
811
+ return True, ""
812
+
813
+
814
+ def published_local(task_id, plat):
815
+ """本地台账口径的已发章数(去重后的章号数,记录数会因重试虚高)。"""
816
+ return len(ledger.published_chapters(task_id, plat))
817
+
818
+
687
819
  def history(task_id=None, plat=None, limit=50):
688
820
  return ledger.recent(task_id=task_id, platform=plat, limit=limit)
689
821
 
@@ -19,6 +19,11 @@ CONFIG = {
19
19
  # 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
20
20
  "min_chapter_chars": 1000,
21
21
  "login_url_marks": ["login", "signin", "passport", "sso"],
22
+ # 章节管理页计数(2026-09-28 实机校准):Element UI 表格,.el-table__row
23
+ # 只中数据行(表头在独立的 header-wrapper 表里),与发章流程的等待选择器同源
24
+ "count_rows_js":
25
+ "()=>[...document.querySelectorAll('.el-table__row')]"
26
+ ".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
22
27
  }
23
28
 
24
29
  CREATE_BOOK = [
@@ -0,0 +1,121 @@
1
+ # -*- coding: utf-8 -*-
2
+ """备份包远程推送(WebDAV / S3 兼容 PUT 端点):data/ 不在 git 里、磁盘一坏
3
+ 全没——本机产品额外给备份包一条出站活路。
4
+
5
+ L0 自由层(读 settings + 依赖 runner 起进程都合法)。curl -T 流式上传:
6
+ 备份包可能 GB 级(含工作目录),绝不能整包读进内存。密钥只存本机
7
+ settings.json,推送时才带 Basic auth 发给用户自己配的端点。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from . import runner # L0→L2 合法方向(小层号依赖大层号);模块级绑定便于测试打桩
14
+
15
+ _MAX_TIMEOUT_S = 3600
16
+
17
+
18
+ def _cfg():
19
+ try:
20
+ from . import settings
21
+ s = settings.load()
22
+ return {"enabled": bool(s.get("backup_remote_enabled")),
23
+ "url": str(s.get("backup_remote_url") or "").strip().rstrip("/"),
24
+ "user": str(s.get("backup_remote_user") or "").strip(),
25
+ "pass": str(s.get("backup_remote_pass") or "")}
26
+ except Exception:
27
+ return {"enabled": False, "url": "", "user": "", "pass": ""}
28
+
29
+
30
+ def push_remote(zip_path, timeout_s=1800):
31
+ """把备份包 PUT 到远程端点。返回 {"ok","skipped","status","error"}。"""
32
+ cfg = _cfg()
33
+ if not cfg["enabled"] or not cfg["url"]:
34
+ return {"ok": False, "skipped": True, "status": 0, "error": "未启用远程备份"}
35
+ from pathlib import Path
36
+ p = Path(zip_path)
37
+ if not p.is_file():
38
+ return {"ok": False, "skipped": False, "status": 0, "error": "备份包不存在"}
39
+ argv = ["curl", "-sS", "-f", "-T", str(p), "--max-time", str(max(60, timeout_s)),
40
+ cfg["url"] + "/" + p.name]
41
+ if cfg["user"] or cfg["pass"]:
42
+ argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
43
+ try:
44
+ r = runner.run_process(argv=argv, timeout=timeout_s + 100)
45
+ except Exception as e:
46
+ return {"ok": False, "skipped": False, "status": 0, "error": str(e)[:200]}
47
+ err = (r.get("stderr") or "").strip().splitlines()
48
+ return {"ok": bool(r.get("ok")), "skipped": False,
49
+ "status": 200 if r.get("ok") else 0,
50
+ "error": "" if r.get("ok") else (err[-1] if err else "上传失败")[:200]}
51
+
52
+
53
+ def test_remote():
54
+ """连通性测试:PUT 一个小探针文件(同端点同凭据)。"""
55
+ import os
56
+ import tempfile
57
+ fd, p = tempfile.mkstemp(prefix="codebee-remote-", suffix=".txt")
58
+ try:
59
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
60
+ f.write("CodeBee remote backup probe")
61
+ return push_remote(p, timeout_s=60)
62
+ finally:
63
+ try:
64
+ os.unlink(p)
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def _curl_common_args():
70
+ cfg = _cfg()
71
+ argv = ["curl", "-sS", "-f", "--max-time", "120"]
72
+ if cfg["user"] or cfg["pass"]:
73
+ argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
74
+ return cfg, argv
75
+
76
+
77
+ def list_remote():
78
+ """列远程端点上的备份包(WebDAV PROPFIND,其他端点回落 GET 目录页)。
79
+ 返回 (文件名列表, 错误)。仅支持 push 时同款 URL 前缀。"""
80
+ cfg, argv = _curl_common_args()
81
+ if not cfg["enabled"] or not cfg["url"]:
82
+ return None, "未启用远程备份"
83
+ r = runner.run_process(
84
+ argv=argv + ["-X", "PROPFIND", "-H",
85
+ "Content-Type: application/xml", "-H", "Depth: 1",
86
+ "--data-binary", "", cfg["url"] + "/"], timeout=150)
87
+ if not r.get("ok"):
88
+ err = (r.get("stderr") or "").strip().splitlines()
89
+ return None, (err[-1] if err else "列举失败(端点可能不支持 PROPFIND)")[:200]
90
+ import re as _re
91
+ text = str(r.get("stdout") or "")
92
+ names = sorted({m for m in _re.findall(
93
+ r"codebee-backup-\d{8}-\d{6}\.zip", text)}, reverse=True)
94
+ return names, None
95
+
96
+
97
+ def pull_remote(name):
98
+ """从远程端点拉备份包到本机 imports 目录(走现有导入向导)。
99
+ name 必须精确匹配 codebee-backup-*.zip 形态(防路径注入)。
100
+ 返回 (本地路径, 错误)。"""
101
+ import re as _re
102
+ name = str(name or "").strip()
103
+ if not _re.match(r"^codebee-backup-\d{8}-\d{6}\.zip$", name):
104
+ return None, "非法的备份包名"
105
+ cfg, argv = _curl_common_args()
106
+ if not cfg["enabled"] or not cfg["url"]:
107
+ return None, "未启用远程备份"
108
+ from . import paths
109
+ dest_dir = paths.DATA_DIR / "imports"
110
+ dest_dir.mkdir(parents=True, exist_ok=True)
111
+ dest = dest_dir / name
112
+ r = runner.run_process(
113
+ argv=argv + ["-o", str(dest), cfg["url"] + "/" + name], timeout=1900)
114
+ if not r.get("ok") or not dest.is_file() or dest.stat().st_size == 0:
115
+ try:
116
+ dest.unlink()
117
+ except OSError:
118
+ pass
119
+ err = (r.get("stderr") or "").strip().splitlines()
120
+ return None, (err[-1] if err else "拉取失败")[:200]
121
+ return str(dest), None
@@ -631,7 +631,7 @@ def _normalize_abort_markers(value):
631
631
  def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
632
632
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
633
633
  stall_timeout=0, repeat_abort=None, deadline=None,
634
- abort_markers=None, activity_timeout=0):
634
+ abort_markers=None, activity_timeout=0, audit_notes=None):
635
635
  """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
636
636
 
637
637
  stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
@@ -652,6 +652,10 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
652
652
  到点会杀掉进程树并返回 deadline_exceeded=True,调用方可把整个任务收口
653
653
  为 timeout,而不是把它误记成普通供应商失败。
654
654
 
655
+ audit_notes:可选字符串列表,逐行追加在审计头命令行下方——用于把
656
+ argv/env 里看不出的运行形态钉进日志(如 claude 凭据=绑定链注入/本机默认),
657
+ 事后诊断不用靠猜。只写形态名,绝不写密钥值。
658
+
655
659
  返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
656
660
  deadline_exceeded, abort_marker};杀树后未能确认整树清空时额外带
657
661
  quiescence_unknown=True(孙进程可能仍在,诊断用,不改变终态语义)。
@@ -695,6 +699,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
695
699
  try:
696
700
  head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
697
701
  "$ " + " ".join(str(a) for a in argv)]
702
+ for _note in (audit_notes or []):
703
+ if _note:
704
+ head.append("· " + str(_note))
698
705
  if stdin_text:
699
706
  capped = stdin_text[:12000]
700
707
  head.append("--- 指令(%d 字符%s)---" % (
@@ -1063,6 +1070,64 @@ def _claude_drift_note(att_model, parsed):
1063
1070
  return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
1064
1071
 
1065
1072
 
1073
+ _LOGIN_FIX_HINTS = {
1074
+ "claude": ("① 绑定页为 claude-code 配置供应商链(如 Bigmodel anthropic 面);"
1075
+ "② 在 ~/.claude/settings.json 的 env 里配 ANTHROPIC_BASE_URL"
1076
+ " + ANTHROPIC_AUTH_TOKEN"),
1077
+ "codex": ("① 绑定页为 codex 配置供应商链;② 本机执行 codex login 完成登录"),
1078
+ }
1079
+
1080
+
1081
+ def _login_hint(kind, error_code, error_text):
1082
+ """凭据注入型 CLI 撞登录闸门的修复指引;非缺凭据或非登录措辞返回空。
1083
+
1084
+ 「无链回落本机默认」时 runner 不注入任何凭据(_resolve_attempts 的 env
1085
+ 恒空),本机 CLI 又没登录 → 每步必死还烧满退避。把修法直接写进错误串,
1086
+ 别让一句「Not logged in」冒充死因(2026-09-25 Mac 端 claude 实案:init
1087
+ JSON 里 apiKeySource=none,链上零候选可换将)。措辞闸保持窄词:
1088
+ login failed / invalid credentials 是 AUTH 语义,不进缺凭据指引。"""
1089
+ if error_code_value(error_code) != ErrorCode.MISSING_CREDENTIAL:
1090
+ return ""
1091
+ low = str(error_text or "").lower()
1092
+ if ("not logged in" not in low and "please run /login" not in low
1093
+ and "please log in" not in low):
1094
+ return ""
1095
+ fix = _LOGIN_FIX_HINTS.get(kind) or (
1096
+ "① 绑定页为该 CLI 配置供应商链(带密钥的供应商条目);"
1097
+ "② 完成该 CLI 的本机登录或在其自家配置里注入 API Key")
1098
+ return (";%s CLI 未登录且本步未注入绑定链凭据,修复二选一:%s"
1099
+ % (kind, fix))
1100
+
1101
+
1102
+ def _cred_audit_note(kind, att, argv):
1103
+ """步骤审计头的凭据模式行(run_process audit_notes 用,所有 CLI 通用)。
1104
+
1105
+ 注入真值按 kind 分流:claude 只认 argv 里的 --settings(_build_call 只在
1106
+ env 带 ANTHROPIC_* 时落它——ORCH_API_KEY 这类别家约定的键对 claude 无效,
1107
+ 不得按 env 非空误报「绑定链注入」);其余 kind 的 env 直接传给进程,
1108
+ env 非空即有效注入。条目级 no_cred 标记(resolve_binding 给纯模型条目
1109
+ 打标)独立点名。只写形态名与变量名,密钥值绝不入日志。
1110
+ 2026-09-25 Mac 实案:纯模型条目零注入,进程死在登录闸门后只能靠 init
1111
+ JSON 的 apiKeySource=none 反推死因;起跑就把凭据模式钉进日志头,且不限
1112
+ claude——codex/kimi/qwen 等 generic 的零凭据起跑同样一眼可读。"""
1113
+ if kind == "claude":
1114
+ if any(str(a) == "--settings" for a in (argv or [])):
1115
+ return "凭据=绑定链(一次性 --settings 注入 ANTHROPIC_*)"
1116
+ if att.get("no_cred"):
1117
+ return ("凭据=无注入(链上纯模型条目零凭据,只传 -m 走本机 claude "
1118
+ "登录态;未登录则 -p 必死在登录闸门)")
1119
+ return ("凭据=本机默认(未注入 ANTHROPIC_*,走本机 claude 登录态;"
1120
+ "未登录则 -p 必死在登录闸门)")
1121
+ if att.get("no_cred"):
1122
+ return ("凭据=无注入(链上纯模型条目零凭据,只传 -m 走本机 CLI 登录态;"
1123
+ "本机未登录/未配置则必死在凭据闸门)")
1124
+ env = att.get("env") or {}
1125
+ if env:
1126
+ return "凭据=绑定链注入(env: %s)" % ",".join(sorted(env))
1127
+ return ("凭据=本机默认(本步未注入任何凭据 env,走 CLI 本机登录态/配置;"
1128
+ "未登录则必死在登录/凭据闸门)")
1129
+
1130
+
1066
1131
  def _log_note(log_path, text):
1067
1132
  if not log_path or not text:
1068
1133
  return
@@ -1189,9 +1254,11 @@ def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_ou
1189
1254
  classified = classify_error_text(detail)
1190
1255
  if classified is not None:
1191
1256
  return classified
1192
- # claude 解析失败(进程 ok 但 JSON 不可解析)
1257
+ # claude 解析失败(进程 ok 但无 result 事件):先按 stdout 认错误——未登录
1258
+ # 闸门只吐一行「Not logged in · Please run /login」就退出,认不出才是
1259
+ # 真正的解析失败
1193
1260
  if kind == "claude" and parsed is None:
1194
- return ErrorCode.PARSE_FAIL
1261
+ return classify_error_text(res.get("stdout") or "") or ErrorCode.PARSE_FAIL
1195
1262
  # claude 明确 is_error
1196
1263
  if parsed and parsed.get("is_error"):
1197
1264
  return ErrorCode.VENDOR_REFUSAL
@@ -1226,7 +1293,8 @@ def _resolve_attempts(agent):
1226
1293
  "own_cp": "codex_provider" in e,
1227
1294
  "codex_provider": e.get("codex_provider"),
1228
1295
  "provider_id": e.get("provider_id") or "",
1229
- "key_id": e.get("key_id") or ""} for e in chain]
1296
+ "key_id": e.get("key_id") or "",
1297
+ "no_cred": bool(e.get("no_cred"))} for e in chain]
1230
1298
  base_model = agent.get("model")
1231
1299
  fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
1232
1300
  models_to_try = ([base_model] if base_model else []) + fb
@@ -1693,6 +1761,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1693
1761
  att["model"], prompt,
1694
1762
  images=images if kind == "codex" else None,
1695
1763
  workdir=workdir)
1764
+ # 凭据模式起跑钉进审计头(所有 CLI):注入真值按 kind 分流,
1765
+ # claude 以 argv 的 --settings 为准,见 _cred_audit_note
1766
+ audit_notes = [_cred_audit_note(kind, att, argv)]
1696
1767
  try:
1697
1768
  repeat_guard = (("Reconnecting...", 2)
1698
1769
  if kind == "codex" else None)
@@ -1715,7 +1786,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1715
1786
  log_path=log_path, stall_timeout=stall_t,
1716
1787
  activity_timeout=activity_t,
1717
1788
  repeat_abort=repeat_guard,
1718
- abort_markers=stream_abort_markers)
1789
+ abort_markers=stream_abort_markers,
1790
+ audit_notes=audit_notes)
1719
1791
  finally:
1720
1792
  # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1721
1793
  for tf in tmp_files:
@@ -1796,6 +1868,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1796
1868
  if _permission_error(out["error"])
1797
1869
  else classify_error_text(out["error"])
1798
1870
  or _classify_failure(res, kind=kind))
1871
+ out["error"] += _login_hint(kind, out["error_code"],
1872
+ out["error"])
1799
1873
  break
1800
1874
  if kind == "codex":
1801
1875
  out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
@@ -1830,6 +1904,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1830
1904
  out["ok"] = False
1831
1905
  out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
1832
1906
  out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
1907
+ out["error"] += _login_hint("claude", out["error_code"], out["error"])
1833
1908
  break
1834
1909
  out["text"] = parsed["text"]
1835
1910
  out["cost_usd"] = parsed["cost_usd"]
@@ -21,9 +21,24 @@ _FILE = paths.DATA_DIR / "settings.json"
21
21
  # 运行时出现(空闲 90s 隐身)。
22
22
  # cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
23
23
  # 运行过程日志/发布截图/bak 残留等超期自动清理;retention 为保留天数。
24
+ # 个人推送通道(core/notify.py 读取):Bark(iOS)/ ntfy(自托管友好)/
25
+ # Server酱(微信)/ Telegram Bot。密钥只存本机 settings.json,不出网(推送
26
+ # 时才带着发给对应服务)。bark_server 留空回落官方 api.day.app。
24
27
  DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
25
28
  "telemetry_errors": True, "publish_daily_cap": 10,
26
29
  "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
30
+ "notify_bark_server": "", "notify_bark_key": "",
31
+ "notify_ntfy_topic": "", "notify_serverchan_key": "",
32
+ "notify_telegram_token": "", "notify_telegram_chat_id": "",
33
+ # 评测台定时回归(evalbench.fire_due,automation tick 调用):
34
+ # enabled=False 关;days 间隔天数控节流;mcp_servers=内置智能体的
35
+ # MCP 服务器清单(JSON 数组字符串,stdio 形态)
36
+ "bench_auto_enabled": False, "bench_auto_days": 7,
37
+ "mcp_servers": "",
38
+ # 备份远程通道(remotesync):导出成功后自动 PUT 到 WebDAV/S3 兼容
39
+ # 端点(Basic auth);密钥只存本机
40
+ "backup_remote_enabled": False, "backup_remote_url": "",
41
+ "backup_remote_user": "", "backup_remote_pass": "",
27
42
  "pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
28
43
  "cleanup_enabled": True, "cleanup_retention_days": 14,
29
44
  # claude_config_sync:打开/运行前防线是否直写 ~/.claude/settings.json。
@@ -40,8 +55,10 @@ MIN_WORKERS, MAX_WORKERS = 1, 24
40
55
 
41
56
 
42
57
  def builtin_workdir():
43
- """内置默认保存路径:<data 目录同级>/workspace。"""
44
- return str(paths.DATA_DIR.parent / "workspace")
58
+ """内置默认保存路径:数据目录内的 workspace/(CodeBee 自己的地盘,
59
+ 启动 ensure_dirs 即创建;老版本落在数据目录同级——Windows 上是
60
+ AppData\\Roaming\\workspace,平时不存在点开即报错,已废弃)。"""
61
+ return str(paths.DATA_DIR / "workspace")
45
62
 
46
63
 
47
64
  def default_workdir():
@@ -118,6 +135,26 @@ def save(patch):
118
135
  cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
119
136
  if "notify_base_url" in patch:
120
137
  cur["notify_base_url"] = str(patch.get("notify_base_url") or "").strip()[:200]
138
+ for _nk, _nmax in (("notify_bark_server", 200), ("notify_bark_key", 128),
139
+ ("notify_ntfy_topic", 200), ("notify_serverchan_key", 128),
140
+ ("notify_telegram_token", 128), ("notify_telegram_chat_id", 64)):
141
+ if _nk in patch:
142
+ cur[_nk] = str(patch.get(_nk) or "").strip()[:_nmax]
143
+ if "bench_auto_enabled" in patch:
144
+ cur["bench_auto_enabled"] = bool(patch.get("bench_auto_enabled"))
145
+ if "bench_auto_days" in patch:
146
+ try:
147
+ cur["bench_auto_days"] = max(1, min(90, int(patch.get("bench_auto_days"))))
148
+ except (TypeError, ValueError):
149
+ return cur, "bench_auto_days 必须是 1-90 的整数"
150
+ if "mcp_servers" in patch:
151
+ cur["mcp_servers"] = str(patch.get("mcp_servers") or "").strip()[:4000]
152
+ if "backup_remote_enabled" in patch:
153
+ cur["backup_remote_enabled"] = bool(patch.get("backup_remote_enabled"))
154
+ for _rk, _rmax in (("backup_remote_url", 300), ("backup_remote_user", 128),
155
+ ("backup_remote_pass", 128)):
156
+ if _rk in patch:
157
+ cur[_rk] = str(patch.get(_rk) or "").strip()[:_rmax]
121
158
  if "pet_enabled" in patch:
122
159
  cur["pet_enabled"] = bool(patch.get("pet_enabled"))
123
160
  if "pet_mode" in patch:
@@ -248,6 +248,14 @@ def register_default_namespaces():
248
248
  FieldDef("max_tokens_per_run", "int", 0,
249
249
  "单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
250
250
  clamp=(0, 100_000_000)),
251
+ # 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
252
+ # 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
253
+ FieldDef("daily_cost_yuan", "float", 0.0,
254
+ "每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
255
+ clamp=(0, 1_000_000)),
256
+ FieldDef("monthly_cost_yuan", "float", 0.0,
257
+ "每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
258
+ clamp=(0, 1_000_000)),
251
259
  ],
252
260
  validate=lambda v: None,
253
261
  )
package/app/core/store.py CHANGED
@@ -18,7 +18,7 @@ import threading
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- from . import artifacts, paths, runner, tracing, volumes
21
+ from . import artifacts, audit, paths, runner, tracing, volumes
22
22
 
23
23
  LOCK = threading.RLock()
24
24
  _TASKS = {}
@@ -148,7 +148,7 @@ def create_task(payload):
148
148
  raise ValueError("目标描述不能为空")
149
149
  title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
150
150
  if not workdir:
151
- # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <data 同级>/workspace)。
151
+ # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <数据目录>/workspace)。
152
152
  # 默认路径允许自动创建;用户显式给的目录仍必须已存在。
153
153
  from . import settings as settings_mod
154
154
  workdir = settings_mod.default_workdir()
@@ -487,8 +487,8 @@ def get_task(task_id):
487
487
  return None
488
488
 
489
489
 
490
- def list_tasks(limit=100, archived=None):
491
- """archived=None 返回全部;False 仅未归档;True 仅已归档。按 id(含时间戳)倒序。"""
490
+ def list_tasks(limit=None, archived=None):
491
+ """按 id 倒序列任务。archived 筛选归档态;limit=None 返回全部。"""
492
492
  with LOCK:
493
493
  ids = sorted(_TASKS.keys(), reverse=True)
494
494
  out = []
@@ -497,7 +497,7 @@ def list_tasks(limit=100, archived=None):
497
497
  if archived is not None and bool(t.get("archived")) != archived:
498
498
  continue
499
499
  out.append(t)
500
- if len(out) >= limit:
500
+ if limit is not None and len(out) >= limit:
501
501
  break
502
502
  return out
503
503
 
@@ -1435,8 +1435,12 @@ def archive_task(task_id, archived=True):
1435
1435
  return True, ""
1436
1436
 
1437
1437
 
1438
- def delete_task(task_id):
1439
- """删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。"""
1438
+ def delete_task(task_id, actor=""):
1439
+ """删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。
1440
+
1441
+ 不可逆且无回收站,故落审计(谁/何时/删了什么——2026-09-28 连载根任务
1442
+ 被删案:事后无人能回答这两问)。连载链的前序任务被后续任务 serial.continues
1443
+ 依赖,删了会把整条链的「继续连载/断点续跑」地基抽掉——这类任务只许归档。"""
1440
1444
  if not _valid_id(task_id):
1441
1445
  return False, "非法的任务 ID"
1442
1446
  with LOCK:
@@ -1445,6 +1449,11 @@ def delete_task(task_id):
1445
1449
  return False, "任务不存在"
1446
1450
  if task.get("status") in ("queued", "running"):
1447
1451
  return False, "运行中的任务不能删除,请先取消"
1452
+ if task.get("type") == "serial_novel":
1453
+ for other in _TASKS.values():
1454
+ if other.get("id") != task_id and \
1455
+ (other.get("serial") or {}).get("continues") == task_id:
1456
+ return False, "该任务是连载链的前序任务(后续任务依赖它),请用「归档」隐藏而不是删除"
1448
1457
  run_ids = []
1449
1458
  for rid, r in _RUNS.items():
1450
1459
  if r.get("task_id") != task_id:
@@ -1458,6 +1467,15 @@ def delete_task(task_id):
1458
1467
  for rid in run_ids:
1459
1468
  _schedule_run_dir_cleanup(rid)
1460
1469
  (paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
1470
+ audit.record("task_delete", {
1471
+ "task_id": task_id,
1472
+ "title": task.get("title") or "",
1473
+ "type": task.get("type") or "",
1474
+ "status": task.get("status") or "",
1475
+ "workdir": task.get("workdir") or "",
1476
+ "run_ids": run_ids,
1477
+ "actor": actor or "local",
1478
+ })
1461
1479
  bump_state()
1462
1480
  return True, ""
1463
1481
 
@@ -1880,7 +1898,7 @@ def clear_stream_state(run_id=None, n=None):
1880
1898
 
1881
1899
  def finish_step(run_id, n, status, summary="", exit_code=None,
1882
1900
  cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
1883
- followups=None, thinking=None):
1901
+ followups=None, thinking=None, partial=False):
1884
1902
  with LOCK:
1885
1903
  run = _RUNS.get(run_id)
1886
1904
  if not run:
@@ -1911,6 +1929,10 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1911
1929
  s["thinking"] = runner.strip_ansi(str(thinking))[:LIVE_TEXT_MAX]
1912
1930
  if followups:
1913
1931
  s["followups"] = list(followups)[:3]
1932
+ if partial:
1933
+ s["partial"] = True
1934
+ else:
1935
+ s.pop("partial", None)
1914
1936
  # 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
1915
1937
  # 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
1916
1938
  s.pop("stream", None)