codebee 0.1.95 → 0.1.97

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 (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -250,21 +250,25 @@ __GOAL__
250
250
  ## 背景与上下文
251
251
  __CONTEXT__"""
252
252
 
253
- SERIAL_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(番茄/七猫/起点)的过稿标准。
253
+ SERIAL_OUTLINE_PROMPT = """你是小说结构编辑。你不代表番茄、七猫或其他平台,也不能预测签约或流量结果。
254
254
 
255
255
  __SKILLS__
256
256
  请为下面的小说目标设计一份连载大纲:共 __N__ 章,每章约 __W__ 字。
257
257
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
258
258
  {"book_title": "书名", "style_anchor": "全书文风锚(80字内,具体可执行:叙述视角与人称、句长基调、语域、对白风格、禁用表达)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)", "highlight": "本章爽点/情绪爆点(一句话:谁在哪里完成什么反转或释放什么情绪)"}]__VOLUMES_KEY__}
259
+ 字段说明:hook 可为自然收束、留白或继续阅读理由,不强制悬念;highlight 表示主要戏剧/情绪变化或回报,无显著爆点时说明叙事功能,不要强造爆点。
259
260
  硬性要求:
260
261
  - book_title 4-15 字,必须考虑平台书名全站唯一(撞名建不了书):避开「我的XX藏不住」
261
262
  「开局XX」等高频套路组合,书名里带本书独有的专名(人名/金手指/设定词),独有专名越多越不易撞名;
262
- - 第 1-3 章是黄金三章:第 1 章前 100 字出现主角、前 200 字出现异常或欲望、前 600 字进入具体困境,
263
- 第 3 章末留大钩子;不要用世界观或环境描写拖延切入;
264
- - 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账和硬旁白推剧情;
263
+ - 把开篇字数位置当阅读观察点而非硬门槛:尽早让读者辨认视角人物、故事承诺和正在发生的事,
264
+ 但按题材、叙述策略与实际效果决定节奏,不为数字机械塞冲突;
265
+ - 每章要有清楚的叙事功能(推进事件、人物/关系、信息、氛围或情绪之一或多项),并检查因果与局面变化;
266
+ 不强制每章都打脸、反转或以悬念断章;
267
+ - 章末可自然收束或留白;大纲中的 highlight 描述主要戏剧/情绪变化或回报,无显著爆点时写清叙事功能,不要强造爆点。
265
268
  - 大纲中的情感转折必须给出触发事件和人物选择,不能只写“误会加深/感情升温”;
266
269
  - style_anchor 是全书唯一的文风锚,每章起草/修订/评审都会注入:必须写明视角人称与句长基调等可执行约束,不要空话;
267
- - 每章必须给出 highlight(爽点/情绪爆点):没有爆点的章节要重新设计,过渡章也要有小冲突小反转;
270
+ - highlight 是本章主要情绪/戏剧变化或读者回报,可为爽、暖、痛、惊、趣味、亲密、发现、安定等;
271
+ 平静或过渡章节可承担必要功能,不为填字段伪造高潮;
268
272
  - 结局必须闭环(完本感),主角有成长弧光;
269
273
  - 题材健康,无违规内容,符合平台签约调性。
270
274
  __VOLUMES__
@@ -286,7 +290,7 @@ __SKILLS__
286
290
  - 主线沿既有脉络推进,新冲突尽量从已埋伏笔中生长,人物性格与前文一致;
287
291
  - 若下方给出「锁定章纲」:本批各章 title/beats/hook 必须逐章从对应章纲提炼对齐
288
292
  (标题沿用、视角不换、节拍不裁并、伏笔揭示位不提前),不得虚构章纲之外的主线事件;
289
- - 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账;
293
+ - 在关键章节写清人物选择、阻碍与后果;不要求每章都有完整冲突闭环或悬念钩子,避免无功能重复;
290
294
  - 复杂情感必须落到触发事件、动作/生理反应和潜台词,不能用一句概括代替;
291
295
  - 延续既定叙述视角和语言锚点(见下方既定文风锚),删掉重复复述,避免节奏变慢;
292
296
  - 题材健康,无违规内容,符合平台签约调性。
@@ -749,15 +753,17 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
749
753
 
