codebee 0.1.105 → 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,15 @@ 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
+
7
16
  ## v0.1.105(2026-10-12)
8
17
 
9
18
  - **claude CLI 输出上限 16000→32000**:全局评审读 40 万字级书稿(约 28 万 token 输入)时按旧值反复续烧 23 分钟后报「response exceeded the 16000 output token maximum」三连挂(实案:单次 $3 零产出);32000=claude CLI 官方默认档,`setdefault` 语义保留——用户显式配置不覆盖
package/README.md CHANGED
@@ -25,12 +25,12 @@ Trae Agent、OpenClaw……),提供
25
25
  ---
26
26
 
27
27
  <!-- relnotes:start -->
28
- ### 最新版更新内容(v0.1.105)
28
+ ### 最新版更新内容(v0.1.106)
29
29
 
30
- - 修复 40 万字级书稿全局评审被 claude 输出上限 16000 顶爆三连挂:上限提到 32000(用户显式配置不覆盖)
31
- - 新增治理契约:外部人工评审请求通道(append-only 台账+幂等流转)+ 能力清单/记忆注入预览接口
32
- - 蜂巢 3D 蜜蜂翅膀精修:更纤薄透明、脉络清晰、手部姿态自然
33
- - 项目记忆注入预算封顶 20000 防挤占
30
+ - 发布台章节批量发布升级:待发自动对账、按卷自动分卷、可直发状态徽章与一键筛选
31
+ - Windows/macOS 桌面版预览打包(CI 预览安装包)
32
+ - 输入区 Codex 风格重排 + 移动端对话控制增强
33
+ - 自由 3D 入口暂对用户隐藏(未达发布质量,保留内部 QA)
34
34
  <!-- relnotes:end -->
35
35
 
36
36
 
@@ -331,8 +331,11 @@ cloudflared tunnel login
331
331
  cloudflared tunnel create codebee
332
332
  cloudflared tunnel route dns --overwrite-dns <隧道UUID> codebee.你的域名.com
333
333
 
