diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts
index 240b767e3e..1bc5a7cb32 100644
--- a/docs/developers/_meta.ts
+++ b/docs/developers/_meta.ts
@@ -21,6 +21,7 @@ export default {
'channel-plugins': 'Channel Plugin Guide',
tools: 'Tools',
'qwen-serve-protocol': 'qwen serve HTTP protocol',
+ daemon: 'Daemon 模式 · 开发者深度指南',
examples: {
display: 'hidden',
diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md
new file mode 100644
index 0000000000..14ee778e81
--- /dev/null
+++ b/docs/developers/daemon/00-index.md
@@ -0,0 +1,149 @@
+# Daemon 开发者文档
+这是 **qwen-code daemon 模式**面向开发者的技术文档集 —— 涵盖 `qwen serve` HTTP daemon、底层的 `acp-bridge` 包、工作区粒度的 MCP transport 池、多客户端权限协调器、Typed Daemon Event Schema v1、TypeScript SDK daemon 客户端,以及所有上层适配器(CLI TUI、IM 渠道机器人、VSCode IDE 等)。
+
+它是对现有文档的补充,而不是替代:
+
+| 现有文档 | 受众 | 仍是该主题的事实来源 |
+| ------------------------------------------------------------------------------------ | ------------------ | ---------------------------------------------------------------------- |
+| [`../../users/qwen-serve.md`](../../users/qwen-serve.md) | 运维 / 使用者 | 启动方式、命令行参数、威胁模型 |
+| [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) | 协议实现者 | HTTP 路由清单、请求/响应结构、错误码 |
+| [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md) | SDK 使用者 | TS 端到端示例 |
+| [`../daemon-client-adapters/`](../daemon-client-adapters/) | 适配器作者(草案) | 每种客户端的设计草案 |
+| [`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) | F2 维护者 | 工作区共享 MCP transport 池设计 v2.2(32 条 review fold-in changelog) |
+
+如果你想 **快速把 daemon 跑起来 + 验证它工作**,直接看 [`20-quickstart-operations.md`](./20-quickstart-operations.md);如果你想 **基于 wire 协议构建一个客户端**,先看 `qwen-serve-protocol.md`;如果你想 **理解 daemon 内部如何工作、扩展它或调试它**,就读本文档集 01–19。
+
+## 阅读顺序
+
+按目标挑路径:
+
+- **想先跑起来再看原理** — 直接 `20 → 17 → 19`(快速上手 + 配置 + 调试),有问题再回来看 01 + 02。
+- **新贡献者** — 依次:`01 → 02 → 03 → 08 → 09 → 10 → 11 → 12`,覆盖系统、运行时、bridge、wire 侧基础。`20` 任意时候作为「跑起来怎么验」的副本。
+- **新增客户端适配器** — `01 → 09 → 10 → 13 → (14 / 15 / 16)`:架构、事件模式、SSE bus、SDK,再看与你最接近的适配器。
+- **修改 MCP 池 / 预算** — `01 → 03 → 05 → 06`。
+- **修改权限相关代码** — `01 → 03 → 04 → 12`。
+- **线上排查问题** — `19 → 18 → 17 → 20`。
+
+## 文档清单
+
+### 基础
+
+- [`01-architecture.md`](./01-architecture.md) — 系统架构、进程拓扑、包关系、6 张顶层时序图。
+
+### 服务端核心
+
+- [`02-serve-runtime.md`](./02-serve-runtime.md) — `runQwenServe` 引导、Express 应用、中间件链、优雅退出。
+- [`03-acp-bridge.md`](./03-acp-bridge.md) — `@qwen-code/acp-bridge` 包内部、会话多路复用、channel 工厂、ACP 子进程拉起。
+- [`04-permission-mediation.md`](./04-permission-mediation.md) — `MultiClientPermissionMediator` 四种策略、N1 超时不变式、取消哨兵。
+- [`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) — F2 引入的 `McpTransportPool`、池条目、反向索引、重启、drain。
+- [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) — `WorkspaceMcpBudget`、模式(off/warn/enforce)、滞回阈值、批量拒绝合并。
+- [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) — `WorkspaceFileSystem` 沙箱、路径策略、审计、`BridgeFileSystem` 契约。
+- [`08-session-lifecycle.md`](./08-session-lifecycle.md) — 创建 / 附加 / 载入 / 恢复、`X-Qwen-Client-Id`、心跳、剔除、元数据。
+- [`09-event-schema.md`](./09-event-schema.md) — Typed Event Schema v1:29 种已知事件、payload、reducer、向前兼容。
+- [`10-event-bus.md`](./10-event-bus.md) — `EventBus`、单调 ID、环形缓冲重放、`Last-Event-ID`、慢消费者反压、`client_evicted`。
+- [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) — 能力注册表、协议版本、Schema 版本、条件广播。
+- [`12-auth-security.md`](./12-auth-security.md) — Bearer 中间件、Host 白名单、CORS 拒绝、Mutation Gate、`--require-auth`、`/health` 豁免、Device Flow。
+
+### 客户端
+
+- [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) — TS SDK:`DaemonClient`、`DaemonSessionClient`、`DaemonAuthFlow`、SSE 解析器、事件 reducer,以及新的 `ui/*` 子包。
+- [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) — **共享 UI Transcript 层**(SDK `ui/*`)。原 `DaemonTuiAdapter.ts` 已在 #4328 中删除;本篇覆盖新的 transcript 归一 / reduce / selector 原语与 webui `DaemonSessionProvider` 消费方。
+- [`15-channel-adapters.md`](./15-channel-adapters.md) — `DaemonChannelBridge` 共享基座 + 钉钉、微信、Telegram 适配器。
+- [`16-vscode-ide-adapter.md`](./16-vscode-ide-adapter.md) — `DaemonIdeConnection`、Loopback 强制、Webview 桥接。
+
+### 参考附录
+
+- [`17-configuration.md`](./17-configuration.md) — 影响 daemon 的环境变量、命令行参数、`settings.json` 键。
+- [`18-error-taxonomy.md`](./18-error-taxonomy.md) — 各层的 typed error 与修复建议。
+- [`19-observability.md`](./19-observability.md) — `QWEN_SERVE_DEBUG`、调试套路、Telemetry 现状缺口。
+
+### 快速上手 / 运维向
+
+- [`20-quickstart-operations.md`](./20-quickstart-operations.md) — 9 种启动姿势、全部 CLI 参数 / env / `settings.json` 速查表、boot 拒启动场景、`curl` 验证清单、`/demo` 用法、`qwen serve` → listening server 的完整调用链、嵌入式调用示例、优雅退出 vs 强退。**想先跑起来再看原理的话从这篇开始。**
+
+## 术语表
+
+- **ACP** — Agent Client Protocol,daemon bridge 与 ACP 子进程之间通过 stdio 跑的 JSON-RPC;不要和客户端用来访问 daemon 的 HTTP 协议混淆。
+- **ACP 子进程** — daemon 拉起的子进程(`qwen --acp`),里面跑真正的 agent 运行时;daemon 的 bridge 把一个 ACP 子进程多路复用给多个连进来的客户端。
+- **acp-bridge** — `@qwen-code/acp-bridge` 包(`packages/acp-bridge/`),负责会话多路复用、权限协调器、事件总线、channel 工厂。
+- **BridgeClient** — `packages/acp-bridge/src/bridgeClient.ts`,封装一条 ACP `ClientSideConnection`,处理 `requestPermission` / `sendPrompt` / `cancelSession`。
+- **Channel 工厂** — 可插拔策略,决定 bridge 如何拉起 / 附加 ACP 子进程:默认 `spawnChannel` 把 `qwen --acp` 跑成子进程;`inMemoryChannel` 在进程内跑用于测试。
+- **DaemonClient** — `packages/sdk-typescript/src/daemon/DaemonClient.ts`,TS SDK 对 daemon 的 HTTP 门面。
+- **DaemonSessionClient** — `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts`,会话级封装,自动跟踪 `lastSeenEventId` 用于 SSE 重放。
+- **EventBus** — `packages/acp-bridge/src/eventBus.ts`,按会话维度的内存 pub/sub:单调 ID、环形缓冲、每订阅者反压。
+- **F1 / F2 / F3 / F4** — [#4175](https://github.com/QwenLM/qwen-code/issues/4175) 的里程碑:F1 bridge 抽取 + `BridgeFileSystem`;F2 工作区共享 MCP transport 池;F3 多客户端权限协调;F4 协议补齐 + `qwen --serve` 同进程托管(进行中)。
+- **MCP** — Model Context Protocol,MCP server 暴露 tool / resource / prompt,daemon 的 ACP 子进程连这些 server。
+- **McpTransportPool** — `packages/core/src/tools/mcp-transport-pool.ts`,F2 的工作区共享池,按 (server 名 + 配置指纹) 复用一个 MCP transport。
+- **Mediator policy** — `first-responder` / `designated` / `consensus` / `local-only` 之一,决定多客户端权限投票如何裁决。
+- **Originator client id** — 触发当前权限请求的那次 prompt 所用的 `X-Qwen-Client-Id`,`designated` 策略只接受这个 id 的投票。
+- **PoolEntry** — `packages/core/src/tools/mcp-pool-entry.ts`,`McpTransportPool` 里的一条记录:一条 MCP transport、引用此条目的会话引用计数、空闲 drain 定时器。
+- **Session scope** — `single`(所有客户端共享一个 ACP 会话)或 `per-client`(每客户端一个会话),默认 `single`。
+- **SSE** — Server-Sent Events,daemon 的出站事件通道(`GET /session/:id/events`)。
+- **Workspace** — daemon 启动时绑定的目录(`--workspace` 或 `cwd`),一个 daemon 进程 = 一个 workspace。
+
+## 本文档集**不**覆盖的内容
+
+- **Java / Python SDK 的 daemon 客户端** — 目前只有 TS SDK 有 daemon 客户端,第 13 篇只覆盖 TS。
+- **Web UI 详细产品形态** — 自 [#4328](https://github.com/QwenLM/qwen-code/pull/4328) 起 `packages/webui/src/daemon/` 已经是真正的 daemon 前端(React `DaemonSessionProvider` + transcriptAdapter,消费 SDK `ui/*` 子包)。架构走法和 selectors 在 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 一并讲;webui 自身的产品形态(设计、布局、复用到哪里)参考 [`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md) 与 [`../daemon-ui/README.md`](../daemon-ui/README.md)。
+- **Zed extension (`packages/zed-extension/`)** — 直接用 stdio ACP 拉起 `qwen --acp`,不走 daemon,不需要 daemon 章节。
+- **F4(进行中)** — 协议补齐和 `qwen --serve` 同进程托管。写文档时该 surface 还不稳定,等落地后再补章。
+
+## 当前 daemon mode 覆盖的功能
+
+下表列出本文档集覆盖的所有功能 surface,按域归类。每条都是 daemon mode 完整产品的一部分,不是「增量」或「PR 合入清单」。
+
+### 服务端核心
+
+| Surface | 实现位置 | 文档落点 |
+|---|---|---|
+| `qwen serve` 引导与 Express 装配 | `packages/cli/src/serve/runQwenServe.ts`、`server.ts` | [`02-serve-runtime.md`](./02-serve-runtime.md) |
+| ACP bridge 与会话多路复用 | `packages/acp-bridge/src/bridge.ts` 等 | [`03-acp-bridge.md`](./03-acp-bridge.md) |
+| 多客户端权限协调(four-policy mediator + N1 timeout invariant + cancel sentinel) | `packages/acp-bridge/src/permissionMediator.ts` | [`04-permission-mediation.md`](./04-permission-mediation.md) |
+| 工作区共享 MCP transport 池(含 fingerprint / OAuth 凭证隔离、子进程 descendant 清理、IDE-close drain、`/mcp refresh` pool gate、reconnect 期 `MCPCallInterruptedError`、`MAX_IDLE_MS` 孤儿回收) | `packages/core/src/tools/mcp-transport-pool.ts`、`mcp-pool-entry.ts`、`mcp-pool-key.ts`、`pid-descendants.ts`、`session-mcp-view.ts` | [`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) |
+| MCP workspace budget guardrails | `packages/core/src/tools/mcp-workspace-budget.ts` | [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) |
+| Workspace FS 沙箱、TOCTOU / symlink / trust gate / atomic write / FsError-over-ACP-wire | `packages/cli/src/serve/fs/`、`packages/acp-bridge/src/bridgeClient.ts` | [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) |
+| Session 生命周期:create / attach / load / resume / heartbeat / eviction / `X-Qwen-Client-Id` 身份 | `packages/acp-bridge/src/bridge.ts`、`bridgeTypes.ts` | [`08-session-lifecycle.md`](./08-session-lifecycle.md) |
+
+### Wire 协议
+
+| Surface | 实现位置 | 文档落点 |
+|---|---|---|
+| Typed event schema v1(29 种已知 event type,含 `state_resync_required` 同步恢复帧、SDK reducer `awaitingResync` 状态机、`RESYNC_PASSTHROUGH_TYPES` 终态白名单) | `packages/sdk-typescript/src/daemon/events.ts` | [`09-event-schema.md`](./09-event-schema.md) |
+| Envelope 级元数据:每帧 `_meta.serverTimestamp`(多客户端时钟一致性)、`tool_call.provenance` + `serverId`(在 `data._meta`) | `packages/cli/src/serve/server.ts` 的 `formatSseFrame`、`packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts` | [`09-event-schema.md`](./09-event-schema.md) |
+| SSE event bus:单调 ID、环形缓冲重放、`Last-Event-ID`、慢消费者反压、环驱逐 → `state_resync_required` 恢复路径 | `packages/acp-bridge/src/eventBus.ts` | [`10-event-bus.md`](./10-event-bus.md) |
+| 能力协商:注册表、协议版本、条件广播 | `packages/cli/src/serve/capabilities.ts` | [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) |
+| 认证与安全模型:bearer + host allowlist + CORS deny + mutation gate + `--require-auth` + `/health` 豁免 + device-flow OAuth | `packages/cli/src/serve/auth.ts`、`packages/cli/src/serve/auth/deviceFlow.ts` | [`12-auth-security.md`](./12-auth-security.md) |
+
+### 客户端 / SDK
+
+| Surface | 实现位置 | 文档落点 |
+|---|---|---|
+| TS SDK daemon client(HTTP/SSE 门面、session 封装、SSE replay、device-flow helper、330s `MCP_RESTART_DEFAULT_TIMEOUT_MS`) | `packages/sdk-typescript/src/daemon/{DaemonClient,DaemonSessionClient,DaemonAuthFlow,sse,events,types}.ts` | [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) |
+| 共享 UI Transcript 层(`DaemonUiEventType` 29 种 UI 友好事件、reducer + selectors、HTML / terminal / tool preview / conformance 渲染原语,给任何 UI 宿主复用) | `packages/sdk-typescript/src/daemon/ui/{types,normalizer,transcript,store,render,terminal,toolPreview,conformance,utils}.ts` | [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) |
+| Web UI daemon 前端(React `DaemonSessionProvider` + transcriptAdapter,第一个共享 UI 层消费方) | `packages/webui/src/daemon/` | [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 「消费方」段 |
+| IM channel 适配器(钉钉 / 微信 / Telegram,共享 `DaemonChannelBridge` 基座) | `packages/channels/` | [`15-channel-adapters.md`](./15-channel-adapters.md) |
+| VSCode IDE daemon 适配器(loopback 强制、webview postMessage 桥接) | `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` | [`16-vscode-ide-adapter.md`](./16-vscode-ide-adapter.md) |
+
+### 参考与运维
+
+| Surface | 文档落点 |
+|---|---|
+| 全部 env / CLI 参数 / `settings.json` 速查 | [`17-configuration.md`](./17-configuration.md) |
+| 各层 typed error 与修复建议 | [`18-error-taxonomy.md`](./18-error-taxonomy.md) |
+| `QWEN_SERVE_DEBUG`、调试套路、telemetry 现状缺口 | [`19-observability.md`](./19-observability.md) |
+| 启动姿势、`curl` 验证清单、`/demo`、调用链 | [`20-quickstart-operations.md`](./20-quickstart-operations.md) |
+
+### 历史 / 已弃用 surface
+
+- **`packages/cli/src/ui/daemon/DaemonTuiAdapter.ts`** 与整个 `packages/cli/src/ui/daemon/` 目录已删除,由共享 UI Transcript 层(第 14 篇)取代。CLI TUI、channel base、VSCode IDE 三条产品路径会陆续迁过去,迁移指南见 [`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md)。
+- **`docs/developers/daemon-client-adapters/tui.md`** 草案已删除,新的 [`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md) 是替代品。
+
+### 向前兼容
+
+- Event schema 是加法协议:未知 type 自动 fallback 到 `narrowDaemonEvent → kind: 'unknown'`,SDK 消费方不会因为新增 event type 而崩。
+- `mcp_server_restart_refused.reason` 是封闭枚举(`MCP_RESTART_REFUSED_REASONS.has` 闸),新加的枚举值在老 SDK 上会被静默丢弃 —— 新 reason 必须配新 SDK 一起发。
+- envelope 上的 `_meta` 走宽松 spread merge,未来加新元数据字段不会破老解析器。
+
+### 版本溯源
+
+这套文档对齐到 `daemon_mode_b_main` 当前 HEAD。覆盖到的源 PR 时间线见 [`#4175`](https://github.com/QwenLM/qwen-code/issues/4175) 的 F 系列里程碑(F1 acp-bridge 抽取 / F2 MCP transport 池 / F3 多客户端权限协调 / F4 协议补齐)。如果要追某条具体功能的提交历史,从对应专题文档底部「参考」节的 PR # 进去比较快。
diff --git a/docs/developers/daemon/01-architecture.md b/docs/developers/daemon/01-architecture.md
new file mode 100644
index 0000000000..02cff32cd5
--- /dev/null
+++ b/docs/developers/daemon/01-architecture.md
@@ -0,0 +1,369 @@
+# Daemon 架构
+## 概览
+
+一个 `qwen serve` 进程坚持 **一 daemon = 一 workspace** 的不变式。它内嵌一个 Express HTTP 服务、持有一个 `acp-bridge` 实例、拉起一个 ACP 子进程(`qwen --acp`)来跑真正的 agent 运行时。多个客户端(CLI TUI、IDE companion、IM channel 机器人、Web BFF、自定义脚本)通过 HTTP + SSE 连进来,要么共享同一个 ACP session(`sessionScope: 'single'`,默认),要么每个客户端各拿一个(`per-client`)。
+
+在 ACP 子进程内部,MCP server 通过 `McpTransportPool`(F2)实现工作区内共享:一对 (server name + 配置指纹) 对应一条 MCP transport,不管被几个 session 发现都只起一份。Bridge 的 `MultiClientPermissionMediator`(F3)在四种策略之一下协调多客户端的权限投票。
+
+本文给出 **系统级全景**,本文档集其余 18 篇文档都挂在它下面。每条主干流程都给一张 Mermaid 时序图,单个组件的实现细节请看对应的专题文档。
+
+## 进程拓扑
+
+```mermaid
+flowchart LR
+ subgraph clients["Clients"]
+ WUI["Web UI
(packages/webui/src/daemon)"]
+ TUI["CLI TUI
(待迁移到 SDK ui/*)"]
+ IDE["VSCode IDE
(packages/vscode-ide-companion)"]
+ CH["Channel bots
(DingTalk / WeChat / Telegram)"]
+ SDK["Any SDK consumer
(packages/sdk-typescript/src/daemon)"]
+ end
+
+ subgraph daemon["qwen serve process (one workspace)"]
+ EXP["Express app
(packages/cli/src/serve/server.ts)"]
+ BR["AcpBridge
(packages/acp-bridge/src/bridge.ts)"]
+ MED["MultiClientPermissionMediator
(F3)"]
+ EB["EventBus per session
(eventBus.ts)"]
+ FS["WorkspaceFileSystem
(cli/src/serve/fs/)"]
+ end
+
+ subgraph child["ACP child process (qwen --acp)"]
+ AGT["QwenAgent runtime"]
+ POOL["McpTransportPool
(F2, core/src/tools)"]
+ BDG["WorkspaceMcpBudget"]
+ end
+
+ subgraph external["External"]
+ MCP1["MCP server A
(stdio)"]
+ MCP2["MCP server B
(websocket)"]
+ end
+
+ WUI -- "HTTP+SSE" --> EXP
+ TUI -- "HTTP+SSE" --> EXP
+ IDE -- "HTTP+SSE (loopback)" --> EXP
+ CH -- "HTTP+SSE" --> EXP
+ SDK -- "HTTP+SSE" --> EXP
+
+ EXP --> BR
+ BR --> MED
+ BR --> EB
+ EXP --> FS
+
+ BR -- "ACP NDJSON over stdio" --> AGT
+ AGT --> POOL
+ POOL --> BDG
+ POOL -- "shared transport" --> MCP1
+ POOL -- "shared transport" --> MCP2
+```
+
+要点:
+
+- daemon 进程与 ACP 子进程通过 `AcpChannel` 连接,默认是真实的子进程 + 一对管道;`inMemoryChannel` 用于测试。
+- 所有架构都被这条「daemon ↔ child」缝隙塑造:HTTP / SSE 在 daemon 终止,agent 决策与工具调用在子进程发生,bridge 是中转。
+
+## 包关系
+
+```mermaid
+flowchart TB
+ subgraph serve["packages/cli/src/serve"]
+ RQS["runQwenServe.ts
(bootstrap)"]
+ SRV["server.ts (Express)"]
+ CAP["capabilities.ts"]
+ AUTH["auth.ts"]
+ FSM["fs/ (sandbox)"]
+ DSP["daemonStatusProvider.ts"]
+ end
+
+ subgraph br["packages/acp-bridge"]
+ BR2["bridge.ts"]
+ BC2["bridgeClient.ts"]
+ EB2["eventBus.ts"]
+ MED2["permissionMediator.ts"]
+ ST2["status.ts"]
+ CH2["channel.ts / spawnChannel.ts"]
+ end
+
+ subgraph core["packages/core/src/tools"]
+ POOL2["mcp-transport-pool.ts"]
+ ENT["mcp-pool-entry.ts"]
+ WBG["mcp-workspace-budget.ts"]
+ SMV["session-mcp-view.ts"]
+ end
+
+ subgraph sdk["packages/sdk-typescript/src/daemon"]
+ DC["DaemonClient.ts"]
+ DSC["DaemonSessionClient.ts"]
+ EVT["events.ts"]
+ SSE["sse.ts"]
+ AUTHF["DaemonAuthFlow.ts"]
+ UI["ui/* (#4328 + #4353)
normalizer / transcript / store / render"]
+ end
+
+ subgraph adapters["Adapters"]
+ WUIP["webui/src/daemon/
DaemonSessionProvider.tsx"]
+ CHB["channels/base/
DaemonChannelBridge.ts"]
+ DT["channels/dingtalk"]
+ WX["channels/weixin"]
+ TG["channels/telegram"]
+ IDEA["vscode-ide-companion/
daemonIdeConnection.ts"]
+ end
+
+ RQS --> SRV
+ RQS --> CAP
+ RQS --> AUTH
+ RQS --> FSM
+ RQS --> BR2
+
+ BR2 --> BC2
+ BR2 --> EB2
+ BR2 --> MED2
+ BR2 --> CH2
+
+ BR2 -.spawns.-> core
+ POOL2 --> ENT
+ POOL2 --> WBG
+ POOL2 --> SMV
+
+ WUIP --> DSC
+ WUIP --> UI
+ CHB --> DSC
+ DT --> CHB
+ WX --> CHB
+ TG --> CHB
+ IDEA --> DSC
+
+ DSC --> DC
+ DC --> EVT
+ DC --> SSE
+ DC --> AUTHF
+ UI --> EVT
+```
+
+记住三条信任边界:
+
+1. HTTP 入口边界:`serve/auth.ts` 中间件链。
+2. bridge ↔ ACP 子进程边界:stdio 上的 NDJSON,没有认证 —— 子进程默认信任 bridge。
+3. agent ↔ MCP server 边界:agent 可能触发涉及宿主资源的工具调用。
+
+## 流程 1:HTTP 请求生命周期
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client (SDK)
+ participant MW as Middleware
(bearer→host→CORS→mutationGate)
+ participant R as Route handler
+ participant BR as AcpBridge
+ participant BC as BridgeClient
+ participant CH as ACP child
+
+ C->>MW: POST /session/:id/prompt
Authorization: Bearer …
X-Qwen-Client-Id: …
+ MW->>MW: bearerAuth (constant-time compare)
+ MW->>MW: hostAllowlist (DNS rebinding guard)
+ MW->>MW: denyBrowserOriginCors
+ MW->>MW: mutationGate (strict on mutating routes)
+ MW->>R: req validated
+ R->>BR: bridge.sendPrompt(sessionId, body, clientId)
+ BR->>BC: client.sendPrompt(sessionId, …)
+ BC->>CH: ACP JSON-RPC over stdin
+ CH-->>BC: ACP response / notifications
+ BC-->>BR: result
+ BR-->>R: result
+ R-->>C: 200 JSON
+```
+
+非流式路由(prompt、cancel、model 切换、metadata、workspace CRUD)以一次 JSON 响应结束。流式输出**不是**在该 HTTP 连接上以分块方式返回,而是走 SSE 通道;见流程 2。
+
+## 流程 2:SSE 事件投递与重放
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant SR as GET /session/:id/events
+ participant EB as EventBus
(per session)
+ participant BC as BridgeClient
+ participant CH as ACP child
+
+ C->>SR: GET …/events
Last-Event-ID: 42 (optional)
+ SR->>EB: subscribe(lastSeenId=42, maxQueued=N)
+ EB-->>SR: replay frames 43..currentTail
(from ring buffer)
+ SR-->>C: NDJSON: id=43, type=session_update, …
+ CH-->>BC: ACP notification (e.g. agent_message_chunk)
+ BC->>EB: publish({type, data})
+ EB-->>SR: enqueue id=N
+ SR-->>C: id=N, type=…, data=…
+ Note over EB,SR: If subscriber queue >= maxQueued,
EventBus emits client_evicted terminal frame
and closes subscriber.
+```
+
+要点:
+
+- 环形缓冲有上限(`eventRingSize`,默认 1024)。
+- 重连客户端如果 `Last-Event-ID` 已经落出环外,会收到合成 catch-up 信号,必须用 `loadSession` / `resumeSession` 重建更深层状态。
+- 慢消费者在队列 75% 触发 `slow_client_warning`,达到上限时收到 `client_evicted`(终态)后被关掉。
+
+## 流程 3:多客户端权限协调
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant CH as ACP child (agent)
+ participant BC as BridgeClient.requestPermission
+ participant MED as Mediator (policy)
+ participant EB as EventBus
+ participant C1 as Client A
(originator)
+ participant C2 as Client B
+
+ CH->>BC: ACP requestPermission(requestId, options)
+ BC->>MED: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs)
+ MED->>EB: publish permission_request
(broadcast to subscribers)
+ EB-->>C1: SSE permission_request
+ EB-->>C2: SSE permission_request
+
+ alt first-responder
+ C2->>MED: POST /permission/:requestId optionId=allow
+ MED-->>BC: resolved
+ BC-->>CH: ACP response
+ MED->>EB: permission_resolved
+ C1->>MED: POST /permission/:requestId (late vote)
+ MED-->>C1: 409 permission_already_resolved
+ else designated
+ C2->>MED: vote (clientId != originatorClientId)
+ MED-->>C2: 403 permission_forbidden
+ C1->>MED: vote (matches originator)
+ MED-->>BC: resolved
+ else consensus (N-of-M)
+ C1->>MED: vote
+ MED->>EB: permission_partial_vote (1/N)
+ C2->>MED: vote
+ MED->>EB: permission_partial_vote (2/N)
+ Note over MED: when tally reaches quorum on one option, resolve
+ else local-only
+ C2->>MED: vote (remote)
+ MED-->>C2: 403 permission_forbidden (remote_not_allowed)
+ Note over MED,CH: blocks until a loopback voter resolves it
+ end
+```
+
+跨策略「逃生口」:任何客户端都可以投 `CANCEL_VOTE_SENTINEL` 把请求短路成 `cancelled / agent_cancelled`。bridge 防止 wire 端通过普通 `optionId` 字段偷偷塞这个哨兵(`InvalidPermissionOptionError`)。
+
+四种策略一句话:
+
+- `first-responder` — 第一个有效投票获胜(默认,保留 live 协作 UX)。
+- `designated` — 只有 `originatorClientId` 能投,其他客户端收 `permission_forbidden`。
+- `consensus` — N-of-M 法定人数,过程中发 `permission_partial_vote` 让 UI 渲染进度。
+- `local-only` — 拒绝任何 HTTP 投票,只接受 loopback。
+
+## 流程 4:MCP transport 池的 acquire / release / restart
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant S as Session in ACP child
+ participant P as McpTransportPool
+ participant SIF as spawnInFlight (dedup)
+ participant E as PoolEntry
+ participant BDG as WorkspaceMcpBudget
+ participant SRV as MCP server
+
+ S->>P: acquire(name, cfg, sessionId)
+ P->>SIF: check inflight for (name+fingerprint)
+ alt cached inflight
+ SIF-->>P: existing promise
+ else cold start
+ P->>BDG: tryReserve(name)
+ BDG-->>P: ok / refused
+ alt refused
+ P-->>S: BudgetExhaustedError
+ else ok
+ P->>E: new PoolEntry(...)
+ E->>SRV: connect transport
+ SRV-->>E: ready
+ E-->>P: connected
+ end
+ end
+ P->>P: sessionToEntries.add(sessionId, id)
+ P-->>S: PooledConnection
+
+ Note over S,P: Session uses entry, then…
+
+ S->>P: release(id, sessionId)
+ P->>E: detach session
+ E->>E: arm drain timer (default 30s)
+ Note over E: refs==0 → drain timer fires → close transport
(MAX_IDLE_MS 5min hard cap survives flap)
+
+ Note over S,P: Operator restart flow…
+ S->>P: restartByName(name, opts?)
+ P->>E: drain + close
+ P->>E: spawn replacement
+ E->>SRV: reconnect
+ P->>EB: publish mcp_server_restarted
+```
+
+要点:
+
+- `releaseSession(sessionId)` 借助 `sessionToEntries` 反向索引,以 O(refs) 释放该 session 持有的所有条目。
+- daemon 关停时 `drainAll()` 置 `draining` 标志(拒绝新的 acquire),并以可配置超时等待所有条目关闭。
+- `restartByName` 可以接 `entryIndex` 来精确重启某条;池里同名多条目时返回 `{entries: RestartResult[]}` 形状。
+
+## 流程 5:生命周期 —— 启动与优雅退出
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Op as Operator (signal)
+ participant RQS as runQwenServe
+ participant APP as Express app
+ participant BR as AcpBridge
+ participant CH as ACP child
+
+ Op->>RQS: qwen serve --workspace … --token …
+ RQS->>RQS: validate flags + canonicalize workspace
+ RQS->>RQS: allocate PermissionAuditRing
+ RQS->>BR: createHttpAcpBridge(options)
+ RQS->>APP: createServeApp(bridge, …)
+ RQS->>APP: listen(host, port)
+ RQS->>RQS: arm SIGINT / SIGTERM handlers
+
+ Op->>RQS: SIGTERM
+ RQS->>BR: dispose device-flow registry
+ RQS->>BR: bridge.shutdown()
+ BR->>CH: send graceful close (10s deadline)
+ CH-->>BR: exit
+ RQS->>APP: server.close() (5s force-close timer)
+ APP->>APP: closeAllConnections() (+2s secondary)
+ Note over Op,RQS: Second SIGTERM during shutdown →
bridge.killAllSync() + process.exit(1) (orphan prevention)
+```
+
+为什么要分两阶段:
+
+- 还在飞的 HTTP 请求、还连着的 SSE 订阅者、子进程里还在跑的工具调用都需要有上限的退出窗口。
+- 任何一条卡过窗口,force-close 路径会接管,避免子进程把 daemon 进程拖住。
+- 第二次 SIGTERM 直接走 `bridge.killAllSync()` + `process.exit(1)`,防孤儿。
+
+## 关键文件
+
+| 关注点 | 文件 |
+| ------------------ | -------------------------------------------------------------------- |
+| Bootstrap | `packages/cli/src/serve/runQwenServe.ts` (308-994) |
+| Express 应用 | `packages/cli/src/serve/server.ts` (261-339) |
+| 能力注册表 | `packages/cli/src/serve/capabilities.ts` (37-215) |
+| Auth 中间件 | `packages/cli/src/serve/auth.ts` (1-60) |
+| Bridge | `packages/acp-bridge/src/bridge.ts` |
+| BridgeClient | `packages/acp-bridge/src/bridgeClient.ts` |
+| 权限协调器 | `packages/acp-bridge/src/permissionMediator.ts` (1-1292) |
+| EventBus | `packages/acp-bridge/src/eventBus.ts` |
+| MCP transport 池 | `packages/core/src/tools/mcp-transport-pool.ts` (104+) |
+| Workspace MCP 预算 | `packages/core/src/tools/mcp-workspace-budget.ts` |
+| Workspace 文件系统 | `packages/cli/src/serve/fs/` |
+| SDK DaemonClient | `packages/sdk-typescript/src/daemon/DaemonClient.ts` (209-1506) |
+| SDK SessionClient | `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts` (61-385) |
+| 事件 schema | `packages/sdk-typescript/src/daemon/events.ts` (13-63) |
+
+## 参考
+
+- 设计 issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)(daemon 总体设计)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)(F 系列里程碑)。
+- 用户使用文档:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。
+- Wire 协议参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
+- F2 设计文档(v2.2,含 32 条 review fold-in):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md)。
+- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) commit 4-6。
diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md
new file mode 100644
index 0000000000..80f058873a
--- /dev/null
+++ b/docs/developers/daemon/02-serve-runtime.md
@@ -0,0 +1,138 @@
+# Serve 运行时
+## 概览
+
+`packages/cli/src/serve/` 是 `qwen serve` 的引导层,负责:把 CLI 参数翻译成 `ServeOptions`、启动期校验、构造 Express 应用、装配中间件链、注册路由、暴露 daemon-host 的 preflight/status provider、维护权限审计环、以及两阶段优雅退出序列。所有 HTTP 形态的东西都在这一层;所有 ACP 形态的东西在下一层 `@qwen-code/acp-bridge`(见 [`03-acp-bridge.md`](./03-acp-bridge.md))。
+
+## 职责
+
+- 解析与校验 `ServeOptions`(`hostname`、`port`、`token`、`requireAuth`、`workspace`、`maxSessions`、`maxConnections`、`eventRingSize`、`mcpClientBudget`、`mcpBudgetMode`、`mcpPoolActive`)。
+- 一次性 **canonicalize** 绑定的 workspace(同一份规范形式同时供 `/capabilities`、`POST /session` 兜底和 bridge 使用)。
+- 拒绝以不安全的姿势启动:非 loopback 绑定无 token;`--require-auth` 无 token;`mcpBudgetMode='enforce'` 无正整数 `mcpClientBudget`;`--workspace` 不存在或不是目录。
+- 构造 `WorkspaceFileSystem` 工厂、权限审计 publisher、`DaemonStatusProvider`、`acp-bridge`。
+- 构造 Express 应用、装配中间件链(`bearerAuth` → `hostAllowlist` → `denyBrowserOriginCors` → 每路由 `mutationGate`)、挂载路由(session、workspace CRUD、文件、Device Flow auth、权限投票)。
+- 绑定监听端口并注册信号 handler。
+- 收到 SIGINT/SIGTERM 时两阶段退出;二次信号强退。
+
+## 架构
+
+**入口**:`runQwenServe(opts, deps)`,文件 `packages/cli/src/serve/runQwenServe.ts:308-994`,返回 `RunHandle`(`{ url, port, close, ... }`)。
+
+**应用工厂**:`createServeApp(opts, getPort, deps)`,文件 `packages/cli/src/serve/server.ts:261-339`,构建 Express `Application`。直接嵌入和测试不走 bootstrap,直接调它。
+
+**能力注册表**:`SERVE_CAPABILITY_REGISTRY`,文件 `packages/cli/src/serve/capabilities.ts:37-215`。每个 tag 带 `since` 版本和可选 `modes`,条件 tag(`require_auth`、`mcp_workspace_pool`、`mcp_pool_restart`)在开关关掉时不广播。详见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md)。
+
+**中间件** `packages/cli/src/serve/auth.ts`:
+
+| 中间件 | 作用 | 说明 |
+| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `denyBrowserOriginCors` | 拒绝任何带 `Origin` 头的请求 | CLI/SDK 永远不发 `Origin`,这是 CSRF 防护。 |
+| `hostAllowlist(bind, getPort)` | Loopback 下校验 `Host` 头属于 `localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal` 加端口的集合 | 防 DNS rebinding,按端口缓存,比较时大小写不敏感。 |
+| `bearerAuth(token)` | 用 SHA-256 + `timingSafeEqual` 常量时间比较 | 无 token(loopback dev 默认)就 open passthrough,`Bearer` 大小写不敏感。 |
+| `createMutationGate({tokenConfigured, requireAuth})` | 路由级 opt-in 闸门,对 Wave 4 修改类路由即便在 loopback 也强制 token | 返回 `401 { code: 'token_required' }`,区别于一般 `Unauthorized`。`/workspace/memory`、`/workspace/agents/*`、`/file/write`、`/workspace/tools/:name/enable`、`/workspace/mcp/:server/restart`、`/workspace/auth/device-flow`、`/workspace/init` 都调 `mutate({strict: true})`。 |
+
+**子系统**:
+
+| 路径 | 作用 |
+| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `serve/fs/` | `WorkspaceFileSystem` 工厂 + `policy.ts`(大小/信任/二进制检查)+ `paths.ts`(canonicalize、resolveWithin、拒绝 symlink)+ `audit.ts` + `errors.ts`(typed `FsError`) |
+| `serve/routes/workspaceFileRead.ts`、`workspaceFileWrite.ts` | `GET /file`、`GET /file/bytes`、`POST /file/write`、`POST /file/edit` 的 HTTP handler |
+| `serve/workspaceMemory.ts` | `GET/POST /workspace/memory`(QWEN.md CRUD) |
+| `serve/workspaceAgents.ts` | `GET/POST/DELETE /workspace/agents`(子 agent CRUD) |
+| `serve/daemonStatusProvider.ts:41-287` | env 快照 + daemon-host preflight cell(Node 版本、CLI 入口、workspace stat、ripgrep、git、npm) |
+| `serve/permissionAudit.ts:1-60` | `PermissionAuditRing`(FIFO 512 条)+ `createPermissionAuditPublisher` |
+| `serve/auth/deviceFlow.ts`、`qwenDeviceFlowProvider.ts` | Device Flow OAuth 路由(见 [`12-auth-security.md`](./12-auth-security.md)) |
+| `serve/demo.ts` | `GET /demo` 的自包含内联 HTML —— 一个浏览器可访问的调试控制台(聊天 UI + 事件日志 + workspace 检视器)。loopback 且不带 `--require-auth` 时注册在 `bearerAuth` **之前**,开发不带 token 就能从浏览器打开;非 loopback 或带 `--require-auth` 时注册在 `bearerAuth` **之后**,未认证探测不能枚举接口。Strict CSP(`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`)+ `X-Frame-Options: DENY`。 |
+
+**Re-export shim**(为兼容 F1 前的 import 路径):
+
+- `serve/eventBus.ts` → `@qwen-code/acp-bridge/eventBus`
+- `serve/status.ts` → `@qwen-code/acp-bridge/status`
+- `serve/httpAcpBridge.ts` → `@qwen-code/acp-bridge`
+
+## 流程
+
+### 启动序列
+
+1. **取并 trim token**:`opts.token` || `QWEN_SERVER_TOKEN`(启动时 trim 一次,防止 `cat token.txt` 把换行带进来导致永远比对不上)。
+2. **hostname 错配兜底**:`--hostname localhost:4170` 直接报错并提示用 `--port`。
+3. **auth 预检**:非 loopback 无 token → 拒绝;`--require-auth` 无 token → 拒绝。
+4. **workspace 校验**:必须绝对路径、必须存在、必须是目录;`EACCES`/`EPERM` 包装成指向参数本身的错误。
+5. **canonicalize workspace**:`canonicalizeWorkspace(rawWorkspace)` 走 `realpathSync.native` 一次,给 `/capabilities`、`POST /session` 兜底、bridge 共用,保证在 symlink / 大小写不敏感 FS 上不分叉。
+6. **MCP 预算校验**:必须正整数;`enforce` 必须配 budget。
+7. **MCP pool 开关推断**:父进程 env 里 `QWEN_SERVE_NO_MCP_POOL=1` 时,`mcpPoolActive` 默认 `false`,capabilities 也会诚实地不广播 `mcp_workspace_pool` + `mcp_pool_restart`。
+8. **per-handle `childEnvOverrides`**:把 `QWEN_SERVE_MCP_CLIENT_BUDGET` 和 `QWEN_SERVE_MCP_BUDGET_MODE` 通过 `BridgeOptions.childEnvOverrides` 传给 ACP 子进程,**不**改 `process.env`(同进程跑两个 daemon 会出 race)。
+9. **boot 一次 `settings.json`**:取 `context.fileName`、`policy.permissionStrategy`、`policy.consensusQuorum`;损坏文件 try/catch 走默认值。之后 **`validatePolicyConfig()`**(`runQwenServe.ts:89+`)解析 `policy.*`,未知 strategy(按 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` 单一事实源校验)或非正整数 `consensusQuorum` 时抛 `InvalidPolicyConfigError`。`consensusQuorum` 设了但策略非 `consensus` 时打 stderr 警告(默认会被静默忽略,浮出来防 operator 误以为它生效)。settings 读 I/O 失败回退默认;`InvalidPolicyConfigError` 重抛让 boot 显式失败。
+10. **分配 `PermissionAuditRing`**(512 条)。
+11. **建 `fsFactory`**:`runQwenServe` 路径默认 `trusted: true`;`createServeApp` 直接调时默认 `trusted: false` 并发警告一次。
+12. **`createHttpAcpBridge`**,见 [`03-acp-bridge.md`](./03-acp-bridge.md)。
+13. **`createServeApp`** 装配 Express。
+14. **`server.listen(port, hostname)`**,resolve 后取真实 `getPort()` 给 host allowlist。
+15. **注册 SIGINT / SIGTERM handler**,驱动优雅退出。
+
+### 优雅退出(两阶段)
+
+1. **第一阶段 —— bridge 收尾**(首次信号):
+ - dispose Device Flow registry(取消所有 pending flow)。
+ - `bridge.shutdown()`:所有 channel 置 `isDying = true`;向每个 ACP 子进程 stdin 发 graceful close;每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s);不退就 `channel.kill()`。
+2. **第二阶段 —— HTTP 收尾**:
+ - `server.close()`(停止接收新连接,等飞行中请求收尾)。
+ - 起 `SHUTDOWN_FORCE_CLOSE_MS`(5s)定时器,到点 `server.closeAllConnections()` 强切 socket。
+ - 起二次 2s deadline,到点继续升级。
+3. **退出中再来一次信号**:
+ - `bridge.killAllSync()` + `process.exit(1)`。防孤儿 —— 子进程卡死也不能拖死 daemon 进程。
+
+## 状态与生命周期
+
+`RunHandle` 暴露:
+
+- `url`:实际监听 URL(ephemeral 端口取 `getPort()` 之后)。
+- `port`:实际端口(`0` 解析后的真实值)。
+- `close({ timeoutMs? })`:给嵌入方 / 测试用的程序化关闭。
+
+`createServeApp` 直接调时只返回 `Application`,不持有生命周期;嵌入方自己写 `listen` 和 shutdown。
+
+## 依赖
+
+| 上游(`serve/` 用了什么) | 下游(谁用了 `serve/`) |
+| ---------------------------------------------------------------------------------------------- | ------------------------------------ |
+| `@qwen-code/acp-bridge`:bridge、event bus、status 类型 | `qwen` CLI 的 `serve` 子命令处理函数 |
+| `packages/core`:`loadSettings`、`getCurrentGeminiMdFilename`、`Config`、`WorkspaceContext` | 任何直接嵌入方(测试、程序化调用) |
+| ACP SDK(`@agentclientprotocol/sdk`):`PROTOCOL_VERSION`、`ClientSideConnection`(经 bridge) | |
+| Express + body-parser、`node:crypto`、`node:fs`、`node:path` | |
+
+## 配置
+
+| 来源 | Key | 效果 |
+| --------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| Env | `QWEN_SERVER_TOKEN` | Bearer token(trim 后)。 |
+| Env | `QWEN_SERVE_NO_MCP_POOL=1` | 强制 `mcpPoolActive=false`。 |
+| Env | `QWEN_SERVE_MCP_CLIENT_BUDGET` / `QWEN_SERVE_MCP_BUDGET_MODE` | 通过 `childEnvOverrides` 传给 ACP 子进程。 |
+| Env | `QWEN_SERVE_DEBUG=1` | 详细 stderr 日志(见 [`19-observability.md`](./19-observability.md))。 |
+| 参数 | `--hostname`、`--port` | 监听绑定。 |
+| 参数 | `--token` | Bearer token(覆盖 env)。 |
+| 参数 | `--require-auth` | 把 bearer 强制到 loopback;无 token 直接拒启动。 |
+| 参数 | `--workspace` | 覆盖 `process.cwd()`。 |
+| 参数 | `--max-sessions`、`--max-connections`、`--event-ring-size` | bridge / Express 上限。 |
+| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 传给 ACP 子进程。 |
+| `settings.json` | `policy.permissionStrategy`、`policy.consensusQuorum` | `MultiClientPermissionMediator` 的策略与法定人数。 |
+| `settings.json` | `context.fileName` | bridge 的 `getCurrentGeminiMdFilename` 覆盖。 |
+
+合并参考见 [`17-configuration.md`](./17-configuration.md)。
+
+## 注意 & 已知局限
+
+- `createServeApp` 没传 `deps.fsFactory` 或 `deps.bridge` 时默认 `trusted: false`,agent 侧 ACP `writeTextFile` 会拒为 `untrusted_workspace`。提示只打一次。
+- `denyBrowserOriginCors` 拒绝**所有**带 `Origin` 的请求;demo 页能跑是因为另一个中间件先把匹配本机 origin 的剥掉了。
+- body-parser 顺序:`mutateGate({strict: true})` 的 401 在 `express.json()` 之后才触发;strict 路径最坏放大成 `--max-connections × express.json({limit: '10mb'})` ≈ 2.5 GB 瞬时(loopback only,刻意接受)。
+- 同进程跑两个 daemon 时必须用 per-handle `childEnvOverrides`;改 `process.env` 会 race(`defaultSpawnChannelFactory` 在 spawn 时刻快照 env)。
+
+## 参考
+
+- `packages/cli/src/serve/runQwenServe.ts:308-994`
+- `packages/cli/src/serve/server.ts:261-339`
+- `packages/cli/src/serve/auth.ts:1-294`
+- `packages/cli/src/serve/capabilities.ts:1-220`
+- `packages/cli/src/serve/types.ts:37-155`(`ServeOptions`、`CapabilitiesEnvelope`)
+- `packages/cli/src/serve/daemonStatusProvider.ts:41-287`
+- `packages/cli/src/serve/permissionAudit.ts:1-60`
+- Issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)。
diff --git a/docs/developers/daemon/03-acp-bridge.md b/docs/developers/daemon/03-acp-bridge.md
new file mode 100644
index 0000000000..e379c19c66
--- /dev/null
+++ b/docs/developers/daemon/03-acp-bridge.md
@@ -0,0 +1,231 @@
+# ACP Bridge
+## 概览
+
+`packages/acp-bridge/` 包是 daemon HTTP 层与 ACP 子进程之间的缝隙拥有者。它被 `packages/cli/src/serve/`(`qwen serve` daemon)消费;在 #4175 F1 step 3 中被抽取出来,让以后的消费方(`channels/base/AcpBridge.ts`、VSCode IDE companion)可以直接复用 bridge 内核而不必反向依赖 cli 包。
+
+bridge 提供:一个 `HttpAcpBridge` 实例、一条 `AcpChannel` 连到 ACP 子进程、在这条 channel 上多路复用的 session、每个 session 的 `EventBus`、一个 `MultiClientPermissionMediator`、一个 `BridgeFileSystem` adapter,外加 ACP 形状的辅助方法(`spawnOrAttach`、`loadSession`、`resumeSession`、`sendPrompt`、`cancelSession`、`respondToPermission`,以及供 workspace 级状态与 MCP 重启用的 extMethod RPC)。
+
+## 职责
+
+- 用可插拔的 `ChannelFactory` spawn 或 attach 到 ACP 子进程。默认 `defaultSpawnChannelFactory`(子进程 `qwen --acp`),测试用 `inMemoryChannel`。
+- 维护 `aliveChannels`(channel 注册表)和 `byId`(session 注册表)。
+- 用 `connection.newSession()` 在一条 ACP child 上多路复用 N 个 HTTP-side session。
+- 用 `promptQueue` 把同一 session 的 prompt 串行化(ACP 强制 「一个 session 同一时刻只能有一个 prompt 在跑」)。
+- 用 `modelChangeQueue` 串行化 `setSessionModel`,防止并发 attach + 不同 model 把 agent 带进非确定状态。
+- 每个 session 一个 `EventBus`,驱动 `GET /session/:id/events`(详见 [`10-event-bus.md`](./10-event-bus.md))。
+- 权限流:`BridgeClient.requestPermission` → `MultiClientPermissionMediator.request` → 扇出 → 收票 → 回 ACP(详见 [`04-permission-mediation.md`](./04-permission-mediation.md))。
+- 文件 IO:通过 `BridgeFileSystem` adapter 处理 ACP 的 `readTextFile` / `writeTextFile`(详见 [`07-workspace-filesystem.md`](./07-workspace-filesystem.md))。
+- workspace 级状态的 extMethod RPC(`/workspace/mcp`、`/workspace/skills`、`/workspace/providers`)和 MCP 重启。
+- 生命周期:`shutdown()` 每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s);二次信号 `killAllSync()` 同步强杀。
+
+## 架构
+
+**公开入口**:`createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge`,文件 `packages/acp-bridge/src/bridge.ts:350+`。
+
+**关键类型**:
+
+| 类型 | 文件 | 作用 |
+| ------------------------------- | ------------------------------ | -------------------------------------------------------------------------------- |
+| `HttpAcpBridge` | `bridgeTypes.ts:30-180+` | 对外接口,全部方法都在这里 |
+| `BridgeSession` | `bridgeTypes.ts:49+` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` |
+| `BridgeOptions` | `bridgeOptions.ts:88-323` | 构造时配置(见 [配置](#配置)) |
+| `AcpChannel` | `channel.ts:21-50` | `{ stream, kill(), killSync(), exited }` 一条 ACP NDJSON channel |
+| `ChannelFactory` | `channel.ts:57-60` | `(workspaceCwd, childEnvOverrides?) => Promise` |
+| `BridgeClient` | `bridgeClient.ts:1-150+` | 封装一条 ACP `ClientSideConnection`,实现 ACP `Client` |
+| `EventBus` | `eventBus.ts` | 每 session 内存 pub/sub,见 [`10-event-bus.md`](./10-event-bus.md) |
+| `MultiClientPermissionMediator` | `permissionMediator.ts:1-1292` | 四策略 mediator,见 [`04-permission-mediation.md`](./04-permission-mediation.md) |
+
+**内部状态**(由 `createHttpAcpBridge` 闭包持有):
+
+| 状态 | 形态 | 用途 |
+| --------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `aliveChannels` | `Map` | channel 注册表;每条 `ChannelInfo` 包括 `channel`、`connection`、`client`(每 channel 一个 `BridgeClient`)、`sessionIds: Set`、`pendingRestoreIds`、`statusClosedReject?`、`isDying: boolean` |
+| `byId` | `Map` | session 注册表;每个 `SessionEntry` 包括 `channel`、`connection`、`events: EventBus`、`promptQueue`、`modelChangeQueue`、`pendingPermissionIds: Set`、`clientIds: Map`、`activePromptOriginatorClientId?`、`attachCount`、`spawnOwnerWantedKill`、`restoreState?`、`sessionLastSeenAt?`、`clientLastSeenAt` |
+| `defaultEntry` | `SessionEntry \| null` | `sessionScope: 'single'` 下共享的那个 session |
+| `defaultPolicy` | `PermissionPolicy` | 由 `BridgeOptions.permissionPolicy` 决定 |
+| `mediator` | `MultiClientPermissionMediator` | 每 bridge 一个 |
+| 常量 | — | `DEFAULT_INIT_TIMEOUT_MS = 10_000`、`MCP_RESTART_TIMEOUT_MS = 300_000`、`DEFAULT_MAX_SESSIONS = 20`、`MAX_EVENT_RING_SIZE = 1_000_000`、`DEFAULT_PERMISSION_TIMEOUT_MS = 5min`、`DEFAULT_MAX_PENDING_PER_SESSION = 64` |
+
+**`isDying` 不变式**:任何 teardown 路径在 await `channel.kill()` 之前必须**同步**置 `ChannelInfo.isDying = true`。`ensureChannel` 把 dying channel 视作不存在,会重新 spawn 一条。否则一个并发 `spawnOrAttach` 在 SIGTERM 宽限窗口(最长 10s)中到来时会 attach 到马上要关掉的 transport,调用方拿到的 sessionId 之后每次请求都 404。**设置位点**(必须同步保持):`ensureChannel`(initialize 失败 + 晚到 shutdown 重检)、`doSpawn`(empty channel 上 newSession 失败)、`killSession`(最后一个 session 离开)、`shutdown`(批量)。
+
+**`BkUyD` 不变式**:置 `isDying = true` 时**不要**清除 `channelInfo`。`killAllSync` 在 SIGTERM 宽限窗口仍需要找到 channel 触发 SIGKILL;`aliveChannels` 持有 dying 项直到 `channel.exited` 触发。
+
+**BridgeClient 早到事件缓冲**:当 ACP `extNotification` 在 `connection.newSession` 响应返回之前(但其内部 MCP discovery 已经触发 budget 事件)到达 `BridgeClient`,事件按 `MAX_EARLY_EVENT_SESSIONS = 64` × `MAX_EARLY_EVENTS_PER_SESSION = 32` × `EARLY_EVENT_TTL_MS = 60_000` 三重上限缓冲,最坏 ~400 KB。否则新 session SSE 重放环的第一个 slot 会丢掉创建期发生的事件。
+
+## 流程
+
+### `spawnOrAttach`(最常用入口)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant R as Route handler
+ participant B as createHttpAcpBridge closure
+ participant CF as ChannelFactory
+ participant CH as AcpChannel
+ participant ACP as ACP child
+ participant M as Mediator
+
+ R->>B: spawnOrAttach({cwd?, sessionScope?, clientId?})
+ B->>B: validate cwd vs boundWorkspace
(WorkspaceMismatchError)
+ alt sessionScope=single and defaultEntry exists
+ B->>B: bump attachCount
register clientId
+ B-->>R: {sessionId, attached: true, restoreState?}
+ else cold path
+ B->>CF: factory(workspaceCwd, childEnvOverrides)
+ CF->>ACP: spawn qwen --acp + pipes
+ CF-->>B: AcpChannel
+ B->>ACP: ACP initialize (timeout=DEFAULT_INIT_TIMEOUT_MS)
+ ACP-->>B: initialize response
+ B->>ACP: connection.newSession({cwd})
+ ACP-->>B: {sessionId}
+ B->>B: build SessionEntry
register in byId / defaultEntry
+ B-->>R: {sessionId, attached: false}
+ end
+```
+
+要点:
+
+- 校验 cwd vs `boundWorkspace`,不一致抛 `WorkspaceMismatchError`。
+- `sessionScope='single'` 且 `defaultEntry` 已存在 → 只 bump `attachCount` 并登记 `clientId`,返回 `attached: true`。
+- 冷路径 → 走 ChannelFactory 拉子进程 → ACP `initialize`(`DEFAULT_INIT_TIMEOUT_MS=10s`)→ `connection.newSession({cwd})` → 构造 `SessionEntry` 注册到 `byId` / `defaultEntry`。
+- `byId.size >= maxSessions` 抛 `SessionLimitExceededError`。
+- `X-Qwen-Client-Id` 不在 `[A-Za-z0-9._:-]{1,128}` 范围 → `InvalidClientIdError`。
+- `server.ts` 的 disconnect-reaper 通过 `attachCount` / `spawnOwnerWantedKill` 跟踪 spawn 拥有者,避免在 spawn 拥有者掉线但其他客户端已经 attach 的情况下把 session 拆掉(review #3889 BQ9tV)。
+
+### Prompt 串行化
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant R as Route
+ participant E as SessionEntry
+ participant Q as promptQueue (FIFO)
+ participant BC as BridgeClient
+ participant ACP as ACP child
+
+ R->>E: sendPrompt(sessionId, body, clientId)
+ E->>E: set activePromptOriginatorClientId = clientId
+ E->>Q: chain off resolved tail
+ Q->>BC: client.sendPrompt(sessionId, body)
+ BC->>ACP: ACP prompt JSON-RPC
+ ACP-->>BC: response (after potentially multiple requestPermission roundtrips)
+ BC-->>E: result
+ E->>E: clear activePromptOriginatorClientId
+ E-->>R: result
+```
+
+要点:
+
+- 队列尾部失败被**吞**掉,避免前一次失败毒害后续 prompt;调用方仍可在自己的 promise 上拿到 rejection。
+- session 上缓存的 `transportClosedReject` 把 prompt promise 与 `channel.exited` race,子进程崩了立刻浮出来而不是 hang。
+
+### 权限流(高层)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant ACP as ACP child (agent)
+ participant BC as BridgeClient.requestPermission
+ participant E as SessionEntry
+ participant M as Mediator
+ participant EB as EventBus
+
+ ACP->>BC: requestPermission(requestId, options)
+ BC->>E: record requestId in pendingPermissionIds
+ BC->>M: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs)
+ M->>EB: publish permission_request (fan-out to subscribers)
+ Note over M: waits for vote / timeout / cancel
+ M-->>BC: PermissionResolution
+ BC-->>ACP: RequestPermissionResponse (selected or cancelled)
+ BC->>E: clear requestId
+```
+
+要点:
+
+- wire 端通过普通 `optionId` 偷塞 `CANCEL_VOTE_SENTINEL` → bridge 在到 mediator 之前抛 `InvalidPermissionOptionError`,这个哨兵只能由 bridge 内部使用来把请求短路成 `cancelled / agent_cancelled`。
+- 详见 [`04-permission-mediation.md`](./04-permission-mediation.md)。
+
+### 退出
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Op as runQwenServe
+ participant B as Bridge
+ participant CHs as Channels
+ participant M as Mediator
+
+ Op->>B: shutdown()
+ B->>CHs: mark every ChannelInfo isDying = true (bulk)
+ B->>M: forgetSession for every sessionId (pending → cancelled/session_closed)
+ par per channel
+ B->>CHs: channel.kill() (await up to KILL_HARD_DEADLINE_MS = 10s)
+ CHs-->>B: exited
+ end
+ B-->>Op: done
+ Note over Op,B: Second signal → killAllSync()
(fire SIGKILL on every alive child synchronously)
+```
+
+## Channel 工厂
+
+`AcpChannel`(`channel.ts:21-50`)是 bridge 的传输抽象。生产用 `defaultSpawnChannelFactory`(`spawnChannel.ts`),把 `qwen --acp` 跑成子进程加一对 stdio 管道;测试用 `inMemoryChannel`,agent 在进程内跑。bridge 不在乎下面是什么机制,只要给 `{ stream, kill, killSync, exited }` 就行。
+
+`ChannelFactory` 接受 `childEnvOverrides`,每个 daemon handle 可以传自己那份 MCP-budget env(`QWEN_SERVE_MCP_CLIENT_BUDGET`、`QWEN_SERVE_MCP_BUDGET_MODE`),不去改 `process.env`(同进程两个 daemon 会 race)。
+
+## 状态与生命周期
+
+- bridge 构造同步完成;首次 `spawnOrAttach` 冷启动 ACP 子进程。
+- `sessionScope: 'single'` 下 `defaultEntry` 与 bridge 同生命周期;channel 在 `sessionIds.size === 0` 且 `isDying = true` 后被回收。
+- `MAX_EVENT_RING_SIZE = 1_000_000` 是 `BridgeOptions.eventRingSize` 的软上限,挡操作者打错值导致 ~500 MB 一个 session OOM。
+- `DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000` 防止一个 wedged 权限请求把 session 的 `promptQueue` 永久 hang。
+- `DEFAULT_MAX_PENDING_PER_SESSION = 64` 对话多的 agent 反压;超出的 `requestPermission` 直接解析为 cancelled 并打 stderr 警告。
+
+## 依赖
+
+| 上游 | 下游 |
+| ------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| `@agentclientprotocol/sdk`:`ClientSideConnection`、`PROTOCOL_VERSION`、ACP 类型 | `packages/cli/src/serve/`(daemon) |
+| `@qwen-code/qwen-code-core`:`ApprovalMode`、`TrustGateError`、`getCurrentGeminiMdFilename` | `packages/channels/base/`(规划中,F4) |
+| `node:crypto`、`node:fs`、`node:path` | `packages/vscode-ide-companion/`(规划中,F4) |
+
+## 配置
+
+`BridgeOptions`(`bridgeOptions.ts:88-323`):
+
+| 键 | 默认 | 作用 |
+| --------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------ |
+| `boundWorkspace` | (必填) | bridge 强制的规范 workspace 路径 |
+| `sessionScope` | `'single'` | `'single'` 所有客户端共享一个 session;`'per-client'` 每客户端一个 |
+| `channelFactory` | `defaultSpawnChannelFactory` | 可插拔 ACP child 工厂 |
+| `initializeTimeoutMs` | `10_000` | ACP `initialize` 握手超时 |
+| `maxSessions` | `20` | `byId.size` 上限;`0`/`Infinity` = 不限;NaN/负值抛错 |
+| `eventRingSize` | `DEFAULT_RING_SIZE` | 每 session 事件环;软上限 `1_000_000` |
+| `permissionResponseTimeoutMs` | `5 min` | mediator 每请求 wallclock |
+| `maxPendingPermissionsPerSession` | `64` | 反压 |
+| `childEnvOverrides` | `{}` | 每 handle 给 ACP child 的 env 增量 / scrub |
+| `persistApprovalMode`、`persistDisabledTools` | — | Wave 4 修改路由的 settings 写钩子 |
+| `contextFilename` | 从 `settings.json` 的 `context.fileName` | 覆盖 `getCurrentGeminiMdFilename` |
+| `statusProvider` | (无) | daemon-host preflight cells |
+| `fileSystem` | (无) | `BridgeFileSystem` adapter |
+| `permissionPolicy` | 从 `settings.json` 的 `policy.permissionStrategy` | 四策略之一 |
+| `permissionConsensusQuorum` | 从 `settings.json` | consensus 策略的 N |
+| `permissionAudit` | `createNoOpPermissionAuditPublisher()` | 接到 `PermissionAuditRing` |
+
+## 注意 & 已知局限
+
+- `MCP_RESTART_TIMEOUT_MS = 300_000`(5 min)—— bridge race deadline 故意设这么长,因为 `McpClientManager.MAX_DISCOVERY_TIMEOUT_MS` 对 stdio MCP 最长 5 min。设短了会在 ACP child 还在后台重连时假超时。
+- `BridgeOptions.eventRingSize > 1_000_000` 构造时抛错。
+- `connection.unstable_resumeSession` 通过 `unstable_session_resume` 能力 tag 暴露并保留 `unstable_` 前缀;ACP 方法形状还可能变,客户端必须 feature-detect。
+- bridge 包是 `@qwen-code/acp-bridge`,通过 `serve/eventBus.ts`、`serve/status.ts`、`serve/httpAcpBridge.ts` 三个 re-export shim 兼容 F1 前的 import 路径。新代码应该直接 import 包。
+
+## 参考
+
+- `packages/acp-bridge/src/bridge.ts`(重点 `createHttpAcpBridge` line 350+)
+- `packages/acp-bridge/src/bridgeClient.ts`
+- `packages/acp-bridge/src/bridgeTypes.ts:30-180+`
+- `packages/acp-bridge/src/bridgeOptions.ts:88-323`
+- `packages/acp-bridge/src/channel.ts:1-60`
+- `packages/acp-bridge/src/spawnChannel.ts`
+- `packages/acp-bridge/src/bridgeErrors.ts`
+- Issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)。
diff --git a/docs/developers/daemon/04-permission-mediation.md b/docs/developers/daemon/04-permission-mediation.md
new file mode 100644
index 0000000000..d96b60b0ae
--- /dev/null
+++ b/docs/developers/daemon/04-permission-mediation.md
@@ -0,0 +1,229 @@
+# 多客户端权限协调
+## 概览
+
+ACP 子进程的 agent 调 `requestPermission` 时,daemon 并不会只转给某一个客户端 —— `sessionScope: 'single'` 下每个连上来的客户端都看得到这个请求,谁回复都行。没有协调器就乱套:迟到的投票无处去、两个客户端 race 同一个请求、一个流氓客户端能盖过 originator 等等。
+
+`MultiClientPermissionMediator`(`packages/acp-bridge/src/permissionMediator.ts:1-1292`)实现了 `PermissionMediator` 契约(`packages/acp-bridge/src/permission.ts`),bridge 的所有 pending + resolved 权限状态都归它管。它按 `PermissionPolicy` 四选一分派投票:
+
+| 策略 | 裁决规则 | 用例 |
+| ----------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------- |
+| `first-responder` | 第一个有效票获胜;后来的拿 `permission_already_resolved` | 实时跨客户端协作 UX(默认) |
+| `designated` | 只允许 prompt 的 `originatorClientId` 裁决;其他人收 `permission_forbidden{designated_mismatch}` | per-tenant SaaS,UI surface 自己拥有审批 |
+| `consensus` | N-of-M 法定人数(pair-token 认证),过程中 `permission_partial_vote` 让 UI 渲进度 | 企业变更评审,两名操作员需达成一致 |
+| `local-only` | 拒绝任何非 loopback 投票,阻塞直到 loopback 客户端裁决 | 工作站,远程控制绝不能授予提权 |
+
+## 职责
+
+- 跟踪每个 pending 请求(`request → vote → resolved` 生命周期)。
+- 给每个请求装上 wallclock 超时(**N1 不变式**:超时必须在 `request()` **同步**装上,不然立刻 cancel 的 session 会把闭包永远 pending 漏掉)。
+- 按 `request()` 时刻捕获的策略派发投票(中途改 daemon 全局策略不影响飞行中请求)。
+- 维护有界 FIFO(`MAX_RESOLVED_PERMISSION_RECORDS = 512`),新近 resolved 的请求重复投票拿结构化 `already_resolved` 而不是 `unknown_request`。
+- 在 per-session EventBus 上发 `permission_partial_vote`(consensus)和 `permission_forbidden`(designated / consensus / local-only)。
+- 在 session teardown 时 `forgetSession(sessionId)` 把 pending 解析为 `{kind: 'cancelled', reason: 'session_closed'}`。
+- 拒绝恶意 / 误注入 `CANCEL_VOTE_SENTINEL`:wire 端 `InvalidPermissionOptionError`,agent 端 `CancelSentinelCollisionError`。
+
+## 架构
+
+### 公开 surface
+
+```ts
+interface PermissionMediator {
+ readonly policy: PermissionPolicy;
+ request(
+ record: PermissionRequestRecord,
+ timeoutMs: number,
+ ): Promise;
+ vote(vote: PermissionVote): PermissionVoteOutcome;
+ forgetSession(sessionId: string): void;
+}
+```
+
+`MultiClientPermissionMediator` 还有 `peekSessionFor(requestId)`、`pendingCount(sessionId)`、内部 audit publisher 等。`BridgeClient` 只依赖 `request()` 那一半(结构化 sub-typing,见 `bridgeClient.ts:30`)。
+
+### `PermissionPolicy` 与 `PermissionVoteOutcome`
+
+```ts
+type PermissionPolicy =
+ | 'first-responder'
+ | 'designated'
+ | 'consensus'
+ | 'local-only';
+
+type PermissionVoteOutcome =
+ | { kind: 'resolved'; resolvedOptionId: string }
+ | { kind: 'recorded'; votesNeeded: number } // consensus 局部
+ | { kind: 'already_resolved'; resolvedOptionId: string }
+ | { kind: 'forbidden'; reason: 'designated_mismatch' | 'remote_not_allowed' }
+ | { kind: 'unknown_request' };
+
+type PermissionResolution =
+ | { kind: 'option'; optionId: string }
+ | {
+ kind: 'cancelled';
+ reason: 'timeout' | 'session_closed' | 'agent_cancelled';
+ };
+```
+
+### Cancel 哨兵
+
+`CANCEL_VOTE_SENTINEL = '__cancelled__'`。bridge 把 voter `{outcome:'cancelled'}` 映射成这个哨兵后再调 `mediator.vote`。mediator 在策略派发**之前**就处理哨兵 —— voter-cancel 在任何策略下都能用,跟 `clientId` / loopback / membership 无关。两道护栏:
+
+1. **`bridge.ts`** 拒掉 wire 端 `optionId === CANCEL_VOTE_SENTINEL` 的投票,抛 `InvalidPermissionOptionError`(恶意 wire 客户端不能靠假报 `optionId` 注入 cancel)。
+2. **`mediator.request`** 拒掉 `allowedOptionIds` 包含哨兵的记录,抛 `CancelSentinelCollisionError`(agent 合法发布 `'__cancelled__'` 选项标签也不能伪装成 cancel)。
+
+这种刻意跨策略 escape 在 `permissionMediator.ts:50-57` 有文档说明,免得未来 maintainer 把它「修掉」。
+
+### Pending 状态
+
+每个 pending 按 `requestId` 索引,包含:
+
+- `policy` —— `request()` 时捕获。
+- `record: PermissionRequestRecord`(requestId、sessionId、originatorClientId、allowedOptionIds、issuedAtMs)。
+- `resolve` / `reject` 闭包。
+- `votesAtIssue`(仅 consensus)—— 发起时 session 上已登记的 `clientIds` 快照;后到的投票必须在这个集合里。
+- `tally`(仅 consensus)—— `Map>` 按 option 计票。
+- `timeoutHandle` —— `request()` 内同步装上的 Node timeout(N1 不变式)。
+- `auditTrail[]` —— 每票审计记录。
+
+### Resolved FIFO
+
+`MAX_RESOLVED_PERMISSION_RECORDS = 512`,FIFO 通过 `resolvedOrder.shift()`(DeepSeek review #4335 / 3271627446,对齐 `PermissionAuditRing`)。只存 `{requestId, sessionId, outcome}`,512 条在正常 UI 重连 / race 窗口下 < 100 KB。
+
+## 流程
+
+### `request()`(N1 不变式)
+
+```mermaid
+flowchart TD
+ A["BridgeClient.requestPermission(record, timeoutMs)"] --> B{"allowedOptionIds.has(SENTINEL)?"}
+ B -->|yes| C["throw CancelSentinelCollisionError"]
+ B -->|no| D["capture policy, snapshot votersAtIssue (consensus)"]
+ D --> E["new Promise: store resolve/reject"]
+ E --> F["arm setTimeout(timeoutMs) → resolve {cancelled, timeout}"]
+ F --> G["pending.set(requestId, entry)"]
+ G --> H["emit audit 'permission.requested'"]
+ H --> I["return Promise to bridge"]
+```
+
+定时器在 entry 对外可见**之前**就装上。否则 `forgetSession` 在 `pending.set` 与 `setTimeout` 之间到来,entry 就成了「pending 但无超时」 —— bridge 的 per-session `promptQueue` 永远 hang。
+
+### `vote()` 派发
+
+```mermaid
+flowchart TD
+ V["vote({requestId, sessionId, clientId?, optionId, receivedAtMs, fromLoopback})"] --> E{"pending entry exists?"}
+ E -->|no| RD{"in resolved FIFO?"}
+ RD -->|yes| AR["return {already_resolved, resolvedOptionId}"]
+ RD -->|no| UR["return {unknown_request}"]
+ E -->|yes| SENT{"optionId == SENTINEL?"}
+ SENT -->|yes| CX["resolve {cancelled, agent_cancelled}; clear pending"]
+ SENT -->|no| POL{"policy"}
+ POL -->|first-responder| FR["resolve {option, optionId}; remember"]
+ POL -->|designated| DG{"clientId == originatorClientId?"}
+ DG -->|no| FOR["emit permission_forbidden{designated_mismatch}; return forbidden"]
+ DG -->|yes| FRR["resolve {option, optionId}; remember"]
+ POL -->|consensus| CN{"clientId in votersAtIssue?"}
+ CN -->|no| FORC["emit permission_forbidden{designated_mismatch}; return forbidden"]
+ CN -->|yes| TAL["tally[option].add(clientId)"]
+ TAL --> Q{"max(tally[*]) >= quorum?"}
+ Q -->|yes| RES["resolve {option, optionId}; remember"]
+ Q -->|no| PV["emit permission_partial_vote; return recorded"]
+ POL -->|local-only| LO{"fromLoopback?"}
+ LO -->|no| FORL["emit permission_forbidden{remote_not_allowed}; return forbidden"]
+ LO -->|yes| RESL["resolve {option, optionId}; remember"]
+```
+
+### `forgetSession()`
+
+session close / 剔除 / bridge shutdown 时调用。对每个 `record.sessionId === sessionId` 的 pending entry:
+
+1. 取消超时。
+2. 用 `{kind: 'cancelled', reason: 'session_closed'}` resolve Promise。
+3. 写一条 audit。
+4. 从 `pending` 删除。
+
+bridge 的 session-teardown 路径永远在 channel-kill 窗口**之前**调 `forgetSession`,pending 不会比 session 活得久。
+
+## 状态与生命周期
+
+- `policy` per-request 捕获。改 daemon 全局策略不影响飞行中请求。
+- `votesAtIssue`(consensus)`request()` 时捕获;request 后到来的客户端可以投票,但 `clientId` 不在那时的快照中 → 拒为 `designated_mismatch`。和 `designated` 的 mismatch 原因刻意重载以保持契约封闭;未来版本如果 SDK 需要区分可以拆。
+- Resolved entry 在 FIFO 里活最多 `MAX_RESOLVED_PERMISSION_RECORDS`(512);evict 后对同 `requestId` 的重复投票返回 `{unknown_request}`。
+- `permission_partial_vote` 只在 `consensus` 下发,别人那不要依赖。
+- `permission_forbidden` 在 `designated` / `consensus` / `local-only` 下发,**不在** `first-responder` 下发。
+
+## 依赖
+
+- [`03-acp-bridge.md`](./03-acp-bridge.md) — bridge 怎么把 `BridgeClient.requestPermission` 接到 `mediator.request`。
+- [`10-event-bus.md`](./10-event-bus.md) — partial-vote / forbidden 帧怎么到客户端。
+- [`09-event-schema.md`](./09-event-schema.md) — `permission_*` 事件的 payload 契约。
+- [`08-session-lifecycle.md`](./08-session-lifecycle.md) — 每次 session 终态都会 `forgetSession()`。
+- [`02-serve-runtime.md`](./02-serve-runtime.md) — `PermissionAuditRing`(512 条 FIFO 审计)。
+
+## 配置
+
+| 来源 | 旋钮 | 效果 |
+| --------------- | --------------------------------------------------------------------------------------------------- | -------------------- |
+| `settings.json` | `policy.permissionStrategy` | 激活 mediator 策略 |
+| `settings.json` | `policy.consensusQuorum` | consensus 的 N |
+| `BridgeOptions` | `permissionPolicy`、`permissionConsensusQuorum`、`permissionAudit` | 程序化覆盖 |
+| 能力 tag | `permission_mediation`(恒;`modes: ['first-responder', 'designated', 'consensus', 'local-only']`) | 构建期支持集 |
+| 能力 envelope | `policy.permission` | 当前 daemon 跑的策略 |
+
+## Consensus 法定人数:默认公式与 M=2 边界
+
+`consensus` 策略激活且 `policy.consensusQuorum` 没显式配置时,mediator 按 **N = floor(M/2) + 1** 算 quorum(`permissionMediator.ts:1030`,`Math.max(1, Math.floor(m / 2) + 1)`)。具体:
+
+| M(`votersAtIssue.size`) | 默认 N | 行为 |
+| ------------------------- | ------ | ------------------------------------------ |
+| 1 | 1 | 单投票者立即裁决 |
+| 2 | 2 | **要求一致同意**,两个客户端必须选同一选项 |
+| 3 | 2 | 多数 |
+| 4 | 3 | 超过半数 |
+| 5 | 3 | 多数 |
+| 6 | 4 | 超过半数 |
+
+**M = 2** 时分票(A 选 X,B 选 Y)**只能靠 per-permission 超时**裁决 —— 哪个选项都到不了一致同意,请求挂到 `permissionResponseTimeoutMs`(默认 5 min)触发,解析为 `{cancelled, timeout}`。mediator 在 `permissionMediator.ts:486-495` 打 stderr 提示这层「一致同意 → 分票走超时」语义,operator 在日志里能看到。
+
+operator 想要 M = 2 时严格多数(不要一致同意)可以显式 `policy.consensusQuorum: 1`,行为塌陷为「第一票即胜」。更宽松配置(比如 M = 4 也强制一致)也通过同字段调。
+
+## Boot 时策略校验
+
+`runQwenServe.validatePolicyConfig(policyConfig)`(`packages/cli/src/serve/runQwenServe.ts:89+`)在 boot 时解析合并后的 settings `policy.*` 段,operator 配错时抛 `InvalidPolicyConfigError`:
+
+- `policy.permissionStrategy` 设了但不在四值集合内。合法集合**运行时派生**自 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`(单一事实源,将来加第五种策略时校验器和能力广播一起更新)。
+- `policy.consensusQuorum` 设了但不是正整数。
+
+外加一条**软警告**(stderr):`consensusQuorum` 设了但 `permissionStrategy !== 'consensus'` —— override 在非 consensus 策略下会被静默丢掉,警告浮出来,operator 不会以为它生效。
+
+`InvalidPolicyConfigError` 导出供测试 `instanceof`;`runQwenServe` 的 boot catch 用它区分 operator 错配(rethrow → 显式 boot 失败)和 settings 读 I/O 失败(fallback 默认)。
+
+## 安全注意:v1 的 client 身份是自报
+
+`X-Qwen-Client-Id` 由 HTTP 客户端**自报**,daemon 在 v1 **不做** proof-of-possession 检查。daemon 校验格式(`[A-Za-z0-9._:-]{1,128}`),按 session 跟踪 attach 的 client id 进 `clientIds`,但任何客户端只要观察到 SSE 帧里的 `originatorClientId`,就能用同 id 注册并在后续请求里冒充 originator。
+
+每个策略的影响:
+
+- **`first-responder`** —— 不受影响,策略不依赖身份。
+- **`designated`** —— 远端客户端可以伪装 `originatorClientId`,对本应只让 prompt 发起人投票的请求投票。**`settings.json` 的 `policy.permissionStrategy` 描述里有显式标注。**
+- **`consensus`** —— 投票按 issue-time `votersAtIssue` 快照闸;快照里如果已经有伪装 id(冒充者在 request 时就 attach 了),它就能投。
+- **`local-only`** —— `fromLoopback: boolean` 由 daemon 按连接的 remote address 盖戳,**不**取自客户端,所以这个策略对 id 伪装免疫,闸是按连接而非按 id。
+
+「pair-token」机制(daemon 在 `POST /session` 发一个 per-session secret,`designated` / `consensus` 投票时必须带)将来 PR 落地,v1 没有。今天想加固 designated 策略的部署应当绑 loopback(`local-only` 天然 robust),或挂在做认证的反代后面。
+
+## 注意 & 已知局限
+
+- **Cancel 哨兵在策略派发之前路由**是刻意的 —— `local-only` 和 `consensus` 都能被任何投 `{outcome: 'cancelled'}` 的客户端取消。这是 agent 侧 abort 路径,文档在 `permissionMediator.ts:50-57`。**`local-only` 特别注意**:远端客户端**不能 RESOLVE**,但**能 ABORT** pending permission。F3 v1 把 cancel 跨策略统一是出于一致性考虑。需要严格 cancel-too(远端调用方完全不能影响 pending)的部署必须跑专用 loopback-bound daemon —— 当下没有 per-policy cancel 闸。
+- **`designated` 与 `consensus` 都用 `designated_mismatch`** 在 `PermissionVoteOutcome` 里重载;mediator 写不同 audit,但 wire 形状一致。未来协议版本可能拆。
+- **匿名投票者(无 `X-Qwen-Client-Id`)** 只在 `first-responder` 和 `local-only`(loopback)下被接受;`designated` / `consensus` 拒。
+- **跨策略 escape** 意味着 cancel 无法被策略 gate。如果部署需要 policy-gated cancel,那是未来契约变化,不要用路由级 check paper-over。
+- **`votesAtIssue` 快照语义**意味着客户端集合在变动中的 consensus 部署会拒掉合法客户端(连入晚于 request 发起)。operator 应当在发起 change-review prompt 之前预先注册协作者的 client id。
+
+## 参考
+
+- `packages/acp-bridge/src/permission.ts:1-177`(冻结契约)
+- `packages/acp-bridge/src/permissionMediator.ts:1-1292`(实现,F3 commit 6+7)
+- `packages/acp-bridge/src/bridgeClient.ts:30`(对 `PermissionMediator` 用结构化 sub-typing)
+- `packages/acp-bridge/src/bridgeErrors.ts`(`CancelSentinelCollisionError`、`InvalidPermissionOptionError`、`PermissionForbiddenError`)
+- `packages/cli/src/serve/permissionAudit.ts:1-60`(audit ring + publisher)
+- Issue:[#4175](https://github.com/QwenLM/qwen-code/issues/4175) F3 系列。
diff --git a/docs/developers/daemon/05-mcp-transport-pool.md b/docs/developers/daemon/05-mcp-transport-pool.md
new file mode 100644
index 0000000000..f4ac4fe2f4
--- /dev/null
+++ b/docs/developers/daemon/05-mcp-transport-pool.md
@@ -0,0 +1,396 @@
+# Workspace MCP Transport 池
+## 概览
+
+`McpTransportPool`(`packages/core/src/tools/mcp-transport-pool.ts:104+`)是 F2(#4175 commit 5)的工作区级共享池:一个 daemon 上的 N 个 ACP session 共享每个唯一 `(serverName + configFingerprint)` 元组对应的一条 transport,不再各 spawn 一份 MCP 子进程。池**在 ACP 子进程里**(`QwenAgent.mcpPool`),用 daemon bootstrap `Config` 构造一次,活过 session 生命周期 —— 条目按 session attach 引用计数,refs 归零后在可配宽限期 drain 回 closed。
+
+它是多 session daemon 不至于把每个 MCP server fork N 份的最大原因。
+
+## 职责
+
+- 每 `(name + fingerprint)` acquire 或 spawn 一条 transport,并发 cold acquire 通过 `spawnInFlight` 去重。
+- 释放 per-session 引用;最后一个引用脱离时 arm drain 定时器。
+- 用硬性 `MAX_IDLE_MS` 上限挡住 ref-count 抖动客户端无限保活。
+- 用反向索引 `sessionToEntries` 让 `releaseSession(sessionId)` 是 O(refs) 而不是 O(entries)。
+- 按需重启条目(`restartByName`):单条目返回 `{restarted, durationMs}`,多条目返回 `{entries: RestartResult[]}`(F2 multi-entry 契约)。
+- daemon shutdown 时 `drainAll` 用可配置超时排空全池;drain 期间拒绝新 acquire。
+- 与 `WorkspaceMcpBudget`(见 [`06-mcp-budget-guardrays.md`](./06-mcp-budget-guardrails.md))联动在 `acquire` 上做 per-name 预留上限;条目 close 且同名无其他 entry 时释放 slot。
+- 通过 `SessionMcpView` 给每 session 一个过滤过的 tool / prompt 快照,免得一个 session 的 discovery 把 tool 注册到其他 session。
+
+## 架构
+
+### 公开 surface
+
+```ts
+class McpTransportPool {
+ constructor(cliConfig: Config, options: McpTransportPoolOptions);
+ acquire(
+ serverName,
+ cfg,
+ sessionId,
+ sessionToolRegistry,
+ sessionPromptRegistry,
+ ): Promise;
+ release(id, sessionId): void;
+ releaseSession(sessionId): void;
+ restartByName(
+ name,
+ opts?,
+ ): Promise;
+ drainAll(opts?): Promise;
+ getBudget(): WorkspaceMcpBudget | undefined;
+ getSnapshot(): McpPoolSnapshot;
+}
+```
+
+`McpTransportPoolOptions`:
+
+- `workspaceContext: WorkspaceContext`(必填)。
+- `debugMode: boolean`。
+- `sendSdkMcpMessage?` —— per-session 回调(池绕过 SDK MCP)。
+- `pooledTransports?: ReadonlySet` —— 默认 `{stdio, websocket}`。HTTP/SSE 故意不入池(header 可能带 session 特定 OAuth state,入池会跨 session 泄漏凭证)。
+- `drainDelayMs?` —— 默认 `30_000`。
+- `entryOptions?: (transport) => PoolEntryOptions`。
+- `budget?: WorkspaceMcpBudget`。
+
+### 内部状态
+
+| 状态 | 类型 | 用途 |
+| ------------------ | --------------------------------------- | ------------------------------------------------------------------------ |
+| `entries` | `Map` | live 条目,key 为 `connectionIdOf(name, fingerprint)` |
+| `unpooledIds` | `Set` | HTTP/SSE 那种非可入池 transport 的条目 |
+| `spawnInFlight` | `Map>` | 并发 cold acquire 去重 |
+| `sessionToEntries` | `Map>` | V21-2 反向索引,让 `releaseSession` 是 O(refs) |
+| `draining` | `boolean` | Wenshao C5 drain 锁;一旦置位所有 `acquire` 都拒 |
+| `nextIndexByName` | `Map` | V21-7 per server 单调 `entryIndex`(dashboard 不会因为新条目出现而抖动) |
+
+### `PoolEntry`(每条目结构体,`mcp-pool-entry.ts`)
+
+状态机:`spawning → active ⇄ (active ↔ reconnect) → (active → draining on last detach, draining → active on attach OR draining → closed on timer)`。
+
+| 字段 | 用途 |
+| ------------------------------------------------------ | ------------------------------------------------------------- |
+| `localStatus: MCPServerStatus` | 由 `MCPServerStatus` 生命周期驱动 |
+| `state: PoolEntryState` | `spawning`/`active`/`draining`/`closed`/`failed` |
+| `generation: number` | 每次 restart bump,订阅者比较探测 reconnect 周期 |
+| `refs: Set` | 当前 attach 的 session id 集合 |
+| `subscribers: Map` | per-session 过滤视图 |
+| `subscriberHandles: Map` | `acquire` 返回的 handle |
+| `toolsSnapshot[]`、`promptsSnapshot[]` | 池级 canonical 快照;`toolsChanged` / `promptsChanged` 时重发 |
+| `drainTimer?` | `refs.size === 0` 时装上,默认 30s;attach 时重置 |
+| `maxIdleTimer?` | **首次** idle 时装上,acquire/release 抖动不重置;默认 5 min |
+| `firstIdleAt?` | 硬性最大空闲的水位线 |
+| `restartInFlight?` | `restart()` 的互斥 |
+
+### `PoolEntryOptions`
+
+```ts
+interface PoolEntryOptions {
+ drainDelayMs: number; // 默认 30_000
+ maxIdleMs: number; // 默认 5 * 60_000
+ maxReconnectAttempts: number; // 默认 3(stdio/ws)或 5(http/sse)
+ reconnectStrategy:
+ | { kind: 'fixed'; delayMs: number }
+ | { kind: 'exponential'; baseMs: number; capMs: number };
+}
+```
+
+`defaultPoolEntryOptions(transport)`(`mcp-pool-entry.ts:58-70`):stdio/ws → `{fixed 5s, 3 次}`;http/sse → `{exponential 1s → 16s, 5 次}`。remote transport 给更长重试预算,因为它们的失败更多是 transient。
+
+## 流程
+
+### `acquire`
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant S as Session
+ participant P as Pool
+ participant SIF as spawnInFlight
+ participant E as PoolEntry
+ participant BDG as WorkspaceMcpBudget
+ participant SRV as MCP server
+
+ S->>P: acquire(name, cfg, sessionId, sessionToolRegistry, sessionPromptRegistry)
+ P->>P: refuse if draining
+ P->>P: connectionId = connectionIdOf(name, fingerprint)
+ P->>P: if !isPoolable(cfg) → mark unpooled
+ alt entry in entries (warm)
+ E-->>P: existing PoolEntry
+ else inflight cold spawn
+ SIF-->>P: existing Promise
+ else cold start
+ P->>BDG: tryReserve(name) (if budget set + poolable)
+ BDG-->>P: 'reserved' | 'already_held' | 'refused'
+ alt refused
+ P->>BDG: recordRefusal(name, transport)
+ P-->>S: BudgetExhaustedError
+ else ok
+ P->>E: spawnEntry(name, cfg)
+ E->>SRV: connect transport
+ SRV-->>E: ready
+ P->>P: entries.set(id, E); nextIndexByName++
+ E-->>P: connected
+ end
+ end
+ P->>E: addSubscriber(sessionId, sessionToolRegistry, sessionPromptRegistry)
+ P->>P: sessionToEntries.add(sessionId, id)
+ P->>P: cancel drain timer (refs>0)
+ P-->>S: PooledConnection { id, serverName, entryIndex, client, toolsSnapshot, promptsSnapshot, on, off, release }
+```
+
+### `release` + drain
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant S as Session
+ participant P as Pool
+ participant E as PoolEntry
+ participant BDG as WorkspaceMcpBudget
+
+ S->>P: release(id, sessionId)
+ P->>E: removeSubscriber(sessionId)
+ P->>P: sessionToEntries.delete(sessionId, id)
+ alt refs > 0
+ E-->>P: ok
+ else refs == 0
+ E->>E: firstIdleAt = now (if unset)
+ E->>E: arm drainTimer(drainDelayMs)
+ E->>E: arm maxIdleTimer(maxIdleMs - elapsed)
+ end
+ Note over E: drainTimer fires →
+ E->>SRV: disconnect transport
+ E->>P: emit 'closed'
+ P->>P: entries.delete(id)
+ P->>P: if !hasNameSibling(name) → BDG.release(name)
+```
+
+`hasNameSibling(name)`(`mcp-transport-pool.ts:181+`)同时遍历 `entries.values()` 和 `spawnInFlight.keys()`;后者要用 `parseConnectionId` 解析(MCP server 名可以合法包含 `::`,`startsWith` 会在 sibling 名以 `${name}::` 开头时假阳性)。
+
+`releaseSession(sessionId)` 从 `sessionToEntries` 读,O(refs) 释放该 session 引用的所有条目然后清索引。bridge 的 session-close 路径用它,不必遍历整个 entry map。
+
+### `restartByName`
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Op as POST /workspace/mcp/:server/restart
+ participant P as Pool
+ participant E as PoolEntry
+ participant SRV as MCP server
+
+ Op->>P: restartByName(name, opts?)
+ alt opts.entryIndex specified
+ P->>E: find entry by (name, entryIndex)
+ else
+ P->>P: gather all entries with matching name
+ end
+ par per entry
+ P->>E: restart() (mutex via restartInFlight)
+ E->>SRV: disconnect
+ E->>SRV: reconnect
+ E->>E: bump generation, re-emit snapshots
+ end
+ alt single entry
+ P-->>Op: {restarted: true, durationMs}
+ else multi-entry
+ P-->>Op: {entries: [{restarted, durationMs, entryIndex}, ...]}
+ end
+```
+
+daemon HTTP 层的预检(Wave-4 PR 17):目标 slot 没有被预留,且重启会让 live count 超 `enforce` 预算时,返回 `{restarted:false, skipped:true, reason:'budget_would_exceed'}`。
+
+### `drainAll`
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant D as Daemon shutdown
+ participant P as Pool
+ participant E as PoolEntries
+
+ D->>P: drainAll({timeoutMs?})
+ P->>P: draining = true (refuse new acquires)
+ par for each entry
+ P->>E: trigger drain (close transport, clear timers)
+ E-->>P: closed
+ end
+ P-->>D: done (or timeout reached, force close)
+```
+
+## 状态与生命周期
+
+- 池构造同步;首次 `acquire` 冷启动 transport。
+- `drainDelayMs`(默认 30s)attach 时取消。
+- `maxIdleMs`(默认 5 min)attach/detach 抖动**不**重置;从**首次** idle 起跳,到点或在 deadline 前 attach 才停。挡 thrashing 客户端。
+- `nextIndexByName` 单调。新条目出现后老条目保留原 index,dashboard 读 `entryIndex` 不抖。
+- Spawn 失败释放预留的 budget slot(V21-4,否则 cold spawn 在 connect 中途崩会永远漏 reservation)。
+
+## 依赖
+
+- `packages/core/src/tools/mcp-client.ts`:`McpClient`、status 枚举、`SendSdkMcpMessage`。
+- `packages/core/src/tools/mcp-pool-entry.ts`:`PoolEntry`、`PoolEntryOptions`、`defaultPoolEntryOptions`。
+- `packages/core/src/tools/mcp-pool-key.ts`:`connectionIdOf`、`parseConnectionId`、`isPoolable`、`mcpTransportOf`、`POOLED_TRANSPORTS_DEFAULT`。
+- `packages/core/src/tools/mcp-pool-events.ts`:`ConnectionId`、`PoolEntryState`、`PoolEvent`。
+- `packages/core/src/tools/session-mcp-view.ts`:per-session 过滤视图。
+- `packages/core/src/tools/mcp-workspace-budget.ts`:`WorkspaceMcpBudget`(见 [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md))。
+- `packages/core/src/tools/mcp-discovery-timeout.ts`:`discoveryTimeoutFor`、`runWithTimeout`。
+
+## 配置
+
+| 来源 | 旋钮 | 效果 |
+| ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| Env | `QWEN_SERVE_NO_MCP_POOL=1` | 杀手锏 —— `QwenAgent.mcpPool` 保持 undefined,回退到 per-session `McpClientManager`(pre-F2 路径) |
+| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 通过 `childEnvOverrides` 传 ACP 子进程;子进程构造 `WorkspaceMcpBudget` 喂给池 |
+| 能力 tag(条件) | `mcp_workspace_pool`、`mcp_pool_restart` | 池开启时一起广播。SDK 都 pre-flight 才能依赖 pool-aware 响应形状 |
+
+### 非入池条目(HTTP / SSE / SDK-MCP)
+
+`pooledTransports` 之外的 transport(HTTP、SSE、SDK-MCP)走另一条路:`createUnpooledConnection(name, cfg, sessionId, ...)`(`mcp-transport-pool.ts:1142`)按 session 起一条 entry,id 形如 `${name}::unpooled-${entryIndex}`。与入池条目的差异:
+
+- 同时存到 `entries` 和 `unpooledIds: Set`,`release` / `releaseSession` 能快速走 detach-即关 的路径(refs 永远最多 1)。
+- 直接调 `McpClient.discover()`,不走池的重放;`applyTools` / `applyPrompts` 都是 no-op,因为 session 的 registry 自己已经持有刚注册的内容(W77 / `attach()` 里 `skipReplay: true`)。
+- workspace 预算照样闸 —— F2 commit 6 关掉了之前 unpooled 绕过 `tryReserve` 的口子;不管入不入池,同一个 `WorkspaceMcpBudget` slot 都被预留,entry close 时释放。
+
+W77 竞态(`cb206da36`):`createUnpooledConnection` 在 await `client.connect()` / `client.discover()` 之前就把 entry 放进 `this.entries`,但只在 `attach()` 成功之后才往 `sessionToEntries[sessionId]` 索引。connect/discover 窗口里并发到来的 `closeStoredSession()` / `releaseSession(sessionId)` 看到空索引,让 unpooled spawn 跑完,`attach()` 接着把 tool/prompt 注册到一个已经关闭的 session。修复:
+
+- `mcp-pool-entry.ts:520`:公开 `isTerminated(): boolean` 探针(`state === 'closed' || state === 'failed'`)。
+- `mcp-pool-entry.ts:529`:`markActive()` 在 `isTerminated()` 时短路,已拆掉的 entry 不能被复活到 `'active'`。
+- 调用方(池的 unpooled 路径)在 await 之间探 `isTerminated()`,父 session 没了就放弃 attach。
+
+这条 race 今天**潜在**(W61/W71 的 per-session `releaseSession` hook 在 F4 才落),但那个 hook 一到这条 race 就变 live —— F2 线上先把它修了。
+
+## `GET /workspace/mcp` 的 pool-aware 快照字段
+
+池激活时,`ServeWorkspaceMcpStatus` 每个 server cell(`packages/acp-bridge/src/status.ts:171-200`,类型主定义在 242)多三个字段:
+
+| 字段 | 类型 | 用途 |
+| ---------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `disabledReason` | `'config' \| 'budget'` | 区分 operator 禁用(`disabled: true` 来自 `disabledMcpServers` 配置)和预算拒绝(`status: 'error', errorKind: 'budget_exhausted'`)。operator 在 dashboard 上不必交叉查 `errors[]` 或 `budgets[]` 才能渲染单 server 行 |
+| `entryCount` | `number`(≥1) | 池模式工作区上同名可有多条 `PoolEntry`(session 注入不同 fingerprint,如 per-session OAuth header)。`QWEN_SERVE_NO_MCP_POOL=1` 关闭池时该字段不存在。新客户端按 `entryCount > 1` 渲「N 条 entry」徽章 |
+| `entrySummary` | `ReadonlyArray<{entryIndex, refs, status}>` | per-entry 分解。`entryIndex` 是 entry 创建时分配的**稳定不透明整数** —— **不是**原始 fingerprint,否则会通过快照 diff 泄漏 OAuth/env 轮换时机。`refs` 是当前 attach 的 session 数。`status` 是 per-entry 运行时状态,dashboard 在聚合 `mcpStatus` 已经 `connected` 但某条 entry 还在重连时仍能显示分项健康 |
+
+`(entryCount, entrySummary)` **广播时永远成对**出现 —— `mcp_workspace_pool` 能力 tag 蕴含两者。老 SDK 客户端按加法协议契约忽略它们。
+
+池快照里还有一个 `subprocessCount` 计数:**只数 `'stdio'` 家族**。websocket / HTTP / SSE 是拨远端 server,本地无 child 进程;早期版本错把 websocket 计入,会让本地资源仪表板虚高。
+
+## 关闭路径:drain 在两条入口都触发
+
+池 drain 不只跑 SIGTERM handler —— IDE 发起的正常关闭路径(`await connection.closed`)也调 `drainAll`。两条路径互为镜像(`packages/cli/src/acp-integration/acpAgent.ts:231-332`,`drainPoolBeforeExit` 助手定义在 231,分别在 306(signal)和 332(ide_close)调用),无论 daemon 是被信号杀掉还是 IDE 干净挂断 connection,pool 都会进 `draining` 状态、拒绝新 acquire、并等所有 entry 关闭。
+
+## `/mcp refresh` 与 boot 期发现走同一池路径
+
+`discoverAllMcpTools`(boot 期发现)和 `discoverAllMcpToolsIncremental`(`/mcp refresh` / 热加载)在池模式下都先查池(`packages/core/src/tools/mcp-client-manager.ts:1977`)。两条 discovery 路径共用同一 gate,避免热加载意外起 per-session client、双算 budget、留下孤儿 transport。
+
+## 重连期间 in-flight 工具调用(`MCPCallInterruptedError`)
+
+底层 MCP transport 静默掉线(连接从 `'active'` / `'draining'` 直接进 `localStatus === DISCONNECTED`,没有显式关闭)时,池把 entry 转 `'failed'`、从 `pool.entries` 驱逐、在 detach 订阅者视图**之前**先 emit `failed` 事件(`mcp-pool-entry.ts:358-440`,emit 在 387)。emit-先于-detach 的顺序重要:订阅者及时收到 `failed` 事件能把 pending `callTool` promise 路由到 `MCPCallInterruptedError`,卡住的 `await client.callTool(...)` 干净 reject 而不是 hang。`forceShutdown` 走的也是同样 emit→detach 顺序(`mcp-pool-entry.ts:720+`,`forceShutdown` 入口 720、emit 在 757)。
+
+## Fingerprint 与 `canonicalOAuth` 归一
+
+池 key 由 `fingerprint(cfg)`(`mcp-pool-key.ts:128+`)计算。哈希字段覆盖所有 transport 定义性的:
+
+> `transport, command, args, cwd, env, url, httpUrl, tcp, headers, timeout, oauth`
+
+per-session 过滤 / 元数据字段(`includeTools`、`excludeTools`、`trust`、`description`、`extensionName`、`discoveryTimeoutMs`)**被排除**,不同 session 用不同过滤共享同一 entry。
+
+OAuth 这一格,`canonicalOAuth(o)`(`mcp-pool-key.ts:75-110`)哈希**每一个** `MCPOAuthConfig` 字段 —— `clientId`、`clientSecret`、`scopes`(排序后)、`audiences`(排序后)、`authorizationUrl`、`tokenUrl`、`redirectUri`、`tokenParamName`、`registrationUrl`。**这是凭证隔离的关键**:仅在 `clientSecret` / `audiences` / `redirectUri` 等字段上有差异的两个 session config 会被正确视作不同 fingerprint,不会共享一条 entry。confidential client(带 `clientSecret`)和 multi-audience token 部署最依赖这条契约。
+
+scope 数组和 audience 数组排序,callsite 顺序不会改 fingerprint;显式 `null` 默认让 undefined 字段哈希等于显式 null。key 里没有 `discoveryTimeoutMs` —— 同 key 不同 timeout 并发 acquire 是「first wins」(对齐 pre-F2 per-session manager 行为)。
+
+`PoolEntry` 持有的 `cfg: MCPServerConfig` 字段是**私有**的,外部代码读 transport 家族要走 `entry.transportKind` getter。这是防止 env / header auth / OAuth 等敏感字段被外部消费方意外读到。
+
+## Extension 卸载:孤儿 entry 由 MAX_IDLE_MS 自然回收
+
+设计上**不**为运行中卸载 MCP extension 加主动回收路径。孤儿 entry(extension 的 `MCPServerConfig` 已不在工作区合并设置里但池里还有 entry)由最后一个订阅者 detach 后的 `MAX_IDLE_MS`(默认 5 min)硬上限自然回收。同步的卸载-回收路径会为 operator 罕见的边缘场景加复杂度,硬上限把孤儿进程超过卸载点的最坏寿命限到 5 分钟。
+
+operator 想要更快的孤儿清理可以重启 daemon 或对已不再配置的 name 触发 `POST /workspace/mcp/:server/restart` —— 会走 disabled-server 路径把 entry 拆掉。
+
+## 自愈观测:transport 错误捕获 + sweep 结果
+
+池底层 self-heal 路径有两块结构化诊断输出:
+
+**`McpClient.lastTransportError: Error | undefined`**(`packages/core/src/tools/mcp-client.ts:106-161, 336-346`)—— `McpClient.onerror` 把最近一次 transport 异常落到私有字段,`connect()` 入口处清零。`PoolEntry` 的「silent transport drop → 'failed'」分支(见上节)通过 `client.getLastTransportError()` 把上游错误透到 `emit({kind:'failed', lastError})` 里,subscriber / dashboard 不必再去 grep stderr 推因。
+
+**`SweepResult`**(内部 interface,**不导出**;`packages/core/src/tools/mcp-pool-entry.ts:130+`)—— `sweepAndDisconnect(reason)` 返 `Promise`:
+
+```ts
+interface SweepResult {
+ pidSweepError?: Error; // listDescendantPids 自身抛了
+ descendantsFound?: number; // 找到的子孙 pid 数
+ descendantsSignaled?: number; // 成功 SIGTERM 的数(可能 < found)
+}
+```
+
+消费方只有 `statusChangeListener` 里的 silent-drop 块。它通过 `descendantsFound` / `descendantsSignaled` 判断 **partial-signal**(信号数少于发现数,子进程在 `listDescendantPids` 与 `sigtermPids` 之间退了或 EPERM)以及 **sweep 本身报错**,结构化打 warn 日志。`forceShutdown` / `doRestart` 路径忽略这个返回 —— 自带 catch 路径已经有更丰富的错误信号。
+
+## 子进程清理:`pid-descendants` 的快照路径
+
+`McpTransportPool` 关停 stdio 子进程时要枚举它们的子孙进程(npx 包装、shell wrapper 等多层 fork 都要被回收)。`packages/core/src/tools/pid-descendants.ts` 暴露 `listDescendantPids(rootPid) → Promise` + `sigtermPids(pids)` 两个原语,给 `sweepAndDisconnect` 用。
+
+### Linux / macOS 主路径
+
+单次 `ps -A -o pid=,ppid=` 快照把整张进程表读出来 → 解析成 `Map` → `walkDescendants(tree, root)` 做 BFS 拿出整棵子树。任何深度都只 fork 一次 `ps`。
+
+`walkDescendants` 维护 `visited: Set`(`root` 也进 visited)防 PID-reuse 循环 —— 快进程 churn 下 `ps -A` 启动到读完之间可能发生 wraparound,理论上能在快照里看到 A→B / B→A 环,没 visited 防御会把 `MAX_DESCENDANTS` 配额填满假数据,挤掉真正的子孙。
+
+### Windows 主路径
+
+单次 `Get-CimInstance Win32_Process | ConvertTo-Csv -Delimiter ","` 快照所有 `(ProcessId, ParentProcessId)` 行,同样落到 `Map` 后走 `walkDescendants`。
+
+`-Delimiter ","` 是显式的,**不能省**。PowerShell 5.1(Windows 自带的版本)`ConvertTo-Csv` 默认遵守系统 locale 的列表分隔符;DE / FR / NL / IT 等 locale 用 `;`,pre-fix 正则 `^"(\d+)","(\d+)"$` 永远不匹配,每次 daemon shutdown 都会回退到 per-pid CIM filter 路径,每个子进程多 ~0.5-1s PowerShell 启动开销。
+
+### Fallback 路径
+
+BusyBox `` BFS,Windows 用 `Get-CimInstance -Filter "ParentProcessId=$p"`(`$p` 是 PowerShell 变量绑定,不是字符串拼接 —— 入口的 `Number.isInteger` 守护今天就够,绑定是 defense-in-depth)。
+
+### 共同约束
+
+两条路径都受 `MAX_DESCENDANTS = 256` / `MAX_DEPTH = 8` 上限保护,防止恶意或退化的进程树把 sweep 拖垮。
+
+snapshot 路径 `maxBuffer: 8MB` 覆盖 ~250k 进程的病态主机;默认 1MB 会在 ~30k 进程时截断 child-process 输出。
+
+性能侧只是**轻度收益**(典型 200-500 进程的开发机解析 < 10ms,相比 per-pid pgrep ~2× 改进);主要收益是 **fork hygiene + 快照一致性**:BFS 一次性看到完整子树,而 pre-fix 的「逐 pid 询问」会在两次询问之间漏掉新 fork 的孙进程。
+
+## 嵌入方注意:`McpClientManager` 构造签名
+
+`McpClientManager` 的构造签名是 `(config, toolRegistry, options?: McpClientManagerOptions)`。直接 import 该类的嵌入方传:
+
+```ts
+new McpClientManager(config, toolRegistry, {
+ eventEmitter,
+ sendSdkMcpMessage,
+ healthConfig,
+ budgetConfig,
+ pool,
+});
+```
+
+测试侧推荐用 `mkManager(overrides?)` factory 把只关心一两个字段的 case 写成单行。
+
+## 实现笔记(内部 helper / 优化,不影响 API)
+
+下游不直接使用但 grep 源码会撞到的内部结构:
+
+- `McpTransportPool.acquire()` 内部两个 helper `attachPooledSession` 与 `rollbackReservationOnSpawnFailure` 把 fast-path attach / post-spawn attach / pooled spawn-in-flight catch 三处共用代码集中(行为不变;race-window 不变式仍由调用点描述)。
+- `SessionMcpView.applyTools` / `applyPrompts` 用 `compileNameFilter(cfg)` 一次性把 `includeTools` / `excludeTools` 编译成 Set,per-tool 命中走 `compiledFilterAccepts(compiled, name)`。导出 `passesSessionFilter` / `passesSessionPromptFilter` 仍然走同一编译路径(单一事实来源)。`excludeTools` 直接等值;`includeTools` 剥首个 `(...)` 后缀让 `toolName(args)` 匹配 `toolName`。
+
+设计文档:[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) §6 全章覆盖 transport 池的状态机、reconnect、drain、descendant sweep。
+
+## 注意 & 已知局限
+
+- **HTTP / SSE transport 不入池** —— 每次 acquire 新起一条只活 session 那么久。原因:header 可能带 session 特定 OAuth state,入池会跨 session 泄漏凭证。
+- **`maxIdleMs` 是抗抖动硬上限**。5 分钟硬空闲意味着即使激进 attach/detach 也不能让 idle transport 钉超 5 分钟。想要长期常驻 transport 的 operator 应该调大 `maxIdleMs` 或者把 server 跑在池外面。
+- **per-server-name 预算 slot** 意味着同名不同 fingerprint 的两条入池条目共占 ONE slot 而不是两个。子进程账面分开通过 `pool.getSnapshot().subprocessCount` 暴露。
+- **`startsWith` 回归** 在 `hasNameSibling` 里被规避,因为 MCP server 名可以合法含 `::`(`mcp-pool-key.test.ts:258`);永远用 `parseConnectionId` 的 `lastIndexOf('::')` 切,不要用字符串前缀匹配。
+- **池 drain 是单向**:`drainAll` 永久置 `draining = true`;要再 work 必须新池。
+
+## 参考
+
+- `packages/core/src/tools/mcp-transport-pool.ts`(整文件;关键行 104+、181+、208+)
+- `packages/core/src/tools/mcp-pool-entry.ts:1-120+`(entry 生命周期)
+- `packages/core/src/tools/mcp-pool-key.ts`(`connectionIdOf`、`parseConnectionId`)
+- `packages/core/src/tools/mcp-pool-events.ts`(事件类型)
+- `packages/core/src/tools/session-mcp-view.ts`(per-session 过滤视图)
+- F2 设计文档(v2.2,含 32 条 review fold-in):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md)。实现契约的事实源;本篇是它的开发者深度阅读。
+- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175)(F2 系列 commit 4-6)。
diff --git a/docs/developers/daemon/06-mcp-budget-guardrails.md b/docs/developers/daemon/06-mcp-budget-guardrails.md
new file mode 100644
index 0000000000..f7386e0c0f
--- /dev/null
+++ b/docs/developers/daemon/06-mcp-budget-guardrails.md
@@ -0,0 +1,152 @@
+# MCP 工作区预算护栏
+## 概览
+
+`WorkspaceMcpBudget`(`packages/core/src/tools/mcp-workspace-budget.ts:55+`)是 F2(#4175 commit 6)的工作区级 MCP client 预算控制器。它持有的状态机和 `McpClientManager` inline 的完全一样(slot 预留、75% 滞回警告、跨 `discoverAllMcpTools*` 一遍 pass 合并 refused-batch),但**一 workspace 一份**住在 `McpTransportPool` 里,而不是每个 ACP child 的 manager 里 N 份。池把 `acquire` / `release` 委托给它,于是上限是**工作区**级上限不是每 session 级。
+
+老的 `McpClientManager` 预算机器保留给独立 qwen 和 SDK MCP server(commit 4 的修复让它们绕过池)。池模式 → `WorkspaceMcpBudget` 强制;standalone / SDK MCP → manager inline 机器强制。不会双数:池模式 discovery 永不调 manager 的 `tryReserveSlot`。
+
+## 职责
+
+- 跟踪 `reservedSlots: Set`(当前持有的 server NAME,slot key per-NAME,对齐 PR 14 v1)。
+- `tryReserve(name) → 'reserved' | 'already_held' | 'refused'` —— 原子同步,并发 `Promise.all` acquire 不能在 await 边界偷过上限。
+- `release(name) → boolean` —— 幂等(`Set.delete` 语义)。
+- `reservedSlots.size / clientBudget` 上升越过 75% 时发一次 `mcp_budget_warning`;下降越过 37.5% 才重新装填。
+- 在 bulk discovery pass 内合并 per-server 拒绝 —— `beginBulkPass()` / `endBulkPass()` 包围期间所有拒绝累成一次 `mcp_child_refused_batch` 事件。
+- 维护 `lastRefusedServerNames` 给快照消费者(`GET /workspace/mcp`)—— 下一个 bulk pass **开始时**才清掉,不是 emit 时;夹在两 pass 之间的快照还能看到上一批拒绝。
+
+## 架构
+
+### 配置
+
+```ts
+new WorkspaceMcpBudget({
+ clientBudget?: number, // undefined = 不限
+ mode: 'off' | 'warn' | 'enforce',
+ onEvent?: (event: McpBudgetEvent) => void,
+});
+```
+
+`mode`:
+
+- `off` —— 所有方法 no-op;`tryReserve` 无条件返回 `'reserved'`;无事件。
+- `warn` —— 跟踪 slot 并在 75% 发 `mcp_budget_warning`,但 `tryReserve` 永不拒绝。
+- `enforce` —— `tryReserve` 超 `clientBudget` 时拒绝;`recordRefusal` 排队 per-server 拒绝;`endBulkPass` 发 `mcp_child_refused_batch`。
+
+### 来自 `mcp-client-manager.ts` 的常量
+
+- `MCP_BUDGET_WARN_FRACTION = 0.75`。
+- `MCP_BUDGET_REARM_FRACTION = 0.375`。
+- `McpBudgetMode = 'off' | 'warn' | 'enforce'`。
+
+### 内部状态
+
+| 状态 | 用途 |
+| -------------------------------------------------- | ----------------------------------------------------------------------------- |
+| `reservedSlots: Set` | 权威预留集合;滞回评估 `size / clientBudget` |
+| `pendingRefusalNames: Set` | 当前 `beginBulkPass` / `endBulkPass` 窗口内累积的拒绝名;`endBulkPass` 时排空 |
+| `pendingRefusalTransports: Map` | 给 emit 的 batch 带每个拒绝 server 的 transport |
+| `lastRefusedServerNames: readonly string[]` | 上一个完成 pass 的拒绝列表,快照可见;下一个 pass 开始才清 |
+| `warnArmed: boolean` | 滞回状态 —— true = 准备好发,false = 已发未 37.5% 下回 |
+| `bulkPassDepth: number` | 嵌套 bulk pass 计数(嵌套时不能双发) |
+
+## 流程
+
+### `tryReserve`
+
+```mermaid
+flowchart TD
+ A["tryReserve(serverName)"] --> B{"reservedSlots.has(name)?"}
+ B -->|yes| AH["return 'already_held'"]
+ B -->|no| C{"budget undefined OR mode == 'off'?"}
+ C -->|yes| R["return 'reserved'"]
+ C -->|no| D{"mode == 'enforce' AND size >= budget?"}
+ D -->|yes| RF["return 'refused'"]
+ D -->|no| ADD["reservedSlots.add(name)"]
+ ADD --> EV["evaluateState() (hysteresis check)"]
+ EV --> R2["return 'reserved'"]
+```
+
+`tryReserve` 是**同步**的。池的 `acquire` 是 async,但 reservation 在任何 `await` 之前完成,两个并发 `Promise.all` acquire 不同名的请求不可能都挤过上限。
+
+### 滞回
+
+```mermaid
+flowchart TD
+ EV["evaluateState() called after every mutation"] --> R["ratio = reservedSlots.size / clientBudget"]
+ R --> U{"warnArmed && ratio >= 0.75?"}
+ U -->|yes| FIRE["fire mcp_budget_warning; warnArmed = false"]
+ U -->|no| D{"!warnArmed && ratio <= 0.375?"}
+ D -->|yes| ARM["warnArmed = true"]
+ D -->|no| NOOP[no-op]
+```
+
+滞回防 75% 上下抖时的 spam。首次越过发一次;不再下到 37.5% 时后续越过不发。
+
+### 拒绝-批合并
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant POOL as pool.discoverAllMcpToolsViaPool
+ participant BDG as WorkspaceMcpBudget
+ participant EB as EventBus
+
+ POOL->>BDG: beginBulkPass()
+ BDG->>BDG: bulkPassDepth++
clear lastRefusedServerNames if outermost
+ loop per server in pass
+ POOL->>BDG: tryReserve(name)
+ alt refused
+ POOL->>BDG: recordRefusal(name, transport)
+ BDG->>BDG: pendingRefusalNames.add; pendingRefusalTransports.set
+ Note over BDG: NO event yet (coalesce)
+ end
+ end
+ POOL->>BDG: endBulkPass()
+ BDG->>BDG: bulkPassDepth--
+ alt outermost (depth == 0) AND pending non-empty
+ BDG->>EB: emit mcp_child_refused_batch
{refusedServers, budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace'}
+ BDG->>BDG: lastRefusedServerNames = drain pendingRefusalNames
+ end
+```
+
+pass 之外的拒绝(比如 lazy `readResource` spawn 完全绕过 bulk pass)inline 发 length-1 batch 保持形状一致。嵌套 pass(`bulkPassDepth > 0`)不发;只有最外层 end-of-pass 才发合并的 batch。
+
+## 状态与生命周期
+
+- 预算控制器在池初始化时一 workspace 一份构造。
+- `clientBudget` 构造后不可变;运行时改动要重建池。
+- `mode` 也不可变(`mode === 'off'` 时 `onEvent` 被 stash 为 `undefined`,defense in depth)。
+- `warnArmed` 初始 true;37.5% 下回时 reset 为 true。
+- `lastRefusedServerNames` 在 `endBulkPass` emit 时**不**清;只在下个 bulk pass 开始时清。这让两 pass 之间的快照路由还能报告上一批拒绝集合(否则 refused-batch 事件刚送达 dashboard 就空了)。
+
+## 依赖
+
+- `packages/core/src/tools/mcp-client-manager.ts` —— 复用 `McpBudgetEvent`、`McpBudgetMode`、`McpRefusedServer`、`MCP_BUDGET_WARN_FRACTION`、`MCP_BUDGET_REARM_FRACTION`、`BudgetExhaustedError`(refused 时由池的 `acquire` 抛)。
+- `packages/core/src/tools/mcp-transport-pool.ts` —— 消费 budget;通过池的 `onEvent` 把事件喂到 daemon EventBus。
+- daemon 快照路由 `GET /workspace/mcp` —— 读 `getReservedSlots()`、`getRefusedServerNames()`、`getReservedCount()`、`getBudget()`、`getMode()`。
+
+## 配置
+
+| 来源 | 旋钮 | 效果 |
+| -------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
+| 参数 | `--mcp-client-budget=N` | 设 `clientBudget` |
+| 参数 | `--mcp-budget-mode={off,warn,enforce}` | 设 `mode`;`enforce` 要求正整数 `clientBudget`(否则 boot-loud 拒) |
+| Env | `QWEN_SERVE_MCP_CLIENT_BUDGET`、`QWEN_SERVE_MCP_BUDGET_MODE` | 通过 `childEnvOverrides` 传 ACP 子进程,子进程的 `readBudgetFromEnv()` 接 |
+| 能力 tag | `mcp_guardrails`(恒;`modes: ['warn', 'enforce']`)、`mcp_guardrail_events`(恒) | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) |
+
+## 注意 & 已知局限
+
+- **预留 key 是 per-NAME**。同名不同 fingerprint(session 注入不同 OAuth header)的两条池条目共占 ONE slot。子进程账面通过池快照的 `subprocessCount` 单独暴露。operator 应当把预算理解为「配置 server slot 数」而不是「子进程数」。
+- **滞回基于预留数不是 live(CONNECTED)数**。reservation 包括 in-flight connect 且 survive 短暂 disconnect,所以滞回在重连周期里稳定。live count 也在事件 payload 的 `liveCount` 里暴露给想看那个 lens 的 SDK。
+- **`warn` 模式永不拒绝**。仍然跟踪并发 `mcp_budget_warning`,但 `tryReserve` 总返 `'reserved'`。拒绝语义只有 `enforce`。
+- **工作区级 budget 事件带 `scope: 'workspace'`** 同时扇出给所有 attach 的 session;SDK reducer 的 `mcpBudgetWarningCount` / `mcpChildRefusedBatchCount` 在同一 connection 上的 session 之间齐步增长。`McpClientManager` 的 per-session 老事件无 `scope`(语义默认 `'session'`)。
+- **杀手锏 `QWEN_SERVE_NO_MCP_POOL=1`** 完全禁池;workspace budget 也禁,回到 per-session `McpClientManager` budget。capabilities envelope 诚实地不广播 `mcp_workspace_pool` / `mcp_pool_restart`。
+- **`ServeMcpBudgetStatusCell.scope`** 是向前兼容的**列表**形状(`budgets[]`)而不是单一 `budget?` 字段。PR 14 v1 发一条 `scope: 'session'`(每个 ACP session 通过 `acpAgent.newSessionConfig()` 创建自己的 `Config` / `McpClientManager`)。`'pool'` scope 是**预留**给 Wave 5 PR 23(与 session-scoped cell 并列的 pool-scoped cell)—— 消费方**必须**容忍未知 `scope` 值的额外条目(丢掉而不是失败),让未来扩展不破 schema。
+
+## 参考
+
+- `packages/core/src/tools/mcp-workspace-budget.ts:1-200+`(整 class)
+- `packages/core/src/tools/mcp-client-manager.ts`(`BudgetExhaustedError`、`McpBudgetEvent`、滞回常量)
+- `packages/core/src/tools/mcp-transport-pool.ts:208+`(池 `acquire` 调 `tryReserve` 的站点)
+- F2 设计文档(v2.2):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) §11(workspace 级 budget)以及 v2.2 changelog 中 W21 / W77 / W88 / W121 / W122 / R3 关于预算与 fingerprint 的 fold-in。
+- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) commit 6。
diff --git a/docs/developers/daemon/07-workspace-filesystem.md b/docs/developers/daemon/07-workspace-filesystem.md
new file mode 100644
index 0000000000..f68bb69443
--- /dev/null
+++ b/docs/developers/daemon/07-workspace-filesystem.md
@@ -0,0 +1,230 @@
+# Workspace 文件系统边界
+## 概览
+
+daemon 不让 HTTP 路由或 ACP 侧 agent 直接碰宿主文件系统。所有 read、write、list、glob、stat 都过 `WorkspaceFileSystem` 边界(`packages/cli/src/serve/fs/`):
+
+- **路径解析** —— canonicalize + 拒绝任何越出 bound workspace 的路径(包括通过 symlink)。
+- **信任 gate** —— workspace 不被信任时拒写(`untrusted_workspace`)。
+- **大小 & 内容策略** —— 读上限(`MAX_READ_BYTES = 256 KiB`)、写上限(`MAX_WRITE_BYTES = 5 MiB`)、二进制检测。
+- **原子性** —— write-then-rename,保留目标 mode,新建文件默认 `0o600`。
+- **审计** —— 每次 access / denial 发结构化事件给 `PermissionAuditRing` / 监控。
+- **typed error** —— 封闭 `FsErrorKind` 联合 ↔ HTTP 状态码。
+
+HTTP 文件路由(`GET /file`、`GET /file/bytes`、`POST /file/write`、`POST /file/edit`、`GET /list`、`GET /glob`、`GET /stat`)和 ACP 侧 `BridgeFileSystem` 适配器(agent 触发的 `readTextFile` / `writeTextFile` 也拿到同样的护栏)都过这个边界。
+
+## 职责
+
+- 把用户传入的路径解析成 branded `ResolvedPath`,下游安全使用。
+- 拒绝 workspace 外的路径(`path_outside_workspace`)和 target 是 symlink 的路径(`symlink_escape`)。
+- 拒绝读超 `MAX_READ_BYTES` / 写超 `MAX_WRITE_BYTES` / 二进制文件(`binary_file`)。
+- workspace 不被信任时拒写 / edit(`untrusted_workspace`) —— 由 `assertTrustedForIntent(intent)` 闸。
+- 遵循 `.gitignore` / `.qwenignore` 模式(`shouldIgnore`)。
+- 原子 write-then-rename,保留目标 mode;新建文件默认 `0o600`。
+- 每次操作发 `fs.access` / `fs.denied` 审计事件。
+- 每次失败都映射到 `FsError`(kind + HTTP 状态),路由 handler 统一序列化。
+
+## 架构
+
+### 模块布局
+
+| 文件 | 用途 |
+| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `paths.ts` | `canonicalizeWorkspace`、`resolveWithinWorkspace`、`hasSuspiciousPathPattern`、branded `ResolvedPath`、`Intent`(`read \| write \| list \| stat \| glob`) |
+| `policy.ts` | `MAX_READ_BYTES`、`MAX_WRITE_BYTES`、`BINARY_PROBE_BYTES`、`assertTrustedForIntent`、`detectBinary`、`enforceReadBytesSize`、`enforceReadSize`、`enforceWriteSize`、`shouldIgnore` |
+| `audit.ts` | `FS_ACCESS_EVENT_TYPE`、`FS_DENIED_EVENT_TYPE`、`createAuditPublisher`、audit payload 类型 |
+| `errors.ts` | `FsError` 类、`isFsError`、`FsErrorKind`(13 种)、`FsErrorStatus`(`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`) |
+| `workspaceFileSystem.ts` | `createWorkspaceFileSystemFactory`、`WorkspaceFileSystem`、`WriteMode`、`ContentHash`、`FsEntry`、`FsStat`、`ListOptions`、`GlobOptions`、`ReadTextOptions`、`ReadBytesOptions`、`WriteTextAtomicOptions` |
+
+### `FsErrorKind` 分类
+
+| Kind | 默认 HTTP | 含义 |
+| ------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `path_outside_workspace` | 400 | 解析后的路径在 workspace 外 |
+| `symlink_escape` | 400 | target 是 symlink(PR 18 + PR 20 的保守姿态) |
+| `path_not_found` | 404 | `ENOENT` |
+| `binary_file` | 422 | text 路由上 sniff 到二进制 |
+| `file_too_large` | 413 | 超 `MAX_READ_BYTES` 或 `MAX_WRITE_BYTES` |
+| `hash_mismatch` | 409 | 乐观并发 `expectedSha256` 不匹配 |
+| `file_already_exists` | 409 | `mode: 'create'` 而文件已存在 |
+| `text_not_found` | 422 | `POST /file/edit` 的 search 字符串不在文件里 |
+| `ambiguous_text_match` | 422 | 需要唯一匹配但匹配到多处 |
+| `untrusted_workspace` | 403 | 写在不被信任的 workspace |
+| `permission_denied` | 403 | OS 级 `EACCES` / `EPERM` |
+| `io_error` | 503 | `ENOSPC` / `EIO` / `EBUSY` / `ETXTBSY` / `ENAMETOOLONG` / `EMFILE` / `ENFILE`。**与 `permission_denied` 严格区分**,否则监控按 errorKind 告警会把「磁盘满」错挂到安全 oncall |
+| `internal_error` | 500 | 非 errno 的边界 error(`TypeError`、bug) |
+| `parse_error` | 400 / 422 | 请求体解析 error(400)或服务级不变式破坏(422) |
+
+### `BridgeFileSystem`(ACP 侧适配器)
+
+`packages/acp-bridge/src/bridgeFileSystem.ts:39-97`:
+
+```ts
+interface BridgeFileSystem {
+ readText(params: ReadTextFileRequest): Promise;
+ writeText(params: WriteTextFileRequest): Promise;
+}
+```
+
+这是 ACP `readTextFile` / `writeTextFile` 的注入接口。bridge 测试 + Mode A 嵌入方可以在 `BridgeOptions` 上不传它;`BridgeClient` 回退到 inline `fs.readFile` / `fs.writeFile` proxy(保留 F1 前行为)。生产 `qwen serve` 通过 `createBridgeFileSystemAdapter(fsFactory)`(`packages/cli/src/serve/bridgeFileSystemAdapter.ts`)把它接上,agent 侧 ACP 写得到与 HTTP 路由一致的 TOCTOU + symlink + 信任闸 + 审计护栏。
+
+适配器**必须**复刻 inline proxy 的两道护栏(注入适配器后 inline 路径完全 bypass):
+
+1. **拒绝非常规文件** —— socket / pipe / char device / procfs / sysfs 即使 `stats.size === 0` 也能流无界数据。inline 路径抛错时带 `describeStatKind(stats)`。
+2. **缓冲大小上限** `READ_FILE_SIZE_CAP = 100 MiB`。否则一个针对 500 MB 日志的 `{ line: 1, limit: 10 }` 请求要花 500 MB RSS 才能返 10 行。
+
+适配器还更进一步:用 `WorkspaceFileSystem.writeTextOverwrite`(PR 18 原语)做 atomic tmp+rename、保留 mode、新建默认 `0o600`、symlink reject,整段在 per-path 锁内。这是**与 F1 前 inline proxy 的偏离** —— 老 proxy 解析 symlink 并写穿 target;现在的 agent 如果之前依赖通过 symlink 写 dotfile,要直接寻址解析后的路径。
+
+### FsError 在 ACP wire 上的保留
+
+`BridgeFileSystem` 适配器抛 `FsError`(`kind: 'untrusted_workspace'` / `'symlink_escape'` / `'file_too_large'` 等)时,ACP SDK 默认 RPC error 序列化只把 `error.message` 当作通用 `-32603 "Internal error"` —— `kind` / `status` / `hint` 在线上被剥掉。下游 agent 的 RPC client 想做 typed UI(auth 重试 vs 文件选择 vs 代理提示)就只能 regex-match 人类可读消息。
+
+`BridgeClient.writeTextFile` 与 `BridgeClient.readTextFile` 装了一道薄护栏(`packages/acp-bridge/src/bridgeClient.ts:40-100+`),捕获 FsError 形状的异常重抛为 ACP `RequestError`:
+
+```ts
+function isFsErrorShape(err: unknown): err is FsErrorShape {
+ return (
+ err instanceof Error &&
+ err.name === 'FsError' &&
+ typeof (err as { kind?: unknown }).kind === 'string'
+ );
+}
+
+function preserveFsErrorOverAcp(err: unknown): never {
+ if (isFsErrorShape(err)) {
+ throw new RequestError(-32603, err.message, {
+ errorKind: err.kind,
+ ...(err.hint !== undefined ? { hint: err.hint } : {}),
+ ...(err.status !== undefined ? { status: err.status } : {}),
+ });
+ }
+ throw err;
+}
+```
+
+agent 的 RPC client 现在拿到 `data.errorKind`(封闭 `FsErrorKind` 值)外加可选 `data.hint`、`data.status`,SDK 消费方按 typed 枚举 dispatch 而不是 regex 消息。
+
+两条设计说明:
+
+- **鸭子类型而非 import** —— `FsError` 住在 `packages/cli/src/serve/fs/errors.ts`,`BridgeClient` 住在 `packages/acp-bridge`,直接 `import { FsError }` 会反向依赖。鸭子检查(`name === 'FsError'` + `kind: string`)与 `mapDomainErrorToErrorKind`(`status.ts`)对 `TrustGateError` / `SkillError` 用的同样思路,跨包打包同问题。
+- **JSON-RPC code 保持 -32603** —— bridge 没法把 `FsError.kind` 可靠映射到 JSON-RPC error code 形状,所以语义信息走结构化 `data` 字段。wire 上状态码(`-32603` "internal error")不变,客户端按 `data.errorKind` 路由。
+
+### 信任 gate
+
+`assertTrustedForIntent(intent)` 查 `Config.isTrustedFolder()`。read / list / stat / glob 总是允许(信任只对写起作用)。在不被信任的 workspace 上 write 意图抛 `FsError('untrusted_workspace', ..., status: 403)`。trust 信号通过 `WorkspaceFileSystemFactoryDeps.trusted: boolean` 注入 —— `runQwenServe` 传 `true`(operator 自己启动 daemon 即默认信任那 workspace);`createServeApp` 直接嵌入默认 `false` 并 process 内告警一次(详见 [`02-serve-runtime.md`](./02-serve-runtime.md))。
+
+## 流程
+
+### 读
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant R as HTTP route OR BridgeFileSystem.readText
+ participant FS as WorkspaceFileSystem
+ participant POL as policy.ts
+ participant FSP as node:fs
+
+ R->>FS: readText(ctx, path, opts)
+ FS->>FS: resolveWithinWorkspace(path) → ResolvedPath OR throw
+ FS->>FS: shouldIgnore? → throw / skip
+ FS->>FSP: stat(path)
+ FSP-->>FS: stats
+ FS->>FS: reject if not regular file (describeStatKind)
+ FS->>POL: enforceReadSize(stats.size, opts.maxBytes?)
→ throw file_too_large OR slice plan
+ FS->>FSP: readFile(path)
+ FSP-->>FS: buffer
+ FS->>POL: detectBinary(buffer)
+ POL-->>FS: isBinary?
+ FS->>FS: reject if binary; sha256 hash; truncate to line window
+ FS->>FS: audit fs.access
+ FS-->>R: { content, sha256, truncated?, meta }
+```
+
+### 写
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant R as POST /file/write OR ACP writeText
+ participant FS as WorkspaceFileSystem
+ participant POL as policy.ts
+ participant FSP as node:fs
+
+ R->>FS: writeTextAtomic(ctx, path, content, opts)
+ FS->>FS: assertTrustedForIntent('write') → throw untrusted_workspace OR ok
+ FS->>FS: resolveWithinWorkspace(path)
+ FS->>POL: enforceWriteSize(content) → throw file_too_large OR ok
+ FS->>FSP: lstat(path) → reject symlink
+ FS->>FS: acquire per-path lock
+ FS->>FSP: stat(existing?) → capture target mode (default 0o600)
+ FS->>FSP: writeFile(tmpPath, content, {mode})
+ FS->>FSP: rename(tmpPath, path) (atomic)
+ FS->>FS: audit fs.access (write)
+ FS-->>R: { sha256, mode, bytesWritten }
+```
+
+atomic write-then-rename 确保 SIGKILL / OOM 写到一半也不会让 target 被截断。`mode: 'create'` 在 lstat 时遇文件已存在中止(`file_already_exists`);`mode: 'overwrite'` 继续;`expectedSha256` 装乐观并发(不匹配 → `hash_mismatch`)。
+
+### `POST /file/edit`(单段文本替换)
+
+在 write 之上加两种失败:
+
+- `text_not_found`(422)—— search 字符串不在文件里。
+- `ambiguous_text_match`(422)—— 需要唯一匹配但匹配到多处(路由契约)。
+
+### 审计扇出
+
+```mermaid
+flowchart LR
+ A["WorkspaceFileSystem op succeeds OR fails"] --> P["createAuditPublisher → emit FS_ACCESS_EVENT_TYPE / FS_DENIED_EVENT_TYPE"]
+ P --> AR["PermissionAuditRing (512 entries, FIFO)"]
+ P --> MON["future: external monitoring sink"]
+```
+
+`FS_ACCESS_EVENT_TYPE` / `FS_DENIED_EVENT_TYPE` 带 ctx、path、intent、outcome、errorKind?、bytesRead/written、sha256?。
+
+## 状态与生命周期
+
+- 工厂在 daemon boot 一次(`runQwenServe` → `resolveBridgeFsFactory` → 适配器)。
+- 每请求构造一个 `RequestContext` 并调工厂 orchestrator 处理那次;不持久 per-file 状态。
+- per-path 锁只活在写操作期间(无跨调用锁;同路径并发写在锁上 race 串行)。
+- 审计环属于 `runQwenServe`,与 permission audit publisher 共享。
+
+## 依赖
+
+- `@qwen-code/qwen-code-core` —— `Ignore`、`isBinaryFile`、`Config.isTrustedFolder()`。
+- `node:fs`、`node:path`、`node:crypto`。
+- `@qwen-code/acp-bridge` —— ACP 侧 `BridgeFileSystem` 契约。
+- HTTP 路由:`packages/cli/src/serve/routes/workspaceFileRead.ts`、`workspaceFileWrite.ts`。
+
+## 配置
+
+| 来源 | 旋钮 | 效果 |
+| ------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | 构造入参 | 是否允许写;`runQwenServe` 默认 `true`,`createServeApp` 默认 `false`(带告警) |
+| 常量 | `MAX_READ_BYTES = 256 KiB` | 读上限;超过 → `file_too_large` |
+| 常量 | `MAX_WRITE_BYTES = 5 MiB` | 写上限;低于 `express.json({ limit: '10mb' })` |
+| 常量 | `BINARY_PROBE_BYTES = 4096` | 二进制检测采样大小 |
+| 能力 tag | `workspace_file_read`、`workspace_file_bytes`、`workspace_file_write` | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) |
+| workspace 文件 | `.gitignore`、`.qwenignore` | 被忽略路径在 `shouldIgnore` 上 `ignored: true` |
+
+## 注意 & 已知局限
+
+- **symlink 直接拒,不跟随**。与 F1 前 inline `BridgeClient.writeTextFile` proxy 的行为偏离。通过 symlink 写 dotfile 的 agent 要改成直接寻址解析后的路径。
+- **`io_error` 与 `permission_denied` 严格区分**。不要混。监控按 errorKind 告警 —— 把 ENOSPC 折进 permission_denied 会让 `df -h` 问题误把安全 oncall 叫起来。
+- **新建文件默认 `0o600`,不是 umask 默认**。write 系统调用的 `mode` 参数绕过 umask。要写公开文件的 agent 必须显式覆盖 mode。
+- **`createServeApp` 默认 `trusted: false`** 嵌入方没注入 `fsFactory` 或 `bridge` 时静默拒 ACP 写为 `untrusted_workspace`。首次告警 stderr 打印一次,之后无提示。详见 [`02-serve-runtime.md`](./02-serve-runtime.md)。
+- **读上限是在解码前强制**。一个 `MAX_READ_BYTES + 1` 的文件即使只要 10 行也会被拒,因为底层 `readFileWithLineAndLimit` 先把整文件读进内存才切行。
+- **`BridgeFileSystem` 适配器必须复刻 inline-proxy 两道护栏**(非常规文件 refusal + 缓冲大小上限)。注入适配器后 inline 路径完全 bypass。
+
+## 参考
+
+- `packages/cli/src/serve/fs/index.ts`(barrel)
+- `packages/cli/src/serve/fs/paths.ts`
+- `packages/cli/src/serve/fs/policy.ts:1-100+`
+- `packages/cli/src/serve/fs/errors.ts:1-80+`
+- `packages/cli/src/serve/fs/audit.ts`
+- `packages/cli/src/serve/fs/workspaceFileSystem.ts`
+- `packages/cli/src/serve/bridgeFileSystemAdapter.ts:1-60`
+- `packages/acp-bridge/src/bridgeFileSystem.ts:39-97`
+- HTTP 路由参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md
new file mode 100644
index 0000000000..e8ee84e233
--- /dev/null
+++ b/docs/developers/daemon/08-session-lifecycle.md
@@ -0,0 +1,181 @@
+# Session 生命周期与身份
+## 概览
+
+daemon **session** 是一段绑定到一个 ACP `sessionId` 的逻辑对话。bridge 为每个 session 维护一个 `SessionEntry`(见 [`03-acp-bridge.md`](./03-acp-bridge.md)),把 ACP child connection 与 HTTP 侧的簿记捆在一起:prompt FIFO、model-change FIFO、event bus、pending permission、attach 的客户端、心跳、restore 状态、终态 tombstone。
+
+daemon **客户端**由 `X-Qwen-Client-Id` 标识 —— 一段不透明、由 daemon 校验的字符串,调用方自行在请求里盖。daemon 自己不会替调用方生成 id;客户端自取并复用,daemon 据此归属投票、审计事件、识别重连。
+
+本文讲清每一次 session 状态迁移(create / attach / load / resume / close / die / evict)以及 daemon 暴露的每个身份相关 surface。
+
+## 职责
+
+- 创建、attach、restore、回收 session。
+- 校验 `X-Qwen-Client-Id`,错的格式直接拒。
+- 跟踪 session 上多个 attach 的客户端(`clientIds: Map`、`attachCount`)。
+- 给出站事件盖 `originatorClientId`。
+- 跑心跳,让 dashboard 知道谁还在连着。
+- 提供 `displayName`,operator 通过 `PATCH /session/:id/metadata` 设置。
+- 推送终态帧(`session_died`、`session_closed`、`client_evicted`、`stream_error`)。
+
+## 架构
+
+| 关注点 | 源 | 说明 |
+| ------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `SessionEntry` | `packages/acp-bridge/src/bridge.ts:183-285` | 每 session 结构体,字段列表见 [`03-acp-bridge.md`](./03-acp-bridge.md) |
+| `BridgeSession`(对外) | `packages/acp-bridge/src/bridgeTypes.ts:49+` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` 回给 HTTP handler |
+| `BridgeSessionState` | `packages/acp-bridge/src/bridgeTypes.ts:73+` | `LoadSessionResponse \| ResumeSessionResponse`,缓存为 `restoreState` |
+| `DaemonSession`(SDK) | `packages/sdk-typescript/src/daemon/types.ts:113` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` |
+| ClientId 校验 | `packages/acp-bridge/src/bridge.ts`(`spawnOrAttach` 附近) | 正则 `[A-Za-z0-9._:-]{1,128}`,违法抛 `InvalidClientIdError` |
+| Session disconnect-reaper | `packages/cli/src/serve/server.ts` | 用 `attachCount` + `spawnOwnerWantedKill` 跟踪 spawn 拥有者断连 |
+
+### 状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> SpawnInProgress: POST /session
+ SpawnInProgress --> Live: newSession success
+ SpawnInProgress --> [*]: initialize failure / spawn error
+ Live --> Live: attach (sessionScope=single, bump attachCount)
+ Live --> Live: detach (decrement attachCount)
+ Live --> RestoreInProgress: POST /session/:id/load or /resume
+ RestoreInProgress --> Live: restoreState cached on entry
+ RestoreInProgress --> Live: RestoreInProgressError (coalesce waiters)
+ Live --> Closed: DELETE /session/:id (last client)
+ Live --> Died: ACP child exit / channel.exited fired
+ Closed --> [*]: session_closed terminal frame
+ Died --> [*]: session_died terminal frame
+```
+
+### Attach 与 Spawn
+
+`sessionScope: 'single'`(默认)下,bridge 的 `defaultEntry` 被所有连进来的客户端共享。`POST /session` 到来时 `defaultEntry` 已存在 → 不 spawn 新 ACP child,直接返回 `attached: true`。bridge 同步 bump `attachCount` 并把调用方的 `X-Qwen-Client-Id` 登记到 `clientIds`。
+
+`sessionScope: 'per-client'`:每次 `POST /session` 新建一个 session。仍然受 `maxSessions` 约束。
+
+### 身份
+
+`X-Qwen-Client-Id` **可选**但**强烈建议**带。daemon 不会替调用方生成;客户端自己挑、在所有请求里复用,daemon 才能归属投票、审计事件、识别重连。
+
+校验:
+
+- 字符集 `[A-Za-z0-9._:-]`。
+- 长度 1–128。
+- 不合规 → `InvalidClientIdError`(`400`)。
+
+daemon 在以下条件全部满足时给出站 SSE 事件盖 `originatorClientId`:
+
+1. 触发该事件的请求带了 `X-Qwen-Client-Id`,且
+2. 该 id 已登记在 session 的 `clientIds` 集合里,且
+3. session 当前有 `activePromptOriginatorClientId`(在跑的 prompt 的内联 `sessionUpdate` 和 `permission_request` 继承该 originator)。
+
+匿名调用(不带 `X-Qwen-Client-Id`)在 `first-responder` 下可用;`designated` 会拒它的投票为 `permission_forbidden{ reason: 'designated_mismatch' }`;`consensus` 同样拒为 `forbidden`(不在发起时 `votersAtIssue` 快照中);`local-only` 是唯一接受匿名 loopback 投票者的策略。
+
+## 流程
+
+### Create or attach
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant R as POST /session
+ participant B as Bridge.spawnOrAttach
+ participant CH as ACP child
+
+ C->>R: POST /session
X-Qwen-Client-Id: alice
{cwd, sessionScope?}
+ R->>R: validate clientId pattern
+ R->>B: spawnOrAttach({cwd, sessionScope, clientId})
+ alt single scope + defaultEntry exists
+ B->>B: bump attachCount; register clientId
+ B-->>R: {sessionId, attached: true, restoreState?}
+ else cold
+ B->>CH: spawn + ACP initialize + newSession
+ CH-->>B: sessionId
+ B->>B: build SessionEntry; register in byId
+ B-->>R: {sessionId, attached: false}
+ end
+ R-->>C: 200 { sessionId, attached, ... }
+```
+
+### Load / Resume
+
+- `POST /session/:id/load` — 重放完整 ACP 历史(`session/load` 通知先于响应返回)。
+- `POST /session/:id/resume` — 不重放(`connection.unstable_resumeSession`,由 `unstable_session_resume` 能力暴露)。
+
+两者都:
+
+1. 在 channel 的 `pendingRestoreIds` 集合里登记,让并发 restore 合并(`RestoreInProgressError`)。
+2. 把 `restoreState` 缓存到 entry,让晚到的 attacher 看到与原始 restore 调用一致的 payload。
+
+### 心跳
+
+`POST /session/:id/heartbeat` 不管带不带 `clientId` 都会更新 `sessionLastSeenAt`。如果请求带了已登记的 `X-Qwen-Client-Id`,`clientLastSeenAt.set(clientId, Date.now())` 也会 bump。v1 **没有** per-client 剔除;revocation 是 F 系列 Wave 5。当前心跳的价值是给 dashboard / 给将来的 PR 24 撤权策略提供观测。
+
+### Metadata
+
+`PATCH /session/:id/metadata` 接受 `{displayName?}`。校验:
+
+- 最长 `MAX_DISPLAY_NAME_LENGTH = 256`。
+- 不能含控制字符(`hasControlCharacter` 拒绝码点 ≤ 0x1f 或 == 0x7f)。
+- 违反 → `InvalidSessionMetadataError`(`400`)。
+
+成功后向所有订阅者广播 `session_metadata_updated`。
+
+### 终态
+
+| 终态帧 | 触发 |
+| ---------------- | --------------------------------------------------------------------------------------------------------------- |
+| `session_closed` | `DELETE /session/:id`(client_close)或程序化关闭 |
+| `session_died` | `channel.exited` 触发(崩溃、被 kill);OS exit 路径下带 `exitCode?` + `signalCode?` |
+| `client_evicted` | EventBus 每订阅者队列溢出(见 [`10-event-bus.md`](./10-event-bus.md)),**非** session 级终态,仅关掉当前订阅者 |
+| `stream_error` | `SubscriberLimitExceededError` 或其他路由流错误 |
+
+每个终态路径都会 `mediator.forgetSession(sessionId)`,把所有 pending permission 解析为 `{kind:'cancelled', reason:'session_closed'}`。
+
+### Disconnect-reaper 守护
+
+spawn 拥有者的 HTTP 响应写不出去时(TCP 在握手中途 reset),路由会 `killSession({ requireZeroAttaches: true })`。如果其他客户端已经 attach 了(`attachCount > 0`),bail 短路、session 继续活着,但 `spawnOwnerWantedKill = true` 留作 tombstone;之后某次 `detachClient()` 把 `attachCount` 拉回 0 时完成延迟回收。没有这个守护,spawn 拥有者快速断连会每隔一次重连就拆掉一个健康 session。
+
+## 状态与生命周期
+
+`SessionEntry` 中和生命周期最密切的字段:
+
+| 字段 | 类型 | 含义 |
+| -------------------------------- | --------------------- | --------------------------------------------------------------- |
+| `clientIds` | `Map` | 已登记 clientId → 引用计数 |
+| `attachCount` | `number` | `spawnOrAttach` 对该 entry 返回 `attached: true` 的次数 |
+| `activePromptOriginatorClientId` | `string?` | 当前在跑的 prompt 的 originator |
+| `restoreState` | `BridgeSessionState?` | load/resume 响应缓存,让晚到 attacher 看到一致 payload |
+| `spawnOwnerWantedKill` | `boolean` | 延迟回收 tombstone |
+| `sessionLastSeenAt` | `number?` | 任何客户端最近一次心跳(epoch ms) |
+| `clientLastSeenAt` | `Map` | per-client 心跳 |
+| `pendingPermissionIds` | `Set` | 当前 pending 的 ACP requestId — cancel/close 时解析为 cancelled |
+
+## 依赖
+
+- ACP 层:`connection.newSession`、`connection.unstable_resumeSession`、`connection.loadSession`。
+- [`03-acp-bridge.md`](./03-acp-bridge.md) — 周围的 bridge 架构。
+- [`04-permission-mediation.md`](./04-permission-mediation.md) — originator + identity 如何驱动策略。
+- [`10-event-bus.md`](./10-event-bus.md) — 终态帧投递。
+
+## 配置
+
+- `BridgeOptions.maxSessions`(默认 20)。
+- `BridgeOptions.sessionScope`(默认 `'single'`)。
+- `BridgeOptions.initializeTimeoutMs`(默认 10s)。
+- 能力 tag:`session_create`、`session_scope_override`、`session_load`、`unstable_session_resume`、`session_list`、`session_close`、`session_metadata`、`session_set_model`、`client_identity`、`client_heartbeat`。
+
+## 注意 & 已知局限
+
+- `connection.unstable_resumeSession` 不稳定;ACP 方法形状还可能变。能力 tag 故意带 `unstable_` 前缀,让客户端 feature-detect 而不是硬绑 v1。
+- v1 **没有** per-client 剔除,只有 per-session 与 per-subscriber 终态。撤权策略是 F 系列 Wave 5 / PR 24。
+- `client_evicted` 是 per-subscriber 不是 per-session;订阅者被剔除的客户端可以重连。
+- 匿名客户端在 `designated` / `consensus` 策略下不能投票。
+
+## 参考
+
+- `packages/acp-bridge/src/bridge.ts:183-285`(SessionEntry 定义)
+- `packages/acp-bridge/src/bridgeTypes.ts:30-180+`(`HttpAcpBridge`、`BridgeSession`、`BridgeSessionState`)
+- `packages/sdk-typescript/src/daemon/types.ts:113+`(`DaemonSession`)
+- `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:61-385`
+- Wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md
new file mode 100644
index 0000000000..94ca6d822d
--- /dev/null
+++ b/docs/developers/daemon/09-event-schema.md
@@ -0,0 +1,238 @@
+# Typed Daemon Event Schema v1
+## 概览
+
+daemon 在 `GET /session/:id/events` 上发的每一帧 SSE 都形如 `{ id, v, type, data, originatorClientId?, _meta? }`,`v: 1` 是当前 `EVENT_SCHEMA_VERSION`。`type` 取自一个封闭的、版本固定的集合 —— `DAEMON_KNOWN_EVENT_TYPE_VALUES`(`packages/sdk-typescript/src/daemon/events.ts:13-63`)共 **29 种**。envelope 的 `_meta` 字段在 SSE 写边界(`server.ts` 的 `formatSseFrame()`)盖上 —— 详见下文 [Envelope 级元数据](#envelope-级元数据)。
+
+SDK 暴露 `narrowDaemonEvent(evt)`,对已知 type 返回一个判别式 `KnownDaemonEvent`,对其他 type 返回 `{ kind: 'unknown' }` —— SDK 消费方无需固定 SDK 版本就能处理向前兼容(更新的 daemon 加了新 type 也不会崩)。
+
+wire 格式见 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md),本文是每个事件的 payload 契约。
+
+## 职责
+
+- 提供事件词汇表的唯一事实来源(line 13 那个常量数组)。
+- 提供每种 type 的 typed envelope(`DaemonEventEnvelope`)。
+- 提供纯 reducer(`reduceDaemonSessionEvent`、`reduceDaemonAuthEvent`),把事件流投影成 SDK view-state。
+- 通过 `typed_event_schema` 能力 tag 广播(信息性 —— 不广播时 `narrowDaemonEvent` 仍 fallback 到 `unknown`)。
+
+## 事件词汇表(29 种)
+
+按域分组。
+
+### Core session
+
+| Type | 方向 | 触发 | Payload 关键字段 |
+| -------------------------- | ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------- |
+| `session_update` | S→C | 任意 ACP `sessionUpdate` 通知(agent text / thought / tool call / plan) | `sessionUpdate: string, content?: ...`(不透明 ACP shape) |
+| `session_metadata_updated` | S→C | `PATCH /session/:id/metadata` | `sessionId, displayName?` |
+| `session_died` | S→C **终态** | `channel.exited` 触发 | `sessionId, reason, exitCode? \| null, signalCode? \| null` |
+| `session_closed` | S→C **终态** | `DELETE /session/:id` 或程序化关闭 | `sessionId, reason: 'client_close' \| string, closedBy?` |
+
+### Subscriber 级合成帧
+
+| Type | 触发 | 备注 |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `client_evicted` | EventBus 每订阅者队列溢出。**无 `id`** | `reason: string, droppedAfter?: number`;只对当前订阅者终态,session 还活着 |
+| `slow_client_warning` | 队列 ≥ 75%(force-push,**无 `id`**) | `queueSize, maxQueued, lastEventId`;37.5% 滞回 re-arm |
+| `stream_error` | `SubscriberLimitExceededError` 或其他路由流错 | `error: string`;订阅终态 |
+| `state_resync_required` | `subscribe({lastEventId})` 时 daemon 环里已不再持有 `[lastEventId+1, earliestInRing-1]` 这段间隙。在剩余 replay 帧**之前**强推。**无 `id`** | `reason: string`(当前恒为 `'ring_evicted'`)、`lastDeliveredId: number`、`earliestAvailableId: number`。**面向恢复,非终态** —— SSE 流保持打开,replay + live 帧继续;SDK reducer 翻转 `awaitingResync = true`,自动跳过 delta,直到调用方调 `loadSession` 重置。daemon 端实现见 `eventBus.ts:359-402`,SDK 端见 `events.ts:870-905` |
+
+### Permissions(F3 + base)
+
+| Type | 方向 | 触发 | Payload 关键字段 |
+| ----------------------------- | ---- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `permission_request` | S→C | agent 调 `requestPermission` | `requestId, sessionId, toolCall, options[]`;envelope 盖 `originatorClientId`(= prompt originator,F3 N3) |
+| `permission_resolved` | S→C | mediator 已裁决 | `requestId, outcome`(ACP `PermissionOutcome`) |
+| `permission_already_resolved` | S→C | 已裁决后投票才到 | `requestId, sessionId, outcome` |
+| `permission_partial_vote` | S→C | `consensus` 策略记录了一次不裁决的投票 | `requestId, sessionId, votesReceived, votesNeeded (≥1), quorum, optionTallies: Record, originatorClientId?` |
+| `permission_forbidden` | S→C | 投票被策略拒绝 | `requestId, sessionId, clientId?, reason: 'designated_mismatch' \| 'remote_not_allowed', originatorClientId?`;匿名投票者无 `clientId` |
+
+### Models
+
+| Type | 方向 | Payload |
+| --------------------- | ---- | -------------------------------------------- |
+| `model_switched` | S→C | `sessionId, modelId` |
+| `model_switch_failed` | S→C | `sessionId, requestedModelId, error: string` |
+
+### MCP guardrails(PR 14b + F2)
+
+| Type | 方向 | Payload |
+| ---------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `mcp_budget_warning` | S→C | `liveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' \| 'enforce', scope?: 'workspace' \| 'session'` |
+| `mcp_child_refused_batch` | S→C | `refusedServers: [{name, transport, reason: 'budget_exhausted'}], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' \| 'session'` |
+| `mcp_server_restarted` | S→C | `serverName, durationMs, entryIndex?`(F2 多 entry) |
+| `mcp_server_restart_refused` | S→C | `serverName, reason: 'budget_would_exceed' \| 'in_flight' \| 'disabled' \| 'restart_failed', entryIndex?, details?`。第 4 个值 `'restart_failed'`(F2 commit 5)携带底层硬失败,`details` 是自由格式字符串,给池模式多 entry restart 用。**封闭集判别**:`MCP_RESTART_REFUSED_REASONS` 拒绝未知 reason,老 SDK reducer 看到加法新值会**默默丢弃**事件(`parseDaemonEvent` 返回 `undefined`)。新 reason 值必须与认识它的 SDK 版本一起发 |
+
+### Mutation control(Wave 4 PR 16+17)
+
+| Type | 方向 | Payload |
+| ----------------------- | ---- | ------------------------------------------------------------------------------------- |
+| `memory_changed` | S→C | `scope: 'workspace' \| 'global', filePath, mode: 'append' \| 'replace', bytesWritten` |
+| `agent_changed` | S→C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` |
+| `approval_mode_changed` | S→C | `sessionId, previous, next, persisted: boolean` |
+| `tool_toggled` | S→C | `toolName, enabled`(下次 ACP child spawn 才生效,不会回溯改动已在跑的 session) |
+| `workspace_initialized` | S→C | `path, action: 'created' \| 'overwritten'` |
+
+### Auth device flow(PR 21)
+
+这些是 workspace-keyed 不是 session-keyed。session reducer 对它们 no-op;`reduceDaemonAuthEvent` 投到 workspace-level state。
+
+| Type | 方向 | Payload |
+| ----------------------------- | ---- | ----------------------------------------------------- |
+| `auth_device_flow_started` | S→C | `deviceFlowId, providerId, expiresAt` |
+| `auth_device_flow_throttled` | S→C | `deviceFlowId, intervalMs` |
+| `auth_device_flow_authorized` | S→C | `deviceFlowId, providerId, expiresAt?, accountAlias?` |
+| `auth_device_flow_failed` | S→C | `deviceFlowId, errorKind, hint?` |
+| `auth_device_flow_cancelled` | S→C | `deviceFlowId` |
+
+## 架构
+
+| 关注点 | 文件:行 | 说明 |
+| -------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------ |
+| `EVENT_SCHEMA_VERSION = 1` | `packages/acp-bridge/src/eventBus.ts:22` | 每帧带 |
+| `DAEMON_KNOWN_EVENT_TYPE_VALUES` | `packages/sdk-typescript/src/daemon/events.ts:13-63` | 封闭列表(长 28) |
+| `DaemonEventEnvelope` | `events.ts:74-78` | 泛型 envelope |
+| `DaemonKnownEventType` | `events.ts:71-72` | `typeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]` |
+| 各事件 payload 类型 | `events.ts:80+` | 每种 type 一个 `DaemonXxxData` interface |
+| `narrowDaemonEvent(evt)` | `events.ts` | 返回 `KnownDaemonEvent \| { kind: 'unknown', value: DaemonEvent }` |
+| `reduceDaemonSessionEvent(state, evt)` | `events.ts` | 投到 `DaemonSessionViewState` |
+| `reduceDaemonAuthEvent(state, evt)` | `events.ts` | 投到 `DaemonAuthState` |
+| `isWorkspaceScopedBudgetEvent(evt)` | `events.ts` | 判别 F2 `scope: 'workspace'` |
+
+### `DaemonSessionViewState`
+
+`reduceDaemonSessionEvent` 填充,CLI TUI adapter、`DaemonChannelBridge`、VSCode IDE 都消费。关键字段:
+
+- `messages: HistoryItem[]` — 由 `session_update` 派生。
+- `pendingPermissionRequests: PermissionRequestData[]` — 当前打开的请求;`permission_resolved` / `permission_already_resolved` / 对自身的 `permission_forbidden` / cancel 时清掉。
+- `latestPermissionResolution?: PermissionOutcome`。
+- `currentModelId?: string` — 由 `model_switched`。
+- `lastModelSwitchError?: string` — 由 `model_switch_failed`。
+- `mcpBudgetWarningCount`、`lastMcpBudgetWarning?` — 由 `mcp_budget_warning`。
+- `mcpChildRefusedBatchCount`、`lastMcpChildRefusedBatch?` — 由 `mcp_child_refused_batch`。
+- `mcpRestartHistory[]` — 由 `mcp_server_restarted` / `mcp_server_restart_refused`。
+- `terminal?: { kind, reason, ... }` — 任何终态帧。
+
+### `DaemonAuthState`
+
+按 `providerId` 一项,由 `auth_device_flow_*` 驱动。每个 flow 暴露 `{deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}`。
+
+## 流程
+
+### Producer 端
+
+```mermaid
+flowchart LR
+ A["ACP child notification"] --> B["BridgeClient.sessionUpdate /
BridgeClient.extNotification"]
+ B --> C{"Mapped to event type?"}
+ C -->|yes| D["EventBus.publish({type, data, originatorClientId?})"]
+ C -->|no| E["No emit (drop or log)"]
+ D --> F["Assigns id + v=1, pushes to ring"]
+ F --> G["Fans to all subscribers"]
+```
+
+### Consumer 端(SDK)
+
+```mermaid
+flowchart LR
+ A["SSE bytes"] --> B["parseSseStream → DaemonEvent[]"]
+ B --> C["narrowDaemonEvent(evt)"]
+ C -->|"kind: 'session_update' | ..."| D["reduceDaemonSessionEvent(state, evt)"]
+ C -->|"kind: 'auth_device_flow_*'"| E["reduceDaemonAuthEvent(state, evt)"]
+ C -->|"kind: 'unknown'"| F["pass-through (forward-compat)"]
+```
+
+## Envelope 级元数据
+
+除了每事件的 `data` payload,daemon 还在 envelope 上盖两个字段:
+
+### `_meta.serverTimestamp` —— daemon 时钟
+
+在 `formatSseFrame()`(`packages/cli/src/serve/server.ts:2602+`)的 SSE 写边界盖,**不**在 `EventBus.publish`。这样内存里的 `BridgeEvent` 类型不变,内部 daemon 消费方看不到 `_meta`,只有 wire 上的 SSE 帧带。
+
+```jsonc
+// 盖完之后 wire 上的一帧
+{
+ "id": 47,
+ "v": 1,
+ "type": "session_update",
+ "data": { ... },
+ "_meta": { "serverTimestamp": 1716287345123 }
+}
+```
+
+merge 保留任何已有 `_meta` 键(`{...existingMeta, serverTimestamp: Date.now()}`)。**当前 daemon 没有任何生产者写 envelope 级 `_meta`** —— wenshao #4360 review 已确认 `ToolCallEmitter` 的元数据嵌在 `event.data._meta`(ACP `session/update` payload 自己的 `_meta`),不是 envelope。顶层 merge 是向前兼容逃生口。
+
+**为什么重要**:多客户端 UI 渲「X 分钟前」或按 emit 时间排序 transcript 块时,老路径用各自本地时钟,跨浏览器 / 标签 / 手机漂几十秒到几分钟。服务端盖戳之后,所有客户端排序一致。
+
+**SDK 访问**:3 处探针(`event.serverTimestamp` / `event._meta.serverTimestamp` / `event.data._meta.serverTimestamp`)在 chiga0 的 PR #4353 规划中。在那合入之前,SDK 消费方可以直接通过 `as any` cast 读 `event._meta?.serverTimestamp` —— wire 上字段已经在了。
+
+### `originatorClientId`
+
+上文事件表已经标注。带了已注册 `X-Qwen-Client-Id` 的请求触发的事件才有(规则见 [`08-session-lifecycle.md`](./08-session-lifecycle.md))。
+
+## Tool-call `_meta`(provenance / serverId)
+
+跟上面 envelope 级 `_meta` 不是同一个:ACP `session/update` payload 自己也带 `_meta`,在 `event.data._meta`。`ToolCallEmitter`(`packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts`)在 `emitStart` / `emitResult` / `emitError` 上盖两个字段:
+
+| 字段 | 类型 | 解析规则(`ToolCallEmitter.resolveToolProvenance`) |
+| ------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
+| `provenance` | `'builtin' \| 'mcp' \| 'subagent'` | 有 `subagentMeta` → `subagent`(最高优先级);tool 名匹配 `mcp____` → `mcp`;其它 → `builtin` |
+| `serverId` | `string`(仅 `provenance === 'mcp'` 时设) | 从 `mcp____` 命名启发提取 |
+
+加上原本就有的 `_meta.toolName`(显示名)。
+
+UI 据此渲染 builtin / MCP server badge / subagent 归属的 tool call,不必再去解析 tool 名字。
+
+## SDK reducer 行为
+
+`reduceDaemonSessionEvent(state, evt)`(`packages/sdk-typescript/src/daemon/events.ts:1100+`)把事件流投到 `DaemonSessionViewState`。三个 resync 相关字段:
+
+- **`awaitingResync: boolean`** —— `state_resync_required` 时置 `true`;调用方代码自己清(典型路径:调 `POST /session/:id/load` 重置 view state)。
+- **`resyncRequiredCount: number`** —— 观测帧计数(病态客户端可能不止一次 resync)。
+- **`lastResyncRequired?: DaemonStateResyncRequiredData`** —— 最近一次 payload。
+
+`awaitingResync = true` 期间 reducer **自动跳过** delta 应用,**只放行**封闭的 `RESYNC_PASSTHROUGH_TYPES` 集合(`packages/sdk-typescript/src/daemon/events.ts:896-902`):
+
+| 放行 type | 为什么 resync 期间也要应用 |
+| ----------------------- | ---------------------------------------------------------------------------- |
+| `state_resync_required` | 二次 resync(少见但可能)要更新 `lastResyncRequired` / `resyncRequiredCount` |
+| `session_died` | 流终态信号即便在 resync limbo 也必须可见 |
+| `session_closed` | 同上 |
+| `client_evicted` | 同上 |
+| `stream_error` | 同上 |
+
+`lastEventId` 在 resync limbo 期间仍然通过 `advanceLastEventId(base)` 单调推进,调用方重置并清掉 `awaitingResync` 后,后续 delta 对齐到正确游标。
+
+## 状态与向前兼容
+
+- 新增已知 type → append 到 `DAEMON_KNOWN_EVENT_TYPE_VALUES`。老 SDK 看到 `kind: 'unknown'` 直接忽略;新 SDK 依赖判别式联合类型。
+- 给已有 payload 加可选字段 → 安全(`{ [key: string]: unknown }` 是开的)。
+- 改已有 payload 的**形状** → break;必须 bump `EVENT_SCHEMA_VERSION` 并依赖 `caps.features.typed_event_schema_v2` 之类的能力 tag 兼容。
+- `id` 是每 session 单调,合成终态帧(`client_evicted`、`slow_client_warning`、`stream_error`)刻意无 id,防止其他订阅者看到序号断档。
+- `originatorClientId` 在 envelope 而非 `data`。F3 的 partial-vote / forbidden payload 同时也把它盖到 `data`(`mergeOriginator`),view-state 消费方就不必保留 envelope。
+
+## 依赖
+
+- [`10-event-bus.md`](./10-event-bus.md) — 投递通道。
+- [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) — SDK 怎么 pre-flight `typed_event_schema`、`mcp_guardrail_events`、`permission_mediation` tag。
+- [`04-permission-mediation.md`](./04-permission-mediation.md) — 权限事件怎么产出。
+- [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) — `narrowDaemonEvent`、reducer、view-state 形状。
+
+## 配置
+
+- 默认广播:`typed_event_schema`(恒)、`mcp_guardrail_events`(恒)、`permission_mediation`(恒,`modes` 列出支持策略)。
+- 没有 env / 参数直接控制 schema 本身;杀手锏 `QWEN_SERVE_NO_MCP_POOL=1` 会让 MCP 事件的 `scope` 字段从 `'workspace'` 变成 缺失 / `'session'`。
+
+## 注意 & 已知局限
+
+- 三种合成帧故意无 `id`,SDK 代码不能假设每个事件都有 id。
+- `permission_partial_vote` 只在 `consensus` 下出现;`permission_forbidden` 在 `designated` / `consensus` / `local-only` 下出现,**不在** `first-responder` 下出现。
+- `mcp_child_refused_batch` 只在 `mode: 'enforce'` 下出现,`warn` 模式从不拒绝。
+- `auth_device_flow_*` 事件不是 session-keyed;通过 `DaemonSessionClient` 消费时必须走 `reduceDaemonAuthEvent`,不要走 session reducer。
+
+## 参考
+
+- `packages/sdk-typescript/src/daemon/events.ts`(整文件)
+- `packages/acp-bridge/src/eventBus.ts:22`(`EVENT_SCHEMA_VERSION`)
+- `packages/cli/src/serve/capabilities.ts:60`(`typed_event_schema`)、`:110`(`mcp_guardrail_events`)、`:211-214`(`permission_mediation`)。
+- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/10-event-bus.md b/docs/developers/daemon/10-event-bus.md
new file mode 100644
index 0000000000..fecdea516e
--- /dev/null
+++ b/docs/developers/daemon/10-event-bus.md
@@ -0,0 +1,199 @@
+# SSE 事件总线与反压
+## 概览
+
+`EventBus`(`packages/acp-bridge/src/eventBus.ts`)是每 session 一份的内存 pub/sub,喂给 daemon 的 `GET /session/:id/events` SSE 路由。它给每个事件分配单调 id、用有界环形缓冲缓存最近事件给 `Last-Event-ID` 重放、把 publish 扇出到所有订阅者、对订阅者实施反压(队列 75% 满时发警告、达到上限时驱逐),还会合成两种终态帧(`client_evicted`、`slow_client_warning`),SDK 把它们当一等事件,但 bus 故意**不**给它们分配 `id`,防止它们占掉本 session 的序列号让其他订阅者看到断档。
+
+`EventBus` 目前是 `acp-bridge` 包内部的实现,bridge 工厂为每 session 闭包持有一份。未来 refactor(文件 line 150–159 提到)会把它升到顶层组件,channels、dual-output 以及未来 WebSocket 传输都能通过同一 bus 订阅,而不必各跑一条并行流。
+
+## 职责
+
+- 给每 session 分配单调事件 id(从 1 起)。
+- 在环形缓冲缓存最近 `ringSize` 个事件,供 `lastEventId` 重放。
+- 把 publish 扇出到至多 `maxSubscribers` 个订阅者。
+- 每订阅者用有界队列;超过上限的订阅者收一个合成终态帧 `client_evicted` 后被关掉。
+- 队列 75% 满时合成 `slow_client_warning` —— 每个 overflow episode 只发一次,37.5% 滞回重新装填。
+- `AbortSignal.abort()` 触发后及时拆订阅。
+- bus close 时(session 拆除)干净地关闭所有订阅者。
+- `publish` 永远不抛(合约:调 `publish` 永远安全)。
+
+## 架构
+
+| 常量 | 值 | 用途 |
+| --------------------------------------- | ----------- | ---------------------------------------------------------- |
+| `EVENT_SCHEMA_VERSION` | `1` | 每帧 `v`;frame 形状破坏性改动时 bump |
+| `DEFAULT_RING_SIZE` | `8000` | per-session 重放环;operator 通过 `--event-ring-size` 覆盖 |
+| `DEFAULT_MAX_QUEUED` | `256` | per-subscriber 队列上限 |
+| `DEFAULT_MAX_SUBSCRIBERS` | `64` | per-session 订阅者上限 |
+| `WARN_THRESHOLD_RATIO` | `0.75` | 触发 `slow_client_warning` 的占比 |
+| `WARN_RESET_RATIO` | `0.375` | 滞回重置占比 |
+| `MAX_EVENT_RING_SIZE`(在 `bridge.ts`) | `1_000_000` | `BridgeOptions.eventRingSize` 软上限,挡打错值 OOM |
+
+### `BridgeEvent`
+
+```ts
+interface BridgeEvent {
+ id?: number; // per session 单调;合成终态帧无 id
+ v: 1; // EVENT_SCHEMA_VERSION
+ type: string; // 29 已知 type 之一或未来扩展
+ data: unknown; // payload,SDK 按 type typed(详见 09)
+ originatorClientId?: string; // 由带 clientId 的请求派生
+}
+```
+
+### `SubscribeOptions`
+
+```ts
+interface SubscribeOptions {
+ lastEventId?: number; // 从该 id 之后重放(Last-Event-ID 重连)
+ signal?: AbortSignal; // 及时拆订阅
+ maxQueued?: number; // per-subscriber 队列上限;默认 256
+}
+```
+
+`subscribe()` 返回 `AsyncIterable`。SSE 路由用 `for await` 消费。注册是**同步**的 —— `subscribe()` 返回时订阅者已经挂上,所以与消费者第一次 `next()` race 的 `publish()` 仍会被投递。
+
+### `BoundedAsyncQueue`
+
+每订阅者的队列,两个关键行为:
+
+- **上限只算 LIVE 项**。`forcePush()` 进的项每条带 `forced: true` 标签,不计入 `maxSize`。这让 `Last-Event-ID` 重放可以强推数百历史帧到新订阅者而不会立刻撞到 live 上限把刚 resume 的订阅者驱逐。
+- **`liveCount` 是字段**,不是由 `forcedInBuf` 位置推导的。之前位置推导在 `slow_client_warning` 开始 mid-stream 强推(推到队尾,不是像 replay 那样推到队头)后就坏了;每条 `forced` 标签位置无关。
+
+`push(value)` 在 LIVE 上限时返回 `false`(既不阻塞也不抛),bus 据此驱逐订阅者。`forcePush(value)` 绕过上限。`close({drain?: boolean})` 默认 drain 已有项;abort 路径用 `drain: false` 直接丢弃。
+
+## 流程
+
+### Publish
+
+```mermaid
+flowchart TD
+ P["publish({type, data, originatorClientId?})"] --> C{"bus closed?"}
+ C -->|yes| RU["return undefined"]
+ C -->|no| AID["assign id = nextId++, v = 1"]
+ AID --> PR["push to ring (shift if > ringSize)"]
+ PR --> FAN["snapshot subscribers, for each sub:"]
+ FAN --> EVCK{"sub.evicted?"}
+ EVCK -->|yes| NEXT[next subscriber]
+ EVCK -->|no| PUSH["sub.queue.push(event)"]
+ PUSH --> OK{"accepted?"}
+ OK -->|no| EVICT["mark evicted; force-push client_evicted; queue.close; sub.dispose"]
+ OK -->|yes| WARN{"!warned && liveSize >= warnThreshold?"}
+ WARN -->|yes| FW["force-push slow_client_warning; warned = true"]
+ WARN -->|no| RES{"warned && liveSize <= warnResetThreshold?"}
+ RES -->|yes| RA["warned = false (hysteresis re-arm)"]
+ RES -->|no| NEXT
+```
+
+`publish` 永远不抛。关闭 bus 之中 publish(shutdown 路径在 await `channel.kill()` 前关每个 session 的 bus)返回 `undefined` 而不是抛,因为 agent 在 bus close 与 channel kill 之间的窗口里还可能发 `sessionUpdate` 通知。
+
+### Subscribe + replay(带环驱逐检测)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant SR as SSE route
+ participant EB as EventBus
+ participant Q as BoundedAsyncQueue
+
+ SR->>EB: subscribe({lastEventId: 42, maxQueued: 256, signal})
+ EB->>EB: refuse if subs.size >= maxSubscribers
(throws SubscriberLimitExceededError)
+ EB->>Q: new BoundedAsyncQueue(256)
+ EB->>EB: subs.add(sub)
+ EB->>EB: earliestInRing = ring[0]?.id
+ alt earliestInRing > lastEventId + 1 (gap evicted)
+ EB->>Q: forcePush state_resync_required
{ reason: 'ring_evicted', lastDeliveredId: 42, earliestAvailableId: earliestInRing }
+ Note over EB,Q: id-less synthetic, frame goes BEFORE replay.
Stream stays open; SDK reducer flips awaitingResync.
+ end
+ loop ring scan
+ EB->>EB: for e in ring where e.id > 42
+ EB->>Q: forcePush(e)
+ end
+ EB->>EB: attach AbortSignal listener
(onAbort → queue.close({drain:false}); dispose)
+ EB-->>SR: AsyncIterable
+ SR->>Q: next() in for-await loop
+```
+
+subscribe 时 `subs.size >= maxSubscribers` 抛 `SubscriberLimitExceededError`,SSE 路由捕获并给被拒客户端序列化一个 `stream_error` 合成帧,免得他们看到一片空。返回空 iterable 会让 oncall 在高负载下分不清「有的客户端收到了,有的没收到」。
+
+### 环驱逐 → `state_resync_required`(恢复流)
+
+当消费方带 `Last-Event-ID: N` 重连,但环里最早留存事件的 `id > N + 1`,说明 `[N+1, earliestInRing-1]` 这段在重连前被 evict 了。朴素重放会默默成功但拿到一个非连续后缀,SDK reducer 当作连续流继续 apply delta,状态就与 daemon 真相分叉 —— 全程没有终态信号。
+
+实现在 `packages/acp-bridge/src/eventBus.ts:359-402`:
+
+1. 算 `earliestInRing = this.ring[0]?.id`。
+2. 若 `earliestInRing > opts.lastEventId + 1`,在重放帧**之前**强推一帧合成:
+ ```jsonc
+ {
+ "v": 1,
+ "type": "state_resync_required",
+ "data": {
+ "reason": "ring_evicted",
+ "lastDeliveredId": ,
+ "earliestAvailableId":
+ }
+ }
+ ```
+3. 之后照常做重放循环。
+
+关键契约(以及 wenshao #4360 review 修正过的几点):
+
+- **无 `id`** —— 与 `client_evicted` 同样的「不占位」模式,不会占掉 per-session 单调序列号让其他订阅者看到断档。
+- **流保持打开** —— 不同于 `client_evicted`(真终态),`state_resync_required` 面向恢复。重放和 live 帧继续。
+- **reducer 自动跳过 delta** —— SDK 端 `awaitingResync = true`,只放行 `state_resync_required` 本身加四个终态帧(`session_died`、`session_closed`、`client_evicted`、`stream_error`),直到调用方调 `loadSession` 清掉标志。详见 [`09-event-schema.md`](./09-event-schema.md) 的 `RESYNC_PASSTHROUGH_TYPES`。
+- **省网络** —— 帧仍然走线,SDK 之后可以计算「漏了什么」的 diff,不需要额外重连一次。
+
+### 驱逐终态
+
+订阅者 LIVE 队列已到 `maxQueued`,再来一次 `push()` 返回 `false`:
+
+1. 标 `sub.evicted = true`。
+2. 构造 `client_evicted` 帧,**无 `id`** —— `{ v: 1, type: 'client_evicted', data: { reason: 'queue_overflow', droppedAfter: <最后投递的 id> } }`。
+3. `queue.forcePush(evictionFrame)` 让消费者 iterator 看到一个终态帧。
+4. `queue.close()` 让 iterator 在终态帧后 unwind。
+5. `sub.dispose()` —— 从 `subs` 移除**并且**解绑 `AbortSignal` listener(**BmJT1 修复**:不这么做时,卡住的消费者闭包会一直存活到 `AbortSignal` 自己 GC)。
+
+### Abort 流
+
+`AbortSignal.abort()` → `onAbort()`:
+
+1. `queue.close({drain: false})` —— 丢弃已缓冲项,免得 SSE 路由继续往没人看的 socket 序列化事件。
+2. `dispose()` —— 通过 `disposed` 标志幂等。
+
+subscribe 时已 abort 的 signal 会在返回 iterator 前同步调一次 `onAbort()`。
+
+## 状态与生命周期
+
+- `nextId` 从 1 起只增不减;`lastEventId` getter 返回 `nextId - 1`。
+- `ring` 有界;满了之后 `shift` 是 O(n)。`ringSize=8000` 在聊天密集 session 上每次 publish 几毫秒,远低于 per-frame 延迟预算。circular-buffer refactor 推迟到 profiling 真的标出它,或 operator 把 `--event-ring-size` 提一个数量级时再做。
+- `close()` 翻转 `closed`、关掉所有订阅者队列、清空 `subs`。之后 `publish()` / `subscribe()` 都是 no-op(`publish` 返 undefined,`subscribe` 返 `emptyAsyncIterable`)。
+- 每 session 一个 `EventBus`。bus close 发生在 `channel.kill()` 之前,shutdown 中飞行的 publish 返 undefined 而不抛。
+
+## 依赖
+
+- 被 `packages/acp-bridge/src/bridge.ts` 消费(`BridgeClient.sessionUpdate` / `extNotification` → `events.publish(...)`)。
+- 被 `packages/cli/src/serve/server.ts` 消费(SSE 路由 → `events.subscribe(...)`,再把 `BridgeEvent` 序列化为 SSE wire)。
+- re-export shim:`packages/cli/src/serve/eventBus.ts` → `@qwen-code/acp-bridge/eventBus`。
+- SDK 消费方:`packages/sdk-typescript/src/daemon/sse.ts`(`parseSseStream`),之后接 `narrowDaemonEvent`(详见 [`09-event-schema.md`](./09-event-schema.md)、[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md))。
+
+## 配置
+
+- `--event-ring-size ` — per-session 环深度,软上限 `MAX_EVENT_RING_SIZE = 1_000_000`。
+- `GET /session/:id/events` 上的 `?maxQueued=N` query 参数,范围 `[16, 2048]`,SDK 在 opt-in 前 pre-flight `caps.features.slow_client_warning`。
+- `BridgeOptions.eventRingSize`(嵌入用例覆盖 daemon 默认)。
+- 能力 tag:`session_events`、`slow_client_warning`、`typed_event_schema`。
+
+## 注意 & 已知局限
+
+- **合成帧无 `id`**。SDK 用 `Last-Event-ID` 重连时不能假设序号连续 —— live 流里看到 「事件 3、5、6,缺 4」是正常的(如果 4 是当时强推给该订阅者的 `slow_client_warning` 或 `client_evicted`,那帧是私有的)。
+- `client_evicted` 是 **per-subscriber** 不是 per-session,同一客户端可以重连。
+- `BoundedAsyncQueue` iterator **不支持并发驱动** —— 两次同时 `.next()` 会 race 同一事件。生产环境是顺序消费(SSE 路由的 `for await`),安全。
+- bus 目前包私有;channels 和 webui 想订阅必须走 daemon HTTP SSE 路由,不能直接 reach 进 bus。Stage 1.5 会把它升到顶层。
+
+## 参考
+
+- `packages/acp-bridge/src/eventBus.ts`(整文件)
+- `packages/acp-bridge/src/bridge.ts`(publish 站点,特别是 `BridgeClient.sessionUpdate` 和 F3 权限事件)
+- `packages/cli/src/serve/server.ts`(SSE 路由 handler — 把 `BridgeEvent` 序列化为 wire SSE)
+- `packages/sdk-typescript/src/daemon/sse.ts`(客户端 SSE wire 解析器)
+- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)(`Last-Event-ID` 重连合约)。
diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md
new file mode 100644
index 0000000000..4bf0e92f21
--- /dev/null
+++ b/docs/developers/daemon/11-capabilities-versioning.md
@@ -0,0 +1,165 @@
+# 能力协商与协议版本
+## 概览
+
+`GET /capabilities` 是 daemon 的 pre-flight 出口。每个 SDK 客户端应该在任何其他路由之前先读它,了解 daemon 说哪个协议版本、开了哪些 feature tag、绑定到哪个 workspace。合约:
+
+- **只有一个协议版本 `v1`。** `SERVE_PROTOCOL_VERSION = 'v1'`、`SUPPORTED_SERVE_PROTOCOL_VERSIONS = ['v1']`。v1 内部纯加法;frame 形态破坏性改动留给 v2。
+- **每 tag 一个 `since` 版本**,未来 v2 可以同时广播 v1 与 v2 tag。
+- **条件广播**。三个 tag(`require_auth`、`mcp_workspace_pool`、`mcp_pool_restart`)只在对应部署开关打开时才广播;tag 存在 = 行为存在。
+- **Capability tag = 行为契约**。在已有 tag 下加新行为会悄悄破坏已有的 pre-flight 检查;**新行为对应新 tag**。
+
+完整注册表在 `packages/cli/src/serve/capabilities.ts:37-215`。
+
+## 职责
+
+- 声明 daemon 可能广播的每个 feature。
+- 按协议版本 + 部署开关过滤实际广播的 feature。
+- 暴露 `getRegisteredServeFeatures()`(全 key、不过滤)、`getAdvertisedServeFeatures(version, toggles)`(过滤后)、`getServeProtocolVersions()`(envelope:`{current, supported}`)。
+- 守住「tag 存在 = 行为存在」的不变式 —— `server.test.ts` 的「every conditional tag advertises when its toggle is on」测试遍历 `CONDITIONAL_SERVE_FEATURES` 的 key,没写 predicate 的新 tag 直接挂测。
+
+## 架构
+
+### Capability envelope
+
+`/capabilities` 返回:
+
+```ts
+{
+ v: 1, // CAPABILITIES_SCHEMA_VERSION
+ mode: 'http-bridge',
+ features: ServeFeature[],
+ workspaceCwd: string,
+ protocol?: { current: 'v1', supported: ['v1'] },
+ policy?: { permission: PermissionPolicy },
+}
+```
+
+`workspaceCwd` 是 daemon 绑定的规范化 workspace(详见 [`02-serve-runtime.md`](./02-serve-runtime.md))。`policy.permission` 是当前激活的 mediator 策略。
+
+### `ServeCapabilityDescriptor`
+
+```ts
+interface ServeCapabilityDescriptor {
+ since: ServeProtocolVersion; // current = 'v1'
+ modes?: readonly string[]; // 多种操作模式时列出
+}
+```
+
+v1 用到 `modes` 的两个 tag:
+
+- `mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] }` —— 客户端依赖 refusal 行为前 pre-flight `'enforce'`。
+- `permission_mediation: { since: 'v1', modes: ['first-responder', 'designated', 'consensus', 'local-only'] }` —— 客户端在这里看到**构建期支持集**;daemon 的**激活策略**在 envelope 的 `policy.permission`。
+
+### 条件 tag
+
+```ts
+export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
+ ServeFeature,
+ (toggles: AdvertiseFeatureToggles) => boolean
+> = new Map([
+ ['require_auth', (t) => t.requireAuth === true],
+ ['mcp_workspace_pool', (t) => t.mcpPoolActive === true],
+ ['mcp_pool_restart', (t) => t.mcpPoolActive === true],
+]);
+```
+
+`Map` 形状把「predicate 判断」和「集合成员」收成一条记录。加一个新条件 tag 要**两处协调修改**:
+
+1. 在 `SERVE_CAPABILITY_REGISTRY` 注册 tag 及其 `since`。
+2. 在 `CONDITIONAL_SERVE_FEATURES` 加 predicate。
+
+基线 tag(Map 里没有)无条件广播 —— 这个决定是**用「不写」表达**的,不需要专门维护一个 Set。
+
+### 38 个 tag(v1,按域)
+
+Foundation:`health`、`capabilities`。
+
+Sessions:`session_create`、`session_scope_override`、`session_load`、`unstable_session_resume`、`session_list`、`session_prompt`、`session_cancel`、`session_events`、`session_set_model`、`session_close`、`session_metadata`、`session_context`、`session_supported_commands`、`session_approval_mode_control`。
+
+Streaming:`slow_client_warning`、`typed_event_schema`。
+
+Identity & heartbeat:`client_identity`、`client_heartbeat`。
+
+Permissions:`session_permission_vote`、`permission_vote`、**`permission_mediation`**(`modes: ['first-responder', 'designated', 'consensus', 'local-only']`)。
+
+Workspace 只读快照:`workspace_mcp`、`workspace_skills`、`workspace_providers`、`workspace_env`、`workspace_preflight`。
+
+Workspace 修改(Wave 4):`workspace_memory`、`workspace_agents`、`workspace_tool_toggle`、`workspace_init`、`workspace_mcp_restart`、`workspace_file_read`、`workspace_file_bytes`、`workspace_file_write`。
+
+MCP guardrails:**`mcp_guardrails`**(`modes: ['warn', 'enforce']`)、`mcp_guardrail_events`、**`mcp_workspace_pool`**(条件)、**`mcp_pool_restart`**(条件)。
+
+Auth:`auth_device_flow`、**`require_auth`**(条件)。
+
+(粗体 = 带 `modes` 或条件。)
+
+## 流程
+
+### Daemon 端:装 envelope
+
+```mermaid
+flowchart LR
+ A["GET /capabilities"] --> B["getAdvertisedServeFeatures(version, toggles)"]
+ B --> C["filter by isFeatureAvailableInProtocol"]
+ C --> D["for each, check CONDITIONAL_SERVE_FEATURES"]
+ D --> E["yes: predicate(toggles) ? include : drop"]
+ D --> F["no: include unconditionally"]
+ E --> G["return ServeFeature[]"]
+ F --> G
+ G --> H["wrap in envelope:
{ v: 1, mode, features, workspaceCwd, protocol, policy }"]
+```
+
+### 客户端:feature pre-flight
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant D as GET /capabilities
+ participant R as Route
+
+ C->>D: GET /capabilities
+ D-->>C: { v, mode, features, workspaceCwd, protocol, policy }
+ C->>C: features.includes('mcp_workspace_pool')?
+ alt yes
+ C->>R: rely on pool-aware response shapes
(e.g. entries[] from /workspace/mcp/:server/restart)
+ else no
+ C->>R: legacy single-entry response shape
+ end
+```
+
+## 状态与生命周期
+
+- `CAPABILITIES_SCHEMA_VERSION` 是 wire envelope 的形状版本(当前 `1`)。bump 它是对 envelope 本身的 break。
+- `SERVE_PROTOCOL_VERSION = 'v1'` 是协议-feature 版本。v1 内加 feature 是加法;老客户端不 pre-flight 新 tag 就看不到,**移除** feature 才是 v2 break。
+- `EVENT_SCHEMA_VERSION = 1` 是 SSE frame 的 `v` 字段(见 [`09-event-schema.md`](./09-event-schema.md)),独立版本轴;bump 事件 schema 不必 bump 协议版本,反之亦然。
+- `unstable_session_resume` 故意带 `unstable_` 前缀,因为 ACP 的 `connection.unstable_resumeSession` 还可能变形状;客户端应 feature-detect 而不是固定 v1。
+
+## 依赖
+
+- 被 `packages/cli/src/serve/server.ts` 读来装 `/capabilities` 响应。
+- Toggle 输入:`runQwenServe` 构造 `{ requireAuth: opts.requireAuth, mcpPoolActive: opts.mcpPoolActive }` 透传到 envelope。
+- envelope 中激活的 `permission` 策略来自 `BridgeOptions.permissionPolicy`(其本身读 `settings.json` 的 `policy.permissionStrategy`)。
+
+## 配置
+
+| 来源 | 旋钮 | 对 capabilities 的影响 |
+| --------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| 参数 | `--require-auth` | `require_auth` tag 出现 |
+| Env | `QWEN_SERVE_NO_MCP_POOL=1` | `mcp_workspace_pool` + `mcp_pool_restart` 消失;MCP 事件不再盖 `scope: 'workspace'` |
+| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 不改 tag 集合(`mcp_guardrails` 永远广播),但改 per-server 预留 + refusal 行为 |
+| `settings.json` | `policy.permissionStrategy` | 设 envelope 的 `policy.permission` |
+
+## 注意 & 已知局限
+
+- **`--require-auth` 遮蔽 pre-flight**。开 `--require-auth` 时所有路由(包括 `/capabilities`)都要 bearer。未认证客户端无法 pre-flight `caps.features.require_auth` 来发现需要认证;这种情形下**401 响应体**就是发现 surface(详见 [`12-auth-security.md`](./12-auth-security.md))。`require_auth` tag 是**认证后确认**,给加固部署的审计 UI 用。
+- **Tag 存在 = 行为存在**。如果未来贡献者在已有 tag 下加行为且没 bump `since`,pre-flight 旧 tag 的 SDK 会默默拿到新行为。约定:**新行为对应新 tag**。
+- **`unstable_*` tag 可能在版本之间变形状**且不 bump 协议版本。依赖时硬绑 SDK 版本。
+- 路由清单在 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md),本文刻意不重复。
+
+## 参考
+
+- `packages/cli/src/serve/capabilities.ts:1-330`(整文件)
+- `packages/cli/src/serve/types.ts:37-155`(`ServeOptions`、`CapabilitiesEnvelope`)
+- `packages/cli/src/serve/server.ts`(envelope 装配)
+- `packages/acp-bridge/src/eventBus.ts:22`(`EVENT_SCHEMA_VERSION`)
+- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/12-auth-security.md b/docs/developers/daemon/12-auth-security.md
new file mode 100644
index 0000000000..8f3f28377d
--- /dev/null
+++ b/docs/developers/daemon/12-auth-security.md
@@ -0,0 +1,246 @@
+# 认证与安全模型
+## 概览
+
+`qwen serve` 默认是本地 daemon,配错就是暴露面。安全模型**分层**,错配时 fail-closed:
+
+1. **绑定** — 非 loopback 绑定无 bearer token **拒启动**。
+2. **Bearer auth** — `bearerAuth` 中间件,常量时间 SHA-256 比较,覆盖除 loopback 上 `/health` 之外的每条路由(`require_auth` 把它扩展到 loopback 与 `/health`)。
+3. **Host 白名单** — loopback 上只接受 `localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal`(带端口),防 DNS rebinding。
+4. **Origin 拒绝** — 任何带 `Origin` 头的请求 `403`。CLI / SDK 永远不发 `Origin`,只有浏览器发。
+5. **每路由 mutation gate** — Wave 4 修改类路由 opt-in,「即便 loopback 无 token 也 401」并带专有 `code: 'token_required'`。
+6. **Device-flow auth** — Provider OAuth 流的独立 surface(`POST /workspace/auth/device-flow` + GET/DELETE on `/:id`)。
+
+本文讲清每一层和 boot 路径强制的每个不变式。
+
+## 职责
+
+- 不安全配置直接拒启动。
+- 通过 bearer(配了的话)+ host(loopback)+ origin 检查闸所有 HTTP 请求。
+- 为 Wave 4 路由提供 per-route mutation gate。
+- 托管 device-flow registry 驱动 provider OAuth 流并通过 SSE 事件可见。
+
+## 架构
+
+### 启动期 refuse 规则
+
+`runQwenServe.ts`:
+
+```ts
+if (!isLoopbackBind(opts.hostname) && !token) {
+ throw new Error('Refusing to bind : without a bearer token. ...');
+}
+if (opts.requireAuth && !token) {
+ throw new Error(
+ 'Refusing to start with --require-auth set but no bearer token configured. ...',
+ );
+}
+```
+
+两个拒绝都是 boot-loud(stderr / 抛给嵌入方),从不静默。#3803 的威胁模型明文禁止 daemon 默默裸跑到 loopback 之外。
+
+### 中间件链(HTTP 请求顺序)
+
+```mermaid
+flowchart LR
+ REQ[Request] --> SO["strip same-origin Origin
(demo page support)"]
+ SO --> CORS["denyBrowserOriginCors"]
+ CORS --> HA["hostAllowlist"]
+ HA --> BA["bearerAuth"]
+ BA --> ROUTE["route handler"]
+ ROUTE --> MG["mutationGate (per-route opt-in)"]
+ MG --> BODY["body parsing + handler"]
+```
+
+(`mutationGate` 是 per-route 中间件,可以选择性 `strict: true`,详见 `packages/cli/src/serve/auth.ts:1-294`。)
+
+### `bearerAuth`
+
+- **没配 token** → 中间件是 no-op(loopback dev 默认)。
+- **配了 token** → 构造时把 token SHA-256 一次;每请求把 candidate 哈希再 `timingSafeEqual` 比较;不走字符串等值短路,不漏时序信息。
+- **scheme 解析**:`Bearer` 大小写不敏感(RFC 7235 §2.1),scheme 与凭证之间允许 `SP\tHTAB` BWS(RFC 7230 §3.2.6),但纯 HTAB 作分隔被拒。
+- **CodeQL 加固**:手写 `indexOf` 解析而不是带 `\s+` / `.+` 重叠的正则,避免多项式正则风险。
+
+### `hostAllowlist`
+
+仅 loopback。按端口缓存 `Set`。允许的 Host:
+
+- `localhost:`、`127.0.0.1:`、`[::1]:`、`host.docker.internal:`。
+- 加上无端口形式(`localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal`),**仅**当绑端口 80 时(RFC 7230 §5.4 默认端口省略)。
+
+Host 比较**大小写不敏感** —— Express 规范 header 名但不规范值,Docker 代理大写 Host(`Localhost:4170`、`HOST.docker.internal`)严格比对会 403。
+
+非 loopback 绑定跳过该中间件(operator 选了暴露面,bearer 顶上挡 Host 伪造)。
+
+### `denyBrowserOriginCors`
+
+任何带 `Origin` 的请求直接 `403 { error: 'Request denied by CORS policy' }`。CLI/SDK 永不发 `Origin`,只有浏览器发。返回确定性 403 而不是 `cors` 包错误回调的 500 HTML。
+
+例外:demo 页的同源 XHR 由 `server.ts` 里另一个中间件先把匹配本机地址的 `Origin` 剥掉。
+
+### `createMutationGate`
+
+per-route opt-in 闸门。行为矩阵:
+
+| daemon 配置 | route opts | 结果 |
+| ------------------------ | --------------- | -------------------------------- |
+| `requireAuth=true` | 任意 | passthrough¹ |
+| 配了 `token` | 任意 | passthrough² |
+| 无 token(loopback dev) | `strict: false` | passthrough |
+| 无 token(loopback dev) | `strict: true` | `401 { code: 'token_required' }` |
+
+¹ `--require-auth` 只在配了 token 时启动,全局 `bearerAuth` 已经 401 过未认证调用。
+² 配了 token 的任何路径,全局 `bearerAuth` 都强制 bearer;这里冗余但无害。
+
+`code: 'token_required'` 与 `bearerAuth` 普通 `Unauthorized` 不同形状,SDK 据此渲染「请用 --token / --require-auth 启动 daemon」提示而不是泛 401。
+
+**Wave 4 strict 路由**:`/workspace/memory`、`/workspace/agents/*`、`/file/write`、`/file/edit`、`/workspace/tools/:name/enable`、`/workspace/mcp/:server/restart`、`/workspace/auth/device-flow`、`/workspace/init`、`/session/:id/approval-mode`。
+
+### `/health` 豁免
+
+loopback 绑定上,`/health` 注册在 bearer 中间件**之前**,pod 内部 liveness 探针不必带 token。非 loopback 绑定下 `/health` 也走 bearer。`--require-auth` 撤销豁免:loopback 上 `/health` 也要 `Authorization: Bearer `。
+
+### v1 的 client 身份 (`X-Qwen-Client-Id`) 是自报
+
+daemon 只校验 `X-Qwen-Client-Id` 的格式(`[A-Za-z0-9._:-]{1,128}`)并按 session 跟踪 attach 的 client id;当下**不做** proof-of-possession 检查。客户端只要观察到 SSE 帧里的 `originatorClientId` 就能用同 id 重新注册,在后续请求里冒充 originator。
+
+影响范围:**`designated`** 策略(远端可以伪装 originator 给本应只属于 prompt 发起人的请求投票);**`consensus`** 策略(如果 `votersAtIssue` 快照里已经有伪装 id,它能投)。**不**影响 `local-only`(按 `fromLoopback` 闸,daemon 按连接 remote address 盖戳),**不**影响 `first-responder`(与身份无关)。
+
+「pair-token」机制(`POST /session` 时 daemon 发一个 per-session secret,`designated` / `consensus` 投票必须带)将来 PR 落地。当下需要加固 designated 策略的部署应当绑 loopback 或挂在做认证的反代后面。详见 [`04-permission-mediation.md`](./04-permission-mediation.md) 各策略的具体影响。
+
+### Device-flow auth
+
+provider 认证(Qwen OAuth 等)的独立 OAuth surface:
+
+- `POST /workspace/auth/device-flow` — 启动一个流;返回 `{deviceFlowId, providerId, expiresAt, verificationUrl, userCode}`。
+- `GET /workspace/auth/device-flow/:id` — 轮询状态。
+- `DELETE /workspace/auth/device-flow/:id` — 取消。
+- `GET /workspace/auth/status` — 当前账号 / provider 快照。
+
+SSE 事件 `auth_device_flow_{started, throttled, authorized, failed, cancelled}` 把流状态扇出给所有订阅者,多客户端 UI 同步。见 [`09-event-schema.md`](./09-event-schema.md)。
+
+实现:`packages/cli/src/serve/auth/deviceFlow.ts` + `qwenDeviceFlowProvider.ts`。
+
+**日志注入 / Trojan-Source 防御**:`sanitizeForStderr(value)`(`deviceFlow.ts:47-72`)剥掉 ASCII C0 / DEL / C1 控制字符**外加** Unicode 同形字符 —— 恶意 IdP 可能用它们伪造日志行或隐藏 payload:
+
+| 范围 | 为什么剥 |
+| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `\x00–\x1f`、`\x7f`、`\x80–\x9f` | ASCII C0 / DEL / C1,日志行伪造、终端控制序列 |
+| U+200B–U+200F | 零宽字符 + LRM / RLM,隐形但能改终端渲染 |
+| U+2028–U+2029 | LINE / PARAGRAPH SEPARATOR,许多 Unicode-aware 终端把它当换行,最直接的日志伪造向量 |
+| U+202A–U+202E | 双向 EMBEDDING / OVERRIDE 控制 |
+| U+2066–U+2069 | 双向 ISOLATE 控制(LRI / RLI / FSI / PDI),[CVE-2021-42574 "Trojan Source"](https://trojansource.codes/) 主攻击向量。恶意 IdP 用 U+2066 (LRI) 替换 U+202D (LRO) 会绕过 EMBEDDING/OVERRIDE 范围却达到同样视觉重排 |
+| U+FEFF | BOM / 零宽不折断空格 |
+
+长度保持(每个被剥码点替换为 `?` 而不是消失),operator 在那索引处仍能看出有东西曾经在。两层都用:`qwenDeviceFlowProvider` 净化 IdP 的 `oauthError`,registry 的 late-poll 观察者净化插值进 audit hint 的 provider 可控值(`latePollResult.kind` / `lateErr.name`)。
+
+**日志注入 / Trojan-Source 防御**:`sanitizeForStderr(value)`(`deviceFlow.ts:47-72`)剥掉 ASCII C0 / DEL / C1 控制字符**外加** Unicode 同形字符 —— 恶意 IdP 可能用它们伪造日志行或隐藏 payload:
+
+| 范围 | 为什么剥 |
+| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `\x00–\x1f`、`\x7f`、`\x80–\x9f` | ASCII C0 / DEL / C1,日志行伪造、终端控制序列 |
+| `–` | Zero-width 字符 + LRM/RLM,隐形但能改终端渲染 |
+| `–` | LINE / PARAGRAPH SEPARATOR,许多 Unicode-aware 终端把它当换行,最直接的日志伪造向量 |
+| `–` | 双向 EMBEDDING / OVERRIDE 控制 |
+| `–` | 双向 ISOLATE 控制(LRI / RLI / FSI / PDI),[CVE-2021-42574 "Trojan Source"](https://trojansource.codes/) 主攻击向量。恶意 IdP 用 U+2066 (LRI) 替换 U+202D (LRO) 会绕过 EMBEDDING/OVERRIDE 范围却达到同样视觉重排 |
+| `` | BOM / 零宽不折断空格 |
+
+长度保持(每个被剥码点替换为 `?` 而不是消失),operator 在那索引处仍能看出有东西曾经在。两层都用:`qwenDeviceFlowProvider` 净化 IdP 的 `oauthError`,registry 的 late-poll 观察者净化插值进 audit hint 的 provider 可控值(`latePollResult.kind` / `lateErr.name`)。
+
+`auth_device_flow` 能力 tag **无条件**广播;路由本身在 daemon 不支持指定 provider 时返 `400 unsupported_provider`。支持的 provider 列表在 `/workspace/auth/status` 而不是 `/capabilities`,保持 descriptor 形状统一。
+
+## 流程
+
+### Bearer auth 正路
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant BA as bearerAuth
+ participant R as Route
+
+ C->>BA: Authorization: Bearer abc...
+ BA->>BA: parse scheme (case-insensitive), strip BWS
+ BA->>BA: SHA-256(candidate)
+ BA->>BA: timingSafeEqual(candidate, expected)
+ BA->>R: next()
+ R-->>C: 200 ...
+```
+
+### Bearer auth 失败模式
+
+都返 `401 { error: 'Unauthorized' }`(`missing header` / `wrong scheme` / `wrong token` 一致),探测者无法区分。
+
+### `--require-auth` 阴影
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Unauth client
+ participant CAPS as GET /capabilities
+ participant BA as bearerAuth
+
+ C->>CAPS: GET /capabilities (no Authorization)
+ CAPS->>BA: pass through middleware
+ BA-->>C: 401 Unauthorized
+ Note over C,BA: client cannot pre-flight require_auth tag
before authenticating. Discovery surface is the 401 body.
+```
+
+认证成功后 `caps.features.includes('require_auth')` 确认部署是 hardened。
+
+### Wave-4 mutation gate 在无 token loopback 上
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant BA as bearerAuth (no-op, no token)
+ participant MG as mutationGate({strict: true})
+ participant R as Handler
+
+ C->>BA: POST /workspace/memory (no Authorization)
+ BA->>MG: passthrough
+ MG-->>C: 401 { code: 'token_required', error: '...' }
+```
+
+## 状态与生命周期
+
+- Bearer token 在 boot 读取并 trim(防 `cat token.txt` 带尾换行默默永不匹配)。
+- 允许 Host 集合按端口缓存;端口变(ephemeral `0` → `listen` 后的真实端口)才重建。
+- mutation gate 在应用构造时构造 `passthrough` 和 `strictDenier` 一次;每路由调用返回缓存闭包(无 per-request 分配)。
+- device-flow registry 在 `shutdown()` 第一阶段释放,pending flow 在 HTTP 收尾前解析为 `cancelled`。
+
+## 依赖
+
+- `node:crypto` —— `createHash`、`timingSafeEqual`。
+- `packages/cli/src/serve/loopbackBinds.ts` —— `isLoopbackBind`。
+- `packages/cli/src/serve/auth/deviceFlow.ts` —— device-flow 状态机。
+- `@qwen-code/acp-bridge` —— 把 device-flow 事件吐到 per-session SSE bus。
+
+## 配置
+
+| 来源 | 旋钮 | 效果 |
+| -------- | ------------------------------------------------ | --------------------------------------------------------------------- |
+| Env | `QWEN_SERVER_TOKEN` | Bearer token(trim 后) |
+| 参数 | `--token` | Bearer token(覆盖 env) |
+| 参数 | `--require-auth` | Bearer 扩展到 loopback + `/health`。仅在配了 token 时启动 |
+| 参数 | `--hostname` | 非 loopback 绑定要求 `--token`(或 env) |
+| 能力 tag | `require_auth`(条件)、`auth_device_flow`(恒) | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) |
+
+## 注意 & 已知局限
+
+- **`--require-auth` 遮蔽 feature pre-flight**。未认证客户端无法发现 `require_auth` tag;它们的发现 surface 是 401 响应体。
+- **mutation gate body-parser 顺序**:strict 路径的 401 在 `express.json()` 之后才发;满载 loopback 监听器最坏 `--max-connections × express.json({limit: '10mb'})` ≈ 2.5 GB 瞬时。loopback only,刻意接受。
+- **同源 Origin 剥离**发生在 `denyBrowserOriginCors` **之前**;如果未来 refactor 把剥离挪走,demo 页会坏。
+- **Token 比较是对 SHA-256 摘要**而不是原始 token,把变长比较收成定长比较,时序泄漏更难做。
+- daemon 当前**没有** mTLS / 请求签名 / per-client 限流,那些是 F 系列 Wave 5+ 项目。
+
+## 参考
+
+- `packages/cli/src/serve/auth.ts:1-294`(整文件)
+- `packages/cli/src/serve/runQwenServe.ts:341-360`(refuse 规则)
+- `packages/cli/src/serve/loopbackBinds.ts`
+- `packages/cli/src/serve/auth/deviceFlow.ts`
+- `packages/cli/src/serve/auth/qwenDeviceFlowProvider.ts`
+- 用户威胁模型:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。
+- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/13-sdk-daemon-client.md b/docs/developers/daemon/13-sdk-daemon-client.md
new file mode 100644
index 0000000000..6ce6ce82bd
--- /dev/null
+++ b/docs/developers/daemon/13-sdk-daemon-client.md
@@ -0,0 +1,262 @@
+# TypeScript SDK Daemon 客户端
+## 概览
+
+`packages/sdk-typescript/src/daemon/` 是 **TypeScript SDK 的 daemon 客户端**。任何 TypeScript / JavaScript 宿主想跟在跑的 `qwen serve` 通话都走它(CLI 自己的 TUI 适配器、channel 机器人后端、VSCode IDE companion、自定义脚本、服务端 Web BFF)。所有其他适配器都依赖它。
+
+包布局有意保持紧凑:
+
+| 文件 | 暴露 |
+| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| `index.ts` | 公开 barrel(`DaemonClient`、`DaemonSessionClient`、`DaemonAuthFlow`、`parseSseStream`、event reducers、types) |
+| `DaemonClient.ts` | 低层 HTTP/SSE 门面 —— 每条 `qwen-serve-protocol.md` 路由一个方法 |
+| `DaemonSessionClient.ts` | session 级封装,自动跟踪 SSE 重放 |
+| `DaemonAuthFlow.ts` | 高层 OAuth Device Flow 助手 |
+| `sse.ts` | `parseSseStream`(NDJSON / SSE 框架解析) |
+| `events.ts` | `narrowDaemonEvent`、`reduceDaemonSessionEvent`、`reduceDaemonAuthEvent`(见 [`09-event-schema.md`](./09-event-schema.md)) |
+| `types.ts` | `DaemonCapabilities`、`DaemonSession`、`DaemonEvent`、`PermissionResponse`、`PromptResult`、MCP / agent / memory / auth 类型 |
+
+走查示例在 [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md);本文是架构/契约参考。
+
+## 职责
+
+- 每条 daemon HTTP 路由提供一个 TS 方法。
+- 给每请求正确盖 bearer token 和 `X-Qwen-Client-Id`。
+- 把 per-call 超时与调用方传入的 `AbortSignal` 组合(不杀长 SSE)。
+- 把 SSE 流解析成 typed `DaemonEvent`。
+- 每 session 跟踪 `lastSeenEventId`,重连正确重放。
+- 暴露 device-flow auth surface 按 daemon 给出的间隔轮询。
+
+## 架构
+
+### `DaemonClient`(`DaemonClient.ts:209-1506`)
+
+构造:
+
+```ts
+new DaemonClient({
+ baseUrl: string, // 默认 'http://127.0.0.1:4170'
+ token?: string,
+ fetch?: typeof globalThis.fetch, // 测试可注入
+ fetchTimeoutMs?: number, // 0 = 禁用;默认 DEFAULT_FETCH_TIMEOUT_MS
+});
+```
+
+方法分组(每个方法可选 `clientId` 用于盖 `X-Qwen-Client-Id`):
+
+| 组 | 方法 |
+| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Plumbing | `health()`、`capabilities()`、`auth`(lazy `DaemonAuthFlow` accessor) |
+| Sessions | `createOrAttachSession`、`loadSession`、`resumeSession`、`listSessions`、`closeSession`、`setSessionMetadata`、`getSessionContext`、`getSessionSupportedCommands`、`setSessionApprovalMode`、`setSessionModel` |
+| Prompting | `prompt`、`cancel`、`heartbeat` |
+| Events | `subscribeEvents`(SSE 生成器)、`subscribeEventsStream`(原始 response) |
+| Permissions | `respondToPermission`、`respondToSessionPermission` |
+| Workspace 快照 | `getWorkspaceMcp`、`getWorkspaceSkills`、`getWorkspaceProviders`、`getWorkspaceEnv`、`getWorkspacePreflight` |
+| Workspace 修改 | `writeWorkspaceMemory`、`readWorkspaceMemory`、`listWorkspaceAgents`、`getWorkspaceAgent`、`createWorkspaceAgent`、`updateWorkspaceAgent`、`deleteWorkspaceAgent`、`toggleWorkspaceTool`、`restartMcpServer`、`initializeWorkspace` |
+| Files | `readFile`、`readFileBytes`、`writeFile`、`editFile`、`listDirectory`、`globPaths`、`statPath` |
+| Auth | `startDeviceFlow`、`pollDeviceFlow`、`cancelDeviceFlow`、`getAuthStatus` |
+
+### `fetchWithTimeout`(BRN1o 行为)
+
+每个请求都过 `fetchWithTimeout`。关键细节:
+
+- **body 读取在定时器作用域内**。之前实现 header 一到就清定时器;代理在 body 中途卡住时 `await res.json()` 会超过 `fetchTimeoutMs` 仍然 hang。当前形态把读 body 的代码作为 callback 传入,定时器覆盖 header 到 body 全程。
+- **`perCallTimeoutMs`** 允许单次调用覆盖 client 级默认。最显眼的使用方是 `restartMcpServer`,SDK 用 `MCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000`(5 分 30 秒)。daemon 自己的 `MCP_RESTART_TIMEOUT_MS` 上限正好是 300 秒 —— client 与之精确相等会与 daemon 响应 race:接近 300 秒完成或失败的重启可能在 daemon 把结构化响应序列化 + 上线 + 编码完之前 client 的 `AbortSignal` 先 fire,给出一个假阳性 `TimeoutError` 而 daemon 其实还在自己预算之内。多出的 30 秒覆盖序列化 + 在线传输 + 两端解码。想要更紧的调用方自己传 `timeoutMs`;传 `0` 完全关闭超时。
+- **`AbortSignal.any`** 把调用方信号与 per-call 定时器信号组合,调用方取消和 per-call 超时都干净 abort。
+- **`AbortController` + 可取消 `setTimeout`** 而不是 `AbortSignal.timeout()`;快速完成的请求不会在 event loop 上留 pending 定时器。`finally` 里 `clearTimeout`。
+- **流式端点(`subscribeEvents`)绕过超时** —— 长 SSE 不能被它杀。
+
+### `DaemonSessionClient`(`DaemonSessionClient.ts:61-385`)
+
+绑一个 session 并自动跟踪 `lastSeenEventId`,SSE 重连重放开箱即用。
+
+```ts
+class DaemonSessionClient {
+ readonly client: DaemonClient;
+ readonly session: DaemonSession;
+ readonly state: DaemonSessionState;
+ private lastSeenEventId: number | undefined;
+
+ static createOrAttach(client, req?): Promise;
+ static load(client, sessionId, req?): Promise;
+ static resume(client, sessionId, req?): Promise;
+
+ events(opts?: DaemonSessionSubscribeOptions): AsyncIterable;
+ prompt(req: PromptRequest): Promise;
+ cancel(): Promise;
+ respondToPermission(...): Promise;
+ setModel(modelServiceId): Promise;
+ heartbeat(): Promise;
+ setMetadata(metadata): Promise;
+ close(): Promise;
+}
+```
+
+`events()` 默认 `resume: true` 代理 `client.subscribeEvents`,把跟踪的 `lastSeenEventId` 传过去,重连从上次停的地方重放。每条 yield 出去的事件 bump `lastSeenEventId`。
+
+### `DaemonAuthFlow`(`DaemonAuthFlow.ts:102-340`)
+
+```ts
+class DaemonAuthFlow {
+ start(opts: { providerId, ... }): Promise;
+}
+interface DaemonAuthFlowHandle {
+ deviceFlowId: string;
+ providerId: string;
+ expiresAt: string;
+ verificationUrl: string;
+ userCode: string;
+ awaitCompletion(opts?): Promise;
+ cancel(): Promise;
+}
+```
+
+`awaitCompletion()` 按 daemon 给出的 `intervalMs` 轮询 `GET /workspace/auth/device-flow/:id` 直到 `authorized` / `failed` / `cancelled`。通过 `client.auth` 懒构造,从不碰 auth 的客户端不付分配开销。
+
+### `parseSseStream`(`sse.ts:70-295`)
+
+把 `Response.body`(`ReadableStream`)转成 `AsyncIterable`。处理:
+
+- LF 与 CRLF 帧。
+- 缓冲溢出上限(16 MiB),防 daemon 发单个荒谬大帧的防御性边界。
+- AbortSignal 接线 —— abort 关掉流和 iterator。
+- 仅注释帧与未知 event 类型(透传为 `DaemonEvent`,SDK 消费方通过 `narrowDaemonEvent` 下游 narrow)。
+
+### 类型(`types.ts`)
+
+主要导出:`DaemonCapabilities`、`DaemonSession`(`{ sessionId, workspaceCwd, attached, clientId?, createdAt? }`)、`DaemonEvent`、`DaemonSessionState`、`DaemonSessionContextStatus`、`DaemonSessionSupportedCommandsStatus`、`PermissionResponse`、`PromptResult`、`HeartbeatResult`、`SetModelResult`、`SessionMetadataResult`,以及 MCP / agent / memory / auth 结果类型。
+
+## 流程
+
+### Create-or-attach 与首次 prompt
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant App as App code
+ participant SC as DaemonSessionClient
+ participant DC as DaemonClient
+ participant D as Daemon
+
+ App->>SC: DaemonSessionClient.createOrAttach(client, {clientId: 'alice'})
+ SC->>DC: client.createOrAttachSession({}, 'alice')
+ DC->>D: POST /session
Authorization: Bearer ...
X-Qwen-Client-Id: alice
+ D-->>DC: {sessionId, attached, clientId}
+ DC-->>SC: DaemonSession
+ SC-->>App: DaemonSessionClient
+
+ App->>SC: prompt({...})
+ SC->>DC: client.prompt(sessionId, req, 'alice')
+ DC->>D: POST /session/:id/prompt
+ D-->>DC: {result}
+ DC-->>SC: PromptResult
+```
+
+### 带重放的订阅
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant App as App code
+ participant SC as DaemonSessionClient
+ participant DC as DaemonClient
+ participant D as Daemon
+ participant P as parseSseStream
+
+ App->>SC: for await (e of session.events())
+ SC->>DC: client.subscribeEvents(sessionId, {lastEventId: }, 'alice')
+ DC->>D: GET /session/:id/events
Last-Event-ID: 42
+ D-->>DC: SSE bytes (replay then live)
+ DC->>P: parseSseStream(res.body, signal)
+ loop per frame
+ P-->>SC: DaemonEvent
+ SC->>SC: bump lastSeenEventId
+ SC-->>App: DaemonEvent
+ App->>App: narrowDaemonEvent + reduce
+ end
+```
+
+### Device Flow 认证
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant App as App
+ participant AF as DaemonAuthFlow
+ participant DC as DaemonClient
+ participant D as Daemon
+
+ App->>AF: start({providerId: 'qwen-oauth'})
+ AF->>DC: client.startDeviceFlow(...)
+ DC->>D: POST /workspace/auth/device-flow
+ D-->>DC: {deviceFlowId, verificationUrl, userCode, intervalMs, expiresAt}
+ DC-->>AF: handle
+ AF-->>App: handle (with awaitCompletion())
+ App->>AF: handle.awaitCompletion()
+ loop until done
+ AF->>D: GET /workspace/auth/device-flow/:id
+ D-->>AF: {status: 'pending' | 'authorized' | ...}
+ AF->>AF: setTimeout(intervalMs)
+ end
+ AF-->>App: final state
+```
+
+## 状态与生命周期
+
+- `DaemonClient` 无连接;构造时什么都没发生。每次方法新起一次 `fetch`。
+- `DaemonSessionClient` 跨 `events()` 调用保留 `lastSeenEventId`,重连从最后看到的重放。
+- `DaemonAuthFlow` 懒 —— `client.auth` 首次访问才构造。
+- SSE iterator 关闭条件:(a) daemon 结束流;(b) `AbortSignal.abort()`;(c) 消费方 break `for await`;(d) 缓冲溢出 16 MiB 上限被撞。
+
+## 依赖
+
+- `globalThis.fetch`(Node 18+ 内置,浏览器,undici 等),`DaemonClient` 可注入测试。
+- 原生 `AbortController` / `AbortSignal.any` / `setTimeout`。
+- 不传递依赖 `@qwen-code/qwen-code-core` 或 `@qwen-code/acp-bridge`,SDK 包完全解耦,外部消费方不会被拉进 daemon 内部。
+
+## `ui/*` 子包([#4328](https://github.com/QwenLM/qwen-code/pull/4328) + [#4353](https://github.com/QwenLM/qwen-code/pull/4353))
+
+SDK 还导出 `packages/sdk-typescript/src/daemon/ui/`,一套面向任何 UI 宿主的「daemon 事件 → transcript blocks」原语:
+
+- `normalizeDaemonEvent(evt)` 把 wire 上 29 种 typed event 映射成 29 种 UI 友好的 `DaemonUiEventType`。
+- `createDaemonTranscriptState()` + `reduceDaemonTranscriptEvents(state, events)` 把 UI 事件流投到 `DaemonTranscriptBlock[]`。
+- `createDaemonTranscriptStore()` 提供 subscribe / dispatch 包装。
+- `render.ts` / `terminal.ts` 给 HTML 与终端基线渲染;`toolPreview.ts` 给 tool call 摘要。
+- selectors:`selectTranscriptBlocksOrderedByEventId`、`selectPendingPermissionBlocks`、`selectCurrentTool`、`selectApprovalMode`、`selectToolProgress`、`selectSubagentChildBlocks`、`formatMissedRange`、`formatBlockTimestamp` 等。
+- `DAEMON_PLAN_TOOL_CALL_ID` 等公开常量。
+- `conformance.ts` 跨宿主一致性测试套件。
+
+第一个真实消费方是 `packages/webui/src/daemon/`(React `DaemonSessionProvider`)。详细架构、词汇表、selector 全表、与老 `DaemonTuiAdapter` 对比见 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md)(标题虽然还叫 CLI TUI,但内容已替换为共享 UI Transcript 层)。
+
+子包从 `@qwen-code/sdk/daemon` 子路径独立导出,老代码继续 `import { DaemonClient }` 不受影响。
+
+## 配置
+
+| 旋钮 | 位置 | 效果 |
+| ------------------ | ------------------------------- | -------------------------------------------------------------------------------------- |
+| `baseUrl` | `DaemonClient` 构造 | daemon URL,尾 slash strip |
+| `token` | `DaemonClient` 构造 | 盖 `Authorization: Bearer` |
+| `fetch` | `DaemonClient` 构造 | 测试注入点 |
+| `fetchTimeoutMs` | `DaemonClient` 构造 | per-call 超时,`0` = 禁用 |
+| `clientId` | 方法可选参数 | `X-Qwen-Client-Id` header(见 [`08-session-lifecycle.md`](./08-session-lifecycle.md)) |
+| `lastEventId` | `DaemonSessionClient` 构造 | 重放游标种子 |
+| `maxQueued` | 每订阅 option | SSE 路由 `?maxQueued=N`;先 pre-flight `caps.features.slow_client_warning` |
+| `perCallTimeoutMs` | 每方法(如 `restartMcpServer`) | 覆盖 client 级超时 |
+
+## 注意 & 已知局限
+
+- **`fetchTimeoutMs` 是 per-call 不是连接级**。长 body 读共享定时器。流式响应必须 per-call 覆盖或把超时设 `0`。
+- **SSE 是超时绕过** —— 长 SSE 不被 `fetchTimeoutMs` 杀;用 `AbortSignal` 做调用方控制。
+- **`parseSseStream` 缓冲上限 16 MiB**,单帧大于此 iterator 中断(daemon 不会合法发那么大的帧)。
+- **`narrowDaemonEvent` 对未来事件 type 返 `kind: 'unknown'`**。SDK 消费方必须处理这条分支而不是假设联合穷举 —— 这就是向前兼容契约。
+- **`client_evicted`、`slow_client_warning`、`stream_error` 不在重放环里**。eviction 后重连从 daemon 的环重放,不会再看到 eviction 帧。
+- **`DaemonClient` 不自动重试**。网络失败以 rejection 浮上来;重连 / 重放策略是调用方的责任(`DaemonSessionClient.events()` 让重放容易,但重连仍要调用方做)。
+
+## 参考
+
+- `packages/sdk-typescript/src/daemon/DaemonClient.ts:209-1506`
+- `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:61-385`
+- `packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts:102-340`
+- `packages/sdk-typescript/src/daemon/sse.ts:70-295`
+- `packages/sdk-typescript/src/daemon/events.ts:1-2101`
+- `packages/sdk-typescript/src/daemon/types.ts:1-942`
+- 端到端示例:[`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md)。
diff --git a/docs/developers/daemon/14-cli-tui-adapter.md b/docs/developers/daemon/14-cli-tui-adapter.md
new file mode 100644
index 0000000000..cc7509fc10
--- /dev/null
+++ b/docs/developers/daemon/14-cli-tui-adapter.md
@@ -0,0 +1,182 @@
+# 共享 UI Transcript 层
+
+> **历史变更**:本文原标题「CLI TUI Daemon 适配器」,描述 `packages/cli/src/ui/daemon/DaemonTuiAdapter.ts` 的实验性 CLI ↔ daemon 渲染胶水。该文件已在 [#4328](https://github.com/QwenLM/qwen-code/pull/4328) 中**删除**(包括整个 `packages/cli/src/ui/daemon/` 目录),替换成本文介绍的「共享 UI Transcript 层」—— 一套住在 SDK 里、任何 UI 宿主(Web、TUI、IDE、IM 渠道)都可消费的 daemon 事件归一与转录原语。CLI TUI、channel、VSCode IDE 的迁移会在后续 PR 落地。
+
+## 概览
+
+`packages/sdk-typescript/src/daemon/ui/` 是 SDK 新增的 `ui/*` 子包,把「daemon SSE 事件 → UI 可渲染 transcript blocks」这条变换链做成可复用原语:
+
+- **归一化层** (`normalizer.ts`):把 daemon wire 上 29 种 typed event(详见 [`09-event-schema.md`](./09-event-schema.md))映射成 UI 友好的 `DaemonUiEventType`(29 种语义事件,命名风格 `assistant.text.delta` / `tool.update` / `session.metadata.changed`)。
+- **状态机** (`transcript.ts`, `store.ts`):纯函数 reducer + 可订阅 store,把 UI 事件流投到一个有序的 `DaemonTranscriptBlock[]`。
+- **渲染器** (`render.ts`, `terminal.ts`, `toolPreview.ts`):transcript blocks → HTML / 终端字符 / tool preview 字符串。宿主可挑用。
+- **conformance** (`conformance.ts`):跨宿主一致性测试套件,channel / TUI / IDE 迁移到这套时用来确保渲染等价。
+
+第一个真实消费方是 **`packages/webui/src/daemon/`**([#4328](https://github.com/QwenLM/qwen-code/pull/4328))—— React `DaemonSessionProvider` + transcriptAdapter,把 webui 从「只渲染 host postMessage」升级成可以直接接 daemon HTTP+SSE 的前端。CLI TUI、channel base、VSCode IDE 后续会复用这套([`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md) 列出了 v2 增量适配指南)。
+
+## 职责
+
+- 把 29 种 daemon wire event 归一成稳定 UI 词汇(`DaemonUiEventType`),让 renderer 不再去读 `rawEvent.data`。
+- 维护 daemon-monotonic SSE 游标(`eventId`)作为**主排序键**,多端 transcript 同序。
+- 用纯 reducer 投到 transcript block 列表(带 selectors 拿 pending permission / current tool / approval mode / tool progress 等)。
+- 提供 HTML 与终端两种基线渲染(宿主可自定义)。
+- 暴露 `DAEMON_PLAN_TOOL_CALL_ID` 等公开常量供宿主拼计划面板。
+- 与 wire 层保持加法语义:未知 type → 归一为 `debug` 事件,永不丢。
+
+## 架构
+
+### 包结构
+
+| 文件 | 暴露 | 用途 |
+|---|---|---|
+| `packages/sdk-typescript/src/daemon/ui/index.ts` | 子包 barrel | 唯一公开入口 |
+| `ui/types.ts` | `DaemonUiEventType`、`DaemonUiEvent*`(按 type 一类一 interface)、`DaemonTranscriptBlock`、`DaemonTranscriptState`、`DaemonUiToolProvenance`、`DAEMON_PLAN_TOOL_CALL_ID` | 全部类型 |
+| `ui/normalizer.ts` | `normalizeDaemonEvent(evt) → DaemonUiEvent`、`getSessionUpdatePayload(evt)` | wire → UI 词汇映射 |
+| `ui/transcript.ts` | `createDaemonTranscriptState()`、`appendLocalUserTranscriptMessage()`、`reduceDaemonTranscriptEvents()`、`rebuildDaemonTranscriptBlockIndex()`、selectors(见下) | 状态机 + 选择器 |
+| `ui/store.ts` | `createDaemonTranscriptStore(initial?)` | 可订阅 store 封装 reducer |
+| `ui/toolPreview.ts` | `createDaemonToolPreview(toolEvent)` | tool call summary 文案 |
+| `ui/render.ts` | `DaemonHtmlRenderOptions`、`DaemonRenderOptions` 加渲染函数 | HTML / 通用渲染 |
+| `ui/terminal.ts` | terminal 专用渲染 | 给 TUI 准备 |
+| `ui/conformance.ts` | 跨宿主一致性测试套件 | 迁移老 adapter 时用 |
+| `ui/utils.ts` | `DaemonUiContentPart` 等辅助 | 内部公用 |
+
+### `DaemonUiEventType` 词汇(29 种)
+
+来自 `ui/types.ts:17-50`。按域分组:
+
+**Chat-stream(Stage 1)**
+- `user.text.delta`、`assistant.text.delta`、`assistant.done`、`thought.text.delta`
+- `tool.update`、`shell.output`
+- `permission.request`、`permission.resolved`
+- `model.changed`、`status`、`error`、`debug`
+
+**Session-meta**
+- `session.metadata.changed`、`session.approval_mode.changed`
+- `session.available_commands`、`session.state_resync_required`
+
+**Workspace(Wave 3-4)**
+- `workspace.memory.changed`、`workspace.agent.changed`
+- `workspace.tool.toggled`、`workspace.initialized`
+- `workspace.mcp.budget_warning`、`workspace.mcp.child_refused`
+- `workspace.mcp.server_restarted`、`workspace.mcp.server_restart_refused`
+
+**Auth flow(Wave 4 OAuth)**
+- `auth.device_flow.started`、`auth.device_flow.throttled`、`auth.device_flow.authorized`
+- `auth.device_flow.failed`、`auth.device_flow.cancelled`
+
+`normalizeDaemonEvent` 把 daemon wire 上的 29 种 typed event(见 [`09-event-schema.md`](./09-event-schema.md))映射进来;未知 type 归一为 `debug`,保留 `rawEvent` 给宿主诊断。
+
+### Reducer / selectors
+
+```ts
+// 创建初态
+const state = createDaemonTranscriptState();
+
+// 应用 SSE 事件序列
+const next = reduceDaemonTranscriptEvents(state, daemonUiEvents);
+
+// selectors
+selectTranscriptBlocks(state); // 全部 blocks
+selectTranscriptBlocksOrderedByEventId(state); // 按 eventId 排序(推荐主键)
+selectPendingPermissionBlocks(state);
+selectCurrentTool(state);
+selectApprovalMode(state);
+selectToolProgress(state, toolCallId);
+selectSubagentChildBlocks(state, parentBlockId);
+isSubagentChildBlock(block);
+formatBlockTimestamp(block.clientReceivedAt);
+formatMissedRange(state); // state_resync_required 后的 "you missed X" 文案
+```
+
+### Store
+
+`createDaemonTranscriptStore()` 提供订阅 / 派发:
+
+```ts
+const store = createDaemonTranscriptStore();
+store.subscribe(() => render(store.getState()));
+store.dispatch(uiEvents); // 内部走 reducer
+```
+
+webui 的 `DaemonSessionProvider` 就是基于它实现 React Context(详见下面「消费方」一节)。
+
+## 流程
+
+### 单条 SSE 事件的端到端
+
+```mermaid
+flowchart LR
+ A["daemon SSE wire frame
type=session_update / permission_request / ..."]
+ A --> B["DaemonClient.subscribeEvents
parseSseStream"]
+ B --> C["narrowDaemonEvent
(09-event-schema.md)"]
+ C --> D["normalizeDaemonEvent
ui/normalizer.ts"]
+ D --> E["DaemonUiEvent
(29 UI-friendly types)"]
+ E --> F["reduceDaemonTranscriptEvents
ui/transcript.ts"]
+ F --> G["DaemonTranscriptState +
DaemonTranscriptBlock[]"]
+ G --> H["renderer
(render.ts HTML / terminal.ts / 宿主自渲)"]
+ G --> I["selectors
selectCurrentTool / selectApprovalMode / ..."]
+```
+
+宿主可以选在 `(E)` 落地(自己写 reducer),也可以接 `(G)` 用现成 selectors。webui 走完整 `(B)→(H)`,TUI 迁移后可能在 `(G)` 接自己的 Ink renderer。
+
+### 与 `state_resync_required` 的配合
+
+`session.state_resync_required` 在 reducer 里被映射成 transcript 的 "missed range" 标记,UI 用 `formatMissedRange(state)` 拿到 "missed events X–Y" 文案。reducer 之后**继续 apply 后续事件**,但会标 block 为 `resyncRecovery: true`,渲染层可加视觉提示。具体语义见 [`10-event-bus.md`](./10-event-bus.md) 的「环驱逐 → state_resync_required」一节。
+
+## 消费方
+
+### `packages/webui/src/daemon/`([#4328](https://github.com/QwenLM/qwen-code/pull/4328) 一起落地)
+
+| 文件 | 暴露 |
+|---|---|
+| `DaemonSessionProvider.tsx` | React `` Provider;`useDaemonSession()`、`useDaemonTranscriptStore()`、`useDaemonTranscriptState()`、`useDaemonTranscriptBlocks()`、`useDaemonPendingPermissions()`、`useDaemonActions()`、`useDaemonConnection()` hooks;`DaemonConnectionStatus` / `DaemonConnectionState` / `DaemonSessionContextValue` 类型 |
+| `transcriptAdapter.ts` | 把 SDK 的 `DaemonTranscriptBlock` 适配成 webui 的 `UnifiedMessage`,包括 markdown 流式 chunk 合并、tool call 摘要等 |
+| `index.ts` | 子包 barrel |
+
+webui 现在能直接连 daemon HTTP+SSE 跑 transcript,不再仅依赖宿主 postMessage 传 ACP 消息(老 `ACPAdapter` 路径仍保留)。
+
+### 后续待迁移
+
+[`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md) 给「web chat 和 web terminal 适配器」写了 v2 增量指南。MIGRATION.md 明文说 **CLI TUI、channel base、VSCode IDE 这三条默认产品路径本 PR 没迁**,会在各自后续 PR 落地(共用 conformance 套件确保渲染等价)。
+
+## 与老 `DaemonTuiAdapter.ts` 的对比
+
+| 维度 | 老 DaemonTuiAdapter(已删) | 新共享 transcript 层 |
+|---|---|---|
+| 所在包 | `packages/cli/src/ui/daemon/` | `packages/sdk-typescript/src/daemon/ui/` |
+| 公开 surface | `DaemonTuiAdapter`、`DaemonTuiUpdate`、`DaemonTuiSessionClient` 接口 | `DaemonUiEventType`、`reduceDaemonTranscriptEvents` + 一组 selectors |
+| 适用范围 | 仅 CLI Ink TUI | Web / TUI / IDE / IM 任一 UI |
+| 状态形态 | TUI 内部 update union | 纯 transcript block 列表 + state 字段 |
+| 排序 | 用 `createdAt` | 用 `eventId`(daemon-monotonic,多端同序) |
+| 未知 type | 在 `reduceDaemonEventToTuiUpdates` 里被丢 | 归一为 `debug` 事件保留 |
+| 测试 | 单包内单测 | 全局 conformance 套件确保跨宿主等价 |
+
+## 依赖
+
+- 上游 wire 类型:`packages/sdk-typescript/src/daemon/events.ts`(详见 [`09-event-schema.md`](./09-event-schema.md))。
+- 下游真实消费方:`packages/webui/src/daemon/`(在用);`packages/cli/src/ui/` 的 TUI、`packages/channels/base/`、`packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` 后续迁移。
+- 平行参考:`docs/developers/daemon-ui/README.md`(upstream 写的子包总览)、`docs/developers/daemon-ui/MIGRATION.md`(v2 迁移指南)、`docs/developers/daemon-client-adapters/web-ui.md`(webui 适配器草案,替代了原 `tui.md`)。
+
+## 配置
+
+- 无运行时配置 —— 全部 reducer / selectors 是纯函数。
+- 宿主自选渲染层:HTML(`render.ts`)/ 终端(`terminal.ts`)/ 自实现。
+- 调试用:`render.ts` 的选项支持 `includeRawEvent: true` 把原始 wire frame 一起放进渲染输出。
+
+## 注意 & 已知局限
+
+- **`DaemonTuiAdapter.ts` 整目录已删** —— 老 `14-cli-tui-adapter.md` 内容里的所有 file:line 引用全部失效。`reduceDaemonEventToTuiUpdates` / `DaemonTuiUpdate` 等类型不再存在;外部代码引用需要改成 `reduceDaemonTranscriptEvents` + `DaemonTranscriptBlock`。
+- **CLI TUI / channel base / VSCode IDE 还没迁过来** —— 它们当前各自仍维护渲染胶水。`docs/developers/daemon-client-adapters/` 下还剩 `ide.md` 和 `channel-web.md` 草案;老 `tui.md` 已被 #4328 删,新 `web-ui.md` 是替代品。
+- **`eventId` 是主排序键** —— `createdAt` 仍保留为 `@deprecated` 别名(`clientReceivedAt`),新代码必须用 `selectTranscriptBlocksOrderedByEventId(state)`。MIGRATION.md 详细给出从 `createdAt` 排序切到 `eventId` 排序的代码差异。
+- **未知 wire type 归一为 `debug`** —— 不再像老 adapter 那样直接丢,保留 `rawEvent`,但 renderer 默认不渲染 `debug`,宿主需要主动 opt-in 才看得到。
+- **包大小**:`ui/*` 子包以 ESM 子路径独立导出(`@qwen-code/sdk/daemon`),不引入额外 React / DOM 依赖;webui 端用 `DaemonSessionProvider` 时才把 React glue 拉进来。
+
+## 参考
+
+- `packages/sdk-typescript/src/daemon/ui/types.ts:17-50`(`DaemonUiEventType` 词汇)
+- `packages/sdk-typescript/src/daemon/ui/transcript.ts`(reducer + selectors,完整列表见上)
+- `packages/sdk-typescript/src/daemon/ui/normalizer.ts`(wire → UI 映射)
+- `packages/sdk-typescript/src/daemon/ui/store.ts`、`render.ts`、`terminal.ts`、`toolPreview.ts`、`conformance.ts`
+- `packages/sdk-typescript/src/daemon/index.ts` 中 `ui/*` 的 re-export 段(line 47–148)
+- `packages/webui/src/daemon/DaemonSessionProvider.tsx`、`transcriptAdapter.ts`
+- Upstream 文档:[`../daemon-ui/README.md`](../daemon-ui/README.md)、[`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md)、[`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md)
+- 上下文 PR:[#4328](https://github.com/QwenLM/qwen-code/pull/4328)(v1 transcript layer + webui Provider)、[#4353](https://github.com/QwenLM/qwen-code/pull/4353)(v2 unified completeness follow-up:扩到 29 类型 + `render.ts` + conformance)
diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md
new file mode 100644
index 0000000000..c320fdf2ab
--- /dev/null
+++ b/docs/developers/daemon/15-channel-adapters.md
@@ -0,0 +1,187 @@
+# Channel 适配器
+## 概览
+
+`packages/channels/` 是 **IM 渠道适配器**,把聊天平台的入站消息翻成 daemon prompt,把 daemon 的出站事件翻回平台消息。现已落地三个具体渠道:钉钉、微信(Weixin)、Telegram。它们共享 `packages/channels/base/` 基座加 `DaemonChannelBridge` —— 后者做 session 多路复用 + SSE 消费。
+
+每个渠道按可配的 `SessionScope`(`per-sender` / `per-group` 等)把一段会话(或一群)映射到一个 daemon session。适配器委托给 `DaemonChannelBridge`,bridge 委托给 SDK 的 `DaemonSessionClient`(见 [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md))。
+
+## 职责
+
+- 从渠道原生传输(钉钉 WebSocket 流、微信 HTTP 长轮询、Telegram Bot 长轮询)收入站消息。
+- 通过 `DaemonChannelSessionFactory` 把 `(senderId, groupId?)` 解析成 daemon session。
+- 把用户消息转成 daemon prompt 并把响应流式回写为出站消息,必要时切块。
+- 渠道原生交互式 prompt 渲染权限请求;非交互时按 `ChannelConfig.approvalMode` 自动批准。
+- 应用 sender / group gating(白/黑名单)与内容规范化(markdown / HTML,按渠道)。
+
+## 架构
+
+### `DaemonChannelBridge`(共享基座,`packages/channels/base/src/DaemonChannelBridge.ts:1-179+`)
+
+```ts
+class DaemonChannelBridge extends EventEmitter {
+ constructor(opts: {
+ sessionFactory: DaemonChannelSessionFactory;
+ config: ChannelConfig;
+ });
+ handleInbound(envelope: Envelope): Promise;
+ shutdown(): Promise;
+}
+```
+
+持有 `Map`,key 是渠道的 chat id(sender / group)。每条记录包括:
+
+- `DaemonChannelSessionClient`(去掉渠道无关方法的 `DaemonSessionClient`)。
+- 一条 live SSE 消费 pump。
+- debounce 的 prompt 组装器(适配把用户输入拆成多条入站消息的平台)。
+- 每请求的自动批准策略。
+
+发的事件:`permission_request`、`permission_resolved`、`outbound_message`、`stream_error`、`session_died`。渠道适配器把它们接到平台原生 API。
+
+### `ChannelBase`(`packages/channels/base/src/ChannelBase.ts`)
+
+每个适配器继承的抽象基:
+
+```ts
+abstract class ChannelBase {
+ abstract start(): Promise;
+ abstract sendOutbound(target, payload): Promise;
+ handleInbound(envelope: Envelope): Promise; // → bridge.handleInbound
+ shutdown(): Promise;
+}
+```
+
+承担共性:sender / group gating、块流式发送(块大小、节流)、入站去抖。
+
+### 各渠道适配器
+
+| 适配器 | 文件 | 传输 | 备注 |
+| -------------- | ---------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------- |
+| 钉钉 | `packages/channels/dingtalk/src/DingtalkAdapter.ts:79-586` | DingTalk Stream SDK WebSocket | 通过 `sessionWebhook` POST 出站;媒体图片走 DT API 下载,base64 进 envelope |
+| 微信(Weixin) | `packages/channels/weixin/src/WeixinAdapter.ts:33-309` | iLink Bot HTTP 长轮询 | 通过专有 `sendText` / `sendImage` 出站;带打字指示 |
+| Telegram | `packages/channels/telegram/src/TelegramAdapter.ts:19-308` | Telegram Bot API 长轮询(grammy) | 通过 `sendMessage` 发 HTML 块 |
+
+每个适配器实现:
+
+1. 入站传输(订阅 / 轮询消息)。
+2. 构造 envelope(`{ senderId, groupId?, text, media?, raw }`)。
+3. sender / group gating(委托给 `ChannelBase`)。
+4. 出站序列化(markdown → HTML / WeChat 原生 / DingTalk 原生)。
+5. 生命周期(start / shutdown)。
+
+### 适配器矩阵
+
+| 适配器 | 传输 | 身份 | 权限 UX | 自动批准 |
+| ------------ | -------------- | ------------------------------------------ | ------------------------- | ------------------------------------------------- |
+| **钉钉** | WebSocket 流 | `senderStaffId`(群里 + `conversationId`) | 通过 DT markdown 内联按钮 | `ChannelConfig.approvalMode = 'auto' \| 'prompt'` |
+| **微信** | HTTP 长轮询 | `senderWxid`(群里 + `groupWxid`) | 纯文本提示 + 回复 token | 同上 |
+| **Telegram** | Bot API 长轮询 | `from.id`(群里 + `chat.id`) | inline keyboard 按钮 | 同上 |
+
+## 流程
+
+### 入站 prompt
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant CH as Channel platform
+ participant AD as Channel adapter
+ participant CB as ChannelBase
+ participant BR as DaemonChannelBridge
+ participant SC as DaemonChannelSessionClient
+ participant D as Daemon
+
+ CH-->>AD: inbound message
+ AD->>AD: build Envelope { senderId, groupId?, text, media? }
+ AD->>CB: handleInbound(envelope)
+ CB->>CB: sender / group gating
+ CB->>BR: handleInbound(envelope)
+ BR->>BR: resolve chatId → ActiveSession (create-or-attach via factory)
+ BR->>SC: session.prompt({...})
+ SC->>D: POST /session/:id/prompt
+```
+
+### SSE 驱动出站
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant D as Daemon
+ participant SC as DaemonChannelSessionClient
+ participant BR as DaemonChannelBridge
+ participant AD as Channel adapter
+ participant CH as Channel platform
+
+ D-->>SC: SSE: session_update (agent_message_chunk)
+ SC-->>BR: DaemonEvent
+ BR->>BR: reduce → outbound chunks (block streaming)
+ BR-->>AD: emit 'outbound_message'
+ AD->>CH: sendText / sendMessage / sendChunk
+```
+
+### 权限自动批准
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant D as Daemon
+ participant SC as DaemonChannelSessionClient
+ participant BR as DaemonChannelBridge
+ participant AD as Channel adapter
+
+ D-->>SC: SSE: permission_request
+ SC-->>BR: DaemonEvent
+ alt config.approvalMode == 'auto'
+ BR->>SC: session.respondToPermission({...})
+ else 'prompt'
+ BR-->>AD: emit 'permission_request' (renders chat-native UI)
+ AD->>BR: user picks option → respondToPermission
+ end
+```
+
+## 状态与生命周期
+
+- `DaemonChannelBridge` 与渠道适配器同生命周期;里面的 session 按 chat 维度活。
+- 每个 chat session 在 SSE 掉的时候自动重连 —— `DaemonSessionClient.events()` 跟踪 `lastSeenEventId`,重放正确。
+- `shutdown()` 关掉所有活 session 和底层传输(渠道的 WebSocket / 长轮询)。
+- 钉钉 WebSocket 流支持 server-push;微信长轮询空响应需 backoff;Telegram 长轮询自带 `timeout` 参数。
+
+## 依赖
+
+- `packages/channels/base/` —— `ChannelBase`、`DaemonChannelBridge`、`types.ts`(`ChannelConfig`、`Envelope`、`SessionScope`、`ChannelPlugin`)。
+- `packages/sdk-typescript/src/daemon/` —— `DaemonSessionClient` 等。
+- 各渠道 SDK:`@dingtalk/stream`(钉钉)、专有 iLink Bot HTTP(微信)、`grammy`(Telegram)。
+
+## 配置
+
+`ChannelConfig`(`packages/channels/base/src/types.ts:1-121`):
+
+| 旋钮 | 效果 |
+| ---------------------------------------- | --------------------------------------------------------- |
+| `sessionScope` | `'per-sender'`、`'per-group'`、`'per-thread'`(渠道定义) |
+| `approvalMode` | `'auto'`(自动应答) / `'prompt'`(渲染 UI) |
+| `allowlist?: string[]` | 允许的 sender id,缺省 = 开放 |
+| `denylist?: string[]` | 拒绝的 sender id |
+| `chunkSize`、`chunkIntervalMs` | 出站块流参数 |
+| `daemon: { baseUrl, token?, clientId? }` | 传给 `DaemonChannelSessionFactory` |
+
+每渠道还有自己的 key(钉钉:`streamCredentials`;微信:`ilinkUrl`、`botId`;Telegram:`botToken`)。
+
+## 注意 & 已知局限
+
+- **渠道**不直接** import `@qwen-code/sdk`**。走 `ChannelBase` → `DaemonChannelBridge` → `DaemonChannelSessionClient`(bridge 从 SDK 构造)。这层间接让 bridge 可以换实现(如测试 stub),渠道无感。
+- **权限 UX 各渠道不同**。钉钉用 markdown 按钮;微信纯文本;Telegram 用 inline keyboard。还没共享的「交互式权限组件」抽象。
+- **自动批准是部署侧决策**,不是 daemon 侧。daemon 的 `permission_mediation` 策略仍然生效;自动批准只是渠道不问人而已。不要把 `auto` 与 `enforce` 级工作流叠加。
+- **每渠道限流 / 单消息大小**是适配器的责任。`DaemonChannelBridge` 只切块;微信单消息大小、Telegram flood 限制需要适配器处理。
+- **无钉钉 / 微信 / Telegram 反向调用** —— 渠道是单向(chat → daemon → chat)。IM 原生 push(如 DT 卡片回调)还没接到 bridge。
+
+## 参考
+
+- `packages/channels/base/src/DaemonChannelBridge.ts:1-179+`
+- `packages/channels/base/src/ChannelBase.ts`
+- `packages/channels/base/src/types.ts:1-121`
+- `packages/channels/dingtalk/src/DingtalkAdapter.ts:79-586`
+- `packages/channels/weixin/src/WeixinAdapter.ts:33-309`
+- `packages/channels/telegram/src/TelegramAdapter.ts:19-308`
+- `packages/channels/plugin-example/`(reference 插件骨架)
+- 渠道插件指南:[`../channel-plugins.md`](../channel-plugins.md)。
+- SDK 参考:[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)。
diff --git a/docs/developers/daemon/16-vscode-ide-adapter.md b/docs/developers/daemon/16-vscode-ide-adapter.md
new file mode 100644
index 0000000000..200f38f556
--- /dev/null
+++ b/docs/developers/daemon/16-vscode-ide-adapter.md
@@ -0,0 +1,197 @@
+# VSCode IDE Daemon 适配器
+## 概览
+
+`packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` 是 **VSCode 扩展的 daemon 适配器**。它让 IDE companion 通过 HTTP + SSE 跟在跑的 `qwen serve` daemon 通话,而不是启动一个进程内 `qwen --acp` stdio 子进程(老 `AcpConnectionState` 路径)。它是 VSCode 宿主侧 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 的同级传输等价物。
+
+IDE 的 chat webview 通过本适配器消费 daemon 事件;权限请求以 VSCode 原生 quick-pick 弹窗呈现。
+
+## 职责
+
+- 从 loopback 校验过的 `baseUrl` 构造 `DaemonClient` + `DaemonSessionClient`。
+- 把 session client 的 SSE 事件按回调派发(`onSessionUpdate`、`onPermissionRequest`、`onAskUserQuestion`、`onEndTurn`、`onDisconnected`)。
+- 构造时强制 **loopback only**(IDE 应当只与同主机 daemon 通话)。
+- 把 daemon 事件桥接到 webview 的 `postMessage`,chat 面板保持同步。
+- 通过 VSCode 原生 quick-pick UI 呈现权限请求。
+- 把 `connect()` 串行化,避免宿主快速 double-call 时 race。
+
+## 架构
+
+### 公开 surface
+
+```ts
+class DaemonIdeConnection {
+ constructor(opts: DaemonIdeConnectionOptions);
+ connect(): Promise;
+ disconnect(): Promise;
+ prompt(req): Promise;
+ cancel(): Promise;
+ respondToPermission(req): Promise;
+ setModel(modelServiceId): Promise;
+
+ onSessionUpdate(cb: (update) => void): Disposable;
+ onPermissionRequest(cb: (req) => void): Disposable;
+ onAskUserQuestion(cb: (q) => void): Disposable;
+ onEndTurn(cb: () => void): Disposable;
+ onDisconnected(cb: (reason) => void): Disposable;
+}
+
+interface DaemonIdeConnectionOptions {
+ baseUrl: string; // 必须 loopback(127.0.0.1 / localhost / [::1])
+ token?: string;
+ workspaceCwd: string;
+ modelServiceId?: string;
+ lastEventId?: number;
+}
+```
+
+### Loopback 校验
+
+构造时(`daemonIdeConnection.ts:161-628`):
+
+```ts
+const parsed = new URL(opts.baseUrl);
+if (!isLoopbackHost(parsed.hostname)) {
+ throw new Error('DaemonIdeConnection: baseUrl must be loopback (...)');
+}
+```
+
+这是 **客户端硬约束**,与 daemon 自己的 `hostAllowlist`(见 [`12-auth-security.md`](./12-auth-security.md))不同。IDE companion 永远不连远程 daemon —— 即便 operator 配了远程。理由:VSCode 的威胁模型假设 workspace 与 daemon 共享同一宿主(文件系统信任等)。
+
+### `createSdkDaemonSessionFactory()`
+
+`daemonIdeConnection.ts:144-159` 的工厂函数:从 `@qwen-code/sdk` 构造 `DaemonClient` 并调 `DaemonSessionClient.createOrAttach()`。connection 类持有工厂而不是直接实例化,方便测试注入 fake。
+
+### 事件派发
+
+connection 跑一个 SSE 消费者(`for await` over `session.events()`),按 type 路由:
+
+| daemon event | IDE 回调 |
+| ------------------------------------------------------------------ | ---------------------------------------- |
+| `session_update`(多数子类型) | `onSessionUpdate` |
+| `session_update`(ask-user-question 变体) | `onAskUserQuestion` |
+| `session_update`(end-turn 标记) | `onEndTurn` |
+| `permission_request` | `onPermissionRequest` |
+| `session_died`、`session_closed`、`client_evicted`、`stream_error` | `onDisconnected`(终态) |
+| 其他(model、MCP、mutation、auth) | 目前 no-op 或仅记日志,未来 webview 暴露 |
+
+### Webview 桥接
+
+connection 类**只做传输**。真正的 VSCode 集成住在 `packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts` 等。Provider 订阅 connection 的回调并翻成 webview 的 `postMessage`。webview 自身用 `packages/webui/` 组件库渲染 —— 见 [`01-architecture.md`](./01-architecture.md) 的适配器矩阵。
+
+### Connect 串行化
+
+`connect()` 内部用队列,宿主快速 double-call(用户在握手中打开 panel 两次)不会 race。第二次 await 第一次;connection 最终落在一个确定状态。
+
+## 流程
+
+### 初次连接
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant H as VSCode host
+ participant C as DaemonIdeConnection
+ participant F as createSdkDaemonSessionFactory
+ participant SDK as DaemonSessionClient
+ participant D as Daemon
+
+ H->>C: new DaemonIdeConnection({baseUrl, token, workspaceCwd})
+ C->>C: validate loopback host
+ H->>C: connect()
+ C->>F: factory({baseUrl, token, workspaceCwd, lastEventId})
+ F->>SDK: DaemonClient + DaemonSessionClient.createOrAttach
+ SDK->>D: POST /session
+ D-->>SDK: DaemonSession
+ F-->>C: DaemonSessionClient
+ C->>SDK: session.events()
+ par event pump
+ SDK->>D: GET /session/:id/events
+ loop per frame
+ D-->>SDK: DaemonEvent
+ SDK-->>C: DaemonEvent
+ C->>C: dispatch by type
+ C->>H: onSessionUpdate / onPermissionRequest / ...
+ end
+ end
+```
+
+### Quick-pick 权限
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant D as Daemon
+ participant SDK as DaemonSessionClient
+ participant C as DaemonIdeConnection
+ participant P as Webview/QuickPick provider
+ participant U as User
+
+ D-->>SDK: permission_request event
+ SDK-->>C: DaemonEvent
+ C-->>P: onPermissionRequest(req)
+ P->>U: vscode.window.showQuickPick(options)
+ U->>P: choose option
+ P->>C: respondToPermission({optionId})
+ C->>SDK: session.respondToPermission(...)
+ SDK->>D: POST /permission/:requestId
+ D-->>SDK: 200 (or 409 already_resolved)
+```
+
+### 断开 / 恢复
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant D as Daemon
+ participant SDK as DaemonSessionClient
+ participant C as DaemonIdeConnection
+ participant H as Host
+
+ D-->>SDK: session_died (or other terminal)
+ SDK-->>C: DaemonEvent
+ C->>C: shut down pump
+ C-->>H: onDisconnected(reason)
+ H->>C: connect() (user-driven retry; resume lastEventId)
+```
+
+## 状态与生命周期
+
+- 构造同步;**无网络 IO**,要等 `connect()`。
+- `connect()` 通过内部队列幂等;二次调串行化。
+- `disconnect()` 通过 `AbortController` 中止 SSE iterator 并清回调。
+- `lastEventId` 在 disconnect 时从 SDK `DaemonSessionClient` 抓出来,下次 `connect()` 可再传以重放。
+
+## 依赖
+
+- `packages/sdk-typescript/src/daemon/` —— `DaemonClient`、`DaemonSessionClient`(真正的传输)。
+- VSCode 扩展 API(`vscode.*`)—— 宿主 API、quick-pick、webview。
+- `packages/webui/src/adapters/ACPAdapter.ts` —— webview 通过 `postMessage` 拿到 ACP 形态消息后渲染。
+
+## 配置
+
+| 旋钮 | 位置 | 效果 |
+| -------------------------------------------- | ------------------- | ------------------------------------------------------- |
+| `baseUrl` | 构造 | daemon URL;必须 loopback |
+| `token` | 构造 | Bearer token(通过 SDK 盖) |
+| `workspaceCwd` | 构造 | `POST /session` 用;必须与 daemon 绑定的 workspace 一致 |
+| `modelServiceId` | 构造 / `setModel()` | 初始 model |
+| `lastEventId` | 构造 | 恢复游标(一般从宿主状态恢复) |
+| VSCode 设置 `qwen.ide.daemonUrl`(或等价键) | 工作区设置 | operator 配的 daemon URL |
+
+## 注意 & 已知局限
+
+- **Loopback only —— 构造时硬拒**。想让 IDE 指向远程 daemon 的 operator 需要 SSH port-forward / 本地代理;适配器永远不连非 loopback URL。
+- **老 `AcpConnectionState` 路径仍是 IDE companion 的主路径**(stdio child)。本适配器是 Mode-B 迁移的同级传输;迁移阻塞项与计划中的 `BridgeFileSystem` 一致工作见 [`../daemon-client-adapters/ide.md`](../daemon-client-adapters/ide.md)。
+- **HTTP 上暂无反向 RPC / 编辑器原生能力 surface**。需要 agent 回调 IDE 的功能(只读 buffer 访问、diff 预览集成)目前只在 stdio 路径有。
+- **Webview ↔ connection 耦合由宿主拥有**,不在本适配器。不要把 webview 专属逻辑塞进 `DaemonIdeConnection`。
+- **`workspaceCwd` 与 daemon 绑定不一致** → `400 workspace_mismatch`,应当作清晰的配置错误暴露,不要重试。
+
+## 参考
+
+- `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts:161-628`
+- `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts:144-159`(`createSdkDaemonSessionFactory`)
+- `packages/vscode-ide-companion/src/types/connectionTypes.ts:1-42`(老 `AcpConnectionState`)
+- `packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts`(webview bridge)
+- `packages/webui/src/adapters/ACPAdapter.ts`(webview ACP-message 适配器)
+- 草案设计:[`../daemon-client-adapters/ide.md`](../daemon-client-adapters/ide.md)
+- SDK 参考:[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)
diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md
new file mode 100644
index 0000000000..0bf8aa2c68
--- /dev/null
+++ b/docs/developers/daemon/17-configuration.md
@@ -0,0 +1,115 @@
+# 配置参考
+## 概览
+
+把所有会影响 `qwen serve` daemon 与适配器的旋钮(env、CLI 参数、`settings.json` 键)汇总到一页。跨切面参考,单 feature 文档链接到此。
+
+## CLI 参数(`qwen serve`)
+
+| 参数 | 类型 | 默认 | 效果 |
+| ------------------------- | ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `--hostname ` | string | `127.0.0.1` | 监听绑定。loopback 值:`127.0.0.1`、`localhost`、`::1`、`[::1]`。非 loopback 要求 boot 时有 bearer token。错配兜底 `host:port` 形(用 `--port`) |
+| `--port ` | int | `4170` | 监听端口;`0` = ephemeral |
+| `--token ` | string | (env) | Bearer token,覆盖 `QWEN_SERVER_TOKEN`,boot 时 trim |
+| `--require-auth` | flag | off | bearer 扩展到 loopback + `/health`,无 token 拒启动 |
+| `--workspace ` | 绝对路径 | `process.cwd()` | 绑定 workspace。必须绝对且为目录;boot 时 canonicalize 一次 |
+| `--max-sessions ` | int | `20`(`DEFAULT_MAX_SESSIONS`) | 活动 session 上限。`0` / `Infinity` = 不限;`NaN`/负值抛错 |
+| `--max-connections ` | int | (server 默认) | HTTP 监听器的 `server.maxConnections` |
+| `--event-ring-size ` | int | `8000`(`DEFAULT_RING_SIZE`) | per-session SSE 重放环;软上限 `1_000_000` |
+| `--mcp-client-budget ` | 正整数 | (未设) | 设 `WorkspaceMcpBudget.clientBudget`,通过 `childEnvOverrides` 传 ACP child |
+| `--mcp-budget-mode ` | `off`/`warn`/`enforce` | (未设) | 设 `WorkspaceMcpBudget.mode`;`enforce` 需 `--mcp-client-budget` |
+| (无 flag) | — | — | env `QWEN_SERVE_NO_MCP_POOL=1` 完全禁池 |
+
+## 环境变量
+
+### `runQwenServe` / Express 中间件读
+
+| Env | 作用 |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `QWEN_SERVER_TOKEN` | Bearer token,boot 时 trim |
+| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes`(不区分大小写)开启详细 stderr(见 [`19-observability.md`](./19-observability.md)) |
+| `QWEN_SERVE_NO_MCP_POOL` | `1` 禁 workspace MCP transport 池(回到 per-session `McpClientManager`;capabilities 不再广播 `mcp_workspace_pool` / `mcp_pool_restart`) |
+
+### 通过 `BridgeOptions.childEnvOverrides` 转发给 ACP child
+
+`runQwenServe` per-handle 构造,防止同进程两个 daemon 在 `process.env` 上 race:
+
+| Env | 作用 |
+| ------------------------------ | ----------------------------------------------------- |
+| `QWEN_SERVE_MCP_CLIENT_BUDGET` | 正整数字符串;ACP child 的 `readBudgetFromEnv()` 消费 |
+| `QWEN_SERVE_MCP_BUDGET_MODE` | `off` / `warn` / `enforce` |
+
+### SDK / 适配器读
+
+| Env | 作用 |
+| ----------------------- | ---------------------------------------------------------- |
+| `QWEN_DAEMON_URL` | daemon base URL(CLI TUI 适配器、channels、IDE companion) |
+| `QWEN_DAEMON_TOKEN` | Bearer token |
+| `QWEN_DAEMON_WORKSPACE` | 覆盖 `POST /session` 的 `cwd` |
+
+## `settings.json` 键
+
+daemon boot 时读一次(`runQwenServe.ts:496+`):`loadSettings(boundWorkspace)`。损坏 try/catch 回退默认。
+
+| 键 | 类型 | 效果 |
+| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | 设 `BridgeOptions.permissionPolicy`;激活值出现在 `/capabilities` 的 `policy.permission`。**boot 校验**通过 `validatePolicyConfig()`,对照 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`;未知字面量抛 `InvalidPolicyConfigError`,boot 显式失败 |
+| `policy.consensusQuorum` | 正整数 | `consensus` 策略的 N。**默认**:`votersAtIssue.size` 的 `floor(M/2) + 1`(M=2 一致同意;更大偶数 M 超过半数)。非 `consensus` 策略下设它会被静默忽略,boot 会打 stderr 警告。非正整数抛 `InvalidPolicyConfigError`。详见 [`04-permission-mediation.md`](./04-permission-mediation.md) |
+| `context.fileName` | string | 覆盖 `getCurrentGeminiMdFilename()`;走 `BridgeOptions.contextFilename` |
+| `tools.disabled` | string[] | 下次 ACP child spawn 时被禁的 tool;通过 `normalizeDisabledToolList()`(`packages/cli/src/config/normalizeDisabledTools.ts`)归一化:非数组 → `[]`;非字符串项跳过;trim 空白;trim 后空串丢弃;去重(保留首次出现顺序)。boot 路径与 `restartMcpServer` settings 刷新都过这函数,`ToolRegistry.has(name)` 精确匹配才一致。**不**做大小写折叠 —— Stage 1 工具名在 registry 全程大小写敏感。`POST /workspace/tools/:name/enable` 与 `tool_toggled` 事件改这里 |
+| `tools.approvalMode` | `'default' \| 'auto' \| ...` | session 默认 approval mode;`POST /session/:id/approval-mode`(带 `persist: true`)写这里 |
+
+## `ServeOptions`(程序化嵌入)
+
+`packages/cli/src/serve/types.ts:37-155` 的 typed options 对象,`runQwenServe` 和 `createServeApp` 都接受。镜像上面 CLI 参数,外加:
+
+| 字段 | 效果 |
+| --------------- | -------------------------------------------------- |
+| `eventRingSize` | 覆盖默认 per-session 环大小 |
+| `mcpPoolActive` | 程序化开关(默认从 `QWEN_SERVE_NO_MCP_POOL` 推断) |
+
+## `BridgeOptions`(程序化 bridge 嵌入)
+
+`packages/acp-bridge/src/bridgeOptions.ts:88-323`,完整表见 [`03-acp-bridge.md`](./03-acp-bridge.md)。要点:
+
+| 字段 | 效果 |
+| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
+| `boundWorkspace` | 必填 canonical workspace |
+| `sessionScope` | `'single'`(默认)vs `'per-client'` |
+| `initializeTimeoutMs`、`maxSessions`、`eventRingSize`、`permissionResponseTimeoutMs`、`maxPendingPermissionsPerSession` | 有界资源 caps |
+| `channelFactory` | 可插拔 ACP child 工厂,默认 `defaultSpawnChannelFactory` |
+| `fileSystem` | `BridgeFileSystem` adapter(见 [`07-workspace-filesystem.md`](./07-workspace-filesystem.md)) |
+| `permissionPolicy`、`permissionConsensusQuorum`、`permissionAudit` | mediator 接线 |
+| `statusProvider` | daemon-host preflight cells |
+| `childEnvOverrides` | per-handle env 增量 / scrub |
+| `contextFilename` | 覆盖 `getCurrentGeminiMdFilename()` |
+
+## 重要默认
+
+| 常量 | 文件 | 值 | 意义 |
+| --------------------------------- | -------------------------- | ----------------- | -------------------------------------------------------- |
+| `DEFAULT_MAX_SESSIONS` | `bridge.ts` | `20` | 每 daemon 抛 `SessionLimitExceededError` 前的上限 |
+| `MAX_EVENT_RING_SIZE` | `bridge.ts` | `1_000_000` | `BridgeOptions.eventRingSize` 软上限(错字防御) |
+| `DEFAULT_RING_SIZE` | `eventBus.ts:76` | `8000` | per-session SSE 重放环深度 |
+| `DEFAULT_MAX_QUEUED` | `eventBus.ts:63` | `256` | per-subscriber 队列上限 |
+| `DEFAULT_MAX_SUBSCRIBERS` | `eventBus.ts:97` | `64` | per-bus 订阅者上限 |
+| `WARN_THRESHOLD_RATIO` | `eventBus.ts:85` | `0.75` | `slow_client_warning` 触发 |
+| `WARN_RESET_RATIO` | `eventBus.ts:87` | `0.375` | 滞回 re-arm |
+| `DEFAULT_INIT_TIMEOUT_MS` | `bridge.ts` | `10_000` | ACP `initialize` 握手超时 |
+| `MCP_RESTART_TIMEOUT_MS` | `bridge.ts` | `300_000` | `/workspace/mcp/:server/restart` 的 bridge race deadline |
+| `DEFAULT_PERMISSION_TIMEOUT_MS` | `bridge.ts` | `5 * 60_000` | 每权限请求 wallclock |
+| `DEFAULT_MAX_PENDING_PER_SESSION` | `bridge.ts` | `64` | 对齐 `DEFAULT_MAX_SUBSCRIBERS` |
+| `MAX_RESOLVED_PERMISSION_RECORDS` | `permissionMediator.ts:77` | `512` | 近期已 resolved 权限的 FIFO |
+| `KILL_HARD_DEADLINE_MS` | `bridge.ts` | `10_000` | per-channel graceful 关闭窗口 |
+| `SHUTDOWN_FORCE_CLOSE_MS` | `runQwenServe.ts` | `5_000` | HTTP server 强关定时器 |
+| `MAX_READ_BYTES` | `fs/policy.ts:33` | `256 * 1024` | 读上限 |
+| `MAX_WRITE_BYTES` | `fs/policy.ts:42` | `5 * 1024 * 1024` | 写上限 |
+| `MAX_DISPLAY_NAME_LENGTH` | `bridge.ts:298` | `256` | session displayName 上限 |
+
+## 交叉参考
+
+- Auth 旋钮:[`12-auth-security.md`](./12-auth-security.md)。
+- 能力和协议版本:[`11-capabilities-versioning.md`](./11-capabilities-versioning.md)。
+- 事件环 / 反压调优:[`10-event-bus.md`](./10-event-bus.md)。
+- MCP 池 / 预算:[`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) 与 [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md)。
+- 权限策略:[`04-permission-mediation.md`](./04-permission-mediation.md)。
+- 用户运维指南:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。
diff --git a/docs/developers/daemon/18-error-taxonomy.md b/docs/developers/daemon/18-error-taxonomy.md
new file mode 100644
index 0000000000..c3fe5274da
--- /dev/null
+++ b/docs/developers/daemon/18-error-taxonomy.md
@@ -0,0 +1,152 @@
+# 错误分类与修复
+## 概览
+
+daemon 的失败模式刻意做成封闭联合,SDK 消费方可以穷举 switch、路由 handler 给出一致 HTTP 响应。本文按三层列每个 typed 错误:
+
+1. **`packages/cli/src/serve/`** —— HTTP 边界(auth、workspace 文件系统、daemon-host preflight)。
+2. **`packages/acp-bridge/`** —— bridge / mediator(daemon ↔ ACP child 缝隙)。
+3. **`packages/sdk-typescript/src/daemon/`** —— SDK 侧包装与结构化错误字段。
+
+Wire 错误形状在 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md);本文加 cause-and-remediation 视角。
+
+## 文件系统边界(`packages/cli/src/serve/fs/errors.ts`)
+
+`FsError` 带 `{ kind, message, status, cause? }`。`FsErrorKind` 联合(13 种,默认 HTTP 状态):
+
+| Kind | HTTP | 原因 | 修复 |
+| ------------------------ | --------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| `path_outside_workspace` | 400 | 解析后越出 workspace | 用 `workspaceCwd` 内的路径;查 `/capabilities` |
+| `symlink_escape` | 400 | 目标是 symlink | 直接寻址解析后的路径;symlink 设计上被拒 |
+| `path_not_found` | 404 | `ENOENT` | 确认存在;Linux 注意大小写敏感 |
+| `binary_file` | 422 | 文本路由 sniff 到二进制 | 用 `GET /file/bytes`;文本路由拒二进制 |
+| `file_too_large` | 413 | 超 `MAX_READ_BYTES`(256 KiB)或 `MAX_WRITE_BYTES`(5 MiB) | byte-range 读;切分写 |
+| `hash_mismatch` | 409 | 乐观并发 `expectedSha256` 不匹配 | 重读文件用新 hash 重试 |
+| `file_already_exists` | 409 | `mode: 'create'` 而文件已存在 | 用 `mode: 'overwrite'` 或换路径 |
+| `text_not_found` | 422 | `POST /file/edit` search 字符串不在文件 | 复核 search;空白/编码不一致最常见 |
+| `ambiguous_text_match` | 422 | 需要唯一匹配但匹到多处 | 在 search 字符串前后加更多上下文使其唯一 |
+| `untrusted_workspace` | 403 | 不被信任的 workspace 上写 | 把 workspace 标信任(`Config.isTrustedFolder()`),或用 `runQwenServe` 而不是 `createServeApp` 直嵌 |
+| `permission_denied` | 403 | OS 级 `EACCES` / `EPERM` | 调整文件 ACL;**不是**安全告警 |
+| `io_error` | 503 | `ENOSPC` / `EIO` / `EBUSY` / `ETXTBSY` / `ENAMETOOLONG` / `EMFILE` / `ENFILE` | 宿主级运维问题(磁盘满、fd 耗尽),叫 ops 而不是安全 |
+| `internal_error` | 500 | 非 errno 错误到达边界 | 报 daemon bug |
+| `parse_error` | 400 / 422 | 请求体解析(400)或服务级不变式破坏(422) | 校验请求体;查 SDK 版本 |
+
+`io_error` 与 `permission_denied` 严格区分是刻意的,监控按 errorKind 路由 —— 把 ENOSPC 折进 `permission_denied` 会让 `df -h` 问题误叫安全 oncall。
+
+## Bridge 错误(`packages/acp-bridge/src/bridgeErrors.ts`)
+
+bridge / mediator 抛的 typed class,多数路由 handler 通过 switch 给出 HTTP 状态。
+
+| 类 | HTTP | 原因 | 修复 |
+| ------------------------------------- | ---- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `SessionNotFoundError` | 404 | sessionId 不在 `byId` | 重建或附加;可能被回收 |
+| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon `boundWorkspace` | 省略 `cwd`(走 bound)或路由到绑定该 `cwd` 的 daemon |
+| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions` | 关旧 session;调 `--max-sessions` |
+| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` 不在 `[A-Za-z0-9._:-]{1,128}` | 清洗 clientId |
+| `InvalidSessionMetadataError` | 400 | `displayName` > 256 或含控制字符 | trim / 清洗 |
+| `InvalidSessionScopeError` | 400 | 未知 `sessionScope` | `'single'` 或 `'per-client'` |
+| `RestoreInProgressError` | 409 | 并发 `loadSession` / `resumeSession` | 等待重试 |
+| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` 文件已存在且无 `force` | 传 `force: true` 或换路径 |
+| `WorkspaceInitPathEscapeError` | 400 | init 路径越出 workspace | 用 `workspaceCwd` 内路径 |
+| `WorkspaceInitSymlinkError` | 400 | init 路径是 symlink | 直接寻址解析后路径 |
+| `WorkspaceInitRaceError` | 409 | init 上 TOCTOU 竞态 | 重试 |
+| `McpServerNotFoundError` | 404 | 未知 server 的 restart | 在 `/workspace/mcp` 核对名字 |
+| `McpServerRestartFailedError` | 500 | ACP child 内部 restart 失败 | 查 ACP child 日志;可能 MCP server 坏了 |
+| `InvalidPermissionOptionError` | 400 | wire 投票通过 `optionId` 注入 `CANCEL_VOTE_SENTINEL` | 改用 `{outcome: 'cancelled'}` 投票而不是 `optionId` |
+| `PermissionForbiddenError` | 403 | 策略拒了投票者(`designated_mismatch` / `remote_not_allowed`) | designated → 用 originator clientId;consensus → 预先注册 voter;local-only → 从 loopback 投票(详见 [`04-permission-mediation.md`](./04-permission-mediation.md)) |
+| `CancelSentinelCollisionError` | 500 | agent 发布 `'__cancelled__'` 作为合法 option 标签 | agent bug —— 改 option 标签 |
+| `PermissionPolicyNotImplementedError` | 500 | 请求的策略未在本 daemon 构建 | 升级 daemon 或改 `policy.permissionStrategy` |
+| `BridgeChannelClosedError` | 503 | ACP child channel 在调用中关闭 | 重连 / 重试;查 `session_died` 找原因 |
+| `BridgeTimeoutError` | 504 | bridge 级 wallclock 超 | 重试;排查底层慢 |
+
+## Boot 时配置错误(`packages/cli/src/serve/runQwenServe.ts`)
+
+| 类 | 何时 | 修复 |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `InvalidPolicyConfigError` | `validatePolicyConfig()` 拒了合并后的 settings:未知的 `policy.permissionStrategy`(按 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` 单一事实源校验)**或** `policy.consensusQuorum` 不是正整数。boot 显式失败 | 改 `settings.json` 里的违规字段。该类支持 `instanceof` 测试;`runQwenServe` 的 boot catch 用它区分配置错配与 settings 读 I/O 失败(后者静默回退默认) |
+
+## Device Flow auth(`packages/cli/src/serve/auth/deviceFlow.ts`)
+
+| 类 | 何时 | 注意 |
+| ---------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `UpstreamDeviceFlowError` | 上游 IdP 在 device-flow 轮询时返了结构化错误 | `oauthError` 字段在插值进 stderr / audit hint 之前过 `sanitizeForStderr` 净化(CVE-2021-42574 / Trojan-Source 防御,见 [`12-auth-security.md`](./12-auth-security.md)) |
+| `DeviceFlowPollTimeoutError` | registry 的 race 定时器在 provider 返回前就触发了 | **provider 代码不能抛此类型**。导出该类只是因为测试需要,但 registry 用运行时品牌 `_isRegistryTimeout: boolean`(**不是** `instanceof`)来闸 `pollTimedOut`。provider 自己 import + 抛 `new DeviceFlowPollTimeoutError(ms)` 仍走 generic provider-throw 审计路径(因为 `_isRegistryTimeout` 默认 `false`),品牌只在内部工厂 `makeRegistryPollTimeoutError(ms)`(race 定时器调用点)设 |
+
+## Daemon-host 错误 kind(`packages/cli/src/serve/status.ts`)
+
+`DaemonErrorKind` 枚举,给 `GET /workspace/preflight` 单元在 daemon-host check 失败时用:
+
+| Kind | 含义 |
+| ---------------- | ----------------------------------- |
+| `missing_binary` | `ripgrep` / `git` / `npm` 不在 PATH |
+| `blocked_egress` | 出站网络探测失败 |
+| `auth_env_error` | auth 相关 env 错 |
+| `init_timeout` | daemon 侧 init 步骤超 wallclock |
+| `protocol_error` | ACP / HTTP 协议不匹配 |
+| `missing_file` | 需要的本地文件缺失 |
+| `parse_error` | 本地文件解析错 |
+
+通过 preflight cell 的 `errorKind` 暴露,让客户端 UI 渲染结构化修复(而不是裸 stack trace)。
+
+## Auth 错误形状
+
+| 状态 | Body | 何时 |
+| ----- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| `401` | `{ error: 'Unauthorized' }` | 缺失 / 错 token / 无 scheme。`missing header` / `wrong scheme` / `wrong token` 一致防探测 |
+| `401` | `{ error: '...', code: 'token_required' }` | 无 token loopback daemon 上的 mutation-gate strict 路由。SDK 渲染「请配 --token / --require-auth」 |
+| `403` | `{ error: 'Request denied by CORS policy' }` | `denyBrowserOriginCors` 拒带 `Origin` 的请求 |
+| `403` | `{ error: 'Invalid Host header' }` | `hostAllowlist` 拒 `Host` 头(防 DNS rebinding) |
+
+完整 auth 模型见 [`12-auth-security.md`](./12-auth-security.md)。
+
+## 权限结果(wire-vs-audit 重载)
+
+`PermissionResolution` 两种终态:
+
+- `{kind: 'option', optionId}` — 投票胜。
+- `{kind: 'cancelled', reason: 'timeout' \| 'session_closed' \| 'agent_cancelled'}` — 被取消。wire 形状是单一 `{outcome: 'cancelled'}`;审计日志通过 `decisionReason.type` 区分 timeout / session_closed / voter-cancelled / agent-cancelled。这种重载是为了不破坏 `permission.ts` 冻结契约而刻意保留。
+
+## SDK 侧错误包装
+
+`DaemonClient` 把 HTTP 错误转成 rejected Promise,rejection value 是解析后的 body。命中 `404` unknown session 的方法 reject `{error, sessionId}`;SDK 当下没把它们包成 typed class(不鼓励调用方 `instanceof Error` + `.message.includes(...)`,改成 switch body 的 `err.code` / `err.kind`)。
+
+`parseSseStream` 16 MiB 缓冲溢出时中断 iterator(防御性边界)。
+
+## 流程
+
+### 把错误浮给用户
+
+```mermaid
+flowchart LR
+ A[HTTP 4xx/5xx body] --> B["switch on body.code OR errorKind"]
+ B --> C["Render remediation per this doc's table"]
+ B --> D["fallback: render body.error as toast"]
+```
+
+### 区分 auth 失败
+
+```mermaid
+flowchart TD
+ A["401 received"] --> B{"body.code == 'token_required'?"}
+ B -->|yes| C["mutation-gate strict — guide user to --token / --require-auth"]
+ B -->|no| D["plain Unauthorized — generic 'check token' UI"]
+```
+
+## 依赖
+
+- 所有错误类从各自包导出;SDK 消费方在同一 Node 进程里可以对 `bridgeErrors.ts` 类型用 `instanceof`。跨 wire 改用 `body.code` / `body.kind` / `body.errorKind` 路由。
+
+## 注意 & 已知局限
+
+- **`io_error` 与 `permission_denied`** 严格区分是刻意的,不要混。
+- **`PermissionForbiddenError` 的 reason(`designated_mismatch` / `remote_not_allowed`)** 在 `designated` 和 `consensus` 之间重载;审计精确区分,wire 不区分。
+- **`CancelSentinelCollisionError` 指示 agent 侧 bug**,不是安全事件 —— bridge 拒掉请求而不是让哨兵默默匹到真实 option。
+- **SDK 侧 typed error 仍在演进**。调用方应当 route on body 字段,而不是依赖 wire 上的 JS 类身份。
+- **`internal_error` 必须查**。它表示 `FsError` 构造时用了为非 errno 路径预留的 kind(程序员错),响应 body 的 `cause` 字段可能带原 throw。
+
+## 参考
+
+- `packages/cli/src/serve/fs/errors.ts:1-80+`(`FsErrorKind`、`FsErrorStatus`)
+- `packages/acp-bridge/src/bridgeErrors.ts`(所有 typed class)
+- `packages/cli/src/serve/status.ts`(`DaemonErrorKind`)
+- `packages/cli/src/serve/auth.ts:101-294`(auth body)
+- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。
diff --git a/docs/developers/daemon/19-observability.md b/docs/developers/daemon/19-observability.md
new file mode 100644
index 0000000000..3ded7e2506
--- /dev/null
+++ b/docs/developers/daemon/19-observability.md
@@ -0,0 +1,148 @@
+# 可观测性与调试
+## 概览
+
+`qwen serve` **当下**带 debug 日志、结构化 preflight cell、内存权限审计环。**没有**当下提供 OpenTelemetry span、Prometheus 指标、结构化日志格式 —— 这些落在 Stage 1.5+。本文是一份针对当前 surface 的实用指南,外加排查时应当意识到的现状缺口。
+
+## 当下有什么
+
+| Surface | 位置 | 用途 |
+| ------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `QWEN_SERVE_DEBUG` stderr 日志 | `bridge.ts:287-295` 及调用点 | env 设 `1` / `true` / `on` / `yes`(不区分大小写),stderr 出现 `qwen serve debug: ...` 行 |
+| `/health` | `server.ts` 路由 | Liveness 探针;`?deep=1` 返回扩展信息 |
+| `/capabilities` | `server.ts` 路由 | pre-flight feature(见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md)) |
+| `/workspace/preflight` | 路由 → `DaemonStatusProvider` | 结构化 readiness cell(Node 版本、CLI 入口、ripgrep、git、npm,子进程活着后多出 ACP 级 cell) |
+| `/workspace/env` | 路由 → `DaemonStatusProvider` | daemon 进程 env 快照(机密 env 只报存在性、剥去 proxy URL 凭证) |
+| `/workspace/mcp` | 路由 → bridge extMethod | 池 / 预算 / 拒绝快照 |
+| `/workspace/skills`、`/workspace/providers` | 路由 | ACP 侧实时快照(无 session 时返回空 idle) |
+| per-session SSE | `GET /session/:id/events` | 实时事件流 |
+| `/demo` 调试控制台 | `GET /demo`(`packages/cli/src/serve/demo.ts`) | 浏览器可访问的单页控制台(聊天 + 事件日志 + workspace 检视 + 权限 UX)。loopback 上 `http://127.0.0.1:4170/demo` 直接开 —— 不写 SDK 就能端到端把 daemon 跑起来的最快方式。loopback-vs-auth 注册规则见 [`02-serve-runtime.md`](./02-serve-runtime.md) |
+| `PermissionAuditRing` | `permissionAudit.ts:1-60` | 内存 FIFO(512 条)权限决策 |
+| mediator 的 `decisionReason` 审计 | `permissionMediator.ts:80-100+` | 内部结构化「为什么这样裁决」记录 |
+
+## 当下**没有**什么
+
+- **没有 OpenTelemetry span / trace**。`docs/developers/development/telemetry.md` 只提到一个 daemon 相关字段(`mcp_servers`)。
+- **没有 Prometheus / metrics 端点**。没有 `process_cpu_seconds_total`、`http_requests_total`、`event_bus_queue_depth` 等。
+- **stderr 日志非结构化**。行带 `qwen serve debug:` / `qwen serve:` 前缀但是纯字符串,不是 JSON。
+- **没有 per-request `requestId` 关联**。一次 HTTP 请求的多行 debug 输出不易整组。
+- **`PermissionAuditRing` 无外部 audit sink 接线** —— 环存在,但向 SIEM / 外部存储扇出的钩子还没。
+
+Stage 1.5+ 会闭合这些缺口(issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §08 Roadmap)。
+
+## 调试套路
+
+### 1. daemon 还活着吗?
+
+```bash
+curl -s http://127.0.0.1:4170/health
+# {"status":"ok"}
+
+curl -s 'http://127.0.0.1:4170/health?deep=1' | jq
+# {"status":"ok","workspaceCwd":"/path","sessions":N,...}
+```
+
+loopback 上 401 → 看 `--require-auth` 是否开(或 `QWEN_SERVE_DEBUG=1` 看启动日志)。
+
+### 2. daemon 广播了哪些 feature?
+
+```bash
+curl -s http://127.0.0.1:4170/capabilities | jq
+```
+
+看:`mcp_workspace_pool`(F2 开?)、`require_auth`(加固?)、`permission_mediation.modes`(支持哪些策略?)、`policy.permission`(激活哪一条?)。
+
+### 3. daemon-host readiness 如何?
+
+```bash
+curl -s http://127.0.0.1:4170/workspace/preflight | jq
+```
+
+`status: 'not_started'` 是 ACP 级;首次 session attach 后才填。`status: 'fail'` 带封闭 `errorKind`(见 [`18-error-taxonomy.md`](./18-error-taxonomy.md)),渲染结构化修复。
+
+### 4. 终端里 tail 一个 session 的 SSE
+
+```bash
+curl -N -H 'Accept: text/event-stream' \
+ -H 'Authorization: Bearer XYZ' \
+ -H 'X-Qwen-Client-Id: debug-tail' \
+ 'http://127.0.0.1:4170/session//events?lastEventId=0'
+```
+
+`-N` 关 curl 输出 buffer。`lastEventId=0` 从头重放。
+
+### 5. 这次权限为什么这么 resolve?
+
+`PermissionAuditRing` 是内存的;今天没 HTTP surface 暴露。开 `QWEN_SERVE_DEBUG=1` 重跑;mediator 每次投票 / 裁决在 stderr 出结构化行,带 `decisionReason.type`。后续 PR 会通过 HTTP 路由暴露 ring。
+
+### 6. 慢消费者在哪?
+
+`slow_client_warning` 每个 overflow episode 在队列 75% 满时发一次。订阅 session SSE 看合成帧;payload 带 `queueSize`、`maxQueued`、`lastEventId`。重复警告 = 一个粘住的慢消费者;查 SDK 消费方的 `for await` 循环。
+
+### 7. 为什么某 MCP server 被拒?
+
+`/workspace/mcp` 快照的 per-cell `disabledReason: 'budget'` + `refusedServerNames` 列表 + `mcp_child_refused_batch` SSE 事件合起来告诉你这一 pass 拒了什么。对照 `/capabilities` 的 `mcp_guardrails.modes`(`enforce` 是否激活?)与 live `--mcp-client-budget`(在 `getReservedSlots()` 可见)。
+
+### 8. daemon 关不掉
+
+第一信号触发优雅退出(见 [`02-serve-runtime.md`](./02-serve-runtime.md))。卡过 10s 时看:
+
+- 卡住的 ACP 子进程不响应 graceful close。
+- 长 SSE 把 HTTP `server.close()` 挂过 `SHUTDOWN_FORCE_CLOSE_MS`(5s)。
+
+**第二个** SIGTERM/SIGINT 触发 `bridge.killAllSync()` + `process.exit(1)`,刻意用。
+
+## 流程
+
+### 典型 triage 流
+
+```mermaid
+flowchart TD
+ A[User reports issue] --> B{daemon alive?}
+ B -->|no| BD[check process; check boot logs]
+ B -->|yes| C{capabilities match expectations?}
+ C -->|no| CD["check --require-auth, QWEN_SERVE_NO_MCP_POOL, settings.json"]
+ C -->|yes| D{preflight all green?}
+ D -->|no| DD["fix the errorKind cell"]
+ D -->|yes| E{issue is session-specific?}
+ E -->|yes| ES["tail SSE for that session;
QWEN_SERVE_DEBUG=1 + reproduce"]
+ E -->|no| EW["check /workspace/mcp,
/workspace/env"]
+```
+
+## 状态与生命周期
+
+- `QWEN_SERVE_DEBUG` 每次检查时读(`isServeDebugLoggingEnabled()`),切换不需重启 —— 但 daemon 已启动后启动日志就没了,除非启动时就配上。
+- `PermissionAuditRing` 有界(512 条,FIFO),老记录静默丢。
+- `DaemonStatusProvider` 每请求重建 cell(无缓存),preflight 不便宜,别没必要狂轮询。
+
+## 依赖
+
+- `process.stderr.write`(无外部日志框架)。
+- `node:process` 看 env / 信号。
+- 当下无 OTel SDK、无 Prometheus client、无外部 sink。
+
+## 配置
+
+| 旋钮 | 效果 |
+| -------------------------- | ------------------------------------------------------------------- |
+| `QWEN_SERVE_DEBUG` | 开 stderr 详细(见 [`17-configuration.md`](./17-configuration.md)) |
+| `PermissionAuditRing` size | 硬编码 512,当下不可配 |
+| `slow_client_warning` 阈值 | `0.75` / `0.375` 硬编码在 `eventBus.ts` |
+
+## 注意 & 已知局限
+
+- **非结构化日志**。纯文本;程序解析脆弱。别用 `stderr` grep 建 dashboard。
+- **没有 correlation id**。把「permission denied」stderr 行与触发 HTTP 请求关联是肉眼活;结构化日志在 Stage 1.5+。
+- **`/workspace/preflight` 的 ACP 级 cell 需要 session 活着**。idle daemon 上 auth / MCP / skills / providers 都 `status: 'not_started'`,是预期不是失败。
+- **`/workspace/env` 对机密只报存在不报值**;响应不要扔到对不可信受众暴露存在性也敏感的位置。
+- **审计环是进程局部**,daemon 重启历史丢。
+- **没有压测套路**。性能 baseline 在 `test/perf-daemon-baseline` 分支;本文不是合适的地方。
+
+## 参考
+
+- `packages/cli/src/serve/daemonStatusProvider.ts:41-287`
+- `packages/cli/src/serve/permissionAudit.ts:1-60`
+- `packages/acp-bridge/src/bridge.ts:287-295`(`isServeDebugLoggingEnabled`、`writeServeDebugLine`)
+- `packages/acp-bridge/src/permissionMediator.ts:80-100+`(`PermissionDecisionReason`)
+- 配置:[`17-configuration.md`](./17-configuration.md)。
+- 错误分类:[`18-error-taxonomy.md`](./18-error-taxonomy.md)。
+- 用户运维指南:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。
diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md
new file mode 100644
index 0000000000..65cf5d42e4
--- /dev/null
+++ b/docs/developers/daemon/20-quickstart-operations.md
@@ -0,0 +1,323 @@
+# 快速上手与运维手册
+
+本篇集中讲「**怎么把 `qwen serve` 跑起来 + 怎么验证它真的能工作 + 内部从 `qwen serve` 到 listening server 的调用链长什么样**」。架构 / 组件 / wire 协议看其他 19 篇专题文档。
+
+## 1. 最短路径
+
+```bash
+qwen serve
+```
+
+输出:
+
+```
+qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/your/cwd)
+qwen serve: bound to workspace "/your/cwd"
+qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
+```
+
+浏览器开 `http://127.0.0.1:4170/demo` 就能看到调试控制台(聊天 UI + 事件流 + workspace 检视)。loopback dev 默认下 `/demo` 注册在 `bearerAuth` **之前**(`packages/cli/src/serve/server.ts:611-612`),无需 token。
+
+## 2. 启动姿势速查
+
+```bash
+# 1. 本地 dev 默认(loopback 无 token)
+qwen serve
+
+# 2. 指定工作区 + ephemeral 端口
+qwen serve --workspace /path/to/repo --port 0
+
+# 3. 加固 loopback dev(loopback 上也强制 bearer)
+QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth
+
+# 4. 暴露给 LAN(非 loopback 必须配 token)
+QWEN_SERVER_TOKEN=$(openssl rand -hex 32) \
+ qwen serve --hostname 0.0.0.0 --port 4170
+
+# 5. 调多 session + 大重放环
+qwen serve --max-sessions 0 --event-ring-size 32000
+
+# 6. 多客户端协作 + 严格预算
+QWEN_SERVER_TOKEN=secret \
+ qwen serve --require-auth \
+ --mcp-client-budget 10 \
+ --mcp-budget-mode enforce
+
+# 7. settings.json 配 consensus 策略后启动
+# settings.json: { "policy": { "permissionStrategy": "consensus", "consensusQuorum": 2 } }
+qwen serve
+
+# 8. 排查问题用
+QWEN_SERVE_DEBUG=1 qwen serve
+
+# 9. 关闭 F2 池(fallback per-session)
+QWEN_SERVE_NO_MCP_POOL=1 qwen serve
+```
+
+加固 loopback 的姿势(3)下 `/demo` 会移到 `bearerAuth` 之后(`server.ts:625-626`),浏览器开就要带 token 头才能用了 —— 通常配脚本或 curl 而不是浏览器。
+
+## 3. 全部启动参数
+
+CLI 定义在 **`packages/cli/src/commands/serve.ts:50-147`**:
+
+| 参数 | 类型 | 默认 | 必填条件 | 作用 |
+|---|---|---|---|---|
+| `--port ` | number | `4170` | — | TCP 端口;`0` = OS 分配 ephemeral |
+| `--hostname ` | string | `127.0.0.1` | 非 loopback 必须配 token | bind 地址。loopback 集合:`127.0.0.1` `localhost` `::1` `[::1]`。`[::1]` 风格自动剥括号;`host:port` 写法直接报错让你改 `--port` |
+| `--token ` | string | env / 无 | 非 loopback 必填;`--require-auth` 必填 | bearer token;trim 一次。**会出现在 `/proc//cmdline`,推荐改用 `QWEN_SERVER_TOKEN`**(boot 时 stderr 也会提示) |
+| `--max-sessions ` | number | `20` | — | 活动 session 上限,超额 spawn 返回 503;`0` = 不限。`NaN` / 负值 throws |
+| `--workspace ` | string | `process.cwd()` | — | 绑定工作区。**必须绝对路径、必须存在、必须是目录**。boot 时 `canonicalizeWorkspace` 一次。`POST /session` 带不一致 `cwd` 时 `400 workspace_mismatch` |
+| `--max-connections ` | number | `256` | — | 监听级 `server.maxConnections`。`0` / `Infinity` 不限。NaN/负值 boot 失败(防 fail-OPEN) |
+| `--require-auth` | boolean | `false` | 必须配 token | bearer 扩展到 loopback **以及** `/health`。无 token 启动直接拒 |
+| `--event-ring-size ` | number | `8000` | — | per-session SSE 重放环深度。软上限 `MAX_EVENT_RING_SIZE = 1_000_000`;越界 boot 抛 |
+| `--http-bridge` | boolean | `true` | — | Stage 1 桥模式(一个 `qwen --acp` 子进程多路复用)。Stage 2 进程内模式还没实现,传 `--no-http-bridge` 会回退并打 stderr |
+| `--mcp-client-budget ` | number | 无 | `mcp-budget-mode=enforce` 时必填 | 工作区 MCP client 上限(PR 14)。必须正整数 |
+| `--mcp-budget-mode ` | `'enforce' \| 'warn' \| 'off'` | budget 设了默认 `warn`,否则 `off` | `enforce` 必须配 `--mcp-client-budget` | `enforce` 拒;`warn` 仅在 75% 报警;`off` 纯观测 |
+
+## 4. 环境变量
+
+| Env | 等效参数 / 作用 |
+|---|---|
+| `QWEN_SERVER_TOKEN` | 等价 `--token`;`--token` 优先。boot 时 trim 一次(防 `cat token.txt` 留尾换行) |
+| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes`(不区分大小写)开 stderr 详细日志 |
+| `QWEN_SERVE_NO_MCP_POOL` | `1` 完全禁工作区 MCP 池(回到 per-session `McpClientManager`,capabilities 不再广播 `mcp_workspace_pool` / `mcp_pool_restart`) |
+| `QWEN_SERVE_MCP_CLIENT_BUDGET` | 等价 `--mcp-client-budget`,daemon 通过 `BridgeOptions.childEnvOverrides` 透传给 ACP 子进程 |
+| `QWEN_SERVE_MCP_BUDGET_MODE` | 等价 `--mcp-budget-mode`,同样透传 |
+
+per-handle env override 是刻意的 —— 同进程跑两个 daemon 不会在 `process.env` 上 race(`defaultSpawnChannelFactory` 在 spawn 时刻快照 env)。
+
+## 5. `settings.json` 也会被读
+
+boot 时一次性 `loadSettings(boundWorkspace)`:
+
+| 键 | 类型 | 行为 |
+|---|---|---|
+| `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | 设 `BridgeOptions.permissionPolicy`。**boot 时 `validatePolicyConfig` 校验**,未知值抛 `InvalidPolicyConfigError`(boot 显式失败,而不是回退默认) |
+| `policy.consensusQuorum` | 正整数 | consensus 策略的 N。默认 `floor(M/2)+1`。非 `consensus` 策略下设了会被静默忽略 + boot 打 stderr 警告 |
+| `context.fileName` | string | 覆盖 `getCurrentGeminiMdFilename()`,影响 `POST /workspace/init` 写哪个文件 |
+| `tools.disabled` | string[] | 经 `normalizeDisabledToolList()` 归一化(trim、丢空、去重)后影响下次 ACP child spawn |
+| `tools.approvalMode` | string | session 默认 approval mode |
+
+settings 读 I/O 失败(损坏 JSON 等)回退默认;`InvalidPolicyConfigError` 例外 —— 配错就直接 boot 失败。
+
+## 6. boot 拒启动场景(fail-loud)
+
+`runQwenServe.ts` 故意在这些场景直接抛错而不是 fallback:
+
+| 场景 | 错误信息开头 |
+|---|---|
+| 非 loopback 没 token | `Refusing to bind … without a bearer token` |
+| `--require-auth` 没 token | `Refusing to start with --require-auth set but no bearer token` |
+| `--workspace` 不存在 / 不是目录 / 不绝对 | `Invalid --workspace ...` |
+| `--workspace` 没权限 stat | `Invalid --workspace ...: permission denied` |
+| `--mcp-client-budget` 非正整数 | `Must be a positive integer` |
+| `--mcp-budget-mode=enforce` 无 budget | `requires a positive mcpClientBudget` |
+| `--hostname` 写成 `localhost:4170` | `looks like a "host:port" combination. Use --port` |
+| `--hostname [::1]:8080` | `Invalid --hostname … brackets indicate an IPv6 literal but the value isn't a clean [addr] form` |
+| `--max-connections` NaN / 负值 | `Must be >= 0` |
+| `--event-ring-size > 1_000_000` | bridge 构造时抛 |
+| `policy.permissionStrategy` 未知值 / `policy.consensusQuorum` 非正整数 | `InvalidPolicyConfigError` |
+
+## 7. 跑起来之后的 curl 验证清单
+
+```bash
+# 1. liveness
+curl http://127.0.0.1:4170/health
+# → {"status":"ok"}
+
+# 1.1 deep health
+curl -s 'http://127.0.0.1:4170/health?deep=1' | jq
+
+# 2. capabilities(看广播了哪些 feature tag)
+curl -s http://127.0.0.1:4170/capabilities | jq
+
+# 3. preflight 看是否就绪
+curl -s http://127.0.0.1:4170/workspace/preflight | jq
+
+# 4. env 快照(机密只报存在性)
+curl -s http://127.0.0.1:4170/workspace/env | jq
+
+# 5. MCP 池 / 预算快照
+curl -s http://127.0.0.1:4170/workspace/mcp | jq
+
+# 6. 创建 session
+curl -s -X POST http://127.0.0.1:4170/session \
+ -H 'Content-Type: application/json' \
+ -H 'X-Qwen-Client-Id: curl-debug' \
+ -d '{}' | jq
+
+# 7. tail SSE(替换 )
+curl -N \
+ -H 'Accept: text/event-stream' \
+ -H 'X-Qwen-Client-Id: curl-debug' \
+ 'http://127.0.0.1:4170/session//events?lastEventId=0'
+
+# 8. demo 页(浏览器)
+open http://127.0.0.1:4170/demo
+```
+
+带 token 的姿势:所有请求加 `-H "Authorization: Bearer $QWEN_SERVER_TOKEN"`。
+
+## 8. demo 页能不能用
+
+**能。** 实现在 `packages/cli/src/serve/demo.ts:8-12` —— 自包含 HTML,无外部依赖,由 `getDemoHtml(port)` 返回。
+
+| 启动姿势 | `/demo` 注册位置 | 浏览器直接打 |
+|---|---|---|
+| loopback + 无 `--require-auth` | `server.ts:611-612`,在 `bearerAuth` **之前** | ✓ 不要 token |
+| loopback + `--require-auth` | `server.ts:625-626`,在 `bearerAuth` **之后** | ✗ 浏览器很难带 Auth 头,用 curl 或 SDK |
+| 非 loopback bind | `server.ts:625-626`,在 `bearerAuth` **之后** | ✗ 同上 |
+
+CSP:`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`;加 `X-Frame-Options: DENY` 防被嵌入 iframe。所以页面只能 fetch `'self'`(同 daemon),不能拉外部脚本 / 样式。
+
+## 9. 从 `qwen serve` 到 listening server 的调用链
+
+```
+qwen serve
+ │
+ ▼ (process)
+packages/cli/index.ts:87 main()
+ │
+ ▼
+gemini.tsx:392 main() — parseArguments()
+ │
+ ▼ (yargs 装配)
+config/config.ts:62 import { serveCommand } ...
+config/config.ts:1009 .command(serveCommand)
+config/config.ts:1020 await yargsInstance.parse()
+ │
+ ▼ (handler 触发)
+commands/serve.ts:148 handler(argv) — boot pre-checks
+commands/serve.ts:208 const { runQwenServe } = await import('../serve/index.js') # lazy load
+commands/serve.ts:210 await runQwenServe({...})
+ │
+ ▼
+serve/runQwenServe.ts:308 runQwenServe(opts, deps)
+ │ ├─ 312-323 trim token
+ │ ├─ 326-339 hostname 错配兜底
+ │ ├─ 341-360 auth 预检
+ │ ├─ 374-419 workspace 校验 + canonicalize
+ │ ├─ 443-484 MCP budget 校验 + childEnvOverrides
+ │ ├─ 496-530 loadSettings + validatePolicyConfig
+ │ ├─ 542-545 PermissionAuditRing + publisher
+ │ ├─ 555-561 resolveBridgeFsFactory
+ │ └─ 563-678 createHttpAcpBridge({...})
+ │
+ ▼
+serve/runQwenServe.ts:665 const app = createServeApp(opts, () => actualPort, {...})
+ │
+ ▼
+serve/server.ts:262 createServeApp() — 构造 Express app(**不监听**)
+ │ ├─ 中间件链(515-617)
+ │ ├─ 路由挂载(641 / 675 / 706 / 753 / 962 / 1785 ...)
+ │ └─ return app
+ │
+ ▼
+serve/runQwenServe.ts:735 server = app.listen(port, hostname, cb)
+ │ ├─ 758 server.maxConnections = cap
+ │ ├─ 762 actualPort = server.address().port
+ │ ├─ 764 写 "qwen serve listening on ..."
+ │ ├─ 805 注册 SIGINT / SIGTERM (onSignal)
+ │ └─ resolve(handle: RunHandle)
+ │
+ ▼
+commands/serve.ts:229 await blockForever() // 永久阻塞,等信号
+```
+
+关键事实:
+
+- **`createServeApp` 只构造,不监听。** 它返回的是 `express()` 实例加挂好中间件 + 路由,调用方自己 `app.listen()`。`server.test.ts` 的 ~25 个 case 就是这样用,所以工厂特意不持有生命周期。
+- **`() => actualPort` 是惰性闭包。** `actualPort` 在 `app.listen` 回调里才赋值(line 762),`hostAllowlist` 中间件查询时按需读,所以 ephemeral 端口(`--port 0`)也能正确闸 `Host` 头。
+- **`await blockForever()` 不是 bug**:yargs `parse()` 如果 resolve,CLI 顶层会 fall-through 进交互式 TUI 入口(gemini.tsx)。SIGINT / SIGTERM 在 `runQwenServe` 里走 `onSignal` 路径,是唯一退出方式。
+
+## 10. HTTP 路由分散在哪些文件
+
+主装配在 `server.ts` 的 `createServeApp()`,对四个模块化路由文件做外挂:
+
+| 路由 | 文件 | 关键行 |
+|---|---|---|
+| `/health`、`/demo`、`/capabilities`、所有 session 路由、device-flow、permission 投票、SSE、单服务器 MCP restart 等 | `packages/cli/src/serve/server.ts` | `611 / 641 / 962 / 1208 / 1785 / 1707 / 1631 …` |
+| `/workspace/memory`(GET/POST) | `packages/cli/src/serve/workspaceMemory.ts` | `86 / 112`;在 `server.ts:706` 挂载 |
+| `/workspace/agents` 全套 CRUD | `packages/cli/src/serve/workspaceAgents.ts` | `107 / 155 / 288 / 315 / 464`;在 `server.ts:713` 挂载 |
+| `GET /file`、`/file/bytes`、`/list`、`/glob`、`/stat` | `packages/cli/src/serve/routes/workspaceFileRead.ts` | `519-523`;在 `server.ts:753` 挂载 |
+| `POST /file/write`、`/file/edit` | `packages/cli/src/serve/routes/workspaceFileWrite.ts` | `286 / 289`;在 `server.ts:756` 挂载 |
+
+完整路由 + wire 协议看 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md);架构看 [`01-architecture.md`](./01-architecture.md)。
+
+## 11. 优雅退出 vs 强退
+
+- **第一次 SIGINT / SIGTERM** → 走 `onSignal`(`runQwenServe.ts:805`) → 两阶段 graceful:
+ 1. `bridge.shutdown()`:每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s),然后 `channel.kill()`。
+ 2. `server.close()`:等飞行中请求收尾,5s `SHUTDOWN_FORCE_CLOSE_MS` 到点 `closeAllConnections()`,再 2s 二次 deadline。
+- **第二次 SIGINT / SIGTERM** 在退出中再来 → `bridge.killAllSync()` 同步 SIGKILL 所有 ACP child + `process.exit(1)`(防孤儿)。
+
+`runQwenServe` 返回的 `RunHandle.close()` 是程序化等价物,给嵌入方 / 测试用。
+
+## 12. 嵌入式调用(绕过 CLI)
+
+```ts
+import { runQwenServe } from '@qwen-code/qwen-code/serve';
+
+const handle = await runQwenServe({
+ port: 0, // ephemeral
+ hostname: '127.0.0.1',
+ mode: 'http-bridge',
+ maxSessions: 20,
+ workspace: '/abs/path/to/repo',
+});
+console.log(`Daemon at ${handle.url}`);
+// ... 用 handle.bridge 直接调或访问 handle.server
+await handle.close(); // 程序化关
+```
+
+或者直接拿 Express app(自己 listen):
+
+```ts
+import { createServeApp } from '@qwen-code/qwen-code/serve';
+
+const app = createServeApp({
+ port: 0,
+ hostname: '127.0.0.1',
+ mode: 'http-bridge',
+ maxSessions: 20,
+}, () => 0, { /* deps: bridge, fsFactory, ... */ });
+
+const server = app.listen(0, '127.0.0.1', () => {
+ console.log('listening on', server.address());
+});
+```
+
+注意:直接调 `createServeApp` 时默认 `fsFactory.trusted = false`,agent 侧 ACP `writeTextFile` 会拒为 `untrusted_workspace`,且首次会打一次 stderr 警告(`server.ts:328-335`)。要么注入 `deps.fsFactory`(带显式 trust),要么注入 `deps.bridge`,要么接受这个 trust-gate-default 姿势。
+
+## 13. 调试套路
+
+详见 [`19-observability.md`](./19-observability.md) 的「调试套路」一节。最常用:
+
+```bash
+# 看 daemon 是否还活着
+curl http://127.0.0.1:4170/health
+
+# 看广播了哪些 capability
+curl -s http://127.0.0.1:4170/capabilities | jq
+
+# 看 daemon-host readiness
+curl -s http://127.0.0.1:4170/workspace/preflight | jq
+
+# tail SSE 看实时事件
+curl -N -H 'Accept: text/event-stream' \
+ 'http://127.0.0.1:4170/session//events?lastEventId=0'
+
+# 详细日志
+QWEN_SERVE_DEBUG=1 qwen serve
+```
+
+## 参考
+
+- CLI 入口:`packages/cli/src/commands/serve.ts:46-232`
+- bootstrap:`packages/cli/src/serve/runQwenServe.ts:308-940`
+- Express 工厂:`packages/cli/src/serve/server.ts:262-1900`
+- 中间件:`packages/cli/src/serve/auth.ts:1-294`
+- bridge 工厂:`packages/acp-bridge/src/bridge.ts:350+`
+- demo 页 HTML:`packages/cli/src/serve/demo.ts:8+`
+- 用户文档:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)
+- wire 协议:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)
diff --git a/docs/developers/daemon/_meta.ts b/docs/developers/daemon/_meta.ts
new file mode 100644
index 0000000000..2171bc5c83
--- /dev/null
+++ b/docs/developers/daemon/_meta.ts
@@ -0,0 +1,23 @@
+export default {
+ '00-index': '索引 / 总览',
+ '01-architecture': '01 · 系统架构',
+ '02-serve-runtime': '02 · Serve 运行时',
+ '03-acp-bridge': '03 · ACP Bridge',
+ '04-permission-mediation': '04 · 多客户端权限协调',
+ '05-mcp-transport-pool': '05 · Workspace MCP Transport 池',
+ '06-mcp-budget-guardrails': '06 · MCP 工作区预算护栏',
+ '07-workspace-filesystem': '07 · Workspace 文件系统边界',
+ '08-session-lifecycle': '08 · Session 生命周期与身份',
+ '09-event-schema': '09 · Typed Event Schema v1',
+ '10-event-bus': '10 · SSE 事件总线与反压',
+ '11-capabilities-versioning': '11 · 能力协商与协议版本',
+ '12-auth-security': '12 · 认证与安全模型',
+ '13-sdk-daemon-client': '13 · TypeScript SDK Daemon 客户端',
+ '14-cli-tui-adapter': '14 · 共享 UI Transcript 层',
+ '15-channel-adapters': '15 · Channel 适配器',
+ '16-vscode-ide-adapter': '16 · VSCode IDE Daemon 适配器',
+ '17-configuration': '17 · 配置参考',
+ '18-error-taxonomy': '18 · 错误分类与修复',
+ '19-observability': '19 · 可观测性与调试',
+ '20-quickstart-operations': '20 · 快速上手与运维手册',
+};