750
754
  # 开篇重设计提示词(开篇闸门专用):第 1 章签约评估的开篇吸引力未达线时,
751
755
  # 回大纲层重设计前三章节拍而不是只修稿——切入点不行是结构病,修文案救不了。
752
- SERIAL_OPENING_REBUILD_PROMPT = """你是网文主编,熟悉签约平台(番茄/七猫/起点)的过稿标准。
753
- 这本书的第 1 章在签约预评估中「开篇吸引力」未达线,评审意见如下:
756
+ SERIAL_OPENING_REBUILD_PROMPT = """你是小说结构编辑,不代表任何平台,也不能预测签约结果。
757
+ 这本书的开篇评审发现吸引力风险,实际意见如下:
754
758
  __CRITIQUE__
755
759
 
756
- 请重设计前 __COUNT__ 章的大纲节拍(只动这 __COUNT__ 章,不碰后续章节),让开篇符合黄金一章标准:
757
- 第 1 章前 100 字出现主角、前 200 字出现异常或冲突、前 600 字进入具体困境、章末留可兑现的钩子;
758
- 切入点必须是具体事件(退婚/陷害/背叛/生死危机等),禁止世界观铺垫开局。
760
+ 请重设计前 __COUNT__ 章的大纲节拍(只动这 __COUNT__ 章,不碰后续章节),针对反馈解决具体问题。
761
+ 先保留原稿有效之处,再提出能改变事件、人物选择或后果的改动;不能只换词、加怪声或添加钩子。
762
+ 前 100/200/600 字只是阅读观察点,不是硬指标;不预设危机、金手指、背叛或打脸。
763
+ 判断开篇是否成立,要看题材承诺是否兑现、事件是否具体、读者能否理解人物处境和下一步,尊重本稿叙述方式。
759
764
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
760
765
  {"chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)", "highlight": "本章爽点/情绪爆点(一句话)"}]}
766
+ 其中 hook 可为自然收束、留白或继续阅读理由,不强制悬念;highlight 无显著爆点时说明叙事功能,不要强造爆点。
761
767
  章序与原大纲一致(从第 1 章起),必须保持书名、主角与核心设定不变,只强化切入与钩子设计。
762
768
 
763
769
  ## 全书目标
