codebee 0.1.104 → 0.1.106

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
@@ -4,6 +4,23 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.106(2026-10-12)
8
+
9
+ - **发布台章节批量发布工作流升级**:待发章节自动对账(已发布/已存草稿自动排除)、按卷计划自动分卷、章节状态徽章一目了然(已过审可直发 / 发布受限 / 审稿未通过 / 已修改需重审 / 待审或评审不足)、「只选可直发」一键筛选;任务按工作区分组管理(工作区视图+区內新建任务)
10
+ - **Windows/macOS 桌面版预览**:新增桌面壳、桌面宿主与运行时锁(防双开),CI 产出预览安装包(npm 主分发不变)
11
+ - **输入区 Codex 风格重排**:目标与对话输入布局更聚焦,配套 markdown 渲染回归测试
12
+ - **移动端任务对话控制增强**
13
+ - **自由 3D 入口暂对用户隐藏**:引擎保留给内部 QA(未达发布质量),hidden/aria-hidden + CSS 双保险防旧缓存/恢复状态重新显示
14
+ - 蜂巢 3D 翅膀/手臂视觉打磨多分支合流收口
15
+
16
+ ## v0.1.105(2026-10-12)
17
+
18
+ - **claude CLI 输出上限 16000→32000**:全局评审读 40 万字级书稿(约 28 万 token 输入)时按旧值反复续烧 23 分钟后报「response exceeded the 16000 output token maximum」三连挂(实案:单次 $3 零产出);32000=claude CLI 官方默认档,`setdefault` 语义保留——用户显式配置不覆盖
19
+ - **治理契约:外部人工评审请求通道**:新增 append-only 评审请求台账(requested→assigned→approved/changes_requested/dismissed/expired 幂等流转)与 `/api/reviews`、`/api/reviews/<id>/transition` 路由,外部智能体可按契约提交待人工评审的产物;配套 `/api/capabilities` 能力清单与 `/api/tasks/<id>/memory/preview` 任务记忆注入预览接口
20
+ - **项目记忆注入预算封顶 20000**:注入预算配置值超过 20000 时按 20000 收敛,防止过大预算挤占提示词(含回归测试锁定)
21
+ - **蜂巢 3D 蜜蜂翅膀精修**:翅膀更纤薄透明、减淡青雾、脉络几何强化,助手蜂手臂/键盘手部姿态与腕端高光对齐,视觉契约测试全程护航(约 40 笔打磨)
22
+ - 英文界面补齐「会话续跑」能力标签词条
23
+
7
24
  ## v0.1.104(2026-10-11)
8
25
 
9
26
  - **连载「一直重写不达标」根因防线(马甲指纹三道防线)**:「落笔入册」「整段连排」类跨章复用此前只能靠评审判死,而重写用的是同一作者模型、长出来的还是同族指纹——循环烧钱永不达标。现把防线前移到生成侧:①**章稿落盘确定性查重闸**:整段(≥40 字)/整句(≥12 字)/完整对白(≥6 字)逐字重复检测,阈值按真实事故章校准,白描短句复沓不误伤;带重复的章稿直接打回,同作者重试与换将的提示词自动追加消重指令(同提示重跑只会长出同族事故);②**起草负样例注入**:近 4 章实际开笔/收束摘录随提示词下发(`__REUSE_GUARD__`),跨章句式同款从评审判据前移成起草约束;③**大纲轮换纪律**:开笔方式与收束形态批内硬性轮换。查重证据自动进评审面可见。生效范围:发版后的新批连载
package/README.md CHANGED
@@ -25,9 +25,12 @@ Trae Agent、OpenClaw……),提供
25
25
  ---
26
26
 
27
27
  <!-- relnotes:start -->
28
- ### 最新版更新内容(v0.1.104)
28
+ ### 最新版更新内容(v0.1.106)
29
29
 
30
- - 连载新防线:跨章复用「马甲指纹」从评审判死前移到起草拦截——落盘查重闸(段落/整句/对白逐字重复直接打回)+ 近章开笔摘录作负样例注入 + 大纲开笔收束批内轮换,重写不再空转
30
+ - 发布台章节批量发布升级:待发自动对账、按卷自动分卷、可直发状态徽章与一键筛选
31
+ - Windows/macOS 桌面版预览打包(CI 预览安装包)
32
+ - 输入区 Codex 风格重排 + 移动端对话控制增强
33
+ - 自由 3D 入口暂对用户隐藏(未达发布质量,保留内部 QA)
31
34
  <!-- relnotes:end -->
32
35
 
33
36
 
@@ -328,8 +331,11 @@ cloudflared tunnel login
328
331
  cloudflared tunnel create codebee
329
332
  cloudflared tunnel route dns --overwrite-dns <隧道UUID> codebee.你的域名.com
330
333
 
331
- :: 之后日常启动(服务 + 隧道一键起):
332
- start-public.bat
334
+ :: 日常启动服务(本机后台窗口):
335
+ start-service.bat
336
+
337
+ :: 启动隧道(另开一个窗口;也可 cloudflared service install 注册为开机自启):
338
+ cloudflared tunnel --config "%USERPROFILE%\.cloudflared\config.yml" run
333
339
  ```
334
340
 
335
341
  - 真本机(不带转发头的 loopback)不受影响。
@@ -0,0 +1,36 @@
1
+ """Small, read-only projections for the sidebar activity view."""
2
+ from __future__ import annotations
3
+
4
+ from . import contracts
5
+
6
+
7
+ def run_activity(run):
8
+ steps = [step for step in (run.get("steps") or []) if isinstance(step, dict)]
9
+ current = next((step for step in reversed(steps)
10
+ if step.get("status") == "running"), None)
11
+ if current is None and steps:
12
+ current = steps[-1]
13
+ failed = next((step for step in reversed(steps)
14
+ if step.get("status") in ("failed", "timeout")), None)
15
+ result = {}
16
+ for name, step in (("current_step", current), ("failed_step", failed)):
17
+ if step:
18
+ result[name] = {key: step[key] for key in
19
+ ("n", "role", "agent", "agent_label", "status") if key in step}
20
+ result[name]["summary"] = " ".join(str(step.get("summary") or "").split())[:240]
21
+ return result
22
+
23
+
24
+ def task_attention(tasks, latest):
25
+ """Approval state is authoritative; never infer approval from a read marker."""
26
+ result = {}
27
+ for task in tasks:
28
+ run = latest.get(task["id"]) or {}
29
+ contract = contracts.get(task["id"]) or {}
30
+ requires_approval = bool(task.get("approval_required") or
31
+ contract.get("approval_required"))
32
+ if not requires_approval or run.get("status") != "done":
33
+ continue
34
+ if (contract.get("approval") or {}).get("status") != "approved":
35
+ result[task["id"]] = {"approval_pending": True}
36
+ return result
@@ -931,7 +931,8 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
931
931
  remaining if remaining is not None else CMD_MAX_TIMEOUT)
932
932
  r = mcp_client.dispatch_full_name(
933
933
  name, args or {}, sandbox=sandbox, workdir=workdir,
934
- disabled_tools=denied, timeout_s=timeout_s)
934
+ disabled_tools=denied, timeout_s=timeout_s,
935
+ cancel_event=cancel_event, deadline=deadline)
935
936
  return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
936
937
  except Exception as e:
937
938
  return "MCP 工具执行失败: %s" % e
@@ -1983,6 +1984,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1983
1984
  disabled_tools=disabled_tools, sandbox=sandbox,
1984
1985
  checkpoint_run_id=checkpoint_run_id,
1985
1986
  tool_output_run_id=tool_output_run_id)
1987
+ if deadline is not None and time.monotonic() >= deadline:
1988
+ return _deadline_fail()
1989
+ if cancel_event is not None and cancel_event.is_set():
1990
+ return _cancel_fail()
1986
1991
  # 运行期注入警示层:外部内容(网页/命令输出/文件)一律
1987
1992
  # 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
1988
1993
  try:
@@ -40,6 +40,20 @@ def _hash(value):
40
40
  return hashlib.sha256(raw.encode("utf-8")).hexdigest()
41
41
 
42
42
 
43
+ def _side_effects(value):
44
+ """Normalize side-effect labels without persisting arbitrary tool output."""
45
+ if isinstance(value, str):
46
+ value = [value]
47
+ if not isinstance(value, (list, tuple, set)):
48
+ return []
49
+ out = []
50
+ for item in value:
51
+ label = str(item or "").strip()[:80]
52
+ if label and label not in out:
53
+ out.append(label)
54
+ return out[:20]
55
+
56
+
43
57
  def _append(row):
44
58
  _DIR.mkdir(parents=True, exist_ok=True)
45
59
  with _file().open("a", encoding="utf-8") as handle:
@@ -48,7 +62,8 @@ def _append(row):
48
62
 
49
63
 
50
64
  def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
51
- idempotency_key=""):
65
+ idempotency_key="", tool_call=None, conversation_ref="",
66
+ side_effects=None):
52
67
  """Record a step start without persisting prompt contents."""
53
68
  now = time.time()
54
69
  try:
@@ -61,6 +76,14 @@ def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
61
76
  "attempt": attempt, "idempotency_key": str(idempotency_key or "")[:160],
62
77
  "prompt_sha256": _hash(prompt), "input_sha256": _hash(input_data),
63
78
  "created_at": now}
79
+ if tool_call is not None:
80
+ row["tool_call_digest"] = _hash(tool_call)
81
+ if conversation_ref:
82
+ row["conversation_ref"] = str(conversation_ref)[:200]
83
+ normalized_effects = _side_effects(side_effects)
84
+ if normalized_effects:
85
+ row["side_effects"] = normalized_effects
86
+ row["repropose_required"] = bool(tool_call is not None or normalized_effects)
64
87
  with _LOCK:
65
88
  try:
66
89
  _append(row)
@@ -73,13 +96,24 @@ def finish(run_id, step, status, *, error="", output=None, metadata=None):
73
96
  status = str(status or "unknown").strip().lower()
74
97
  if status not in _TERMINAL:
75
98
  status = "unknown"
99
+ safe_metadata = {}
100
+ for key, value in (metadata or {}).items():
101
+ name = str(key)[:40]
102
+ if name == "side_effects":
103
+ safe_metadata[name] = _side_effects(value)
104
+ elif name == "repropose_required":
105
+ safe_metadata[name] = bool(value)
106
+ elif isinstance(value, (str, int, float, bool)):
107
+ safe_metadata[name] = value
76
108
  row = {"event_id": "cp-" + secrets.token_hex(10), "event": "finish",
77
109
  "run_id": str(run_id or "")[:80], "step": int(step),
78
110
  "status": status, "error": scrub_text(error, limit=500),
79
- "output_sha256": _hash(output), "metadata": {
80
- str(k)[:40]: v for k, v in (metadata or {}).items()
81
- if isinstance(v, (str, int, float, bool))
82
- }, "created_at": time.time()}
111
+ "output_sha256": _hash(output), "metadata": safe_metadata,
112
+ "created_at": time.time()}
113
+ if "side_effects" in safe_metadata:
114
+ row["side_effects"] = safe_metadata["side_effects"]
115
+ if "repropose_required" in safe_metadata:
116
+ row["repropose_required"] = safe_metadata["repropose_required"]
83
117
  with _LOCK:
84
118
  try:
85
119
  _append(row)
@@ -135,6 +169,15 @@ def replay_preview(run_id):
135
169
  if key in starts:
136
170
  item.setdefault("prompt_sha256", starts[key].get("prompt_sha256"))
137
171
  item.setdefault("attempt", starts[key].get("attempt", 1))
172
+ metadata = item.get("metadata") or {}
173
+ for field in ("tool_call_digest", "conversation_ref", "side_effects",
174
+ "repropose_required"):
175
+ if field not in item and field in metadata:
176
+ item[field] = metadata[field]
177
+ item.setdefault("side_effects", [])
178
+ item["repropose_required"] = bool(
179
+ item.get("repropose_required") or item.get("side_effects")
180
+ or item.get("tool_call_digest"))
138
181
  steps.append(item)
139
182
  unknown = next((x for x in steps if x.get("status") in ("unknown", "running")
140
183
  or x.get("event") != "finish"
package/app/core/jobs.py CHANGED
@@ -314,6 +314,13 @@ def cancel_restart_drain():
314
314
  _restart_drain = False
315
315
 
316
316
 
317
+ def begin_shutdown_drain():
318
+ """Stop accepting work before a confirmed desktop shutdown cancels runs."""
319
+ global _restart_drain
320
+ with _pool_lock:
321
+ _restart_drain = True
322
+
323
+
317
324
  def _close_unstarted(job, message, statuses=("queued",)):
318
325
  """无法启动时统一收口 run/task,供所有入口复用。"""
319
326
  try:
@@ -2374,6 +2374,7 @@ def catalog_view():
2374
2374
  "model": read_model(e),
2375
2375
  "orch_kind": (e.get("orch") or {}).get("kind"),
2376
2376
  "orch_enabled": orch_enabled,
2377
+ "capabilities": registry.capability_manifest(e),
2377
2378
  "update": update_info(e),
2378
2379
  "has_install": bool(e.get("install")),
2379
2380
  "has_upgrade": bool(e.get("upgrade")),
@@ -110,18 +110,29 @@ def _cache_file(source_id):
110
110
 
111
111
  # ---------------------------------------------------------------- 网络边界
112
112
 
113
+ def _reject_url_userinfo(url):
114
+ """拒绝 URL authority 中的凭据;错误不回显可能含令牌的原始 URL。"""
115
+ parsed = urlparse(url or "")
116
+ if parsed.username is not None or parsed.password is not None:
117
+ raise ValueError("URL 不允许携带用户信息")
118
+ return parsed
119
+
120
+
113
121
  def assert_public_url(url):
114
122
  """SSRF 防护:仅 https;host 解析出的所有 IP 都不得是环回/私有/保留地址。
115
123
  返回 (host, port),不合法直接抛 ValueError。"""
116
- u = urlparse(url or "")
124
+ u = _reject_url_userinfo(url)
117
125
  if u.scheme != "https":
118
- raise ValueError("仅允许 https 地址: %s" % url)
126
+ raise ValueError("仅允许 https 地址")
119
127
  host = u.hostname
120
128
  if not host:
121
- raise ValueError("URL 缺少主机名: %s" % url)
122
- if u.port is not None and not (0 < u.port < 65536):
123
- raise ValueError("端口非法: %s" % url)
124
- port = u.port or 443
129
+ raise ValueError("URL 缺少主机名")
130
+ try:
131
+ port = u.port or 443
132
+ except ValueError:
133
+ raise ValueError("端口非法") from None
134
+ if not (0 < port < 65536):
135
+ raise ValueError("端口非法")
125
136
  infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
126
137
  if not infos:
127
138
  raise ValueError("主机无法解析: %s" % host)
@@ -856,7 +867,7 @@ def _download_git(entry, tmp):
856
867
  text=True, encoding="utf-8", errors="replace",
857
868
  env=env)
858
869
  except subprocess.TimeoutExpired:
859
- raise ValueError("git 克隆超时: %s" % inst["url"])
870
+ raise ValueError("git 克隆超时")
860
871
  if proc.returncode == 0:
861
872
  break
862
873
  err = (proc.stderr or "").strip()[-200:]
@@ -875,6 +886,7 @@ def _download_git(entry, tmp):
875
886
  def _download_git_any(entry, tmp):
876
887
  """git 类来源安装:codeload tarball 优先(完整、最新)→ jsdelivr 逐文件
877
888
  兜底(codeload 不可达时)→ git 浅克隆收尾(非 github 托管 / 精确 ref)。"""
889
+ _reject_url_userinfo(entry["install"].get("url"))
878
890
  try:
879
891
  return _gh_tarball(entry, tmp)
880
892
  except ValueError as e:
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
  import json
20
20
  import hashlib
21
21
  import os
22
+ import queue
22
23
  import re
23
24
  import shutil
24
25
  import subprocess
@@ -28,6 +29,7 @@ import time
28
29
  from pathlib import Path
29
30
 
30
31
  _LOCK = threading.RLock()
32
+ _REFRESH_LOCK = threading.Lock()
31
33
  _TOOLS_CACHE = {} # server_key → (ts, tools)
32
34
  _TOOLS_TTL = 300.0
33
35
  _TOOLS_CONFIG_SHA = ""
@@ -35,6 +37,7 @@ _TOOLS_CONFIG_SHA = ""
35
37
  MAX_SERVERS = 4
36
38
  MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
37
39
  _NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
40
+ _MODEL_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
38
41
  _INIT_TIMEOUT = 15.0
39
42
  _CALL_TIMEOUT = 60.0
40
43
  _CALL_TIMEOUT_MAX = 300.0
@@ -64,6 +67,7 @@ def parse_servers(text):
64
67
  if len(arr) > MAX_SERVERS:
65
68
  return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
66
69
  out = []
70
+ names = set()
67
71
  for item in arr:
68
72
  if not isinstance(item, dict):
69
73
  return None, "MCP 服务器配置必须是对象"
@@ -71,6 +75,11 @@ def parse_servers(text):
71
75
  command = str(item.get("command") or "").strip()
72
76
  if not _NAME_RE.match(name):
73
77
  return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
78
+ if "__" in name:
79
+ return None, "服务器名 %s 不能包含工具分隔符 __" % name
80
+ if name in names:
81
+ return None, "MCP 服务器名 %s 重名" % name
82
+ names.add(name)
74
83
  if not command:
75
84
  return None, "服务器 %s 缺 command" % name
76
85
  args = item.get("args") if isinstance(item.get("args"), list) else []
@@ -152,12 +161,16 @@ def _bubblewrap_argv(server, sandbox, roots, cwd):
152
161
  class _Session:
153
162
  """一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
154
163
 
155
- def __init__(self, server, sandbox=None, workdir=None, timeout_s=None):
164
+ def __init__(self, server, sandbox=None, workdir=None, timeout_s=None,
165
+ cancel_event=None, deadline=None):
156
166
  self.server = server
157
167
  self.sandbox = sandbox
158
168
  self.workdir = workdir
159
169
  self.timeout_s = timeout_s
160
- self.deadline = None
170
+ self.deadline = deadline
171
+ self.task_deadline = deadline
172
+ self.cancel_event = cancel_event
173
+ self._nest = None
161
174
  self.proc = None
162
175
  self.lines = None # reader 线程产出的行队列
163
176
  self.err = None
@@ -190,7 +203,9 @@ class _Session:
190
203
 
191
204
  def __enter__(self):
192
205
  if self.timeout_s is not None:
193
- self.deadline = time.monotonic() + max(0.0, float(self.timeout_s))
206
+ call_deadline = time.monotonic() + max(0.0, float(self.timeout_s))
207
+ self.deadline = min(self.deadline, call_deadline) if self.deadline is not None else call_deadline
208
+ self._check_interrupted()
194
209
  try:
195
210
  argv, env, cwd = _sandbox_launch(self.server, self.sandbox, self.workdir)
196
211
  if self.deadline is not None and time.monotonic() >= self.deadline:
@@ -199,18 +214,22 @@ class _Session:
199
214
  argv,
200
215
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
201
216
  stderr=subprocess.PIPE, text=False,
202
- env=env, cwd=cwd, start_new_session=True)
217
+ env=env, cwd=cwd, start_new_session=(os.name != "nt"))
203
218
  except Exception as e:
204
219
  raise RuntimeError("MCP 服务器启动失败: %s" % e)
205
- import queue as _queue
206
- self.lines = _queue.Queue()
207
- self._reader_threads = [
208
- threading.Thread(target=self._pump_out, daemon=True),
209
- threading.Thread(target=self._pump_err, daemon=True),
210
- ]
211
- for thread in self._reader_threads:
212
- thread.start()
213
220
  try:
221
+ import queue as _queue
222
+ self.lines = _queue.Queue()
223
+ self._reader_threads = [
224
+ threading.Thread(target=self._pump_out, daemon=True),
225
+ threading.Thread(target=self._pump_err, daemon=True),
226
+ ]
227
+ for thread in self._reader_threads:
228
+ thread.start()
229
+ if os.name == "nt":
230
+ from . import beekeeper
231
+ self._nest = beekeeper.BeeNest()
232
+ self._nest.adopt(self.proc)
214
233
  init_timeout = _INIT_TIMEOUT
215
234
  if self.deadline is not None:
216
235
  init_timeout = min(init_timeout, self.deadline - time.monotonic())
@@ -220,57 +239,48 @@ class _Session:
220
239
  "protocolVersion": "2024-11-05", "capabilities": {},
221
240
  "clientInfo": _CLIENT_INFO}, timeout=init_timeout)
222
241
  self._notify("notifications/initialized")
223
- except Exception:
242
+ except BaseException:
224
243
  # Context manager __exit__ is not called when __enter__ raises.
225
244
  self.__exit__(None, None, None)
226
245
  raise
227
246
  return self
228
247
 
229
248
  def __exit__(self, *exc):
249
+ proc = self.proc
250
+ if proc is None:
251
+ return False
230
252
  try:
231
- if self.proc and self.proc.stdin:
253
+ if self._nest is not None:
254
+ self._nest.close()
255
+ except Exception:
256
+ pass
257
+ if os.name != "nt" or proc.poll() is None:
258
+ try:
259
+ from . import runner
260
+ runner._kill_tree(proc.pid)
261
+ except Exception:
232
262
  try:
233
- self.proc.stdin.close()
263
+ proc.kill()
234
264
  except Exception:
235
265
  pass
266
+ try:
267
+ proc.wait(timeout=2)
268
+ except Exception:
269
+ pass
270
+ if proc.stdin is not None:
236
271
  try:
237
- if self.proc and self.proc.poll() is None:
238
- # bwrap runs in its own process group; terminate descendants
239
- # as well as the launcher so timed-out MCP servers cannot
240
- # leave background children holding pipes or doing work.
241
- try:
242
- from . import runner
243
- runner._kill_tree(self.proc.pid)
244
- except Exception:
245
- pass
246
- self.proc.kill()
247
- except Exception:
248
- pass
249
- try:
250
- if self.proc:
251
- self.proc.wait(timeout=1)
272
+ proc.stdin.close()
252
273
  except Exception:
253
274
  pass
254
- finally:
255
- # Close all parent-side pipe handles even if initialization failed.
256
- # Do not close a stream while its reader is blocked in read(): the
257
- # buffered stream lock can make close wait forever if an escaped
258
- # descendant still holds the pipe open. Let daemon readers finish
259
- # on EOF and close only streams no thread currently owns.
260
- for thread in self._reader_threads:
275
+ for thread, pipe in zip(self._reader_threads, (proc.stdout, proc.stderr)):
276
+ if thread is not None and thread.is_alive():
277
+ thread.join(timeout=0.5)
278
+ # 读线程持有管道锁时主线程 close 可能卡到孙进程自然退出。
279
+ if pipe is not None and (thread is None or not thread.is_alive()):
261
280
  try:
262
- thread.join(timeout=0.2)
281
+ pipe.close()
263
282
  except Exception:
264
283
  pass
265
- for name in ("stdout", "stderr"):
266
- pipe = getattr(self.proc, name, None) if self.proc else None
267
- readers_alive = any(
268
- thread.is_alive() for thread in self._reader_threads)
269
- if pipe and not readers_alive:
270
- try:
271
- pipe.close()
272
- except Exception:
273
- pass
274
284
  return False
275
285
 
276
286
  def _send(self, obj):
@@ -281,8 +291,15 @@ class _Session:
281
291
  def _notify(self, method, params=None):
282
292
  self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
283
293
 
294
+ def _check_interrupted(self):
295
+ if self.cancel_event is not None and self.cancel_event.is_set():
296
+ raise RuntimeError("任务已取消")
297
+ if self.task_deadline is not None and time.monotonic() >= self.task_deadline:
298
+ raise RuntimeError("任务总时限已到")
299
+
284
300
  def _request(self, method, params, timeout):
285
301
  """单请求单响应;忽略通知与未知 id 的服务端请求。"""
302
+ self._check_interrupted()
286
303
  duration = max(0.0, float(timeout))
287
304
  if self.deadline is not None:
288
305
  duration = min(duration, self.deadline - time.monotonic())
@@ -293,13 +310,15 @@ class _Session:
293
310
  "method": method, "params": params or {}})
294
311
  deadline = time.monotonic() + duration
295
312
  while True:
313
+ self._check_interrupted()
296
314
  remain = deadline - time.monotonic()
297
315
  if remain <= 0:
298
316
  raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
299
317
  try:
300
- line = self.lines.get(timeout=remain)
301
- except Exception:
302
- raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
318
+ wait = min(remain, 0.1) if self.cancel_event is not None else remain
319
+ line = self.lines.get(timeout=wait)
320
+ except queue.Empty:
321
+ continue
303
322
  if line is None:
304
323
  raise RuntimeError("MCP 服务器提前退出")
305
324
  line = line.strip()
@@ -327,10 +346,11 @@ def list_tools(server, *, sandbox=None, workdir=None):
327
346
  tools = []
328
347
  for t in (res.get("tools") or [])[:16]:
329
348
  if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
349
+ schema = t.get("inputSchema", t.get("input_schema"))
330
350
  tools.append({"name": t["name"],
331
351
  "description": str(t.get("description") or "")[:400],
332
- "input_schema": t.get("input_schema")
333
- if isinstance(t.get("input_schema"), dict) else
352
+ "input_schema": schema
353
+ if isinstance(schema, dict) else
334
354
  {"type": "object", "properties": {}}})
335
355
  return {"ok": True, "tools": tools, "error": ""}
336
356
  except Exception as e:
@@ -338,12 +358,13 @@ def list_tools(server, *, sandbox=None, workdir=None):
338
358
 
339
359
 
340
360
  def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
341
- *, sandbox=None, workdir=None):
361
+ *, sandbox=None, workdir=None, cancel_event=None, deadline=None):
342
362
  """调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
343
363
  try:
344
364
  timeout = min(_CALL_TIMEOUT_MAX, max(0.001, float(timeout_s or _CALL_TIMEOUT)))
345
365
  with _Session(server, sandbox=sandbox, workdir=workdir,
346
- timeout_s=timeout) as sess:
366
+ timeout_s=timeout, cancel_event=cancel_event,
367
+ deadline=deadline) as sess:
347
368
  res = sess._request("tools/call",
348
369
  {"name": tool_name,
349
370
  "arguments": arguments if isinstance(arguments, dict) else {}},
@@ -364,6 +385,12 @@ def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
364
385
  # ---- 面向 builtin_agent 的合并视图(带缓存) ----
365
386
 
366
387
  def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
388
+ """Serialize discoveries so callers cannot observe incomplete refreshes."""
389
+ with _REFRESH_LOCK:
390
+ return _tool_specs_cached(force=force, sandbox=sandbox, workdir=workdir)
391
+
392
+
393
+ def _tool_specs_cached(force=False, *, sandbox=None, workdir=None):
367
394
  """全部服务器的工具合并清单:[{server, name(原名), full_name, description,
368
395
  input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
369
396
  global _TOOLS_CONFIG_SHA
@@ -398,8 +425,8 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
398
425
  disabled = set(policy.normalize_disabled_tools(
399
426
  (sandbox or {}).get("disabled_tools")))
400
427
  out = []
401
- with _LOCK:
402
- _TOOLS_CACHE.clear()
428
+ discovered = {}
429
+ seen_names = set()
403
430
  for srv in servers:
404
431
  res = list_tools(srv, sandbox=sandbox, workdir=workdir)
405
432
  if not res.get("ok"):
@@ -410,25 +437,36 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
410
437
  if (t["name"] in srv.get("disabled_tools", [])
411
438
  or t["name"] in disabled or full_name in disabled):
412
439
  continue
440
+ if not _MODEL_TOOL_NAME_RE.fullmatch(full_name) or full_name in seen_names:
441
+ continue
442
+ seen_names.add(full_name)
413
443
  specs.append({"server": srv["name"], "name": t["name"],
414
444
  "full_name": full_name,
415
445
  "description": t["description"],
416
446
  "input_schema": t["input_schema"]})
417
- with _LOCK:
418
- _TOOLS_CACHE[(scope_sha, srv["name"])] = (time.time(), specs)
447
+ discovered[(scope_sha, srv["name"])] = (time.time(), specs)
419
448
  out.extend(specs)
420
449
  if len(out) >= MAX_MCP_TOOLS:
421
450
  break
451
+ with _LOCK:
452
+ if config_sha != _TOOLS_CONFIG_SHA:
453
+ return []
454
+ for key in list(_TOOLS_CACHE):
455
+ if isinstance(key, tuple) and key[0] == scope_sha:
456
+ del _TOOLS_CACHE[key]
457
+ _TOOLS_CACHE.update(discovered)
422
458
  return out[:MAX_MCP_TOOLS]
423
459
 
424
460
 
425
461
  def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
426
- sandbox=None, workdir=None, disabled_tools=None):
462
+ sandbox=None, workdir=None, disabled_tools=None,
463
+ cancel_event=None, deadline=None):
427
464
  """mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
428
- m = re.match(r"^mcp__([a-z0-9_-]{1,24})__(.+)$", str(full_name or ""))
429
- if not m:
465
+ full_name = str(full_name or "")
466
+ sname, sep, tool = full_name[5:].partition("__") if full_name.startswith("mcp__") \
467
+ else ("", "", "")
468
+ if not sep or not _NAME_RE.fullmatch(sname) or "__" in sname or not tool:
430
469
  return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
431
- sname, tool = m.group(1), m.group(2)
432
470
  servers, err = parse_servers(_settings_text())
433
471
  if err:
434
472
  return {"ok": False, "text": "", "error": err}
@@ -443,7 +481,8 @@ def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
443
481
  if sandbox is None or not workdir:
444
482
  return {"ok": False, "text": "", "error": "MCP 调用缺少任务沙箱策略或工作目录,拒绝启动"}
445
483
  return call_tool(srv, tool, arguments, timeout_s=timeout_s,
446
- sandbox=sandbox, workdir=workdir)
484
+ sandbox=sandbox, workdir=workdir,
485
+ cancel_event=cancel_event, deadline=deadline)
447
486
 
448
487
 
449
488
  _SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块