334
- :: 之后日常启动(服务 + 隧道一键起):
335
- start-public.bat
334
+ :: 日常启动服务(本机后台窗口):
335
+ start-service.bat
336
+
337
+ :: 启动隧道(另开一个窗口;也可 cloudflared service install 注册为开机自启):
338
+ cloudflared tunnel --config "%USERPROFILE%\.cloudflared\config.yml" run
336
339
  ```
337
340
 
338
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:
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:
@@ -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)——本模块
@@ -807,6 +807,18 @@ def _cost_gate_block():
807
807
  return None
808
808
 
809
809
 
810
+ def _run_sandbox(run_id):
811
+ """Return a run's persisted sandbox policy, or the unrestricted default hint.
812
+
813
+ Low-level callers such as budget-gate tests may use a run id before creating
814
+ a task. The runner still enforces any task policy when one is present.
815
+ """
816
+ run = store.get_run(run_id) or {}
817
+ task_id = run.get("task_id")
818
+ task = store.get_task(task_id) if task_id else None
819
+ return (task or {}).get("sandbox") or {}
820
+
821
+
810
822
  def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
811
823
  timeout, resume, step, log_abs, images=None, require_tools=False,
812
824
  deadline=None):
@@ -846,9 +858,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
846
858
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
847
859
  images=images, require_tools=require_tools, deadline=deadline,
848
860
  command_context={"run_id": session_run_id,
849
- "sandbox": (store.get_task(
850
- (store.get_run(session_run_id) or {}).get("task_id")
851
- ) or {}).get("sandbox") or {},
861
+ "sandbox": _run_sandbox(session_run_id),
852
862
  "allow_terminal": not readonly},
853
863
  checkpoint_run_id=session_run_id)
854
864
 
@@ -880,9 +890,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
880
890
  resume=resume, images=images, require_tools=require_tools,
881
891
  deadline=deadline,
882
892
  command_context={"run_id": session_run_id,
883
- "sandbox": (store.get_task(
884
- (store.get_run(session_run_id) or {}).get("task_id")
885
- ) or {}).get("sandbox") or {},
893
+ "sandbox": _run_sandbox(session_run_id),
886
894
  "allow_terminal": not readonly},
887
895
  checkpoint_run_id=session_run_id)
888
896
  # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
@@ -215,6 +215,67 @@ def pending(task_id, platform):
215
215
  return out, ""
216
216
 
217
217
 
218
+ def chapter_review_states(task, items, quality, hard_ok, hard_reason="", direct_ok=True):
219
+ """逐章最新评审证据与实际发布门禁同时展示,不把单章通过当作放行。"""
220
+ from .. import store, quality_gate
221
+ try:
222
+ reviewed_through = int(quality.get("reviewed_through") or 0)
223
+ except (ValueError, TypeError):
224
+ reviewed_through = 0
225
+ runs = []
226
+ for tid in store.serial_chain_ids(task.get("id")):
227
+ runs.extend(store.task_runs(tid) or [])
228
+ evidence = {}
229
+ for run in sorted(runs, key=lambda r: str(r.get("id") or ""), reverse=True):
230
+ verdict = run.get("verdict") if run.get("status") == "done" else None
231
+ if not isinstance(verdict, dict):
232
+ continue
233
+ for score in verdict.get("chapter_scores") or []:
234
+ if not isinstance(score, dict):
235
+ continue
236
+ try:
237
+ n = int(score.get("chapter") or 0)
238
+ except (ValueError, TypeError):
239
+ continue
240
+ if n > 0 and n not in evidence:
241
+ evidence[n] = (score, run)
242
+ for item in items:
243
+ score, run = evidence.get(item["chapter_no"], ({}, {}))
244
+ state, reason = "pending", "尚无有效单章评审"
245
+ try:
246
+ valid = (score.get("passed") is True
247
+ and int(score.get("reviewer_count") or 0) >= quality_gate.MIN_REVIEWERS
248
+ and score.get("reviewer_consensus") is True)
249
+ except (ValueError, TypeError):
250
+ valid = False
251
+ if score.get("passed") is False:
252
+ state, reason = "failed", "单章质量评审未通过"
253
+ elif score and not valid:
254
+ reason = "有效评审人数不足或未形成共识"
255
+ elif valid:
256
+ state, reason = "passed_blocked", "单章已过审,发布门禁未过"
257
+ if not hard_ok:
258
+ reason = hard_reason
259
+ elif not quality.get("allowed"):
260
+ reason = ";".join(quality.get("blockers") or []) or reason
261
+ elif item["chapter_no"] > reviewed_through:
262
+ reason = "目标章节超出当前发布评审版本"
263
+ elif not direct_ok:
264
+ reason = "平台发布流程尚未校准,可人工确认发布,暂不可自动直发"
265
+ else:
266
+ state, reason = "ready", "单章及发布门禁均已通过"
267
+ try:
268
+ reviewed_at = time.mktime(time.strptime(run.get("ended_at") or "", "%Y-%m-%d %H:%M:%S"))
269
+ if os.path.getmtime(os.path.join(task.get("workdir") or "", item["file"])) > reviewed_at + 1:
270
+ state, reason = "stale", "正文在评审后修改,需要重新评审"
271
+ except (OSError, ValueError, TypeError):
272
+ pass
273
+ item.update(review_status=state, review_reason=reason,
274
+ review_passed=valid and state != "stale",
275
+ publish_ready=state == "ready", review_run_id=run.get("id") or "")
276
+ return items
277
+
278
+
218
279
  def status(task_id):
219
280
  """前端视图:待发清单 + 护栏状态 + 自动发布进度。
220
281
 
@@ -233,6 +294,7 @@ def status(task_id):
233
294
  if not _book_ready(info):
234
295
  hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
235
296
  blockers = []
297
+ quality = {"allowed": False, "blockers": []}
236
298
  if task and _book_ready(info):
237
299
  quality = manager._quality_release_guard(task, plat, action="publish")
238
300
  if not quality.get("allowed"):
@@ -240,15 +302,24 @@ def status(task_id):
240
302
  ok, why = hard_ok, hard_why
241
303
  if ok and blockers:
242
304
  ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
305
+ direct_ok = calibrated(plat)
306
+ chapter_review_states(task or {}, pend, quality, hard_ok, hard_why, direct_ok)
307
+ published = ledger.published_chapters(task_id, plat)
308
+ drafted = ledger.drafted_chapters(task_id, plat) - published
243
309
  books.append({"platform": plat, "bound": True,
244
310
  "title": info.get("title") or "",
245
311
  "pending": len(pend),
246
- "items": pend[:200],
312
+ "items": pend,
313
+ "published_latest": max(published, default=0),
314
+ "next_chapter": pend[0]["chapter_no"] if pend else None,
315
+ "ready_count": sum(p["publish_ready"] for p in pend),
316
+ "review_passed_count": sum(p["review_passed"] for p in pend),
317
+ "drafted_nos": sorted(drafted),
247
318
  "guard_ok": ok, "guard_reason": why,
248
319
  "draft_ok": hard_ok, "draft_reason": hard_why,
249
320
  "quality_blockers": blockers,
250
- "drafted": len(ledger.drafted_chapters(task_id, plat)),
251
- "calibrated": calibrated(plat)})
321
+ "drafted": len(drafted),
322
+ "calibrated": direct_ok})
252
323
  run = _running.get(task_id) or None
253
324
  if run:
254
325
  run = dict(run)
@@ -102,6 +102,11 @@ def published_chapters(task_id, platform):
102
102
  out.add(int(n))
103
103
  try:
104
104
  ent = book_for(task_id, platform) or {}
105
+ # 用户确认的连续发布进度独立于平台数行结果;跨卷校准漏行不能
106
+ # 让已发布的旧章重新进入待发。它是章号边界,不是平台章节数量。
107
+ through = int(ent.get("confirmed_published_through") or 0)
108
+ if 0 < through <= 100000:
109
+ out.update(range(1, through + 1))
105
110
  for n in (ent.get("remote_published_nos") or []):
106
111
  n = int(n)
107
112
  if n > 0: