codebee 0.1.101 → 0.1.102

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.
@@ -24,6 +24,7 @@ import time
24
24
  PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
25
25
  IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
26
26
  IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
27
+ TRANSIENT_RETRY_S = 5 # 连接类瞬断的重试前静默(等浏览器缓过来)
27
28
 
28
29
  _CAP_DEFAULT = 10
29
30
  _STREAK_DEFAULT = 3
@@ -95,6 +96,36 @@ def _fail_window_text():
95
96
  return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
96
97
 
97
98
 
99
+ # 连接类瞬断标记(2026-10-10 存草稿实案:45 章 fill 时「连接被关闭」整链
100
+ # 停摆)——命中即对该章重试一次再弃,不因浏览器抖动废掉整批
101
+ _TRANSIENT_MARKS = ("连接断开", "连接被关闭", "10053", "10054", "等响应超时",
102
+ "已中止", "目标已关闭")
103
+ # 幂等拒起标记(manager 层台账/实况对账的拒绝话术)——命中即跳过该章,
104
+ # 不是失败
105
+ _IDEMPOTENT_MARKS = ("已成功发布过", "已存过草稿")
106
+
107
+
108
+ def _is_transient(err):
109
+ e = str(err or "")
110
+ return any(m in e for m in _TRANSIENT_MARKS)
111
+
112
+
113
+ def _is_idempotent_reject(err):
114
+ e = str(err or "")
115
+ return any(m in e for m in _IDEMPOTENT_MARKS)
116
+
117
+
118
+ def other_batch_running(task_id):
119
+ """除本任务外是否还有别的自动发布/存草稿批次在跑。命中返回对方
120
+ task_id,否则空串——草稿链与之互斥(共用平台浏览器会互踩页签,
121
+ 2026-10-10 实案:连载发章占着浏览器,草稿流 fill 被掐断连接)。"""
122
+ with _LOCK:
123
+ for tid, ent in _running.items():
124
+ if tid != str(task_id) and ent.get("status") == "running":
125
+ return str(tid)
126
+ return ""
127
+
128
+
98
129
  # ---------------------------------------------------------------- 待发枚举
99
130
  # 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
100
131
  # vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
@@ -282,7 +313,10 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
282
313
  as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
283
314
  不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
284
315
  (章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
285
- 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。"""
316
+ 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
317
+ 草稿链三条专属护栏(2026-10-10 假成功案):起跑前先 attach-only 对账
318
+ 平台实况并重算待发(已发布/已存稿剔除,对不了账就拒起);跑批期间
319
+ 检测到另一批自动发布即让位停止(平台浏览器互斥)。"""
286
320
  from .. import store
287
321
  from . import ledger, manager
288
322
  if platform not in manager.PLATFORMS:
@@ -301,6 +335,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
301
335
  cur = _running.get(task_id) or {}
302
336
  if cur.get("status") == "running":
303
337
  return False, "该任务已有自动发布进行中,请等本轮结束"
338
+ if as_draft:
339
+ other = other_batch_running(task_id)
340
+ if other:
341
+ return False, ("检测到另一批自动发布/存草稿进行中(任务 %s)——两边"
342
+ "共用平台浏览器会互踩,请等它跑完再存草稿" % other)
304
343
  task = store.get_task(task_id)
305
344
  if not task:
306
345
  return False, "任务不存在"
@@ -329,6 +368,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
329
368
  pend, err = pending(task_id, platform)
330
369
  if err:
331
370
  return False, err
371
+ want = None
332
372
  if only is not None:
333
373
  try:
334
374
  want = {int(n) for n in (only or []) if n}
@@ -346,13 +386,49 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
346
386
  st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
347
387
  "done": 0, "total": len(pend), "status": "running", "error": "",
348
388
  "auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
349
- "last_chapter": 0}
389
+ "last_chapter": 0, "skipped": 0, "notes": []}
350
390
  with _LOCK:
351
391
  _running[task_id] = st
352
392
 
353
393
  def run():
354
394
  try:
