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.
- package/CHANGELOG.md +733 -720
- package/LICENSE +21 -21
- package/README.md +516 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +118 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +246 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +2055 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +648 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mcp_client.py +200 -29
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +319 -59
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +84 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +139 -0
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +106 -22
- package/app/core/publish/browser.py +694 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +212 -165
- package/app/core/publish/flow.py +832 -640
- package/app/core/publish/flows-fanqie-calibrated.json +361 -336
- package/app/core/publish/ledger.py +51 -2
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2239 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +33 -5
- package/app/core/settings_schema.py +282 -271
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +24 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -0
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +865 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +179 -10
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +563 -21
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +165 -162
- package/app/ui/board.js +509 -305
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +125 -9
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +60 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +189 -9
- package/bin/tutti.js +147 -147
- package/package.json +49 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/planner.py
CHANGED
|
@@ -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
|
-
-
|
|
263
|
-
|
|
264
|
-
-
|
|
263
|
+
- 把开篇字数位置当阅读观察点而非硬门槛:尽早让读者辨认视角人物、故事承诺和正在发生的事,
|
|
264
|
+
但按题材、叙述策略与实际效果决定节奏,不为数字机械塞冲突;
|
|
265
|
+
- 每章要有清楚的叙事功能(推进事件、人物/关系、信息、氛围或情绪之一或多项),并检查因果与局面变化;
|
|
266
|
+
不强制每章都打脸、反转或以悬念断章;
|
|
267
|
+
- 章末可自然收束或留白;大纲中的 highlight 描述主要戏剧/情绪变化或回报,无显著爆点时写清叙事功能,不要强造爆点。
|
|
265
268
|
- 大纲中的情感转折必须给出触发事件和人物选择,不能只写“误会加深/感情升温”;
|
|
266
269
|
- style_anchor 是全书唯一的文风锚,每章起草/修订/评审都会注入:必须写明视角人称与句长基调等可执行约束,不要空话;
|
|
267
|
-
-
|
|
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
|
-
|
|
756
|
+
SERIAL_OPENING_REBUILD_PROMPT = """你是小说结构编辑,不代表任何平台,也不能预测签约结果。
|
|
757
|
+
这本书的开篇评审发现吸引力风险,实际意见如下:
|
|
754
758
|
__CRITIQUE__
|
|
755
759
|
|
|
756
|
-
请重设计前 __COUNT__ 章的大纲节拍(只动这 __COUNT__
|
|
757
|
-
|
|
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}
|
package/app/core/portguard.py
CHANGED
|
@@ -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
|
package/app/core/presence.py
CHANGED
|
@@ -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
|