From a5ad820275244d2f73257f202cc94e519adad985 Mon Sep 17 00:00:00 2001
From: liuhailong <857688528@qq.com>
Date: Wed, 23 Sep 2026 16:16:17 +0800
Subject: [PATCH 1/4] feat(webui): add ws event stream, worker embed transport,
capability negotiation
- Event bus + SSE adapter: extract SSE wire mechanics behind a per-cid ordered bus; state/control pushes emit onto the bus so future WS adapter can subscribe (golden SSE bytes unchanged)
- WebSocket /api/stream: RFC 6455 server subset (handshake, frames, ping/pong), ring-buffer replay with seq resume, snapshot baseline on underrun, heartbeat, token-bucket inbound quota; upgrade gated by MCODE_WEBUI_TRANSPORT=ws (default sse)
- Engine-host worker embed: third transport (boot/prompt/steer/cancel over MessagePort RPC v:1), chat.js three-way selection with MCODE_ENGINE=acp default, embed consumer maps events to chat lines
- Capability negotiation: declarative sessionCapabilities + lazy probe + legacy fallback, replaces static UNSUPPORTED blacklist
- Tests: ws frame, ring buffer, event bus, sse golden, mcode embed, embed consumer, engine mode, ws server; contract checks stay green (216 tests)
Docs: arch_net_draft/arch_net_solution under docs/drafts, API.md + SECURITY-NOTES + alignment script updated
---
packages/webui/docs/API.md | 4 +
packages/webui/docs/API.zh-CN.md | 4 +
packages/webui/docs/drafts/README.md | 14 +
.../webui/docs/drafts/arch_net_draft_0922.md | 494 +++++++++++++++++
.../docs/drafts/arch_net_solution_0922.md | 266 +++++++++
packages/webui/package.json | 1 +
packages/webui/references/SECURITY-NOTES.md | 9 +
.../webui/scripts/check-docs-alignment.mjs | 2 +
packages/webui/server.js | 11 +
packages/webui/server/lib/acp-client.js | 3 +
packages/webui/server/lib/capability.js | 212 +++++++
packages/webui/server/lib/config.js | 7 +
packages/webui/server/lib/embed-consumer.js | 120 ++++
.../webui/server/lib/engine-host.worker.js | 238 ++++++++
packages/webui/server/lib/event-bus.js | 73 +++
packages/webui/server/lib/mcode-embed.js | 518 ++++++++++++++++++
packages/webui/server/lib/mcode-rpc.js | 91 +--
packages/webui/server/lib/ring-buffer.js | 102 ++++
packages/webui/server/lib/sse-adapter.js | 159 ++++++
packages/webui/server/lib/state-bus.js | 337 +++---------
packages/webui/server/lib/ws-frame.js | 399 ++++++++++++++
packages/webui/server/lib/ws-server.js | 267 +++++++++
packages/webui/server/router.js | 11 +
packages/webui/server/routes/chat.js | 61 ++-
.../test/fixtures/engine-host.stub.worker.js | 133 +++++
packages/webui/test/lib-capability.test.js | 178 ++++++
.../webui/test/lib-embed-consumer.test.js | 115 ++++
packages/webui/test/lib-engine-mode.test.js | 27 +
packages/webui/test/lib-event-bus.test.js | 74 +++
packages/webui/test/lib-mcode-embed.test.js | 317 +++++++++++
packages/webui/test/lib-ring-buffer.test.js | 104 ++++
packages/webui/test/lib-sse-golden.test.js | 133 +++++
packages/webui/test/lib-ws-frame.test.js | 477 ++++++++++++++++
packages/webui/test/lib-ws-server.test.js | 210 +++++++
release/public-source.json | 22 +
35 files changed, 4874 insertions(+), 319 deletions(-)
create mode 100644 packages/webui/docs/drafts/README.md
create mode 100644 packages/webui/docs/drafts/arch_net_draft_0922.md
create mode 100644 packages/webui/docs/drafts/arch_net_solution_0922.md
create mode 100644 packages/webui/server/lib/capability.js
create mode 100644 packages/webui/server/lib/embed-consumer.js
create mode 100644 packages/webui/server/lib/engine-host.worker.js
create mode 100644 packages/webui/server/lib/event-bus.js
create mode 100644 packages/webui/server/lib/mcode-embed.js
create mode 100644 packages/webui/server/lib/ring-buffer.js
create mode 100644 packages/webui/server/lib/sse-adapter.js
create mode 100644 packages/webui/server/lib/ws-frame.js
create mode 100644 packages/webui/server/lib/ws-server.js
create mode 100644 packages/webui/test/fixtures/engine-host.stub.worker.js
create mode 100644 packages/webui/test/lib-capability.test.js
create mode 100644 packages/webui/test/lib-embed-consumer.test.js
create mode 100644 packages/webui/test/lib-engine-mode.test.js
create mode 100644 packages/webui/test/lib-event-bus.test.js
create mode 100644 packages/webui/test/lib-mcode-embed.test.js
create mode 100644 packages/webui/test/lib-ring-buffer.test.js
create mode 100644 packages/webui/test/lib-sse-golden.test.js
create mode 100644 packages/webui/test/lib-ws-frame.test.js
create mode 100644 packages/webui/test/lib-ws-server.test.js
diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md
index f8a47f0c..604bd4a0 100644
--- a/packages/webui/docs/API.md
+++ b/packages/webui/docs/API.md
@@ -59,6 +59,10 @@ Returns the current `state` object for this CID. See
{ "ok": true, "version": "0.1.3", "running": {"active": false}, … }
```
+### `GET /api/stream`
+
+WebSocket event stream endpoint (design doc `docs/drafts/arch_net_solution_0922.md` §7.2). The upgrade passes the same gate chain (origin / LAN / token) as `GET /api/events`; with the default `MCODE_WEBUI_TRANSPORT=sse` the upgrade is refused (404), and with `ws` an RFC 6455 handshake is accepted. Server-to-client frames are WS text JSON: `hello` (`{v:1, type:"hello", payload:{resumeSupported, latestSeq, heartbeatMs, ringCapacity}}`), `state.snapshot` and `control` event frames carrying `seq`/`ts`, and `error` frames. The client may send only JSON text frames (`resume`/`ping`/`pong`/`close`); binary frames close the connection with 1002. Resume: `{v:1, type:"resume", payload:{lastSeq}}` replays buffered events in strictly increasing `seq` order; when the ring buffer has underrun, the most recent `state.snapshot` is sent as the baseline. Heartbeats are WS ping control frames (default 30s; two missed pongs close with 1001). The inbound token-bucket quota is 20 frames/s sustained with a burst of 40; exceeding it closes with 1013. The shipped SPA does not use this endpoint.
+
### `GET /api/events`
Server-Sent Events stream for this CID. The connection stays open
diff --git a/packages/webui/docs/API.zh-CN.md b/packages/webui/docs/API.zh-CN.md
index 2d11f592..cb74f6a1 100644
--- a/packages/webui/docs/API.zh-CN.md
+++ b/packages/webui/docs/API.zh-CN.md
@@ -59,6 +59,10 @@
{ "ok": true, "version": "0.1.3", "running": {"active": false}, … }
```
+### `GET /api/stream`
+
+WebSocket 事件流端点(技术方案 `docs/drafts/arch_net_solution_0922.md` §7.2)。升级门链(origin / LAN / token)与 `GET /api/events` 相同;默认 `MCODE_WEBUI_TRANSPORT=sse` 时拒绝升级(404),设为 `ws` 时接受 RFC 6455 握手。服务端 → 客户端帧为 WS text JSON:`hello`(`{v:1, type:"hello", payload:{resumeSupported, latestSeq, heartbeatMs, ringCapacity}}`)、带 `seq`/`ts` 的 `state.snapshot` 与 `control` 事件帧、`error` 帧。客户端 → 服务端仅接受 JSON text 帧(`resume`/`ping`/`pong`/`close`),二进制帧以 1002 关闭。断线恢复:`{v:1, type:"resume", payload:{lastSeq}}` 按 seq 严格递增重放缓冲事件;环形缓冲欠载时以最近 `state.snapshot` 为基线。心跳为 WS ping 控制帧(默认 30 秒,连续 2 次无 pong 以 1001 关闭);入站帧令牌桶配额为稳态 20 帧/秒、突发 40,超出以 1013 关闭。发行版 SPA 不使用本端点。
+
### `GET /api/events`
此 CID 的服务端推送事件(Server-Sent Events)流。连接会无限期保持
diff --git a/packages/webui/docs/drafts/README.md b/packages/webui/docs/drafts/README.md
new file mode 100644
index 00000000..229c0581
--- /dev/null
+++ b/packages/webui/docs/drafts/README.md
@@ -0,0 +1,14 @@
+# drafts/ 文档集索引
+
+| 文件 | 性质 | 状态 |
+|---|---|---|
+| [arch_net_draft_0922.md](arch_net_draft_0922.md) | 网络层设计**提案**(493 行精简版) | 基准 |
+| [arch_net_solution_0922.md](arch_net_solution_0922.md) | **技术方案**(可实施级:拓扑/协议/对齐义务/切片验收) | 现行 |
+
+阅读顺序:提案(问题与选型)→ 技术方案(实施规格)。
+
+演进规则:**方案改动先落盘再改代码;实现偏差即时归档进技术方案 §10 决策记录。**
+
+术语约束:标准编程名词,禁自造缩写(历史讨论中已否决:自造编号体系、SQLite 消息队列、常驻进程监督循环、PTY 驱动 TUI)。
+
+历史:2026-09-22 提案四稿(精简重组);2026-09-23 技术方案初版。
\ No newline at end of file
diff --git a/packages/webui/docs/drafts/arch_net_draft_0922.md b/packages/webui/docs/drafts/arch_net_draft_0922.md
new file mode 100644
index 00000000..369474db
--- /dev/null
+++ b/packages/webui/docs/drafts/arch_net_draft_0922.md
@@ -0,0 +1,494 @@
+# Web UI 网络层设计草案(2026-09-22)
+
+> **状态**:草案,未实现;所有内容均为提案,不改变当前发布行为。
+> **定位**:[ARCHITECTURE.md](../ARCHITECTURE.md) 的前瞻配套文档;方案接受后相关章节迁入正式文档,本文归档。
+> **范围**:浏览器 ↔ webui 后端(浏览器通信)、webui 后端 ↔ 引擎(引擎集成),及安全、迁移等横切关注点。
+> **修订**:四稿 —— 精简重组:去除自定义编号(目标/缺陷/边界改为具名引用),合并次要图表,保留关键设计与核心图表。
+
+## 1. 目标与非目标
+
+**目标**
+
+1. 下行投递从「至多一次的快照流」升级为「至少一次的有序增量事件流」,支持断线续传。
+2. 下行负载从 O(状态总量) 降为 O(事件增量)。
+3. 上行交互(审批、问答、计划确认、取消)使用结构化消息,不再伪装成聊天内容。
+4. 引擎能力面完整可用:取消、模式切换、队列、会话中权限切换。
+5. 安全门链语义逐条映射到新传输,不新增攻击面;保留对接外部/旧版引擎的 ACP 通道。
+6. 引擎集成按角色分层,子 agent 由受评审的模板实例化,层间通信不引入消息代理。
+
+**非目标**:多用户鉴权与多实例扩展;重设计 ACP 协议本身;PTY 驱动 TUI;更换前端渲染技术栈。
+
+## 2. 术语
+
+| 术语 | 英文 | 定义 |
+|---|---|---|
+| 控制面 / 数据面 | control plane / data plane | 指令上行通道 / 状态与事件下行通道 |
+| 快照 / 增量 | snapshot / delta | 某时刻完整状态 / 相对前一状态的最小变更 |
+| 事件溯源 | event sourcing | 以有序事件日志为事实来源,重放重建状态 |
+| 单调序列号 | monotonic sequence number | 连接内连续递增、不回退的帧编号 |
+| 环形缓冲区 | ring buffer | 固定容量、新帧覆盖最旧帧的内存重放窗口 |
+| 断线续传 | resume | 携带最后序列号重连,服务端从该点续发 |
+| 至少一次 | at-least-once | 不丢帧的投递保证(配合幂等消费) |
+| 多路复用 | multiplexing | 单条连接承载多条逻辑流 |
+| 能力协商 | capability negotiation | 握手期声明并探测双方支持的能力集 |
+| 崩溃域 | crash domain | 一个故障波及的进程/线程边界 |
+| 可重入性 | reentrancy | 同一模块被多个并发会话安全复用 |
+| 传输抽象层 | transport abstraction | 同一接口契约下可互换的传输实现集合(`runMcode` → `NormalizedEvent`) |
+| 模板 agent | agent template / blueprint | 以受评审文件定义方法论与验收标准,按需实例化为子 agent 进程 |
+| 黑板 | blackboard | 多 agent 共享的异步协作介质(本方案为工作区文件) |
+
+## 3. 现状(as-is)
+
+### 3.1 拓扑
+
+```mermaid
+flowchart TD
+ subgraph B["浏览器 SPA"]
+ UI["render() 循环,镜像 state 对象"]
+ end
+ subgraph S["webui 后端"]
+ G["门链:CORS → Origin/CSRF → LAN → token → 限流 → 只读"]
+ R["REST 路由 /api/*"]
+ SB["state-bus.js:per-cid clientState
60Hz 节流合并 + 字节级 diff"]
+ E1["GET /api/events(SSE)"]
+ E2["GET /api/alerts(SSE)"]
+ N["mcode-acp.js / mcode-exec.js:NormalizedEvent 归一化"]
+ end
+ subgraph ENG["引擎子进程(每活动标签一个)"]
+ A["mcode acp:ndjson JSON-RPC 2.0 over stdio"]
+ X["mcode exec(回退传输)"]
+ end
+ DB[("runtime-state.sqlite
权威会话存储")]
+ UI -->|"POST /api/send 等,响应仅 ack"| G
+ G --> R
+ R --> SB
+ A -->|"session/update 通知"| N
+ N --> SB
+ SB -->|"匿名 data: 帧 = 全量状态快照"| E1
+ E1 --> UI
+ SB --> E2
+ SB -.->|"读:列表/回填;写:级联删除"| DB
+ A -.-> DB
+```
+
+三条事实链:**浏览器通信 = REST 控制面 + SSE 数据面**(`POST /api/send` 立即返回 `{ok:true}`,结果全部走 SSE);**引擎集成 = ACP 子进程**(`initialize` 握手后 `session/new|load|list|prompt|close`,事件以 `session/update` 回传归一化);**旁路 = 直连 SQLite**(列表、回填、级联删除不经 ACP)。
+
+### 3.2 浏览器通信细节
+
+- SSE 常规推送全部是**匿名 `data:` 帧,内容为全量 state 快照**;仅 4 个命名控制事件(`auth.token_rotated`、`token.first_run`、`needs_authorization`、`authorization_decided`)。
+- 推送优化:16ms 窗口节流合并(last-call-wins)+ 与上一帧逐字节相同则跳过——两者都是快照模型的补偿,单帧大小仍为 O(状态总量)。
+
+### 3.3 引擎集成细节
+
+- 传输:Agent Client Protocol(ACP),ndjson JSON-RPC 2.0 over stdio;每活动标签一个子进程 + 一个列表用单例(会话列表 30s 缓存、命令目录 24h 缓存)。
+- 能力探测是**静态黑名单**:`mcode-rpc.js` 的 `UNSUPPORTED` 集合硬编码自 mcode 0.1.5 实测;而本仓库引擎(`packages/tui/src/acp/agent.ts`)已实现 `setMode`、`setConfigOption`、`cancel`、`fork`、`resume`,并在 `initialize` 响应中发布能力与扩展方法清单——黑名单已与实际漂移。
+- 取消 = 杀子进程(SIGTERM → 2s 后 SIGKILL);权限模式仅能在 spawn 时以 `--permission` 传入。引擎侧自有并发上限(如并发 `session/new` ≤ 8、生命周期 64/会话、256 全局)。
+
+### 3.4 限制清单(现状数值)
+
+| 限制 | 默认值 | 环境变量 | 出处 |
+|---|---|---|---|
+| 端口 | 18090,占用向后探测(≤20 次);显式指定则固定 | `PORT` / `--port` | `config.js`、`port.js` |
+| 绑定 | `127.0.0.1`(loopback 默认) | `HOST` / `lanBind` | `config.js` |
+| 限流 | 60 次/分钟、burst 100;持 token ×2;loopback 豁免 | `MCODE_WEBUI_RATE_LIMIT(_BURST)` | `rate-limit.js` |
+| 上传 | 请求体 50 MiB / 单文件 25 MiB / 配额 200 MiB,中途超限即 413 | `MCODE_WEBUI_UPLOAD_*` | `upload.js` |
+| 回合空闲看门狗 | 流静默 120s 判超时(事件续命,非墙钟) | `MCODE_WEBUI_PROMPT_IDLE_TIMEOUT` | `idle-watchdog.js` |
+| 转录回填 | ≤400 行 / ≤200 KB | 无 | ARCHITECTURE.md §2.2 |
+| token / trustedOrigins | token ≤256 字符;白名单 ≤16 条 × 1–200 字符 | `/api/settings` | `auth.js`、`settings.js` |
+| 依赖 | 0 个 npm 运行时依赖;Node ≥22.19 | — | `docs/webui.md` |
+
+### 3.5 现状问题清单(按用户可感知的影响归纳)
+
+| 用户可感知的影响 | 技术根因 | 对策 |
+|---|---|---|
+| 会话越长,流式输出时界面越卡、流量消耗越大(弱网与手机上明显)——每帧推送都是全量状态快照,大小与聊天历史长度成正比 | 快照模型 | §5 方案 B |
+| 网络闪断后页面整体重新加载(「转圈」);侧栏会话列表先闪空再弹回;断线期间的模型输出丢一段且无任何提示 | SSE 单向、无序列号,重连只能全量重同步 | §5 方案 B |
+| 切回长会话只能看到最近约 400 行 / 200 KB 的历史,更早的记录在 webui 里看不到 | 快照模型装不下完整历史 | §5 方案 B |
+| 点「停止」会杀掉整个引擎进程:进行中的输出直接丢弃、下一回合有秒级重启延迟,不是优雅取消;会话中途无法把权限从「询问」切成「自动」;部分功能按钮点击后提示「不支持」,即使新版引擎实际已支持 | 缺运行时能力协商(静态黑名单),取消与权限切换没有协议通道 | §8 阶段 0、§6 |
+| 回合启动失败时界面残留「思考中」,要刷新页面才能恢复(该竞态目前靠补丁压制而非根治);弹窗应答与普通消息共用一条通道、靠特判区分,是同类竞态的温床 | 上行无结构化通道,请求与事件分属两条无序通道 | §5 方案 B |
+| 偶发侧栏多出空会话条目(探测会话堆积,靠清理补丁压制);偶发引擎子进程卡死,表现为发送后长时间无响应 | 子进程生命周期管理固有成本 | §6 分层 |
+| 短时间频繁刷新或切换会触发限流报错(429);管理员重置 token 后,其他已打开的设备全部掉线且需手动获取新地址;同一浏览器开约三个及以上标签页时请求可能排队变慢(浏览器同源连接数上限,每标签占用两条长连接) | 按请求认证、轮询消耗限流配额、SSE 每用途一条连接 | §5 方案 B |
+| 列表/删除与会话协议走不同通道(直连数据库)——用户基本无感,属升级引擎版本时行为可能不一致的维护性风险 | ACP 能力与性能缺口 | §6 分层 |
+
+## 4. 同类方案
+
+**DeepSeek Harness web**:web 服务与 agent 运行时同进程;单条多路复用 WebSocket(`/api/remote.mux`)承载全部逻辑流,类型化 RPC + 事件流,心跳保活,升级期认证与 Origin 校验;服务端可向浏览器发起带关联 ID 的请求(审批、提问天然双向)。
+
+**Kimi Code Web**(`kimi web`):FastAPI + 每 session 一个 CLI 子进程;REST 承载 CRUD,每 session 一条 WebSocket;连接后先从 `wire.jsonl` 重放历史再转实时(断线零丢失);token 走 URL fragment,握手期校验 token/Origin/LAN。
+
+| 维度 | mcode webui(现状) | dsh web | kimi web |
+|---|---|---|---|
+| 下行通道 | SSE ×2,快照流 | 单条多路复用 WebSocket,类型化事件流 | 每 session 一条 WebSocket,事件流 |
+| 投递保证 | 至多一次 + 全量重同步 | 连接内有序 | 至少一次(日志重放) |
+| 上行通道 | REST(交互伪装成消息) | WebSocket 双向 + 服务端主动请求 | WebSocket 双向 |
+| 断线恢复 | `GET /api/state` 全量 | 重连 + 事件流恢复 | `wire.jsonl` 重放,零丢失 |
+| 引擎关系 | ACP 子进程(公开标准协议) | 同进程直调 | 私有协议子进程 |
+| 负载模型 | O(状态总量)/帧 | O(增量)/帧 | O(增量)/帧 |
+| 认证 | 按请求 | 按连接(升级期) | 按连接 + fragment token |
+
+两者共同验证:「有序增量事件流」优于「无序快照流」,「按连接认证」优于「按请求认证」。
+
+## 5. 浏览器通信选型
+
+### 5.1 候选方案
+
+- **方案 A(维持现状)**:REST + SSE 快照。已实现、可 curl 调试、客户端镜像即可;但 §3.5 前四类问题全部成立且互相耦合。适用:迁移期过渡。
+- **方案 B(选定)**:**WebSocket 事件流 + REST**。REST 保留为控制面(发送、会话、上传、设置),WebSocket 单连接承载全部下行事件流与结构化上行。
+- **方案 C(回退通道)**:SSE + `Last-Event-ID` 重放。改动最小、免费获得至少一次投递;但上行仍是 REST、仍两条长连接,事件化改造工作量与方案 B 相同却拿不到上行收益。定位:方案 B 的降级开关。
+
+| 维度(权重) | A 现状 | B WebSocket 事件流 | C SSE + Last-Event-ID |
+|---|---|---|---|
+| 投递保证(高) | ✗ 至多一次 | ✓ 至少一次 + 精确续传 | ◐ 至少一次 |
+| 上行结构化(高) | ✗ | ✓ | ✗ |
+| 负载效率(高) | ✗ O(总量) | ✓ O(增量) | ◐ O(增量) |
+| 连接数(中) | ✗ ×2/标签 | ✓ ×1 | ✗ ×2 |
+| 代理兼容(中) | ◐ 需关缓冲 | ◐ 少数代理不支持 Upgrade | ◐ 需关缓冲 |
+| 实现成本(中) | ✓ 零 | ✗ 高(协议 + reducer + RFC 6455 决策) | ◐ 中 |
+| 客户端复杂度(中) | ✓ 镜像即可 | ◐ 需 reducer | ◐ 需 reducer |
+| 可调试性(低) | ✓ curl | ◐ 需工具/日志 | ✓ curl |
+
+### 5.2 方案 B 设计
+
+**核心原理:先快照、后增量(snapshot-then-delta)**。连接建立先发一次 `state.snapshot` 建立基线,此后只发增量事件;客户端用确定性归约函数(reducer)维护状态。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant B as 浏览器
+ participant S as webui 后端
+ Note over B,S: 握手:HTTP Upgrade /api/stream(Origin 校验 + token,一次性)
+ S-->>B: {type:"hello", seq:0, resumeSupported:true}
+ B->>S: {type:"resume", lastSeq:412}
+ alt lastSeq 在环形缓冲区范围内
+ S-->>B: 帧 413..427(按序重放)
+ else 落后太多 / 未知
+ S-->>B: {type:"state.snapshot", seq:428}(全量基线)
+ end
+ S-->>B: 实时帧 seq=429…(单调递增,无空洞)
+ Note over B,S: 心跳:ping/pong 每 30s
+```
+
+**帧格式**:
+
+```ts
+// 服务端 → 客户端(连接内 seq 单调递增、连续、不重置)
+interface ServerFrame {
+ v: 1;
+ seq: number; // 从 1 开始
+ ts: number; // epoch ms
+ type: ServerEventType;
+ payload: unknown; // 按 type 的 schema
+ ref?: string; // 对客户端请求的响应携带
+}
+
+// 客户端 → 服务端
+interface ClientFrame {
+ v: 1;
+ type: ClientEventType;
+ ref?: string; // 请求/响应关联 ID
+ payload: unknown;
+}
+```
+
+**事件目录**(与 `NormalizedEvent` 一一对应):
+
+| 方向 | type | 说明 |
+|---|---|---|
+| ↓ | `state.snapshot` | 全量基线(与 ARCHITECTURE.md §4 同构) |
+| ↓ | `chat.line_appended` / `chat.delta` | 行追加与流式增量 |
+| ↓ | `tool.call_started` / `tool.call_updated` | 工具调用生命周期 |
+| ↓ | `run.started` / `run.finished` / `run.failed` | 回合生命周期(stopReason、usage) |
+| ↓ | `context.updated` / `usage.updated` / `commands.updated` / `session_list.updated` | 派生状态更新 |
+| ↓ | `interaction.ask_raised` / `permission_raised` / `plan_raised` | 交互请求(弹窗) |
+| ↓ | `auth.token_rotated` / `alert.raised` | 令牌轮转与告警(合并入单连接) |
+| ↑ | `resume` | 携带 lastSeq 断线续传 |
+| ↑ | `interaction.answer` / `permission.decision` / `plan.decision` | 结构化交互应答(替代 `isAskAnswer` 伪装) |
+| ↑ | `run.cancel` | 取消(与 REST `/api/stop` 并存) |
+| ↑ | `pong` | 心跳应答 |
+
+**负载对比**:
+
+```
+快照模型(现状) 增量模型(方案 B)
+帧 1 ████████████████ ≈8 KiB ▎ ≈100 B chat.line_appended
+帧 2 ████████████████ ≈8 KiB ▎ ≈110 B chat.delta
+帧 N ████████████████ ≈8 KiB ▎ ≈120 B run.finished
+每帧 = O(state 总量),含命令目录 每帧 = O(事件大小),与历史长度无关
+流式期间 ≈ 数百 KiB/s ≈ 数 KiB/s
+```
+
+**收益**:§3.5 前四类问题成批消失;回填上限可移除(历史 = 重放 + 快照基线);reducer 为纯函数可测;弱网体验从全量重同步变为精确续传。
+**成本**:前端需实现 reducer 与续传;WebSocket 不能 curl(缓解:事件落日志 + 开发期只读端点);依赖策略需决策(§7.5);少数代理不支持 Upgrade(缓解:保留 SSE 回退开关)。
+
+## 6. 引擎集成选型
+
+### 6.1 候选方案
+
+- **方案 A(ACP 子进程,现状)**:进程隔离、版本解耦(`MCODE_CMD` 可指向任意安装)、公开标准协议;代价是能力黑名单、每事件序列化、子进程生命周期管理成本、单连接单活动会话。
+- **方案 B(进程内嵌入)**:直接 import `@mavis/local-runtime-v2` 应用服务(`./cli-service`、`./session-system` 等导出即为此设计),取消/模式切换/队列/会话中权限全部成为直接方法调用,类型编译期对齐、零序列化;代价是同崩溃域、可重入性未验证、进程级信号冲突风险。
+- **方案 C(Worker 线程嵌入)**:引擎跑在 `worker_threads`,经 `MessagePort`(结构化克隆)传请求与事件。兼顾 B 的类型化收益与接近进程级的隔离;`worker.terminate()` 是干净的强取消边界,未捕获异常不跨线程传播。
+
+### 6.2 TUI(互补能力,非传输)
+
+| 子形态 | 结论 |
+|---|---|
+| 共享会话存储(SQLite + `session/load`) | 保留延续:webui 已能接管任意 TUI 会话 |
+| `/web` 交接(TUI 内一键交给浏览器) | 建议采纳:上一行的产品化包装 |
+| PTY 驱动 TUI 本体 | 否决:终端仿真层脆弱、不可测 |
+
+### 6.3 角色分层分配(选定方案)
+
+三个方案不互斥为「默认/回退」,而是按角色各就其位,隔离强度随信任梯度递增:
+
+| 角色 | 会话 origin | 传输 | 能力面 | 隔离强度 | 生命周期 |
+|---|---|---|---|---|---|
+| 主 agent(交互会话) | `main` | Worker 线程内嵌 | 完整:cancel/steer/queue/goal/会话中权限 | 线程级(`terminate()`) | 跟随标签页 |
+| side-chat(侧边轻会话) | `side` | headless 子进程(`mcode exec`) | 刻意收窄:单回合、maxSteps 默认 6、spawn 时定权限 | 进程级 | 跟随会话,关闭即退出 |
+| 子 agent(后台/并行任务,按模板实例化 §7.8) | `task` | ACP 子进程 | 会话化:多回合、流式、cancel、fork | 进程级 | 跟随任务,可独立终止 |
+
+机制性优点:**隔离梯度匹配信任梯度**(可重入性验证范围收窄到主会话——任意 side-chat 与子 agent 都在进程里,不触碰主 agent 的 Worker);**三条路径全部复用既有代码**(headless 与 ACP 传输已存在,新增仅 `mcode-embed`);**与方案 B 协同**(三个角色 = 三条逻辑事件流,复用在同一条 WebSocket 上)。
+
+**四条硬边界(阶段 1 验收条件)**:
+
+| 边界 | 内容 |
+|---|---|
+| 编排边界 | 「子 agent → ACP」仅指 webui 发起的后台/并行代理;引擎内部 delegation 留在 Worker 内进程内完成,绝不外绕 webui ACP(否则形成循环编排) |
+| 晋升路径 | side-chat 必须可晋升为主会话(等价于一次 `session/load` + origin 变更);无晋升则 headless 能力上限成为体验陷阱 |
+| 可重入性范围 | 风险收窄为「主会话 × 主会话」;保守起点「每活动标签一个 Worker」,验证后再合并 |
+| 静态分配 | 角色 → 传输是架构常量,不提供改派配置;主 agent 的 ACP 逃生门属部署级开关 |
+
+**场景覆盖**:产品 webui 按上表分层;`MCODE_CMD` 指向外部引擎时子进程层随之运行该版本(side-chat 天然充当金丝雀层);Docker 同产品默认;显式进程隔离需求时主 agent 降级 ACP;TUI 互通走存储共享 + `/web`。
+
+## 7. 目标架构
+
+### 7.1 拓扑
+
+```mermaid
+flowchart TD
+ subgraph B["浏览器 SPA"]
+ RD["事件归约器(reducer)
状态 = snapshot + Σ events"]
+ end
+ subgraph S["webui 后端进程"]
+ G["升级期门链:Origin + token + 入站配额"]
+ WS["WebSocket /api/stream
单连接多路复用:main / side / sub 流"]
+ RB["环形缓冲区(per-cid 重放窗口)"]
+ REST["REST /api/*(控制面)"]
+ TR["传输抽象层 runMcode() → NormalizedEvent"]
+ EMB["mcode-embed(主 agent)"]
+ HLT["mcode-exec(side-chat,既有)"]
+ ACPT["mcode-acp(子 agent,既有)"]
+ end
+ subgraph WK["工作线程"]
+ SVC["local-runtime-v2 应用服务(主 agent 会话)"]
+ end
+ SC["headless 子进程 ×N
side-chat"]
+ SUB["ACP 子进程 ×N
子 agent"]
+ DB[("runtime-state.sqlite
journal + 任务账本")]
+ B -->|"Upgrade + 一次性认证"| G
+ G --> WS
+ B -->|"REST(保留)"| REST
+ WS <-->|"有序帧 seq=1..n,流标识路由"| RD
+ WS --- RB
+ REST --> TR
+ EMB --> TR
+ HLT --> TR
+ ACPT --> TR
+ SVC <-->|"MessagePort"| EMB
+ SC <-->|"行分隔 stream-json"| HLT
+ SUB <-->|"JSON-RPC / stdio"| ACPT
+ SVC --- DB
+ SC --- DB
+ SUB --- DB
+ TR --> RB
+```
+
+关键点:传输抽象层(`runMcode → NormalizedEvent`)不变,`mcode-embed` 是实现该契约的第三个传输;两条通信链路(浏览器通信 / 引擎集成)独立演进、独立回退。
+
+### 7.2 WebSocket 协议规格
+
+| 项 | 规格 |
+|---|---|
+| 端点 | `GET /api/stream`(HTTP Upgrade),单连接承载全部逻辑流 |
+| 流标识 | 下行帧携带 `stream` 字段(`main` / `side:` / `sub:`)做多路路由;`seq` 按连接全局递增(全序),流标识只做路由不做排序 |
+| 认证 | 升级期 `?token=`(或首帧 `auth`),连接作用域;本机豁免规则同现状 |
+| Origin | 升级期白名单校验(等价现状 CSRF 门,loopback 不豁免页面身份) |
+| 序列号 | 连接内从 1 起单调递增、连续,全部帧共用同一序列空间 |
+| 重放窗口 | per-cid 环形缓冲区(初值 4096 帧);`resume {lastSeq}` 续传;落后超窗降级为 `state.snapshot` 基线 |
+| 心跳 | 服务端 ping 每 30s;连续 2 次未 pong 判死连接并释放资源 |
+| 入站配额 | 每连接 20 帧/秒、burst 40;超限警告帧后关闭(等价限流门语义) |
+| 帧约束 | 仅文本帧(JSON,UTF-8);单帧上限 1 MiB;非法 UTF-8 按规范关闭;相邻同目标 `chat.delta` 可保序合并 |
+| 只读与错误 | 非本机连接的写类帧拒绝;错误帧复用现有消毒规则(截断 200 字符、去控制字符) |
+
+### 7.3 保留的 REST 面
+
+| 端点组 | 处置 |
+|---|---|
+| `POST /api/send`、`/api/sessions*`、`/api/upload`(multipart)、`/api/settings`、`/api/models*`、`/api/protocol/*`、`/api/trajectory/*` | 保留(控制面;multipart 不适合 WebSocket) |
+| `GET /api/state` | 保留(调试与降级基线) |
+| `GET /api/events`、`GET /api/alerts`(SSE) | 迁移期保留为回退通道,阶段 3 起仅特性开关可启用 |
+| `POST /api/stop` | 保留,同时提供 `run.cancel` 等价路径 |
+
+### 7.4 安全门映射(语义逐条对齐,不新增面)
+
+| 现状 HTTP 门 | WebSocket 等价物 |
+|---|---|
+| CORS 可信 Origin 反射 | 升级期 Origin 白名单校验,拒绝 403 |
+| Origin/CSRF 门(含 loopback) | 同上,升级期一次性判定(连接即页面身份) |
+| token(按请求) | 升级期一次,连接作用域 |
+| 限流 60/min per {IP,token} | REST 面不变;WebSocket 改为每连接入站配额 |
+| 只读门(非本机非 GET) | 非本机连接的写类帧直接拒绝 |
+| loopback 默认绑定 | 不变 |
+
+### 7.5 依赖策略
+
+手写 RFC 6455 服务端子集(握手、帧编解码、掩码校验、分片、close/ping/pong;不实现压缩扩展,约 300–400 行 + 一致性测试)可保持「零 npm 运行时依赖」的成文原则;引入 `ws` 库则边界用例久经考验但破例需书面理由。**推荐**:先手写并以一致性测试门护航,成本过高则降级引入 `ws`,决策门在阶段 2 原型完成时。
+
+### 7.6 故障模式
+
+| 故障 | 检测 | 恢复 |
+|---|---|---|
+| WebSocket 断开 | 心跳超时 / TCP 错误 | 退避重连 + `resume`;超窗则快照基线 |
+| 环形缓冲区欠载 | 服务端比对窗口头 | 直接发 `state.snapshot` |
+| 主 agent Worker 崩溃 | `worker.on(error/exit)` | 发 `run.failed` + 按策略重建(会话由 SQLite 恢复) |
+| side-chat / 子 agent 进程崩溃 | 退出码 / ACP 子进程退出 | 上报失败并更新任务账本;side-chat 下回合自然重建;不影响主 agent Worker |
+| 服务重启 | 客户端连接失败 | 重连 + 快照重建;按任务账本重挂接存活子 agent |
+| 异常客户端 | 入站配额 + 帧校验 | 警告帧 → 关闭;REST 面仍受既有门链保护 |
+
+### 7.7 层间通信(三类 agent 之间)
+
+**原则:控制走活通道,状态进 SQLite。** 拓扑为星型(hub-and-spoke):webui 后端是唯一同时持有三类活句柄(MessagePort、headless stdio、ACP stdio)的组件,agent 之间无对等通信需求,不引入消息代理。
+
+```mermaid
+flowchart TD
+ BR["浏览器(WebSocket 多路复用:main / side / sub 流)"]
+ HUB["webui 后端 = 通信枢纽"]
+ W["主 agent(Worker 线程)"]
+ SC["side-chat ×N"]
+ SUB["子 agent ×N"]
+ DB[("SQLite:journal + 任务账本")]
+ BR <-->|"事件流↓ / 决策↑"| HUB
+ HUB <-->|"MessagePort"| W
+ HUB <-->|"stdio"| SC
+ HUB <-->|"ACP"| SUB
+ W -.->|"工具调用桥:delegation 工具结果回填"| HUB
+ W --- DB
+ SC --- DB
+ SUB --- DB
+```
+
+| | 主 agent(Worker) | side-chat(headless) | 子 agent(ACP) |
+|---|---|---|---|
+| **下达** | MessagePort 直接方法调用:`prompt` / `enqueueMessage` | spawn argv + stdin,prompt 即任务 | `session/new` → `session/prompt`;后续 `mcode/session/queue/enqueue` |
+| **反馈**(中途介入) | `runtime.steer()` | 无 steer 对象(单回合):等待或终止 | `mcode/session/steer`、`queue/steer`、`session/cancel` |
+| **上传** | MessagePort 事件流(`NormalizedEvent`) | stdout 行分隔 stream-json + 退出码 | `session/update` 通知 + prompt 响应(stopReason、usage) |
+| **取消** | 语义 cancel 或 `worker.terminate()` | SIGTERM → SIGKILL | `session/cancel`(能力协商后),否则 kill |
+
+「队列」原语已在正确位置:引擎的 `mcode/session/queue/*` 扩展即「agent 忙时给它的下一条消息」,webui 只需接到 WebSocket 上行,无需自建队列。
+
+跨层路由三条:① 三层上传事件按流标识汇入同一 WebSocket;② **主 agent ↔ 子 agent = 工具调用桥**——嵌入方向由 `host-contract.ts` 确立(宿主向引擎暴露能力),后台代理工具沿同一模式:主 agent 调用 delegation 工具 → 执行落在 webui → spawn ACP 子 agent → 结果作为工具结果回填 Worker;③ 审批/问答沿各层 interaction 事件 → 浏览器 → 原通道返回。
+
+SQLite 角色 = **journal + 任务账本**,非消息队列:会话/事件日志(既有,三层同库,支撑侧栏/搜索/trajectory/晋升);任务账本(新增小表:task id、session id、status、owner)用于重启重挂接与审计。否决 SQLite 消息队列:星型枢纽已持有活管道,broker 是轮询中间人;流式/取消/steer 需要 push 与背压;跨进程写队列表需锁调优且重新耦合崩溃域。消息队列成立的条件(对等无共同父级、跨机分布、独立守护进程存活)当前均不成立。
+
+### 7.8 会话可见性与多 agent 形态
+
+#### origin 分类
+
+side-chat 与子 agent 会话**必须持久化**(晋升与审计依赖同库),但默认不混排主列表。分类标记住 webui overlay(`sessions.json` 的 `kind` 字段扩展),不改引擎 schema:
+
+| origin | 主列表默认 | 可见位置 | 回收 |
+|---|---|---|---|
+| `main` | ✓ 显示 | 主列表 | 现有规则 |
+| `side` | ✗ 隐藏 | 独立「侧边会话」分区/开关 | 未晋升超时 → 扩展启动清理 |
+| `task` | ✗ 隐藏 | 任务面板(按 team 分组)+ trajectory | 任务终态后按账本清理 |
+| `tui` / `exec` | ✓ 显示 | 主列表 | 现有规则 |
+
+晋升 = origin 从 `side` 改 `main`(一次 `session/load` + 一行 overlay 变更)。
+
+#### team 编排
+
+| 模式 | 编排者 | 适用 |
+|---|---|---|
+| fan-out / fan-in | webui 枢纽(或主 agent 经工具桥) | 并行侦察、多方案对比 |
+| lead + workers | lead 子 agent 自己的引擎(进程内 delegation,编排边界递归适用);webui 经 `delegation/get` 只读观察 | 分层委托 |
+| peer mesh | 否决——对等通信需要共享收件箱,与否决消息代理同理 | —— |
+
+跨成员共享状态用**黑板 = 工作区文件**(成员用既有文件工具读写,可 diff、可审计、零新增设施)。team 是任务账本一等对象;并发上限:每队 ≤8、全局 ≤16。
+
+#### 模板 agent:定义常驻,进程按需
+
+「常驻」的正确定义是**规格持久、进程按需**:方法论与验收标准写进受评审的模板文件,需要时复刻启动——运行时即兴编写提示词正是方法论疏漏的根源(编写时机在运行时、编写者是无评审的模型)。
+
+| | 即兴提示词 | 模板实例化 |
+|---|---|---|
+| 规格编写 | 运行时每次重写 | 开发时一次编写 |
+| 方法论/验收 | 靠模型发挥,常漂移 | 每次复刻一致,内置 checklist |
+| 评审与版本 | 无 | 进仓库,可 diff、随产品发版 |
+| 审计 | 无法回答 | 账本记录模板 id + 版本 |
+
+```markdown
+---
+name: code-reviewer
+version: 2
+model: minimax_api/MiniMax-M3
+permission: read # spawn 时权限(复用既有机制)
+tools: [read, grep, glob] # 工具授权收窄
+acceptance: 必须产出 checklist 全绿的审查报告
+---
+# 方法论 / # 验收标准(正文)
+```
+
+```mermaid
+flowchart LR
+ T["模板文件(受评审、可版本化)"]
+ L["任务账本:template id + version"]
+ A["ACP 子进程:spawn 时权限 + 模型配置"]
+ SR["system-reminder 注入模块(既有路径)"]
+ T -->|"复刻"| A
+ T -->|"登记版本"| L
+ A -->|"模板正文作为系统提示种子"| SR
+```
+
+实例化复用三处既有设施(system-reminder 注入路径、spawn 时权限、per-session 模型配置),新增仅模板文件 + 解析器 + 账本两个字段。模板与 skill 的区别:skill 是注入会话的说明书,模板是完整定义 agent 的规格(人格、工具授权、权限、模型、方法论、验收),模板可引用 skill。延伸收益:模板与产品同仓同版本,「同一模板在新旧引擎各跑一遍比对输出」即现成回归用例;side-chat 亦可由轻量 persona 模板实例化。
+
+## 8. 迁移计划
+
+| 阶段 | 内容 | 风险 |
+|---|---|---|
+| **阶段 0(偿还技术债,不动传输)** | 用 `initialize` 返回的 `agentCapabilities` + 扩展方法清单做运行时能力协商,替换静态黑名单;接通引擎已实现的 `session/cancel` | 低 |
+| **阶段 1(引擎集成层,分层落地)** | 新增 `mcode-embed`(Worker 形态)承载主 agent;side-chat / 子 agent 沿用既有路径;实现晋升路径与任务账本;验收含四条边界与主会话可重入性;主 agent 保留 `MCODE_ENGINE=acp` 逃生门 | 中 |
+| **阶段 2(浏览器通信层,双轨)** | WebSocket 服务端(含 RFC 6455 决策)、环形缓冲区、流标识与多路复用、事件目录与前端 reducer;模板实例化、origin 分类与侧栏分区随之落地;`MCODE_WEBUI_TRANSPORT=sse\|ws` 开关,默认 sse | 高 |
+| **阶段 3(收敛)** | 默认翻转为 ws;`isAskAnswer` 伪装、stale-sync 补丁、状态残留补丁按事件目录重构删除;SSE 转为特性开关回退;(可选)持久化事件日志支持跨重启重放与回填上限移除 | 中 |
+
+测试策略:RFC 6455 一致性单测(握手、帧长边界、未掩码关闭、分片、跨帧 UTF-8、close/ping/pong);序列号性质测试(重放无空洞无重复、欠载降级、ref 唯一);三条固定泳道(主 agent 嵌入 / side-chat headless / 子 agent ACP)× 前端双轨(sse/ws)同一套行为断言。回退:阶段 1 = 主 agent 降级 ACP;阶段 2/3 = `MCODE_WEBUI_TRANSPORT=sse`;开关均在启动期读取。
+
+## 9. 风险与待决问题
+
+| 风险 | 等级 | 缓解 |
+|---|---|---|
+| RFC 6455 边界用例实现缺陷 | 中 | 一致性测试门先行;不达标降级引入 `ws` |
+| 引擎应用服务不可重入 | 高(阶段 1) | 分层已收窄为主会话 × 主会话;验证失败则每标签一个 Worker 或主 agent 维持 ACP |
+| Worker 环境不兼容引擎依赖 | 中 | 兼容性清单先行;不兼容模块走 ACP 兜底 |
+| 前端 reducer 状态漂移 | 中 | reducer 纯函数 + 定期快照对账,漂移即重建 |
+| 三传输并存维护成本 / side-chat 每回合 spawn 延迟 | 低–中 | 角色静态分配锁三条泳道;spawn 延迟第一版接受,必要时进程保活 |
+
+待决:环形缓冲区容量定值;持久化事件日志位置与保留;`/api/send` 是否迁移上行;多标签共享会话(状态键从 cid 迁到会话);`/api/alerts` 并轨时机;side-chat 回收时限与进程保活策略;任务账本表结构;Worker 拓扑合并时机;team 上限定值;模板存放位置(仓库 / workspace / 叠加)与是否引用 skill;验收失败的任务终态语义。
+
+## 附录 A:证据索引
+
+| 主题 | 出处 |
+|---|---|
+| SSE 快照推送、节流合并、diff 门、命名控制事件 | `server/lib/state-bus.js` |
+| 发送即确认、`isAskAnswer` 伪装、状态残留补丁 | `server/routes/chat.js` |
+| SQLite 旁路(列表/回填/级联删除)、≤400 行回填 | `server/lib/db.js`、`docs/ARCHITECTURE.md` §2.2 |
+| 限流、上传三段限制、空闲看门狗、端口规则 | `server/lib/rate-limit.js`、`upload.js`、`idle-watchdog.js`、`config.js` |
+| ACP 静态黑名单 vs 引擎实际能力、并发上限、queue/steer/delegation 扩展 | `server/lib/mcode-rpc.js`、`packages/tui/src/acp/agent.ts`、`extensions.ts` |
+| 子进程生命周期补丁、空闲挂起、探测会话清理 | `acp.mjs`、`server/lib/acp-client.js` |
+| 进程内入口、宿主能力契约(工具调用桥依据)、后台运行时 | `packages/local-runtime-v2`(`package.json`、`src/local/host-contract.ts`、`src/background-runtime.ts`) |
+| 系统提示注入路径(模板实例化依据) | `packages/agent-modules`、`docs/ARCHITECTURE.md` §2.1 |
+| dsh / kimi 同类方案 | `@deepseek-ai/dsh-api-gateway`(本机);`MoonshotAI/kimi-cli` `src/kimi_cli/web/` |
+
+## 附录 B:参考资料
+
+- RFC 6455(WebSocket Protocol):;WHATWG HTML(EventSource):
+- Kimi Code Web 文档:
+- 本仓库:`docs/architecture.md`、`packages/webui/docs/ARCHITECTURE.md`、`docs/API.md`、`references/SECURITY-NOTES.md`
\ No newline at end of file
diff --git a/packages/webui/docs/drafts/arch_net_solution_0922.md b/packages/webui/docs/drafts/arch_net_solution_0922.md
new file mode 100644
index 00000000..185803cf
--- /dev/null
+++ b/packages/webui/docs/drafts/arch_net_solution_0922.md
@@ -0,0 +1,266 @@
+# Web UI 网络层与进程/线程拓扑技术方案(2026-09-23)
+
+> **状态**:技术方案(可实施级)。依据 [arch_net_draft_0922.md](arch_net_draft_0922.md)(提案)展开;方案改动先落盘再改代码,实现偏差归档至 §10 决策记录。
+> **硬约束**:① 前端 SPA 零修改(`packages/webui/public/` 不动,REST 响应形状与 SSE 帧语义逐字节兼容);② 暂时兼容原有方案(默认旧行为,新路径可开关);③ 全量 webui 测试套件与 `check-docs-alignment` 为验收门。
+
+## 1. 前端零修改不变量(golden 等价点)
+
+SPA 依赖的每一种线上行为必须逐字节保持(以 `server/lib/state-bus.js` 实际代码为准):
+
+| 等价点 | 精确规格 |
+|---|---|
+| 状态快照帧 | 匿名默认事件:`data: ` + `JSON.stringify(snapshot)` + 两个换行;不带 `event:` 行 |
+| 节流合并 | `STATE_PUSH_THROTTLE_MS`(默认 16ms,0 禁用);窗口内 last-call-wins,只写最后一份 payload |
+| 字节级 diff 门 | 与上次成功写出的 payload 逐字节相同则跳过不写 |
+| 命名控制事件 | `event: ` + `data: ` + 两个换行;`auth.token_rotated` 的 data 为原文(不包 JSON),`token.first_run` / `needs_authorization` / `authorization_decided` 的 data 为 JSON 字符串 |
+| SSE 头 | `SSE_HEADERS` 四字段逐字保持(`text/event-stream`、`no-cache`、`keep-alive`、`X-Accel-Buffering: no`) |
+| REST 形状 | 所有 `/api/*` 响应的 JSON 键序与错误消毒(截断 200 字符、去换行/控制字符)不变 |
+
+## 2. 进程/线程拓扑
+
+### 2.1 现状
+
+```
+webui 进程(1 主线程)
+├─ HTTP 服务(router + 门链)/ SSE ×2 / state-bus
+├─ mcode acp 子进程 ×N ← 每活动浏览器标签一个(stdio JSON-RPC 2.0)
+├─ mcode acp 子进程 ×1 ← 列表/命令单例(30s / 24h 缓存)
+└─ mcode acp 子进程 ×0..1 ← 命令探测临时进程(用完即删)
+```
+
+问题:标签数线性放大进程数;子进程生命周期补丁集合(挂起批量拒绝、探测会话清理、缓存绕行);取消 = 杀进程;能力面靠静态黑名单。
+
+### 2.2 目标拓扑
+
+```mermaid
+flowchart TD
+ subgraph PROC["webui 进程"]
+ MT["主线程:HTTP 服务 + SSE 适配器 + 事件总线 + 传输抽象层 + 门链"]
+ WK["引擎宿主 Worker 线程 ×W
local-runtime-v2 应用服务(主 agent 会话)"]
+ end
+ SC["side-chat headless 子进程 ×N
(mcode exec,每回合一换)"]
+ SUB["子 agent ACP 子进程 ×M
(mcode acp,每任务一个)"]
+ LEG["兼容模式:主 agent ACP 子进程
(MCODE_ENGINE=acp,= 现状拓扑)"]
+ MT <-->|"MessagePort(结构化克隆)"| WK
+ MT <-->|"stdio 行分隔 stream-json"| SC
+ MT <-->|"stdio ndjson JSON-RPC 2.0"| SUB
+ MT <-->|"stdio JSON-RPC 2.0"| LEG
+```
+
+### 2.3 拓扑关系表
+
+| 成员 | 职责 | 生命周期 | 崩溃域 | 数量模型 |
+|---|---|---|---|---|
+| 主线程 | 门链、REST、SSE 适配器、事件总线、传输选择 | 进程级 | 进程 | 1 |
+| 引擎宿主 Worker | 承载主 agent 会话(嵌入模式) | 跟随标签/会话(可重入验证后合并) | 线程(`terminate()`) | W = 活动标签数(保守)→ 1(验证后) |
+| side-chat 子进程 | 轻会话单回合 | 每回合一换 | 进程 | 0..N(并发 ≤4,规划值) |
+| 子 agent 子进程 | 后台/并行任务 | 每任务一个 | 进程 | 0..M(并发 ≤8,规划值) |
+| ACP 兼容子进程 | 兼容模式下的主 agent | 现状(每标签) | 进程 | 按开关 |
+
+### 2.4 引擎宿主 Worker 生命周期
+
+```mermaid
+stateDiagram-v2
+ [*] --> Booting: 创建 Worker
+ Booting --> Ready: booted(引擎加载成功)
+ Booting --> Fallback: boot-failed(引擎加载失败)
+ Ready --> Degraded: fatal(引擎未捕获异常)
+ Degraded --> Fallback: 重建失败
+ Degraded --> Ready: 重建成功
+ Fallback --> [*]: 回退 ACP 传输(调用方决策)
+ Ready --> [*]: shutdown / terminate
+```
+
+回退触发:boot 动态 import 失败、Worker 未捕获异常(`fatal` 消息)、MessagePort 断开。回退语义:`runMcode` 传输选择层改走 `mcode-acp`(兼容路径),用户无感;当前仓库未构建引擎 dist 时全部走 Fallback 属预期。
+
+## 3. 网络拓扑
+
+### 3.1 链路图
+
+```mermaid
+flowchart LR
+ BR["浏览器 SPA
(零修改)"]
+ subgraph SV["webui 主线程"]
+ EP["HTTP 端点:REST + GET /api/events(SSE)×2
+ 规划:GET /api/stream(WebSocket)"]
+ end
+ WK["引擎宿主 Worker"]
+ SC["side-chat 子进程"]
+ SUB["子 agent 子进程"]
+ BR -->|"HTTP/1.1:REST(JSON)+ SSE(text/event-stream)"| EP
+ EP -.->|"规划:WebSocket(RFC 6455 子集)"| BR
+ EP <-->|"MessagePort:结构化克隆"| WK
+ EP <-->|"stdio:行分隔 stream-json"| SC
+ EP <-->|"stdio:ndjson JSON-RPC 2.0"| SUB
+```
+
+### 3.2 链路 × 进程边界映射
+
+| 链路 | 协议 | A 端(进程/线程) | B 端(进程/线程) |
+|---|---|---|---|
+| REST 控制面 | HTTP/1.1 JSON | 浏览器主线程 | webui 主线程 |
+| SSE 数据面 ×2 | HTTP/1.1 text/event-stream | 浏览器主线程 | webui 主线程 |
+| WebSocket(规划) | RFC 6455 子集 | 浏览器主线程 | webui 主线程 |
+| 嵌入引擎 RPC | MessagePort 结构化克隆 | webui 主线程 | 引擎宿主 Worker |
+| headless 传输 | stdio 行分隔 stream-json | webui 主线程 | side-chat 子进程 |
+| ACP 传输 | stdio ndjson JSON-RPC 2.0 | webui 主线程 | 子 agent / ACP 兼容子进程 |
+
+## 4. 内部数据流:事件总线与适配器
+
+```mermaid
+flowchart LR
+ SRC["pushStateFor / 命名控制推送"]
+ BUS["事件总线 event-bus.js
cid 分区,seq 单调递增"]
+ SSEA["SSE 适配器 sse-adapter.js
(现状语义:16ms 合帧 + diff 门)"]
+ WSA["WebSocket 适配器(规划)
事件流 + 环形缓冲区重放"]
+ SRC --> BUS
+ BUS --> SSEA --> SPA1["SPA(现行消费)"]
+ BUS --> WSA --> SPA2["SPA(规划消费)/ 调试客户端"]
+```
+
+接口签名(第一阶段载荷 = state 快照 + 控制事件;事件级增量随嵌入传输落地接入):
+
+```js
+// event-bus.js
+emitEvent(cid, event) // {type:"state.snapshot", snapshot} | {type:"control", name, data}
+subscribeEvents(cid, sink) // sink({seq, ts, event}) → unsubscribe()
+// state-bus.js(兼容 facade,调用点不变)
+pushStateFor(cid, opts) // 构建快照 → emitEvent(opts.silent 不下发)
+```
+
+演进路径:阶段一总线承载快照 + 控制事件(SSE 兼容面零变化);`mcode-embed` 落地后 NormalizedEvent 级增量直接进总线,WebSocket 适配器按 `stream` 字段多路复用(`main`/`side:`/`sub:`),SSE 适配器继续折叠为快照。
+
+## 5. 引擎集成层
+
+### 5.1 传输抽象层
+
+接缝(按代码形态更正,2026-09-23):`runMcodeAcp(content, opts) → Promise<结果 r>`(内部经 streamAcpPrompt 写聊天行并 finalize);exec 传输为 spawn 描述符 + `collectExecResult` 收集(同样写聊天行);`runMcodeEmbed(content, opts) → AsyncGenerator`(生成器返回值 = 同形结果 r,聊天行由消费侧承接)。结果 r 形状:answer / thinking / status / error / usage / sessionId / durationMs / stopReason / tps。三实现:`mcode-acp`(既有)、`mcode-exec`(既有)、`mcode-embed`(新增,Worker RPC 桥,聊天行消费器随接线落地)。传输选择层按 `MCODE_ENGINE` 开关分发,回退链:embed 失败 → acp(默认即 acp)。
+
+### 5.2 能力协商(三层策略)
+
+1. **声明清单**:`initialize` 响应的 `agentCapabilities.sessionCapabilities`(list/fork/resume/close)+ `_meta["minimax-code/extensions"].methods`(`session/activate`、`mcode/session/*`)→ 直接采信。
+2. **惰性探测**:未声明方法(如 `session/set_mode`、`session/set_config_option`)首次调用即探测——用故意非法但无副作用的参数(缺必填字段),按错误码分类:`-32601`(Method not found)= 不支持并缓存;`-32602`(invalidParams)/ `-32000`(resourceNotFound)/ 成功 = 支持。`session/cancel` 为 notification,恒视为支持(尝试无害)。
+3. **旧引擎回退**:`initialize` 无任何声明 → 沿用既有 `UNSUPPORTED` 静态表语义(前端 501 语义逐字节不变)。
+
+### 5.3 Worker RPC 协议(MessagePort,v:1)
+
+| 方向 | 消息 | 字段 |
+|---|---|---|
+| 主→Worker | `boot` | `{workspace}` |
+| 主→Worker | `prompt` | `{sessionId?, content, model, permission}` |
+| 主→Worker | `steer` / `cancel` | `{sessionId, text}` / `{sessionId}` |
+| 主→Worker | `shutdown` | — |
+| Worker→主 | `booted` / `boot-failed` | `{engineVersion}` / `{error}` |
+| Worker→主 | `event` | `payload: NormalizedEvent`(流式) |
+| Worker→主 | `reply` | `{id, ok, payload|error}` |
+| Worker→主 | `prompt-done` | `{stopReason, usage}` |
+| Worker→主 | `fatal` | `{error}`(未捕获异常/引擎崩溃) |
+
+boot 动态 import 引擎应用服务(`@mavis/local-runtime-v2/cli-service` 或相对路径)置于 try/catch,失败发 `boot-failed` 由调用方回退。
+
+## 6. 兼容性矩阵
+
+| 子系统 | 现状 | 方案后 | 开关(默认) |
+|---|---|---|---|
+| SPA 静态资源 | 原样 | 原样(零修改) | — |
+| REST 形状 | 见 §1 | 逐字节不变 | — |
+| SSE 帧 | 见 §1 | 逐字节不变(SSE 适配器) | — |
+| 命名控制事件 | 4 个 | 不变 | — |
+| ACP 线协议 | ndjson JSON-RPC 2.0 | 不变(保留为兼容/子 agent 传输) | — |
+| 会话存储 | runtime-state.sqlite | 不变 | — |
+| 门链 | CORS→Origin→LAN→token→限流→只读 | 不变 | — |
+| 主 agent 引擎传输 | 每标签 ACP 子进程 | 嵌入 Worker(可回退) | `MCODE_ENGINE`(`acp`) |
+| 浏览器下行通道 | SSE | SSE + 规划 WebSocket | `MCODE_WEBUI_TRANSPORT`(`sse`) |
+
+新开关落地时在 `config.js` 声明 `export const` 并同步 §7 六处对齐面。
+
+## 7. 对齐义务矩阵
+
+`scripts/check-docs-alignment.mjs` 的 6 项交叉校验(已核实源码):
+
+| 检查 | 单一事实源关系 | 机制要点 |
+|---|---|---|
+| 1 | `package.json` capabilities → `README.md` + `docs/CAPABILITIES.md` | 每个 capability 名两处各出现一次 |
+| 2 | `README.md` 行内反引号端点引用 → `router.js` | 解析 `METHOD /api/path` 反引号对 |
+| 3 | `docs/API.md` 端点标题 → `router.js` | 标题须为 `` ### `METHOD /api/path` `` 反引号形式 |
+| 4 | `SECURITY-NOTES.md` env 变量 → `config.js` | 仅校验命中 `KNOWN_ENV_VARS` 显式集合的词;新开关须加集合 + 同名 `export const` |
+| 5 | `package.json` 回环解析 + capability 形状 | `description` ≥ 30 字符 |
+| 6 | cleanup-orphans 端点一致性 | `docs/API.md` ↔ `router.js` |
+
+**每切片同步义务**(更新对齐代码要及时——同一变更内完成全部触点):
+
+| 切片 | 必须同改的对齐面 |
+|---|---|
+| WebSocket `/api/stream` 端点 | `router.js` + `package.json`(endpoints,若有 capability 还需 ≥30 字符描述)+ `docs/API.md` 标题 + `README.md`(若提及)+ `config.js` + `SECURITY-NOTES.md` + `KNOWN_ENV_VARS` 集合 |
+| `MCODE_ENGINE` / `MCODE_WEBUI_TRANSPORT` 开关 | `config.js` export const + `SECURITY-NOTES.md` + `KNOWN_ENV_VARS` 集合 |
+| 纯内部模块(事件总线/帧库/能力协商/embed 骨架) | 无对齐面(不新增端点/env) |
+
+## 8. 实施切片与验收门
+
+| 切片 | 文件 | 测试验收 | 回退 |
+|---|---|---|---|
+| 事件总线 + SSE 适配器 | `event-bus.js`、`sse-adapter.js`、`state-bus.js` | golden 帧等价 + SSE 契约守护 6 测试 | `git revert`(行为不变设计) |
+| 能力协商 | `capability.js`、`mcode-rpc.js` | 声明/探测/回退三层单测 | 静态表回退 |
+| WebSocket 帧库 + 环形缓冲 | `ws-frame.js`、`ring-buffer.js` | RFC 6455 一致性矩阵 | 纯新增,无回退需要 |
+| 引擎宿主 Worker 骨架 | `mcode-embed.js`、`engine-host.worker.js` | RPC 往返 + NormalizedEvent 对齐 | boot-failed → ACP |
+| WebSocket 端点集成 | `ws-server.js`、`router.js` 等 | 协议一致性 + 门链映射 | `MCODE_WEBUI_TRANSPORT=sse` |
+
+全局验收门:① 全量 `pnpm --filter @mavis/webui test` 绿(对照绿基线:契约面 36/36);② `node scripts/check-docs-alignment.mjs` PASS(6/6);③ `release/public-source.json` 清单登记(`node scripts/source-inventory.mjs --write` + check:source);④ `git diff packages/webui/public` 为空(前端零修改证明)。
+
+## 9. 风险与回退
+
+| 风险 | 缓解 |
+|---|---|
+| RFC 6455 边界缺陷 | 一致性测试门先行;不达标暂缓端点集成(帧库为纯新增无害) |
+| 引擎服务不可重入 | 分层收窄为主会话 × 主会话;验证失败维持 ACP |
+| Worker 环境不兼容引擎 | boot-failed 兜底全量回退 ACP(已可测) |
+| SSE 行为漂移 | golden 等价为合并门;基线 36/36 对照 |
+| 对齐门破坏 | §7 矩阵同改纪律 + 每轮 check-docs-alignment 守卫 |
+
+## 10. 决策记录
+
+(预留:实现与规格的偏差由主协调者在此归档——原因、替代方案、影响面。)
+
+### 2026-09-23 能力协商切片
+
+1. **错误文案统一**:不支持方法的错误信息由「mcode 0.1.5 acp does not implement …」统一为「mcode acp does not implement … (server returns "Method not found")」。影响面:仅 toast 文案;前端契约 code:'unsupported' 与 501 语义不变。
+2. **cancel 能力位恒 true + cancelSession 改 notification**:`session/cancel` 在引擎侧是 notification(onNotification 承接),旧 request 路径会永挂;改为 `client.notify` 后尝试无害,能力位恒 true 属既定改进(旧实现不再依赖引擎支持该方法)。chat.js 的杀进程兜底保持不变。
+3. **注册表播种时序**:活动注册表在 acp-client 启动成功时按 initialize 结果播种;进程内首个 RPC 若发生在任何 client 启动之前,沿用旧回退语义(一次 501),client 就绪后自然生效。实际拓扑(列表单例先行)使该窗口趋近于零。
+4. **实现形态**:能力注册表归 capability.js 所有(零本地依赖),acp-client 播种、mcode-rpc 消费,避免循环依赖;UI 能力映射为可变对象,routes/protocol.js 零修改。
+
+### 2026-09-23 WebSocket 帧库切片(成员三报告,防御性收紧 2 条)
+
+5. **分片重组消息总量受 maxFrameBytes 约束**(超出 → 1009):仅限单帧则 N×1MiB 分片可放大占满内存(不可信客户端 DoS 面)。替代方案(只限单帧)被否决;影响面:总量超 1 MiB 的分片消息被拒,与提案 §7.2「单帧上限 1 MiB」语义一致。
+6. **close 线上 code 校验与长度字段最小化编码校验**(非法 code / 1 字节 close 体 / 非最小编码长度 → 1002):RFC §7.4.1/§5.2 MUST 要求拒绝;影响面仅畸形输入路径,正常 close 行为不变。
+
+### 2026-09-23 事件总线 + SSE 适配器切片
+
+7. **STATE_PUSH_THROTTLE_MS 默认值更正**:实测代码默认为 0(节流禁用、每次推送同步写出),此前文档表述的「默认 16ms」来自过时注释。golden 以代码为准:默认行为不变,窗口仅在显式设置 env 时启用。§1 的 16ms 表述按此更正。
+8. **节流判定改为调用期读 env**(`currentThrottleMs()`),导出常量保留为导入期快照仅供内省:既有测试契约「cache-bust 重导入状态模块即可读到新 env」要求行为跟随 env 变化;生产环境 env 进程内恒定,线上行为不变。
+9. **总线并行落点 + SSE 兼容面直写**:`pushStateFor` 与命名控制推送同时 emit 到事件总线(WebSocket 适配器订阅面)并按旧路径直写 SSE。原因:既有测试直接操纵 `sseByCid`(绕过 `setSseClient`),订阅驱动会破坏该契约;影响面:零线上差异,WebSocket 适配器接线时可切换为订阅驱动(届时同步调整测试装配方式)。
+
+### 2026-09-23 引擎宿主 Worker 切片(成员五报告,偏差 7 条 + 发现 2 条)
+
+10. **NormalizedEvent 标签用 `kind`**(非文档的 `type`):以代码形态为准逐字段对齐 `mcode-acp.js` 流回调载荷。连带发现:`docs/ARCHITECTURE.md` §3 的传输契约描述与两实现不符(`runMcodeAcp` 实为 Promise、`runMcodeExec` 为 spawn 描述符 + `collectExecResult`)——§5.1 已按代码形态更正,ARCHITECTURE.md 的修订列为文档跟进项。
+11. **导出面扩展**:`steerEmbed`/`cancelEmbed`(RPC 往返验收入口)、`stopEmbed` 返回 `Promise`(确定性验证无悬挂句柄)、`bootEngineHost` 扩展 `workspace`/`workerData` 参数(单参调用兼容)。
+12. **引擎适配器缝(createEngineAdapter)**:prompt/steer/cancel 的引擎调用点为适配器契约;真实引擎会话编排(组合应用服务)超出传输骨架切片且 dist 未构建不可测,未接线时明确回复 `engine-adapter-missing`。嵌入传输全量点亮依赖该适配器落地(后续波次)。
+13. **语义收束优于强杀**:空闲超时以语义 cancel 收束(非 terminate);error 事件落定后不再回写(finalize 恰好一次,修正了 mcode-acp 的晚回写瑕疵);失败路径返回全九字段结果形状。
+
+### 2026-09-23 embed 接线(chat.js 三路选择 + 聊天行归约器)
+
+14. **归约器范围取舍**(embed-consumer.js):收尾记账镜像 streamAcpPrompt 的真实值/估算两条分支;mavis 真值覆盖层与工具输出全文渲染留待共享归约器提取轮次(当前 embed 依赖的引擎适配器缝未接线,无真实回合可跑,先保契约正确)。工具行最小化为标记行(→ title)。
+15. **回退语义定案**:boot 失败自动回退旧传输(ACP/exec);**回合级失败不回退**(如 engine-adapter-missing 显示为失败告警)——避免部分事件已落聊天行后的重复回合。`MCODE_ENGINE=embed` 在引擎适配器落地前的预期行为即失败告警 + 显式可选,不影响默认路径。
+16. **/api/stop 的 embed 路径**(cancelEmbed 语义取消)留待下一波;当前 embed 回合的停止走 host shutdown 兜底。`MCODE_USE_ACP=0` exec 逃生路径保持原样,优先级:embed → exec → acp。
+
+### 2026-09-23 WebSocket 事件流端点(GET /api/stream)
+
+17. **协议取舍**:(a) 恢复欠载以「最近 state.snapshot 为基线」近似严格基线(严格版需按需重建快照,留待共享快照构建器提取时精确化);(b) 馈送按连接引用计数订阅,最后连接断开即退订,无连接期事件不入环形缓冲(恢复时走快照回退);(c) 心跳/配额/环形容量经参数注入便于测试,生产默认 30 秒 / 稳态 20 帧每秒 + 突发 40 / 4096 条;(d) 普通 GET → 426,升级门链复用 origin / LAN / token 三关(与 /api/events 同款),默认 MCODE_WEBUI_TRANSPORT=sse 时直接拒绝升级;(e) CLOSE_CODE 附加 1008 / 1013(RFC 6455 §7.4.1 保留值,附加常量不影响既有测试);(f) 解码器按 RFC 6455 §5.3 严格要求客户端帧掩码(未掩码帧以 1002 拒绝,实测确认),服务端帧不掩码;非浏览器客户端须自备掩码编码(RFC 义务,非本实现的宽容/严格选择)。
+
+### 2026-09-23 WebSocket 端点集成测试(补充决策)
+
+19. **契约修正(推翻 9 号决策两处,守护测试优先)**:(a) 错误消息恢复 "mcode 0.1.5" 溯源(合并措辞:`mcode acp does not implement X (mcode 0.1.5 server returns "Method not found")`,同时满足 checks 的 /mcode 0\.1\.5/ 与能力测试的 /does not implement|Method not found/);(b) session/cancel 由「恒 supported + notify」改为**声明条件式**——能力注册表未声明支持时短路为 unsupported 且不触碰 client(守护 checks/lib-mcode-rpc.check.mjs 的 no mcode spawn;该派生曾引发种子级联:真实 client 启动后 initialize 重播种注册表,令 set_mode/activate 逃逸黑名单),仅当引擎 initialize 声明该方法才走 notification 语义。前端能力映射 cancel 位随之在旧引擎下为 false。
+
+18. **集成测试的三个实证**:(a) 环形缓冲 `replay()` 返回 `{seq, item}` 包装条目,`frameForItem` 归一化兼容包装/裸条目两种形状;(b) hello 帧携带 `cid` 回显(协议补充,客户端确认身份 + 测试注入对齐);(c) 测试基建语义:升级套接字脱离 http 连接跟踪后 `server.close()` 回调在超时/异常路径个别不落定,测试清理以 500ms 竞速尽力而为(仅测试基建,不影响生产关闭语义——生产进程由 SIGINT/SIGTERM 钩子收尾)。入站配额按帧计数(TCP 粘包下按数据块计数无意义)。
+
+## 附:文档集
+
+见 [README.md](README.md) 索引。
\ No newline at end of file
diff --git a/packages/webui/package.json b/packages/webui/package.json
index 29583f2d..4e42683f 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -83,6 +83,7 @@
"endpoints": {
"health": "GET /api/health",
"events": "GET /api/events (SSE)",
+ "stream": "GET /api/stream (WebSocket event stream, MCODE_WEBUI_TRANSPORT=ws)",
"state": "GET /api/state",
"sessions": "GET|POST|DELETE /api/sessions[/:id|/switch]",
"chat": "POST /api/send|stop|cmd",
diff --git a/packages/webui/references/SECURITY-NOTES.md b/packages/webui/references/SECURITY-NOTES.md
index 56e0bfe3..3cd82ba4 100644
--- a/packages/webui/references/SECURITY-NOTES.md
+++ b/packages/webui/references/SECURITY-NOTES.md
@@ -515,6 +515,15 @@ log + a disabled feature) — it does not crash.
registry-installed or non-canonical layouts point at the right
binary explicitly. Resolution priority: env override > `$MCODE_CMD`
derived > dev layout fallback.
+- Network-topology wave 2 (`docs/drafts/arch_net_solution_0922.md` §6/§7):
+ two opt-in env switches, both defaulting to the pre-existing behavior:
+ `MCODE_ENGINE` (`acp`, default — the per-turn subprocess transport;
+ `embed` — in-process engine hosted on a worker thread with automatic
+ fallback to `acp` on boot failure) and `MCODE_WEBUI_TRANSPORT` (`sse`,
+ default — the snapshot channel the shipped SPA consumes unchanged;
+ `ws` — the additive `GET /api/stream` endpoint, which the shipped SPA
+ does not use). Neither switch weakens the gate chain: the WebSocket
+ upgrade passes the same origin/token checks as `/api/events`.
- Cross-platform: there is **no CI matrix**. The only CI is the
marketplace root gate (single ubuntu / Node 22 job: `npm ci` +
`npm run check`, which recursively runs every file under `test/`
diff --git a/packages/webui/scripts/check-docs-alignment.mjs b/packages/webui/scripts/check-docs-alignment.mjs
index 3f90581a..bd613720 100644
--- a/packages/webui/scripts/check-docs-alignment.mjs
+++ b/packages/webui/scripts/check-docs-alignment.mjs
@@ -279,6 +279,8 @@ const KNOWN_ENV_VARS = new Set([
"MAVIS_DATA_DIR",
"SQLITE3_BIN",
"DEBUG_INJECT",
+ "MCODE_ENGINE",
+ "MCODE_WEBUI_TRANSPORT",
]);
console.log(`${TAG.dim("[4/6]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`);
diff --git a/packages/webui/server.js b/packages/webui/server.js
index c6f05c59..c824b98e 100644
--- a/packages/webui/server.js
+++ b/packages/webui/server.js
@@ -25,6 +25,7 @@ import { installGlobalErrorHandlers, MCODE_CMD, UPLOAD_DIR, WEBUI_DATA_DIR, PORT
import { listenWithPortFallback, MAX_PORT_ATTEMPTS } from './server/lib/port.js'
import { LAN_IP } from './server/lib/lan.js'
import { handleRequest } from './server/router.js'
+import { handleStreamUpgrade } from './server/lib/ws-server.js'
import { runStartupCleanup } from './server/cleanup.js'
import { shutdownMcodeAcpSingleton } from './server/lib/acp-client.js'
import { init as initSettings, getPersistPath, getTokenEnabled } from './server/lib/settings.js'
@@ -80,6 +81,16 @@ setAuthTokenEnabled(getTokenEnabled())
const server = http.createServer(handleRequest)
+// 波次 2b(docs/drafts/arch_net_solution_0922.md §7.2):WebSocket 事件流升级钩子。
+// 门链(origin/LAN/token)在 handleStreamUpgrade 内与 /api/events 同款执行;
+// 默认 MCODE_WEBUI_TRANSPORT=sse 时该端点拒绝升级,发行版 SPA 不受影响。
+// 其余升级路径一律拒绝(无升级监听时 Node 本就关套接字,行为等价)。
+server.on("upgrade", (req, socket, head) => {
+ const pathname = (req.url || "/").split("?")[0]
+ if (pathname === "/api/stream") handleStreamUpgrade(req, socket, head)
+ else socket.destroy()
+})
+
// 端口回退 (见 server/lib/port.js): 默认端口被占用时向后找空闲端口, 显式 PORT
// 不回退。日志里的端口必须是实际绑定值 —— 启动器 (mcode-web / mcode webui) 正是
// 从 "listening on" 这一行取要打开的 URL, 而 origin 信任集 / share URL 按
diff --git a/packages/webui/server/lib/acp-client.js b/packages/webui/server/lib/acp-client.js
index 48aa9c45..264ac02b 100644
--- a/packages/webui/server/lib/acp-client.js
+++ b/packages/webui/server/lib/acp-client.js
@@ -5,6 +5,7 @@
import { McodeAcpClient } from "../../acp.mjs";
import { DEFAULT_WORKSPACE, MCODE_RUNTIME_DB } from "./config.js";
+import { syncActiveCapabilities } from "./capability.js";
import { deleteMcodeSessionFromDb } from "./db.js";
// v0.5.bu: 拉 mcode 真实 session 列表(mcode acp session/list 协议)
@@ -26,6 +27,8 @@ export async function getMcodeAcpClient() {
try {
await client.start();
_mcodeAcpSingleton = client;
+ // 播种运行时能力注册表(声明清单/惰性探测/旧引擎回退,见 capability.js)
+ syncActiveCapabilities(client.capabilities);
console.log(`[acp] singleton client started pid=${client.pid || "?"}`);
return client;
} catch (e) {
diff --git a/packages/webui/server/lib/capability.js b/packages/webui/server/lib/capability.js
new file mode 100644
index 00000000..e58022e0
--- /dev/null
+++ b/packages/webui/server/lib/capability.js
@@ -0,0 +1,212 @@
+// webui/server/lib/capability.js
+// ACP 方法能力协商(三层策略,替代旧 mcode-rpc.js 的静态 UNSUPPORTED 黑名单)。
+//
+// 三层策略:
+// 1. 声明清单 — initialize 响应的 agentCapabilities.sessionCapabilities
+// 与 _meta["minimax-code/extensions"].methods 直接采信;
+// 2. 惰性探测 — 未声明方法首次真实调用即探测:Method not found(-32601)
+// 判为不支持并缓存;其余错误或成功判为支持;
+// 3. 旧引擎回退 — initialize 无任何声明(declared=false)时沿用
+// LEGACY_UNSUPPORTED 静态表语义(0.1.5 实测行为)。
+//
+// 设计约束:本模块零依赖、不 import 任何本地模块(避免 acp-client ↔ mcode-rpc
+// 循环依赖)。契约修正(决策记录 19):session/cancel 不再恒视为支持——
+// 未声明时短路为 unsupported 且不派生 client(守护 no mcode spawn),
+// 声明后由 mcode-rpc 走 notification 语义。
+
+/**
+ * 旧引擎(无任何能力声明)不支持的方法集合(mcode 0.1.5 实测语义)。
+ * 注意:session/cancel 不参与回退判定(notification 恒尝试,见 classifyMethod)。
+ */
+export const LEGACY_UNSUPPORTED = new Set([
+ "session/set_mode",
+ "session/set_config_option",
+ "session/cancel",
+ "session/activate",
+ "session/fork",
+ "session/resume",
+ "session/delete",
+]);
+
+/** 核心方法:协议基础能力,任何引擎都视为支持。 */
+const CORE_ALWAYS_SUPPORTED = new Set([
+ "initialize",
+ "session/new",
+ "session/load",
+ "session/prompt",
+ "session/list",
+ "session/close",
+]);
+
+/** sessionCapabilities 键 → ACP 方法名映射。 */
+const SESSION_CAPABILITY_METHODS = {
+ list: "session/list",
+ fork: "session/fork",
+ resume: "session/resume",
+ close: "session/close",
+};
+
+/** UI 能力位键 → 方法名映射(GET /api/protocol/capabilities 响应形状,保持 12 键不变)。 */
+export const UI_METHOD_KEYS = {
+ set_mode: "session/set_mode",
+ set_config_option: "session/set_config_option",
+ cancel: "session/cancel",
+ activate: "session/activate",
+ fork: "session/fork",
+ resume: "session/resume",
+ delete: "session/delete",
+ load: "session/load",
+ close: "session/close",
+ list: "session/list",
+ new: "session/new",
+ prompt: "session/prompt",
+};
+
+/**
+ * 解析 initialize 响应的声明式能力。
+ *
+ * @param {object} initializeResult initialize 的 JSON-RPC result
+ * @returns {{ declared: boolean, session: Record, extensionMethods: string[] }}
+ */
+export function resolveDeclaredCapabilities(initializeResult) {
+ const r = initializeResult && typeof initializeResult === "object"
+ ? initializeResult
+ : {};
+ const sessionCaps =
+ r.agentCapabilities && typeof r.agentCapabilities === "object"
+ ? r.agentCapabilities.sessionCapabilities
+ : undefined;
+ const ext = r._meta && typeof r._meta === "object"
+ ? r._meta["minimax-code/extensions"]
+ : undefined;
+ const extensionMethods = Array.isArray(ext && ext.methods)
+ ? ext.methods.filter((m) => typeof m === "string")
+ : [];
+ const session = {};
+ let anySessionDeclared = false;
+ for (const [key, method] of Object.entries(SESSION_CAPABILITY_METHODS)) {
+ const present = Boolean(
+ sessionCaps && typeof sessionCaps === "object" && key in sessionCaps,
+ );
+ session[method] = present;
+ if (present) anySessionDeclared = true;
+ }
+ return {
+ declared: anySessionDeclared || extensionMethods.length > 0,
+ session,
+ extensionMethods,
+ };
+}
+
+/**
+ * 判定一个 JSON-RPC 错误是否为「方法不存在」(Method not found)。
+ * 兼容数值码 -32601、字符串码与 message 文本匹配。
+ *
+ * @param {unknown} error 被拒的错误对象(acp.mjs 附带 data: jsonrpc error)
+ */
+function isMethodNotFound(error) {
+ if (!error) return false;
+ const code = error && error.data ? error.data.code : undefined;
+ if (code === -32601 || code === "-32601") return true;
+ const msg = String(
+ (error && error.data && error.data.message) || (error && error.message) || "",
+ );
+ return /method not found/i.test(msg);
+}
+
+/**
+ * 创建能力注册表。
+ *
+ * @param {object} [opts]
+ * @param {object} [opts.initializeResult] initialize 结果;缺省按旧引擎回退
+ */
+export function createCapabilityRegistry({ initializeResult } = {}) {
+ const declared = resolveDeclaredCapabilities(initializeResult);
+ const supported = new Set();
+ const unsupported = new Set();
+ // 声明清单预置
+ for (const [method, present] of Object.entries(declared.session)) {
+ (present ? supported : unsupported).add(method);
+ }
+ for (const m of declared.extensionMethods) supported.add(m);
+ for (const m of CORE_ALWAYS_SUPPORTED) supported.add(m);
+ // 旧引擎回退:无声明 → 黑名单语义(含 session/cancel——契约修正 19:
+ // 未声明时短路不得触碰 client;声明 extensionMethods 含之才视为支持)
+ if (!declared.declared) {
+ for (const m of LEGACY_UNSUPPORTED) unsupported.add(m);
+ }
+
+ return {
+ /** @returns {"supported"|"unsupported"|"unknown"} */
+ classify(method) {
+ if (supported.has(method)) return "supported";
+ if (unsupported.has(method)) return "unsupported";
+ return "unknown";
+ },
+ /** 真实调用结果即探测结果:Method not found → 不支持并缓存,其余 → 支持。 */
+ recordProbeResult(method, error) {
+ unsupported.delete(method);
+ supported.delete(method);
+ (error && isMethodNotFound(error) ? unsupported : supported).add(method);
+ },
+ markSupported(method) {
+ unsupported.delete(method);
+ supported.add(method);
+ },
+ /** @returns {{ supported: string[], unsupported: string[], unknown: string[] }} */
+ snapshot() {
+ const known = new Set([
+ ...Object.values(UI_METHOD_KEYS),
+ ...declared.extensionMethods,
+ ...CORE_ALWAYS_SUPPORTED,
+ ]);
+ const out = { supported: [], unsupported: [], unknown: [] };
+ for (const m of [...known].sort()) {
+ out[this.classify(m)].push(m);
+ }
+ return out;
+ },
+ };
+}
+
+/**
+ * 显式惰性探测的无副作用参数(缺必填字段,服务端在副作用前即以
+ * invalidParams / resourceNotFound 拒绝)。真实调用的错误同样可作探测
+ * 结果(recordProbeResult),本函数供显式预探测路径使用。
+ *
+ * @param {string} method ACP 方法名
+ */
+export function probeParamsFor(method) {
+ if (method === "session/set_mode") return { sessionId: "" };
+ if (method === "session/set_config_option") return { sessionId: "" };
+ return {};
+}
+
+// ------------------------------------------------------------------
+// 进程级活动注册表(acp-client 播种 / mcode-rpc 消费,无循环依赖)
+// ------------------------------------------------------------------
+let activeRegistry = createCapabilityRegistry({});
+
+/** @returns {ReturnType} */
+export function getActiveRegistry() {
+ return activeRegistry;
+}
+
+/**
+ * 用 initialize 结果播种活动注册表并刷新 UI 能力映射。
+ * 由 acp-client 在 client 启动成功后调用。
+ */
+export function syncActiveCapabilities(initializeResult) {
+ activeRegistry = createCapabilityRegistry({ initializeResult });
+ refreshCapabilityUi();
+}
+
+/** UI 能力映射(可变对象,routes/protocol.js 每次请求读取最新值)。 */
+export const CAPABILITY_UI = {};
+
+function refreshCapabilityUi() {
+ for (const [key, method] of Object.entries(UI_METHOD_KEYS)) {
+ CAPABILITY_UI[key] = activeRegistry.classify(method) !== "unsupported";
+ }
+}
+refreshCapabilityUi();
\ No newline at end of file
diff --git a/packages/webui/server/lib/config.js b/packages/webui/server/lib/config.js
index 725aa06a..81569f7b 100644
--- a/packages/webui/server/lib/config.js
+++ b/packages/webui/server/lib/config.js
@@ -134,6 +134,13 @@ export const TOKEN_STDOUT = process.env.MCODE_WEBUI_TOKEN_STDOUT === "1";
export const DEFAULT_MODEL =
process.env.MCODE_MODEL || "minimax_api/MiniMax-M3";
export const DEFAULT_TIMEOUT = process.env.MCODE_TIMEOUT || "120s";
+// v2 波次 2(arch_net_solution_0922.md §6/§8):引擎传输开关。
+// 默认 "acp" = 旧行为不变(每回合 mcode acp 子进程);"embed" = 引擎宿主
+// Worker 线程(boot 失败自动回退 acp)。
+export const MCODE_ENGINE = process.env.MCODE_ENGINE || "acp";
+// 浏览器下行通道开关:默认 "sse" = 旧行为不变(SPA 消费的快照通道);
+// "ws" = 新增 /api/stream 端点(SPA 不使用,规划接入)。
+export const MCODE_WEBUI_TRANSPORT = process.env.MCODE_WEBUI_TRANSPORT || "sse";
export const DEFAULT_MAX_STEPS = Number(process.env.MCODE_MAX_STEPS) || 6;
export const MAX_CONCURRENT = Number(process.env.MCODE_MAX_CONCURRENT) || 3;
export const UPLOAD_DIR =
diff --git a/packages/webui/server/lib/embed-consumer.js b/packages/webui/server/lib/embed-consumer.js
new file mode 100644
index 00000000..942806b4
--- /dev/null
+++ b/packages/webui/server/lib/embed-consumer.js
@@ -0,0 +1,120 @@
+// webui/server/lib/embed-consumer.js
+// mcode-embed 传输的聊天行归约器 —— 传输接缝的消费侧(技术方案 §5.1)。
+//
+// 职责:消费 runMcodeEmbed 的 NormalizedEvent 流,把增量落成 cs.chat 行
+// (▲ 思考 / ● 正文 / → 工具标记),回合收尾时清理流式光标、记账用量、
+// 复位 running 并推送状态。与 streamAcpPrompt(mcode-acp.js)/ collectExecResult
+// (mcode-exec.js)职责对位;生成器自身已累积并 finalize 结果 r,本模块只做
+// 呈现层归约,不重复计算 r。
+//
+// 范围说明(决策记录 14):收尾记账镜像 streamAcpPrompt 的真实值/估算两条
+// 分支;mavis 真值覆盖层与工具输出全文渲染为后续共享归约器提取轮次。
+
+import { streamUpdateLine } from "./sessions.js";
+import { pushStateFor } from "./state-bus.js";
+
+/**
+ * 归约一轮 embed 事件流并返回生成器的结果 r。
+ *
+ * @param {AsyncGenerator