355
- for item in pend:
395
+ items = pend
396
+ if as_draft:
397
+ # 起草前对账(2026-10-10 假成功案):台账只记得自己发的章,
398
+ # 手工补交的只有平台知道——attach-only 刷一次校准再重算待发,
399
+ # 已发布/已存稿章就地剔除,不去撞序号重复。对不了账(浏览器
400
+ # 未连接/忙)整个拒起:存草稿本来就需要平台浏览器,宁等空窗
401
+ # 不盲跑;平台没配章节计数时跳过对账维持旧路径。
402
+ ok_r, why_r = manager.sync_published(task_id, platform,
403
+ manual=False)
404
+ if not ok_r and "未配置章节计数" not in str(why_r or ""):
405
+ st["status"] = "error"
406
+ st["error"] = ("存草稿前对账失败:%s。为防把已发布章重复存稿,"
407
+ "本次未起草;请连接平台浏览器并等它空闲后重试"
408
+ % why_r)
409
+ return
410
+ fresh, _ = pending(task_id, platform)
411
+ if want is not None:
412
+ fresh = [p for p in fresh if p["chapter_no"] in want]
413
+ if not fresh:
414
+ st["status"] = "done"
415
+ st["message"] = ("对账后没有可存草稿的章节(所选均已发布或"
416
+ "已存稿);确需重存请先在平台删旧稿")
417
+ return
418
+ if len(fresh) != st["total"]:
419
+ st["notes"].append("对账剔除 %d 章已发布/已存稿,实存 %d 章"
420
+ % (st["total"] - len(fresh), len(fresh)))
421
+ st["total"] = len(fresh)
422
+ items = fresh
423
+ for item in items:
424
+ if as_draft:
425
+ other = other_batch_running(task_id)
426
+ if other:
427
+ st["status"] = "error"
428
+ st["error"] = ("检测到另一批自动发布已启动(任务 %s),"
429
+ "存草稿让位停止;已存 %d 章落平台草稿箱,"
430
+ "等对方跑完再续" % (other, st["done"]))
431
+ return
356
432
  g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
357
433
  if not g_ok:
358
434
  st["status"] = "error"
@@ -366,6 +442,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
366
442
  ok2, err2 = manager.upload_chapter_async(
367
443
  task_id, platform, str(Path(wd) / item["file"]),
368
444
  **upload_kwargs)
445
+ if not ok2 and as_draft and _is_idempotent_reject(err2):
446
+ # 幂等拒起=这章已发布/已存过稿:跳过,不是失败——
447
+ # 整链不该因「没活干」停摆
448
+ st["skipped"] += 1
449
+ st["notes"].append("第 %d 章跳过:%s"
450
+ % (item["chapter_no"], str(err2)[:60]))
451
+ continue
452
+ if not ok2 and as_draft and _is_transient(err2):
453
+ # 连接类瞬断(连载流互踩/浏览器抖动):重试一次再弃
454
+ st["notes"].append("第 %d 章连接中断,%d 秒后重试一次"
455
+ % (item["chapter_no"],
456
+ TRANSIENT_RETRY_S))
457
+ time.sleep(TRANSIENT_RETRY_S)
458
+ ok2, err2 = manager.upload_chapter_async(
459
+ task_id, platform, str(Path(wd) / item["file"]),
460
+ **upload_kwargs)
369
461
  if not ok2:
370
462
  st["status"] = "error"
371
463
  st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
@@ -387,12 +479,15 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
387
479
  if item["chapter_no"] not in ledger.drafted_chapters(
388
480
  task_id, platform):
389
481
  st["status"] = "error"
390
- st["error"] = ("第 %d 章存草稿未确认,后续章节未存(详见"
391
- "发布台账与截图存证)" % item["chapter_no"])
482
+ st["error"] = ("第 %d 章存草稿未确认(台账未落草稿账),"
483
+ "后续章节未存;截图存证见发布台账,若实际"
484
+ "已保存成功,把页面反馈文案照截图校准进"
485
+ "流程表 expect_text 步骤"
486
+ % item["chapter_no"])
392
487
  return
393
488
  st["done"] += 1
394
489
  st["last_chapter"] = item["chapter_no"]
395
- if st["done"] < st["total"]:
490
+ if st["done"] + st["skipped"] < st["total"]:
396
491
  time.sleep(PACE_S)
397
492
  continue
398
493
  if not auto_submit:
@@ -416,9 +511,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
416
511
  time.sleep(PACE_S)
417
512
  st["status"] = "done"
418
513
  if as_draft:
419
- st["message"] = ("已存草稿 %d 章(未发布,不受质量闸拦不代表可直发);"
420
- "请到平台章节管理/草稿箱逐章检查后提交发布"
421
- % st["done"])
514
+ st["message"] = ("已存草稿 %d 章%s(未发布,不受质量闸拦不代表可"
515
+ "直发);请到平台章节管理/草稿箱逐章检查后提交"
516
+ "发布"
517
+ % (st["done"], ("、跳过 %d 章" % st["skipped"])
518
+ if st["skipped"] else ""))
422
519
  elif not auto_submit:
423
520
  st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
424
521
  except Exception as e: # 线程内绝不能悬挂无终态
@@ -110,12 +110,17 @@ UPLOAD_CHAPTER = [
110
110
  "optional": True},
111
111
  ]
112
112
 
113
- # 发章存草稿(2026-10-09 全部发草稿):与 UPLOAD_CHAPTER 同一张填表前段,
114
- # 终点不同——点编辑器头部「存草稿」直接落草稿箱,不走「下一步→内容检测→
115
- # 发布提示→提交」发布链。草稿不上线,批量连跑无需人工确认;提交发布由
116
- # 用户到章节管理/草稿箱手工完成。verify 沿用章节管理页断言(草稿行同样
117
- # 出现在列表里,标题/章号标记可命中);若平台改版把草稿挪去独立草稿箱页,
118
- # 首轮失败的 step 截图会指出来,照 flows-fanqie.json 校准即可。
113
+ # 发章存草稿(2026-10-09 全部发草稿;2026-10-10 假成功案重构):
114
+ # 与 UPLOAD_CHAPTER 同一张填表前段,终点不同——点编辑器头部「存草稿」直接
115
+ # 落草稿箱,不走「下一步→内容检测→发布提示→提交」发布链。三点教训入表:
116
+ # 1) submit 带 always=true:草稿流程跑在 auto_submit=false 语义下,而这里的
117
+ # 「存草稿」点击本身就是保存动作——被人工闸吞掉时 manager 把「填好未存」
118
+ # 记成成功(44 章假成功实案)。
119
+ # 2) expect_text 等编辑器页保存反馈为主凭据(标记文案以真机校准为准,失败
120
+ # 自动落 fail 截图);「已保存」这类自动保存字样刻意不入标记——编辑器
121
+ # 自动保存不可依赖,不能当本次点击的证据。
122
+ # 3) verify 仍走章节管理页兜底(新章草稿行同样在列表里);对「已发布章重复
123
+ # 起草」它必然假通过——该情形由起草前对账(auto 层)剔除,不靠本表。
119
124
  UPLOAD_CHAPTER_DRAFT = [
120
125
  {"do": "navigate", "url": "{editor_url}"},
121
126
  {"do": "url_any", "any": ["fanqienovel.com"]},
@@ -131,10 +136,11 @@ UPLOAD_CHAPTER_DRAFT = [
131
136
  {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
132
137
  {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
133
138
  {"do": "shot", "name": "chapter-filled-draft"},
134
- {"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
135
- {"do": "js_click", "text": "存草稿", "scope": "button", "tries": 10,
136
- "optional": True, "skip_if_modal": True},
137
- {"do": "sleep", "s": 2.5},
139
+ {"do": "submit", "text": "存草稿", "scope": "button", "tries": 15, "always": True},
140
+ {"do": "sleep", "s": 1.2},
141
+ {"do": "shot", "name": "chapter-draft-clicked"},
142
+ {"do": "expect_text",
143
+ "any": ["保存成功", "存草稿成功", "草稿已保存", "已存草稿"], "timeout": 12},
138
144
  {"do": "verify", "url": "{chapter_manage_url}",
139
145
  "any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
140
146
  ]
@@ -558,7 +558,10 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
558
558
  if log:
559
559
  log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
560
560
  elif act == "submit":
561
- if not auto_submit:
561
+ # always=true 的提交步(草稿流的「存草稿」)本身就是保存动作,
562
+ # 不吃 auto_submit 人工闸——被闸吞掉时流程停在「填好未存」,
563
+ # manager 却照记成功(2026-10-10 44 章假成功实案)
564
+ if not auto_submit and not st.get("always"):
562
565
  note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
563
566
  if shot:
564
567
  shot("ready-manual-submit")
@@ -686,6 +689,31 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
686
689
  finally:
687
690
  if new_tab:
688
691
  vpage.close_tab()
692
+ elif act == "expect_text":
693
+ # 页面反馈等待(草稿保存凭据,2026-10-10):轮询页面文本直到
694
+ # 出现任一成功标记(toast/状态字样)——verify 的章节管理页对
695
+ # 「已发布章重复起草」必然假通过,编辑器保存反馈才是本次动作
696
+ # 自己的证据。超时即失败:宁可带 fail 截图拦下,不可把「没
697
+ # 存上」记成成功。标记文案以真机校准为准。
698
+ marks = [str(m) for m in (st.get("any") or [])]
699
+ if not marks:
700
+ continue
701
+ deadline = time.time() + float(st.get("timeout") or 12)
702
+ hit = ""
703
+ while time.time() < deadline:
704
+ body_txt = str(page.call(
705
+ "()=>(document.body.innerText||'')", timeout=15) or "")
706
+ hit = next((m for m in marks if m in body_txt), "")
707
+ if hit:
708
+ break
709
+ time.sleep(0.8)
710
+ if not hit:
711
+ raise FlowError("未见保存成功反馈(等了 %ds,标记:%s)——"
712
+ "若截图里实际已保存成功,请把真实反馈文案"
713
+ "校准进流程表"
714
+ % (int(float(st.get("timeout") or 12)),
715
+ "、".join(marks)[:60]))
716
+ note(i, "反馈命中「%s」" % hit)
689
717
  elif act == "volume":
690
718
  # 发章带卷(2026-10-08 批量自动发布·自动分卷):确保目标卷
691
719
  # 在平台上存在(缺则走「新建分卷」)。平台新章按卷自动归类、
@@ -897,6 +897,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
897
897
  book_ref = dict(book)
898
898
  if _st(plat).get("status") == "busy":
899
899
  return False, "该平台有操作正在进行中"
900
+ if as_draft:
901
+ # 草稿与别的自动发布批次互斥(2026-10-10 实案:连载发章占着浏览器,
902
+ # 草稿流 fill 被掐断连接)——单章草稿同样不让起
903
+ from . import auto as _auto
904
+ other = _auto.other_batch_running(task_id)
905
+ if other:
906
+ return False, ("检测到另一批自动发布进行中(任务 %s),平台浏览器会"
907
+ "被互踩;等它跑完再存草稿" % other)
900
908
  ch_no, title, body, err = read_chapter(chapter_file)
901
909
  if err:
902
910
  return False, err
@@ -0,0 +1,95 @@
1
+ """Optional process sandbox launchers.
2
+
3
+ The default CodeBee install has no container dependency. Docker is an explicit
4
+ opt-in backend selected by a task policy and a configured image; this module
5
+ only builds argv and never silently falls back to a weaker backend.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+
15
+ class SandboxUnavailable(RuntimeError):
16
+ pass
17
+
18
+
19
+ def requested_backend(sandbox):
20
+ value = str((sandbox or {}).get("backend") or "native").strip().lower()
21
+ return value or "native"
22
+
23
+
24
+ def docker_argv(command, *, sandbox, workdir, image=None, extra_args=(),
25
+ container_workdir=None,
26
+ container_command=("/bin/sh", "-lc")):
27
+ """Build a restricted ``docker run`` argv for a command or stdio server."""
28
+ if requested_backend(sandbox) != "docker":
29
+ raise SandboxUnavailable("Docker 后端未被任务策略启用")
30
+ docker = shutil.which("docker")
31
+ if not docker:
32
+ raise SandboxUnavailable("当前系统没有可用的 Docker CLI")
33
+ root = Path(workdir or ".").expanduser().resolve(strict=True)
34
+ image = str(image or (sandbox or {}).get("docker_image") or "").strip()
35
+ if not image or len(image) > 240 or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
36
+ raise SandboxUnavailable("Docker 后端必须配置可信镜像")
37
+ roots = []
38
+ for value in (sandbox or {}).get("allowed_roots") or [str(root)]:
39
+ candidate = Path(value).expanduser().resolve(strict=True)
40
+ # Docker currently mounts exactly one root. Require the task workspace
41
+ # itself; narrower roots need a separately resolved container cwd and
42
+ # must never accidentally broaden into an ancestor mount.
43
+ if candidate != root:
44
+ raise SandboxUnavailable("Docker 目前只支持挂载任务工作目录")
45
+ if candidate not in roots:
46
+ roots.append(candidate)
47
+ if not roots:
48
+ raise SandboxUnavailable("Docker 后端没有允许目录")
49
+ selected_root = next((candidate for candidate in roots
50
+ if candidate == root or candidate in root.parents
51
+ or root in candidate.parents), None)
52
+ if selected_root is None:
53
+ raise SandboxUnavailable("当前工作目录不在 Docker 允许目录内")
54
+ if len(roots) != 1:
55
+ raise SandboxUnavailable("Docker 暂只支持一个明确的允许目录")
56
+ argv = [docker, "run", "--rm", "--init", "--read-only",
57
+ "--security-opt", "no-new-privileges", "--cap-drop=ALL",
58
+ "--ipc=none", "--pids-limit", "256", "--memory", "2g", "--cpus", "2",
59
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"]
60
+ if hasattr(os, "getuid") and hasattr(os, "getgid"):
61
+ argv += ["--user", "%d:%d" % (os.getuid(), os.getgid())]
62
+ else:
63
+ argv += ["--user", "65534:65534"]
64
+ argv += ["--network", "none" if not bool((sandbox or {}).get("network", True)) else "bridge"]
65
+ for name in (sandbox or {}).get("env_allowlist") or []:
66
+ name = str(name)
67
+ if name and name in os.environ:
68
+ # Value is supplied through the filtered Docker client environment;
69
+ # never place credentials in argv, where process/log inspection sees them.
70
+ argv += ["--env", name]
71
+ for candidate in roots:
72
+ if "," in str(candidate):
73
+ raise SandboxUnavailable("Docker 允许目录包含不支持的逗号")
74
+ argv += ["--mount", "type=bind,src=%s,dst=/workspace" % str(candidate)]
75
+ if container_workdir is None:
76
+ rel_workdir = root.relative_to(selected_root).as_posix()
77
+ container_workdir = "/workspace" + ("/" + rel_workdir if rel_workdir != "." else "")
78
+ if not str(container_workdir).startswith("/") or ".." in Path(container_workdir).parts:
79
+ raise SandboxUnavailable("Docker 容器工作目录无效")
80
+ argv += ["--workdir", str(container_workdir), *list(extra_args), image]
81
+ if isinstance(command, (list, tuple)):
82
+ argv += [str(x) for x in command]
83
+ else:
84
+ argv += list(container_command) + [str(command)]
85
+ return argv
86
+
87
+
88
+ def docker_env(sandbox):
89
+ """Docker receives only explicitly allowed host environment values."""
90
+ out = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
91
+ "HOME": "/tmp", "TMPDIR": "/tmp"}
92
+ for name in (sandbox or {}).get("env_allowlist") or []:
93
+ if name in os.environ:
94
+ out[str(name)] = os.environ[str(name)]
95
+ return out
package/app/core/store.py CHANGED
@@ -145,8 +145,15 @@ def create_task(payload):
145
145
  goal = _text(payload.get("goal"), "goal")
146
146
  workdir = _text(payload.get("workdir"), "workdir")
147
147
  if not goal:
148
- raise ValueError("目标描述不能为空")
149
- title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
148
+ # 扫榜选材的菜单承诺「方向可留空,默认分析总榜热门题材」——
149
+ # paihang.rank_scan_prompt 与 pipeline 两侧均已支持空 goal,这里放行;
150
+ # 其余类型目标必填不变。
151
+ if payload.get("type") == "rank_scan":
152
+ title = title or flow.get("name") or "扫榜选材"
153
+ else:
154
+ raise ValueError("目标描述不能为空")
155
+ else:
156
+ title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
150
157
  if not workdir:
151
158
  # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <数据目录>/workspace)。
152
159
  # 默认路径允许自动创建;用户显式给的目录仍必须已存在。
@@ -2128,7 +2135,7 @@ def clear_stream_state(run_id=None, n=None):
2128
2135
 
2129
2136
  def finish_step(run_id, n, status, summary="", exit_code=None,
2130
2137
  cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
2131
- followups=None, thinking=None, partial=False):
2138
+ followups=None, thinking=None, partial=False, acp_events=None):
2132
2139
  with LOCK:
2133
2140
  run = _RUNS.get(run_id)
2134
2141
  if not run:
@@ -2163,6 +2170,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
2163
2170
  s["partial"] = True
2164
2171
  else:
2165
2172
  s.pop("partial", None)
2173
+ if acp_events:
2174
+ s["acp_events"] = [dict(item) for item in acp_events[:200]
2175
+ if isinstance(item, dict)]
2166
2176
  # 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
2167
2177
  # 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
2168
2178
  s.pop("stream", None)
package/app/main.py CHANGED
@@ -2848,6 +2848,7 @@ class Handler(BaseHTTPRequestHandler):
2848
2848
  "thinking": s.get("thinking") or "",
2849
2849
  "stream": (s.get("stream") or "") if running else "",
2850
2850
  "activity": (s.get("activity") or []) if running else [],
2851
+ "acp_events": s.get("acp_events") or [],
2851
2852
  "live": s.get("live") or 0,
2852
2853
  })
2853
2854