@@ -0,0 +1,84 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Execution policy primitives shared by tools and runners."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def normalize_sandbox(spec=None, workdir=None):
10
+ spec = spec if isinstance(spec, dict) else {}
11
+ root = Path(workdir or os.getcwd()).expanduser().resolve()
12
+ roots = []
13
+ requested_roots = spec.get("allowed_roots")
14
+ if requested_roots is not None and not isinstance(requested_roots, (list, tuple)):
15
+ raise ValueError("allowed_roots 必须是路径列表")
16
+ explicit_roots = bool(requested_roots)
17
+ for value in requested_roots or [str(root)]:
18
+ try:
19
+ candidate = Path(value).expanduser().resolve()
20
+ except (TypeError, OSError, ValueError):
21
+ continue
22
+ # The task payload is not an authority to grant host filesystem access.
23
+ # Sandbox roots may narrow the selected workspace, never widen it.
24
+ if candidate != root and root not in candidate.parents:
25
+ continue
26
+ if candidate not in roots:
27
+ roots.append(candidate)
28
+ if not roots:
29
+ if explicit_roots:
30
+ raise ValueError("allowed_roots 没有工作目录内的有效路径")
31
+ roots = [root]
32
+ env = []
33
+ for value in spec.get("env_allowlist") or []:
34
+ name = str(value or "").strip()
35
+ if name and name.replace("_", "a").isalnum() and name not in env:
36
+ env.append(name)
37
+ try:
38
+ timeout = max(1, min(7 * 24 * 3600, int(spec.get("timeout_s") or 3600)))
39
+ except (TypeError, ValueError):
40
+ timeout = 3600
41
+ try:
42
+ output = max(1024, min(100 * 1024 * 1024, int(spec.get("max_output_bytes") or 10 * 1024 * 1024)))
43
+ except (TypeError, ValueError):
44
+ output = 10 * 1024 * 1024
45
+ network_value = spec.get("network", True)
46
+ if isinstance(network_value, bool):
47
+ network = network_value
48
+ elif isinstance(network_value, str) and network_value.strip().lower() in ("true", "false"):
49
+ network = network_value.strip().lower() == "true"
50
+ else:
51
+ # Invalid policy input must never silently widen network access.
52
+ network = False
53
+ return {"allowed_roots": [str(x) for x in roots], "env_allowlist": env,
54
+ "network": network, "timeout_s": timeout,
55
+ "max_output_bytes": output,
56
+ "disabled_tools": normalize_disabled_tools(spec.get("disabled_tools"))}
57
+
58
+
59
+ def normalize_disabled_tools(items):
60
+ if not isinstance(items, (list, tuple, set)):
61
+ return []
62
+ out = []
63
+ for value in items:
64
+ name = str(value or "").strip()[:120]
65
+ if name and name not in out:
66
+ out.append(name)
67
+ if len(out) >= 200:
68
+ break
69
+ return out
70
+
71
+
72
+ def path_allowed(path, sandbox):
73
+ try:
74
+ candidate = Path(path).expanduser().resolve()
75
+ return any(candidate == Path(root) or Path(root) in candidate.parents
76
+ for root in (sandbox or {}).get("allowed_roots") or [])
77
+ except (TypeError, OSError, ValueError):
78
+ return False
79
+
80
+
81
+ def filter_env(env, sandbox):
82
+ values = env if isinstance(env, dict) else {}
83
+ allow = (sandbox or {}).get("env_allowlist") or []
84
+ return {str(k): str(v) for k, v in values.items() if str(k) in allow}
@@ -1,111 +1,111 @@
1
- # -*- coding: utf-8 -*-
2
- """启动端口清场(用户拍板 2026-09-21):端口被占时先清场再启动。
3
-
4
- - **自家旧实例**(命令行含本包 main.py 完整路径,或 npm 包内 app/main.py)
5
- → 杀树(TerminateProcess 直杀)——升级/重启最常见的占用者就是没退干净
6
- 的 CodeBee 自己;控制台进程对温和信号(WM_CLOSE)无反应,温和关不掉
7
- 正是用户「旧进程太难杀」的痛点,所以自家实例直接强杀。
8
- - **别人的进程** → 只报告占用者,不发送任何信号;启动流程不得替用户
9
- 关闭可能正在开发中的服务。
10
- - 系统/自身进程拒关(portscan 内置护栏)。
11
- """
12
- from __future__ import annotations
13
-
14
- import logging
15
- import os
16
- import shlex
17
- import subprocess
18
-
19
- from . import portscan, runner
20
-
21
- log = logging.getLogger(__name__)
22
-
23
- PS_EXE = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
24
-
25
-
26
- def proc_cmdline(pid):
27
- """读进程命令行;失败返回空串。仅用于确认旧 CodeBee 实例身份。"""
28
- try:
29
- pid = int(pid)
30
- except (TypeError, ValueError):
31
- return ""
32
- if pid <= 0:
33
- return ""
34
- if os.name != "nt":
35
- try:
36
- with open("/proc/%d/cmdline" % pid, "rb") as fh:
37
- return fh.read().decode("utf-8", "replace").replace("\x00", " ").strip()
38
- except Exception:
39
- return ""
40
- try:
41
- r = subprocess.run(
42
- [PS_EXE, "-NoProfile", "-Command",
43
- "(Get-CimInstance Win32_Process -Filter 'ProcessId = %d').CommandLine" % pid],
44
- capture_output=True, timeout=10)
45
- return r.stdout.decode("utf-8", "replace").strip() if r.returncode == 0 else ""
46
- except Exception:
47
- return ""
48
-
49
-
50
- def _command_tokens(cmdline):
51
- try:
52
- return [token.strip().strip('"').strip("'")
53
- for token in shlex.split(str(cmdline or ""), posix=False)]
54
- except (TypeError, ValueError):
55
- return []
56
-
57
-
58
- def is_own_instance(cmdline, main_script, port=None):
59
- """按启动器、脚本独立参数和端口精确确认 CodeBee 实例。"""
60
- tokens = _command_tokens(cmdline)
61
- if len(tokens) < 2:
62
- return False
63
- launcher = os.path.splitext(os.path.basename(tokens[0]))[0].lower()
64
- if launcher not in ("python", "python3", "py"):
65
- return False
66
- script = ""
67
- for token in tokens[1:]:
68
- if token.startswith("-"):
69
- continue
70
- script = token
71
- break
72
- if not script or not os.path.isabs(script):
73
- return False
74
- actual = os.path.normcase(os.path.normpath(os.path.abspath(script)))
75
- expected = os.path.normcase(os.path.normpath(os.path.abspath(str(main_script))))
76
- packaged = actual.replace("\\", "/").lower().endswith(
77
- "/node_modules/codebee/app/main.py")
78
- if actual != expected and not packaged:
79
- return False
80
- if port is None:
81
- return True
82
- try:
83
- wanted = str(int(port))
84
- except (TypeError, ValueError):
85
- return False
86
- explicit_port = None
87
- for i, token in enumerate(tokens):
88
- if token == "--port" and i + 1 < len(tokens):
89
- explicit_port = tokens[i + 1]
90
- elif token.startswith("--port="):
91
- explicit_port = token.split("=", 1)[1]
92
- # argparse 默认端口可不出现在旧实例参数中;非默认端口必须显式相符。
93
- return ((explicit_port == wanted) if explicit_port is not None
94
- else wanted == "8765")
95
-
96
-
97
- def clear_stale_port(port, main_script):
98
- """清掉占用端口的进程。返回 (是否清掉, 人话说明)。"""
99
- holders = [p for p in portscan.listening_ports() if p.get("port") == int(port)]
100
- if not holders:
101
- return True, ""
102
- pid = int(holders[0].get("pid") or 0)
103
- if pid <= 4 or pid == os.getpid():
104
- return False, "占用者是系统进程/自身,拒绝清理"
105
- if is_own_instance(proc_cmdline(pid), main_script, port=port):
106
- try:
107
- runner._kill_tree(pid)
108
- return True, "已结束旧实例 PID %d" % pid
109
- except Exception as e:
110
- return False, "旧实例 PID %d 清理失败 %s" % (pid, str(e)[:80])
111
- return False, "占用者非 CodeBee(PID %d),未自动关闭" % pid
1
+ # -*- coding: utf-8 -*-
2
+ """启动端口清场(用户拍板 2026-09-21):端口被占时先清场再启动。
3
+
4
+ - **自家旧实例**(命令行含本包 main.py 完整路径,或 npm 包内 app/main.py)
5
+ → 杀树(TerminateProcess 直杀)——升级/重启最常见的占用者就是没退干净
6
+ 的 CodeBee 自己;控制台进程对温和信号(WM_CLOSE)无反应,温和关不掉
7
+ 正是用户「旧进程太难杀」的痛点,所以自家实例直接强杀。
8
+ - **别人的进程** → 只报告占用者,不发送任何信号;启动流程不得替用户
9
+ 关闭可能正在开发中的服务。
10
+ - 系统/自身进程拒关(portscan 内置护栏)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import subprocess
18
+
19
+ from . import portscan, runner
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ PS_EXE = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
24
+
25
+
26
+ def proc_cmdline(pid):
27
+ """读进程命令行;失败返回空串。仅用于确认旧 CodeBee 实例身份。"""
28
+ try:
29
+ pid = int(pid)
30
+ except (TypeError, ValueError):
31
+ return ""
32
+ if pid <= 0:
33
+ return ""
34
+ if os.name != "nt":
35
+ try:
36
+ with open("/proc/%d/cmdline" % pid, "rb") as fh:
37
+ return fh.read().decode("utf-8", "replace").replace("\x00", " ").strip()
38
+ except Exception:
39
+ return ""
40
+ try:
41
+ r = subprocess.run(
42
+ [PS_EXE, "-NoProfile", "-Command",
43
+ "(Get-CimInstance Win32_Process -Filter 'ProcessId = %d').CommandLine" % pid],
44
+ capture_output=True, timeout=10)
45
+ return r.stdout.decode("utf-8", "replace").strip() if r.returncode == 0 else ""
46
+ except Exception:
47
+ return ""
48
+
49
+
50
+ def _command_tokens(cmdline):
51
+ try:
52
+ return [token.strip().strip('"').strip("'")
53
+ for token in shlex.split(str(cmdline or ""), posix=False)]
54
+ except (TypeError, ValueError):
55
+ return []
56
+
57
+
58
+ def is_own_instance(cmdline, main_script, port=None):
59
+ """按启动器、脚本独立参数和端口精确确认 CodeBee 实例。"""
60
+ tokens = _command_tokens(cmdline)
61
+ if len(tokens) < 2:
62
+ return False
63
+ launcher = os.path.splitext(os.path.basename(tokens[0]))[0].lower()
64
+ if launcher not in ("python", "python3", "py"):
65
+ return False
66
+ script = ""
67
+ for token in tokens[1:]:
68
+ if token.startswith("-"):
69
+ continue
70
+ script = token
71
+ break
72
+ if not script or not os.path.isabs(script):
73
+ return False
74
+ actual = os.path.normcase(os.path.normpath(os.path.abspath(script)))
75
+ expected = os.path.normcase(os.path.normpath(os.path.abspath(str(main_script))))
76
+ packaged = actual.replace("\\", "/").lower().endswith(
77
+ "/node_modules/codebee/app/main.py")
78
+ if actual != expected and not packaged:
79
+ return False
80
+ if port is None:
81
+ return True
82
+ try:
83
+ wanted = str(int(port))
84
+ except (TypeError, ValueError):
85
+ return False
86
+ explicit_port = None
87
+ for i, token in enumerate(tokens):
88
+ if token == "--port" and i + 1 < len(tokens):
89
+ explicit_port = tokens[i + 1]
90
+ elif token.startswith("--port="):
91
+ explicit_port = token.split("=", 1)[1]
92
+ # argparse 默认端口可不出现在旧实例参数中;非默认端口必须显式相符。
93
+ return ((explicit_port == wanted) if explicit_port is not None
94
+ else wanted == "8765")
95
+
96
+
97
+ def clear_stale_port(port, main_script):
98
+ """清掉占用端口的进程。返回 (是否清掉, 人话说明)。"""
99
+ holders = [p for p in portscan.listening_ports() if p.get("port") == int(port)]
100
+ if not holders:
101
+ return True, ""
102
+ pid = int(holders[0].get("pid") or 0)
103
+ if pid <= 4 or pid == os.getpid():
104
+ return False, "占用者是系统进程/自身,拒绝清理"
105
+ if is_own_instance(proc_cmdline(pid), main_script, port=port):
106
+ try:
107
+ runner._kill_tree(pid)
108
+ return True, "已结束旧实例 PID %d" % pid
109
+ except Exception as e:
110
+ return False, "旧实例 PID %d 清理失败 %s" % (pid, str(e)[:80])
111
+ return False, "占用者非 CodeBee(PID %d),未自动关闭" % pid
@@ -1,77 +1,77 @@
1
- """Small local agent presence registry for the operator dashboard."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import threading
6
- import time
7
-
8
- from . import paths
9
-
10
- _LOCK = threading.RLock()
11
- _FILE = paths.DATA_DIR / "agent_presence.json"
12
- _STALE_S = 180
13
-
14
-
15
- def _load():
16
- try:
17
- value = json.loads(_FILE.read_text(encoding="utf-8"))
18
- return value if isinstance(value, dict) else {}
19
- except Exception:
20
- return {}
21
-
22
-
23
- def _save(value):
24
- _FILE.parent.mkdir(parents=True, exist_ok=True)
25
- tmp = _FILE.with_suffix(".tmp")
26
- tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
27
- tmp.replace(_FILE)
28
-
29
-
30
- def heartbeat(agent_id, label="", capabilities=None, task_id="", status="idle", metadata=None):
31
- aid = str(agent_id or "").strip()[:100]
32
- if not aid:
33
- raise ValueError("agent_id 必填")
34
- if capabilities is not None and not isinstance(capabilities, list):
35
- raise ValueError("capabilities 必须是列表")
36
- if status is not None and not isinstance(status, str):
37
- raise ValueError("status 必须是文本")
38
- now = time.time()
39
- with _LOCK:
40
- data = _load()
41
- safe_meta = metadata if isinstance(metadata, dict) else {}
42
- # Presence is a hot, user-controlled endpoint; keep the durable
43
- # registry bounded even when an agent sends oversized diagnostics.
44
- safe_meta = {str(k)[:80]: str(v)[:500] for k, v in list(safe_meta.items())[:30]}
45
- data[aid] = {"agent_id": aid, "label": str(label or aid)[:120],
46
- "capabilities": [str(x)[:80] for x in (capabilities or [])][:30],
47
- "task_id": str(task_id or "")[:100], "status": str(status or "idle")[:30],
48
- "metadata": safe_meta,
49
- "last_seen": now}
50
- _save(data)
51
- return data[aid]
52
-
53
-
54
- def list_agents():
55
- now = time.time()
56
- with _LOCK:
57
- rows = []
58
- for row in _load().values():
59
- item = dict(row)
60
- try:
61
- last_seen = float(item.get("last_seen") or 0)
62
- except (TypeError, ValueError):
63
- last_seen = 0.0
64
- age = max(0, now - last_seen)
65
- item["age_s"] = int(age)
66
- item["online"] = age <= _STALE_S
67
- rows.append(item)
68
- return sorted(rows, key=lambda x: (not x["online"], x.get("label") or ""))
69
-
70
-
71
- def remove(agent_id):
72
- with _LOCK:
73
- data = _load()
74
- existed = str(agent_id or "") in data
75
- data.pop(str(agent_id or ""), None)
76
- _save(data)
77
- return existed
1
+ """Small local agent presence registry for the operator dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ import time
7
+
8
+ from . import paths
9
+
10
+ _LOCK = threading.RLock()
11
+ _FILE = paths.DATA_DIR / "agent_presence.json"
12
+ _STALE_S = 180
13
+
14
+
15
+ def _load():
16
+ try:
17
+ value = json.loads(_FILE.read_text(encoding="utf-8"))
18
+ return value if isinstance(value, dict) else {}
19
+ except Exception:
20
+ return {}
21
+
22
+
23
+ def _save(value):
24
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
25
+ tmp = _FILE.with_suffix(".tmp")
26
+ tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
27
+ tmp.replace(_FILE)
28
+
29
+
30
+ def heartbeat(agent_id, label="", capabilities=None, task_id="", status="idle", metadata=None):
31
+ aid = str(agent_id or "").strip()[:100]
32
+ if not aid:
33
+ raise ValueError("agent_id 必填")
34
+ if capabilities is not None and not isinstance(capabilities, list):
35
+ raise ValueError("capabilities 必须是列表")
36
+ if status is not None and not isinstance(status, str):
37
+ raise ValueError("status 必须是文本")
38
+ now = time.time()
39
+ with _LOCK:
40
+ data = _load()
41
+ safe_meta = metadata if isinstance(metadata, dict) else {}
42
+ # Presence is a hot, user-controlled endpoint; keep the durable
43
+ # registry bounded even when an agent sends oversized diagnostics.
44
+ safe_meta = {str(k)[:80]: str(v)[:500] for k, v in list(safe_meta.items())[:30]}
45
+ data[aid] = {"agent_id": aid, "label": str(label or aid)[:120],
46
+ "capabilities": [str(x)[:80] for x in (capabilities or [])][:30],
47
+ "task_id": str(task_id or "")[:100], "status": str(status or "idle")[:30],
48
+ "metadata": safe_meta,
49
+ "last_seen": now}
50
+ _save(data)
51
+ return data[aid]
52
+
53
+
54
+ def list_agents():
55
+ now = time.time()
56
+ with _LOCK:
57
+ rows = []
58
+ for row in _load().values():
59
+ item = dict(row)
60
+ try:
61
+ last_seen = float(item.get("last_seen") or 0)
62
+ except (TypeError, ValueError):
63
+ last_seen = 0.0
64
+ age = max(0, now - last_seen)
65
+ item["age_s"] = int(age)
66
+ item["online"] = age <= _STALE_S
67
+ rows.append(item)
68
+ return sorted(rows, key=lambda x: (not x["online"], x.get("label") or ""))
69
+
70
+
71
+ def remove(agent_id):
72
+ with _LOCK:
73
+ data = _load()
74
+ existed = str(agent_id or "") in data
75
+ data.pop(str(agent_id or ""), None)
76
+ _save(data)
77
+ return existed