---
url: /wave-agent/specs/ui/stdio-transport.md
description: >-
  宿主（插件端/桌面端）驱动 `wave --stdio` 的 JSON-RPC 传输层：CLI
  解析同步、首次启动的进度文案、多会话路由、消息拉取/增量通知与失败诊断，以及下发 UI 的载荷裁剪（工具图片字节不下发）
---

# 功能规格说明：Stdio 传输层

**创建日期**：2026-07-24

## 概述

编辑器插件（VS Code 扩展、JetBrains 插件等）不在插件宿主进程中运行 Agent 逻辑，而是通过 JSON-RPC 2.0 over stdio 与 `wave --stdio` 子进程通信。本规格覆盖该传输层的完整生命周期：CLI 的解析/运行、JSON-RPC 通信协议、共享进程的多会话路由、以及错误诊断。

**CLI 交付方式**：VSCE 将 wave CLI 内置进扩展包（三件套：`bin/wave-code.js` 版本探测 shim + `package.json` + `dist/bundle/wave.mjs`，合计约 2.9MB），运行时由扩展宿主自身的 Node 运行时（`process.execPath`）直接执行，不依赖客户系统安装 Node.js/npm；JB 插件同样内置三件套进插件包，但运行在 JVM 中、没有宿主 Node，运行时**一律使用插件自己管理的托管运行时**——首次启动取一份官方 Node 到 `~/.wave/runtime/node/`，之后长期复用（见「JB 插件自动获取托管 Node 运行时」），不探测客户系统的 Node.js，全程不需要用户自己安装 Node。两个客户端均不再依赖 npm 全局安装的 `wave-code` 包，内置 CLI 随各自插件发版更新（运行时按 `dist/bundle/wave.mjs` 的内容字节判定是否刷新用户目录副本，见「内置 CLI 同步」，版本号相同但代码已变也会刷新）。CLI 的 grep 工具依赖 `@vscode/ripgrep`（JS 包装 + 平台 rg 二进制，约 5.2MB）与图片编解码依赖 sharp **不打包**，由 **CLI 自己在启动期**按需从 npmmirror 下载到共享 `~/.wave/cli/node_modules/` 并缓存（两个客户端共享同一 runtime 目录，下载互用，一处装好三处可用）；下载失败**不阻断启动**——grep 报"ripgrep is not available"、超限图片走降级路径，下次启动自动重试（机制见「边界情况 · 运行时依赖按需自装」）。两个客户端均通过同一套 stdio 协议与架构与 `wave --stdio` 通信。

### 架构

```
                     Editor Plugin (扩展入口)
                    /     |      \
          sidebarSession  tabSessions[]  windowSessions[]
                    \     |      /
                   ChatSession (每个会话一个)
                      │ 持有
                   StdioAgent (类型化包装层 + 状态缓存)
                      │
          ┌───────────┼───────────┐
          │           │           │
   NotificationRouter  │    FileService / SessionService / PluginService
          │           │           │ (utility 请求，无 session 上下文)
    register/unregister│
          │     StdioClient (单共享实例)
          │           │
    按 sessionId 分发   spawn
          │           │
    StdioAgent      wave --stdio (子进程)
```

**关键设计决策**：所有会话（sidebar/tab/window）共享同一个 `StdioClient` 子进程实例，通过 JSON-RPC 信封中的 `sessionId` 字段区分不同会话的请求和通知。

## 用户场景与测试 *（必填）*

### 用户故事：VSCE 使用内置 CLI（优先级：P1）

作为 VS Code 扩展用户，我希望扩展直接使用随扩展发布的内置 wave CLI，以便无需安装 Node.js/npm、无需任何手动配置即可开始使用。

**为什么是这个优先级**：这是整个 stdio 传输层的前提——没有 CLI，插件无法做任何事情。内置方案将 CLI 与扩展捆绑发布，消灭了对客户系统 Node.js（≥ 22）与 npm 的依赖；grep 依赖 rg 不打包，由 CLI 在启动期按需下载并缓存（宿主不代劳，下载失败也不挡启动），避免安装包膨胀，也不让可选能力挡住插件启动。

**独立测试**：在一台未安装 Node.js 且未安装 `wave-code` 的机器上安装插件，打开聊天面板，验证扩展直接运行内置 CLI 并启动子进程；首次启动（启动期自装）后 `~/.wave/cli/node_modules/@vscode/` 下出现下载的 rg 二进制，再次启动不重复下载。

**验收场景**：

1. **假设** 扩展已安装且内置 CLI（`dist/wave-cli/bin/wave-code.js` + `package.json` + `dist/bundle/wave.mjs`）随扩展发布，**当**插件初始化时，**则**将内置 CLI 复制到用户可写的 `~/.wave/cli/`（扩展安装目录只读），解析到其中的 `bin/wave-code.js` 作为入口，用扩展宿主运行时（`process.execPath`）执行 `bin/wave-code.js --stdio`，不查找系统 PATH、不执行 npm。
2. **假设** `WAVE_CLI_PATH` 环境变量指向一个存在的工作区构建（开发场景），**当**插件初始化时，**则**优先使用该路径而非内置 CLI，便于本地开发调试。
3. **假设** 二进制路径已解析成功，**当**同一插件生命周期内再次调用解析时，**则**直接返回缓存的路径，不重复查找、不重复复制。
4. **假设** 内置 CLI 文件缺失或不可读（安装损坏），**当**插件初始化时，**则**抛出明确错误并提示用户重新安装扩展，不尝试从网络安装。
5. **假设** 内置 CLI 的 grep 依赖 rg 尚未下载，**当**插件首次启动 CLI 时，**则**由 CLI 自己在**启动期**从 npmmirror 下载 `@vscode/ripgrep` JS 包装与当前平台的 rg 二进制到共享 `~/.wave/cli/node_modules/@vscode/`（解析先看 CLI 自身位置、再回落以 `~/.wave/cli/node_modules` 为锚点，因此 npm 等把依赖提升到可解析位置的安装方式无需下载），并在依赖就位前不对外提供会话服务；宿主不参与下载、不显示下载文案（阻塞期间由 webview 的加载态呈现）。
6. **假设** rg 已下载过（`~/.wave/cli/node_modules/@vscode/` 下存在当前平台的 rg 二进制），**当**插件再次启动 CLI 时，**则**直接复用缓存，不重复下载。
7. **假设** rg 下载失败（网络不可达、registry 超时等），**当**插件初始化时，**则**CLI 照常启动、不产生初始化错误：本次运行 Grep 工具报"ripgrep is not available"（其余功能不受影响），下次启动自动重试下载——可选依赖不得把插件启动判死。
8. **假设** 共享 runtime 目录全新（`~/.wave/cli/node_modules/` 不存在）、sharp 与 rg 都需下载，**当** CLI 启动期依次自装这两个依赖时，**则**两个依赖都装上、marker 一次记录两者，且 rg 在**同一次运行的进程内**即可用（Grep 正常工作）——sharp 安装顺带建立的 `node_modules/` 不得让随后对 rg 的可用性探测把本进程的 rg 解析判死。

***

### 用户故事：JB 插件使用内置 CLI（优先级：P1）

作为 JetBrains 插件用户，我希望插件直接使用随插件发布的内置 wave CLI，以便无需安装 npm 包、无需任何手动配置即可开始使用。

**为什么是这个优先级**：这是整个 stdio 传输层的前提——没有 CLI，插件无法做任何事情。内置方案将 CLI 与插件捆绑发布，消灭了对 npm 全局安装 `wave-code` 的依赖（PATH 查找、`npm install -g` 自动安装/升级、版本检查全部移除）。CLI 运行时**一律使用插件自己管理的托管运行时**（`~/.wave/runtime/node/`，固定版本取一次、长期复用，见下一条「JB 插件自动获取托管 Node 运行时」故事）——**不探测客户系统的 Node.js**，因此 PATH 查找、nvm/asdf/volta/fnm 路径差异、登录 shell 差异、Windows `where node` 命中商店存根（0 字节的 `WindowsApps\node.exe`，执行会弹商店）这些环境依赖问题全部消失。插件安装包不塞多平台运行时（jar 会膨胀上百 MB），但用户也不再需要自己装 Node：后端与运维客户的受限开发机上常常没有 Node、也不允许自行安装，把「先装 Node 再重启编辑器」当成前置条件正是此前最大的使用门槛。grep 依赖 rg 不打包，由 CLI 在启动期按需下载并缓存（宿主不代劳，下载失败也不挡启动），避免安装包膨胀，也不让可选能力挡住插件启动。

**独立测试**：在一台未安装 `wave-code` npm 包的机器上安装插件，打开聊天面板，验证插件直接运行内置 CLI 并启动子进程；在**未安装 Node.js** 与**已安装 Node.js 22** 两台机器上分别验证，两者行为完全一致（都使用 `~/.wave/runtime/node/` 下的托管运行时，不出现安装 Node 的提示）；首次启动（启动期自装）后 `~/.wave/cli/node_modules/@vscode/` 下出现下载的 rg 二进制，再次启动不重复下载。

**验收场景**：

1. **假设** 插件已安装且内置 CLI（`resources/wave-cli/bin/wave-code.js` + `package.json` + `dist/bundle/wave.mjs`）随插件 jar/zip 发布，**当**插件初始化时，**则**将内置 CLI 复制到用户可写的 `~/.wave/cli/`（插件安装目录只读），解析到其中的 `bin/wave-code.js` 作为入口，用**托管运行时**（`<runtime>`，见下条故事）执行 `<runtime> <entry> --stdio`，不查找系统 PATH 的 `wave`、不执行 npm、不查找系统 PATH 的 `node`。
2. **假设** `WAVE_CLI_PATH` 环境变量指向一个存在的工作区构建（开发场景），**当**插件初始化时，**则**优先使用该路径而非内置 CLI，便于本地开发调试。
3. **假设** 二进制路径已解析成功，**当**同一插件生命周期内再次调用解析时，**则**直接返回缓存的路径，不重复查找、不重复复制。
4. **假设** 内置 CLI 文件缺失或不可读（安装损坏），**当**插件初始化时，**则**抛出明确错误并提示用户重新安装插件，不尝试从网络安装。
5. **假设** 内置 CLI 的 grep 依赖 rg 尚未下载，**当**插件首次启动 CLI 时，**则**由 CLI 自己在**启动期**从 npmmirror 下载 `@vscode/ripgrep` JS 包装与当前平台的 rg 二进制到共享 `~/.wave/cli/node_modules/@vscode/`（解析先看 CLI 自身位置、再回落以 `~/.wave/cli/node_modules` 为锚点，因此 npm 等把依赖提升到可解析位置的安装方式无需下载），并在依赖就位前不对外提供会话服务；宿主不参与下载、不显示下载文案（阻塞期间由 webview 的加载态呈现）。
6. **假设** rg 已下载过（`~/.wave/cli/node_modules/@vscode/` 下存在当前平台的 rg 二进制），**当**插件再次启动 CLI 时，**则**直接复用缓存，不重复下载。
7. **假设** rg 下载失败（网络不可达、registry 超时等），**当**插件初始化时，**则**CLI 照常启动、不产生初始化错误：本次运行 Grep 工具报"ripgrep is not available"（其余功能不受影响），下次启动自动重试下载——可选依赖不得把插件启动判死。
8. **假设** 共享 runtime 目录全新（`~/.wave/cli/node_modules/` 不存在）、sharp 与 rg 都需下载，**当** CLI 启动期依次自装这两个依赖时，**则**两个依赖都装上、marker 一次记录两者，且 rg 在**同一次运行的进程内**即可用（Grep 正常工作）——sharp 安装顺带建立的 `node_modules/` 不得让随后对 rg 的可用性探测把本进程的 rg 解析判死。
9. **假设** 系统未安装 Node.js、装了 v18、或 `which/where node` 命中的是损坏安装/商店存根，**当**插件尝试解析 CLI 运行时，**则**这些情况**全部不影响结果**：插件不做任何系统 Node 探测，直接用托管运行时（下条故事）执行内置 CLI；不得报「请先安装 Node.js」或「请升级 Node.js」。
10. **假设** 用户机器上的系统 Node 由 nvm/asdf/volta/fnm 等版本管理器提供（未进入非登录 shell 的 PATH），**当**插件初始化时，**则**同样无需关心——不读登录 shell 环境、不做版本管理器路径推断，行为与没有 Node 的机器完全一致。
11. **假设** 托管运行时已存在于 `~/.wave/runtime/node/` 且 marker 记录的版本与平台与插件期望一致，**当**插件初始化时，**则**直接复用、不重复下载、不额外执行探测命令——缓存语义与 `~/.wave/cli/node_modules/` 下的 rg/sharp 一致：同一台机器上的 VSCE/JB/桌面端共享同一份。
12. **假设** 托管运行时取用失败（网络不可达、镜像不提供该平台包、下载被企业代理替换成错误页、解压后无法执行），**当**插件尝试解析运行时，**则**抛出可操作错误：含失败原因、实际请求的完整 URL、以及「隔离网络请把镜像基址指向内部镜像（`WAVE_NODE_MIRROR`）」的指引；不得进入半可用状态，下次启动自动重试。

***

### 用户故事：JB 插件自动获取托管 Node 运行时（优先级：P1）

作为 JetBrains 用户，我希望插件自己管理一份 CLI 运行时，以便我装完插件就能用——不用先去装 Node、不必有劳 IT 审批，也不用担心我机器上的 Node 版本和别人的不一致。

**为什么是这个优先级**：JetBrains 插件跑在 JVM 里，没有宿主 Node 可借用（VSCE 有扩展宿主、桌面端有 Electron），此前只能借用系统 Node.js ≥ 22——而「远端/受限开发机上没有 Node、也不允许装」正是客户投诉的核心场景。这里**刻意不做**「有系统 Node 就用系统 Node、没有才下载」的分级：分级看着省一次流量，实际把 PATH/版本管理器/登录 shell/商店存根/损坏安装这一整类环境差异全部请回来，每种差异都会变成一类现场问题（而且同一份 CLI 在不同人机器上跑在不同 Node 版本上，问题不可复现）。所以**一律用托管运行时**：首次启动取一次（一份 Linux Node 压缩后约 28–52MB，五个平台塞进 jar 就是上百 MB，故不做成内置），之后长期复用、与其它客户端共享同一份。

**独立测试**：在未安装 Node.js 的机器上装插件，打开聊天面板，验证首启自动取到运行时并正常开工（有进度/加载态，不出现「请安装 Node」提示）；关掉编辑器重开，验证不重复下载；`rm -rf ~/.wave/runtime/node` 并断网后重开，验证报出含 URL 与镜像指引的错误、且不进入半可用状态；在一台**已装 Node 22** 的机器上重复首启流程，验证它同样下载并落在 `~/.wave/runtime/node/`（不借用系统 Node）。

**验收场景**：

1. **假设** 插件启动时 `~/.wave/runtime/node/` 不存在或 marker 记录不匹配（**与机器上有没有 Node.js、版本是多少无关**），**当**插件初始化时，**则**按当前平台取一份官方 Node 运行时到 `~/.wave/runtime/node/`，只用其中的 `bin/node`（不带 npm/headers/docs）执行内置 CLI；可取的平台即官方发行物覆盖的六个（`linux-x64`/`linux-arm64`/`darwin-arm64`/`darwin-x64`/`win-x64`/`win-arm64`），其余平台给出可操作错误而不是下载一个跑不起来的东西——**musl（Alpine）特此点名**：官方 Node 根本不发 musl 构建，代码里按 musl 直接报错（提示改用 glibc 发行版或 `WAVE_NODE_PATH`），而不是让 glibc 二进制在 exec 阶段报一句难懂的 loader 错误。
2. **假设** 运行时版本由插件固定（与 CLI 的 `engines.node >= 22` 对齐，不跟随镜像上的「最新版」），**当**插件初始化时，**则**只取该固定版本；缓存标记（marker）记录版本与平台，任一不符即视为缺失、重新取用。
3. **假设** 运行时来源默认是公共 npm 镜像（与 rg/sharp 同一镜像域名），**当**需要覆盖（私有化隔离网络指向内部镜像）时，**则**可通过 `WAVE_NODE_MIRROR` 环境变量指定基址；未设置时用默认值，不新增 settings 键。
4. **假设** 下载完成，**当**插件落盘前校验，**则**必须用镜像同目录的 `SHASUMS256.txt` 做 sha256 比对（真校验，可抓「被代理替换成 HTML 错误页」「下载被截断」），再实际执行 `<runtime> --version` 断言成功（可抓「平台/架构包错」）；任一不过即删除半成品并报错，不得把它当成可用运行时。校验值就在归档旁边，因此「拿不到校验值」不能被当成「跳过校验」的借口。
5. **假设** `WAVE_NODE_PATH` 指向一个 Node 解释器（企业已有统一管控的运行时、或开发调试想指定自己的构建），**当**插件初始化时，**则**直接用它启动 CLI、不下载；不可执行时报出明确错误。这是唯一的逃生阀——除此之外不做任何自动探测：不读 PATH、不读 nvm/asdf/volta/fnm、不用 IDE 自带的 node（IDE 自带那份的版本随 IDE 升级而变，不是我们验证过的运行时）。
6. **假设** 同一台机器上多个客户端（VSCE/JB/桌面端）并存，**当**各自需要运行时，**则**共用 `~/.wave/runtime/node/`：先到者写入、后到者复用，并发写入必须由 marker/原子替换保证「要么旧的好件、要么完整的新件」，不得出现被截断的 `bin/node`。

***

### 用户故事：内置 CLI 同步（内容字节判定）（优先级：P1）

作为编辑器插件用户，我希望插件运行时始终运行随插件发布的内置 CLI 的同一份代码，以便获得一致的功能体验——同步判据是 `dist/bundle/wave.mjs` 的内容字节而非版本号，避免「插件已更新但用户目录副本仍是旧代码」的静默不一致（本地 dev 重装、GUI 单独发版都可能带着新字节而版本号未 bump）。

**为什么是这个优先级**：运行副本与内置 CLI 不一致会导致协议不兼容、功能缺失或运行时崩溃。VSCE 与 JB 均通过"内置 + 随插件发布"从机制上保证一致，无运行期独立升级流程；但内置 CLI 的 wave-code 版本号与插件版本号相互独立（不保证同号），且同版本也可能携带不同字节，故每次初始化时比较内置与运行副本 `dist/bundle/wave.mjs` 的内容字节，相同则复用、不同则重新复制。

**独立测试**：VSCE：安装扩展后确认 `~/.wave/cli/vscode/` 副本与内置 CLI 字节一致；升级扩展（内置字节已变）后确认副本重新复制且字节一致；同版本重装（内置字节未变）确认不重复复制。JB：同上验证 `~/.wave/cli/jetbrains/` 与内置字节一致。

**验收场景**：

1. **假设** VSCE 扩展已安装且内置 CLI（`dist/wave-cli/bin/wave-code.js` + `package.json` + `dist/bundle/wave.mjs`）随扩展发布，**当**插件初始化时，**则**直接使用内置 CLI（其 `package.json` 的 wave-code 版本与扩展版本相互独立，可不同号），无独立运行期升级流程。
2. **假设** VSCE 扩展升级到新版本（内置 `dist/bundle/wave.mjs` 字节变化），**当**插件初始化时，**则**重新复制内置 CLI 到 `~/.wave/cli/vscode/`，但保留 `node_modules/`（已缓存的运行时依赖 rg/sharp），不重复下载；**当**内置字节与运行副本一致（同版本重装或扩展侧改动未触及 CLI），**则**不重新复制、直接复用。
3. **假设** JB 插件已安装且内置 CLI（`resources/wave-cli/bin/wave-code.js` + `package.json` + `dist/bundle/wave.mjs`）随插件发布，**当**插件初始化时，**则**直接使用内置 CLI（其 `package.json` 的 wave-code 版本与插件版本相互独立，可不同号），无 npm 安装/升级流程。
4. **假设** JB 插件升级到新版本（内置 `dist/bundle/wave.mjs` 字节变化），**当**插件初始化时，**则**重新复制内置 CLI 到 `~/.wave/cli/jetbrains/`，但保留 `node_modules/`（已缓存的运行时依赖 rg/sharp），不重复下载；**当**内置字节与运行副本一致，**则**不重新复制、直接复用。
5. **假设** JB 插件与其它客户端（VSCE/桌面端）共用 `~/.wave/cli/` 下的 rg 缓存，**当**各客户端各自初始化时，**则**每个客户端在自己的 per-end 子目录（`vscode/`/`jetbrains/`/`desktop/`）内按内容字节判据独立刷新副本，互不覆盖；共享的 `node_modules/` rg 缓存保留、互不删除。

***

### 用户故事：首次启动的进度文案（运行时 / CLI / 工具依赖）（优先级：P2）

作为用户，我希望在首次启动的加载动画下方看到当前正在进行的步骤（下载运行时、同步 CLI、下载工具依赖…），以便我知道时间花在哪一步，而不是把一次几十 MB 的下载误认为界面卡死。

**为什么是这个优先级**：三个客户端的开机路径上都压着「几十 MB、几十秒」的下载与拷贝——JB 与桌面端远端要取一份完整 Node 运行时（压缩后 28–52MB，远端还要经 ssh / `wsl.exe` 推过去再校验安装），内置 CLI 的副本要复制或流式推送，CLI 启动期还要把 ripgrep 与 sharp（合计约 25MB）装进共享的 `~/.wave/cli/node_modules/`。这些步骤此前全是静默的：同一个加载动画既可能几百毫秒就结束（第二天再启动，全部命中缓存），也可能持续几十秒（新机器首次启动、远端主机首次连接、刚清过缓存），用户既无法判断「慢」还是「卡死」，也没有任何线索解释「为什么这次特别慢」。按阶段说文案是最便宜的进度表达：不加遮罩、不改启动链路，只把宿主与 CLI 已经在做的步骤如实说出来。

**独立测试**：在一台从未运行过 wave 的机器上安装三个客户端中的任一个，首次打开聊天面板，验证加载动画下方按「下载运行时 → 安装运行时 → 同步 CLI → 下载工具依赖 → 启动会话」推进（VSCE 无运行时阶段），最终文案随动画一起消失；再次启动（运行时、CLI、工具依赖全部命中缓存）验证不出现任何文案；`rm -rf ~/.wave/cli/node_modules` 后重启验证只出现「下载工具依赖」这一阶段；在桌面端恢复一台从未连接过的远程主机（或 WSL 发行版）上的会话，验证多出「连接主机 → 检查远端环境」两个前置阶段。

**验收场景**：

1. **假设** 客户端首次启动、各步骤都需要下载或拷贝，**则** 阶段文案必须按下列顺序只向前推进，逐字如下，且每个宿主只展示自己真实经历的那些：「正在连接 <主机名>…」（桌面端 SSH / WSL，WSL 用发行版名）→「正在检查远端环境…」（同上）→「正在下载 Node.js 运行时（约 50 MB）…」→「正在安装 Node.js 运行时…」→「正在同步 Wave CLI…」→「正在下载工具依赖（搜索与图片处理）…」→「正在启动会话…」；不得回退到先前阶段，也不得整段文案跳字抖动。
2. **假设** 某个阶段已经开始，**则** 该阶段文案必须显示在本客户端「首次启动 / 恢复」加载态的下方（桌面端为恢复扫光动画正下方，VSCE / JB 为面板加载态下方）：单行居中、不换行、不挤压或顶动动画位置；主机名过长时截断显示，不得撑开布局。
3. **假设** 缓存全部命中（运行时 marker 与平台匹配、CLI 副本字节一致、工具依赖 marker 存在），**当** 启动或恢复进行中，**则** 不得出现下载 / 安装 / 同步 / 工具依赖文案（文案只描述实际在做的步骤，不许承诺没发生的事），只保留「正在启动会话…」这类真实发生的阶段或其后的直接就绪。
4. **假设** 某个阶段从开始到结束不足约 300 毫秒，**则** 该阶段文案不得出现（沿用「分支加载中」同一阈值口径，避免本地快速路径文案闪一下）；某阶段持续超过该阈值时，文案必须在该阶段结束前显示出来，不得延迟到下一阶段才出现。
5. **假设** 桌面端正在恢复一条远程主机 / WSL 会话，**当** 用户又点击或切换到另一条会话，**则** 先前那次连接的进度不得显示在当前分屏（文案归属最新的目标，与加载态同一条归属规则）；VSCE / JB 每个面板只显示自己那次启动的进度。
6. **假设** 某步骤失败（运行时取不到、CLI 推送失败、远端不可达），**当** 该步骤结束，**则** 文案必须与加载态一起消失，并按既有语义落到可操作错误提示（见「初始化失败诊断」）；**当** 失败的是工具依赖下载（既有语义：只降级、不阻断启动），**则** 文案同样必须消失、会话照常可用（grep 报 «ripgrep is not available»、超限图片降级），不得停留在「正在下载工具依赖」上，也不得为此弹初始化错误。
7. **假设** 进度上报中，**则** 上报必须是旁路：它不得进入启动 / 恢复流程的完成条件（通知发不出去、无人消费都不影响启动），不得推迟加载态出现，也不得因为等它而让任何步骤变慢。
8. **假设** 某个步骤正在下载，**则** 文案只描述正在下载的组件，**不得** 显示百分比或剩余时间（下载流没有可靠的总量，编一个进度只会变成假信息）；Bash 沙箱运行时（`@anthropic-ai/sandbox-runtime`）按需安装、只发生在用户第一次真正使用沙箱工具时，**不属于开机路径**，不出现在开机文案里。

***

### 用户故事：初始化失败诊断（优先级：P1）

作为编辑器插件用户，我希望在 CLI 无法启动时获得明确的错误信息和可操作的修复指引，以便快速解决问题而不是面对晦涩的技术错误。

**为什么是这个优先级**：JB 插件自己取运行时之后，「机器上没有 Node」不再是错误路径（那是常态，自动兜底即可）；真正需要清晰诊断的是**运行时取用失败**——网络被隔离、镜像不提供该平台包、下载被代理替换成错误页。这类失败若不给出实际请求的 URL 和镜像配置指引，用户与运维只能看到一句「加载失败」，而私有化客户恰恰是最可能踩到它的人。VSCE 内置 CLI 后不受系统 Node 影响，但子进程启动失败与运行期崩溃仍需清晰诊断。

**独立测试**：JB：在一台断网的 Windows 机器上安装插件，打开聊天面板，验证错误消息包含实际请求 URL 与镜像配置指引（不出现任何 Node.js 安装/升级指引）。VSCE：删除扩展包内的内置 CLI 文件后打开聊天面板，验证错误提示重新安装扩展。

**验收场景**：

1. **假设** VSCE 内置 CLI 文件缺失或无法用宿主运行时执行（安装损坏），**当**插件尝试启动子进程时，**则**在编辑器通知中显示明确错误，提示重新安装扩展，不涉及任何 Node.js 安装指引。
2. **假设** VSCE 的 rg 下载失败（网络不可达等），**当**插件初始化时，**则**不显示初始化错误（CLI 照常启动，Grep 报"ripgrep is not available"），下次启动自动重试下载。
3. **假设** JB 的托管运行时取不到（网络不可达 / 镜像无该平台包 / 校验不通过），**当**插件尝试解析二进制时，**则**在编辑器通知中显示可操作错误：失败原因 + 实际请求的完整 URL + 「隔离网络请把 `WAVE_NODE_MIRROR` 指向内部镜像」的指引；**不得**出现「请先安装 Node.js (nodejs.org)」「请升级 Node.js」这类把成本推回用户的文案。
4. **假设** JB 内置 CLI 文件缺失或不可读（安装损坏），**当**插件尝试启动子进程时，**则**在编辑器通知中显示明确错误，提示重新安装插件，不尝试从网络安装。
5. **假设** JB 的 rg 下载失败（网络不可达等），**当**插件初始化时，**则**不显示初始化错误（CLI 照常启动，Grep 报"ripgrep is not available"），下次启动自动重试下载。
6. **假设** CLI 子进程启动后立即退出（exit code 非 0），**当**插件检测到进程退出时，**则**在编辑器通知中显示错误，包含 stderr 输出（如果有），并建议用户检查 CLI 安装。
7. **假设** 任何初始化错误发生后，**当**用户查看编辑器输出面板的 Wave 通道时，**则**能看到完整的错误堆栈和上下文信息用于诊断。

***

### 用户故事：JSON-RPC 通信（优先级：P1）

作为扩展开发者，我希望通过标准 JSON-RPC 2.0 协议与 CLI 子进程进行双向通信，以便发送请求、接收响应和订阅通知。

**为什么是这个优先级**：JSON-RPC 是整个传输层的通信基础，所有上层功能（发消息、执行命令、状态同步）都依赖它。

**独立测试**：启动插件后发送一条消息，验证请求通过 stdin 发出、响应从 stdout 返回、通知被正确分发。

**验收场景**：

1. **假设** 插件向 CLI 发送一个请求，**当**请求发出时，**则**stdin 写入一行 JSON，格式为 `{"id": <自增整数>, "method": "<方法名>", "params": {...}, "sessionId": "<会话ID>"}`。
2. **假设** CLI 返回一个响应，**当**stdout 收到一行 JSON 时，**则**通过 `id` 字段匹配到 pending 请求，`result` 字段 resolve 请求 Promise，`error` 字段 reject 请求 Promise。
3. **假设** CLI 推送一个通知，**当**stdout 收到包含 `method` 但无 `id` 的 JSON 时，**则**将其作为通知分发到已注册的处理器，不创建 pending 请求。
4. **假设** stdout 收到一行无法解析为 JSON 的内容，**当**处理该行时，**则**静默忽略该行，不影响后续消息处理。
5. **假设** stdout 收到一行 JSON 但值为 `null` 或非对象类型，**当**处理该行时，**则**静默忽略该行，不影响后续消息处理。
6. **假设** 插件发送一个通知（非请求），**当**通知发出时，**则**stdin 写入一行 JSON，格式为 `{"method": "<方法名>", "params": {...}, "sessionId": "<会话ID>"}`，不含 `id` 字段，不期待响应。

***

### 用户故事：共享进程与多会话路由（优先级：P1）

作为同时打开多个聊天面板（sidebar + tab + window）的用户，我希望所有会话共享同一个 CLI 子进程，通过 sessionId 区分各自的消息流，以便减少资源占用同时保持会话隔离。

**为什么是这个优先级**：共享进程是多面板架构的基础，路由错误会导致消息串台、状态混乱。

**独立测试**：打开 sidebar 聊天和一个 tab 聊天，在两个面板分别发送消息，验证各自收到正确的响应和通知，不串台。

**验收场景**：

1. **假设** 插件初始化完成，**当**创建多个 ChatSession（sidebar/tab/window）时，**则**所有 session 共享同一个 StdioClient 实例，不创建多个子进程。
2. **假设** 某个 session 发送请求，**当**请求携带该 session 的 sessionId 时，**则**CLI 返回的响应通过 id 匹配直接返回给发起者，不经过路由。
3. **假设** CLI 推送一个带 sessionId 的通知，**当**NotificationRouter 收到时，**则**将通知分发到 sessionId 对应的 StdioAgent，不分发给其他 session。
4. **假设** CLI 推送一个不带 sessionId 的全局通知（如 `authUrl`），**当**NotificationRouter 收到时，**则**将其分发到已注册的全局处理器。
5. **假设** session 的 sessionId 发生变更（如 restoreSession），**当**收到 `sessionIdChange` 通知时，**则**NotificationRouter 将旧 sessionId 的映射迁移到新 sessionId，确保后续通知仍能正确路由。
6. **假设** 某个 session 被销毁（关闭 tab/window），**当**执行 destroy 时，**则**从 NotificationRouter 取消注册该 session，但**不**销毁共享的 StdioClient（其他 session 仍在使用）。
7. **假设** 所有 session 都已销毁，**当**插件本身被关闭时，**则**ChatProvider.dispose() 先销毁所有 session，再 dispose 共享的 StdioClient。

***

### 用户故事：进程生命周期与崩溃处理（优先级：P2）

作为用户，我希望 CLI 子进程的异常退出能被妥善处理，pending 请求不会永远挂起，以便插件在进程崩溃时能给出明确反馈而不是无限等待。

**为什么是这个优先级**：当前进程崩溃后 pending 请求会被 reject（已实现），但没有自动恢复机制，且 stderr 输出对用户不可见。

**独立测试**：在插件运行期间手动 kill CLI 子进程，验证 pending 请求被 reject、后续操作给出明确错误。

**验收场景**：

1. **假设** CLI 子进程意外退出（非正常 dispose），**当**进程 `exit` 事件触发时，**则**所有 pending 请求被 reject（错误信息包含退出码），StdioClient 标记为 disposed。
2. **假设** StdioClient 已 disposed，**当**尝试发送新请求时，**则**立即 reject，不尝试写入 stdin。
3. **假设** CLI 子进程向 stderr 输出内容，**当**stderr 收到数据时，**则**输出到编辑器输出面板的 Wave 通道（而非仅 console.error），便于用户和开发者诊断。
4. **假设** dispose() 被调用，**当**执行销毁时，**则**杀掉子进程、标记 disposed，且多次调用 dispose() 是幂等的。
5. **假设** CLI 子进程退出后 StdioClient 已 disposed，**当**用户尝试在聊天面板发送消息时，**则**显示明确的错误消息告知用户连接已断开，需要重启插件。

***

### 用户故事：状态缓存与同步读取（优先级：P2）

作为扩展 UI 层，我希望 StdioAgent 在本地缓存最新状态（消息列表、任务、权限模式等），以便在渲染时同步读取，无需 await 异步请求。

**为什么是这个优先级**：React 组件渲染是同步的，异步获取状态会导致渲染闪烁和竞态条件。本地缓存确保 UI 始终有最新状态可读。

**独立测试**：调用 `getMessages` 拉取后读取 StdioAgent.messages，验证缓存已更新为 CLI 侧的最新消息列表。

**验收场景**：

1. **假设** UI 需要完整消息列表（webviewReady、clearChat、restoreSession），**当**调用 `getMessages` 请求时，**则**StdioAgent 向 CLI 发起请求，将返回的 `Message[]` 更新到 `this.messages` 缓存。`messagesChange` 不再承载消息（它只剩 re-attach 时一条不含内容的结构信号，宿主收到后自己 `getMessages`），消息缓存不由推送维护（压缩不走拉取——见「Webview 按需拉取全量消息」场景 7）。
2. **假设** CLI 推送 `tasksChange` 通知，**当**StdioAgent 处理该通知时，**则**更新 `this.tasks` 缓存并触发 `onTasksChange` 回调。
3. **假设** CLI 推送 `permissionModeChange` 通知，**当**StdioAgent 处理该通知时，**则**更新 `this.permissionMode` 缓存并触发 `onPermissionModeChange` 回调。
4. **假设** CLI 推送 `loadingChange` 通知，**当**StdioAgent 处理该通知时，**则**更新 `this.latestTotalTokens` 缓存并触发 `onLoadingChange` 回调。
5. **假设** StdioAgent 的某个回调未设置（为 undefined），**当**对应通知到达时，**则**正常更新缓存但不触发回调，不抛出错误。
6. **假设** StdioAgent 收到 `sessionIdChange` 通知，**当**处理该通知时，**则**更新 `this.sessionId` 缓存、触发 `onSessionIdChange` 回调，同时 NotificationRouter 执行 rekey。
7. **假设** StdioAgent 收到 `workdirChange` 通知，**当**处理该通知时，**则**更新 `this.currentWorkdir` 缓存并触发 `onWorkdirChange` 回调。

***

### 用户故事：Webview 按需拉取全量消息（优先级：P1）

作为 webview UI 层，我希望完整消息列表只在主动请求时获取（webviewReady、clearChat、restoreSession），而不是订阅持续的 `messagesChange` 全量推送，以便流式更新保持纯增量，长会话不因每次 chunk 全量序列化而下发。

**为什么是这个优先级**：移除 `onMessagesChange` 后，stdio 通道上的消息数据流必须是"增量通知 + 按需拉取"：流式期间只流动消息/块粒度的增量通知（bash 模式命令以 user 消息 + bash tool block 承载，经 `userMessageAdded`/`toolBlockUpdated` 增量定位），全量列表仅作为对 webview 主动请求（`getMessages`）或初始化（`webviewReady` → `setInitialState`）的响应下发（初始化与重建都下发整份列表，即压缩后的那段上下文）。这是消息流式化架构在传输层的核心诉求。

**独立测试**：打开插件聊天面板发送一条触发流式的消息，在 CLI 侧记录 stdout：流式期间仅出现 `assistantMessageAdded`/`assistantContentUpdated` 等增量通知，无 `messagesChange` 或等价的全量推送；面板初始化后出现一次 `getMessages` 请求，执行 rewind 后**不出现** `getMessages`——宿主只回一条不带消息载荷的 `rewindResult`，两端各自按锚点截断本地列表；执行 compact 后**不出现** `getMessages`，只出现一条带 `message` 的 `compactBlockAdded`，端上表现为把该条消息 `appendMessage` 进消息列表；bash 模式命令（`!ls`）期间仅出现携带 `messageId` 的 `userMessageAdded`/`toolBlockUpdated` 增量通知。

**验收场景**：

1. **假设**插件 webview 首次加载，**当**发送 `webviewReady` 时，**则**宿主调用 `getMessages` 拉取**最近一页**消息（常驻口径仍是压缩后的上下文，见 [message-rendering-system.md](message-rendering-system.md)「首屏一页，上翻按需取更早」）放进 `setInitialState` 下发，回包带「还有没有更早」；更早的内容由页面在上翻时按页取，取回的页只进页面侧列表、不写回宿主或 SDK 的消息列表
2. **假设**助手正在流式响应，**当**CLI 产生新 chunk 时，**则**webview 仅收到增量通知（`userMessageAdded`/`assistantMessageAdded`/`assistantContentUpdated`/`toolBlockUpdated` 等）并就地更新对应消息块，全程无全量列表下发
3. **假设**用户执行 clearChat / restoreSession，**当**操作完成时，**则**webview（或宿主代表 webview）主动发起 `getMessages` 请求，以返回的整份列表整表重建消息区
4. **假设** bash 模式命令（`!ls`）执行，**当**宿主转发增量通知时，**则**webview 经 `userMessageAdded` 创建 user 消息、`toolBlockUpdated` 就地更新 bash tool block（含实时输出），无需拉取全量列表
5. **假设** CLI 侧移除 `messagesChange` 通知，**当**StdioAgent 收到流式增量通知时，**则**不再通过任何全量消息推送更新缓存；`this.messages` 仅在 `getMessages` 响应或显式初始化时更新
6. **假设** 流式正文中途断连、宿主降级为非流式重发（见 core/ai-error-handling.md），**当** 需要丢弃已渲染的半截助手消息时，**则** CLI 推送定向删除通知 `assistantMessageDiscarded`（负载 `{messageId}`），消费端按 id 就地移除该条消息与缓存中的同 id 条目，全程无全量列表下发
7. **假设**会话发生压缩（自动或手动），**当**压缩写入完成时，**则**CLI 推送 `compactBlockAdded`（负载 `{content, message}`，`message` 为摘要块那条完整消息），宿主把它追加进自己的消息缓存并经 `appendMessage` 下发给 webview——全程不发起 `getMessages`；`message` 缺席（版本错配）时宿主回落为 `getMessages` 拉取
8. **假设**用户执行 rewind，**当**宿主完成会话文件重写时，**则**它不发起 `getMessages`：宿主按与 webview 相同的规则切掉自己缓存中锚点消息最后一次出现之后的部分，并回发一条**不含消息载荷**的 `rewindResult`（`requestId` 归属键 + `ok` / `error`），webview 收到匹配的回执后对本地列表做同样的截断；随后的 `setInitialState` 以「保留 webview 现有列表」的形式下发，列表不入载荷。请求未携带 `requestId`（旧 webview / 远程页）时退回 `getMessages` 全量拉取；`ok: false` 或 `requestId` 不匹配的回执不得触发截断

***

### 用户故事：流式通知纯增量负载（优先级：P1）

作为 stdio 传输层，我希望跨进程增量通知只携带增量片段（delta）而非累积值，以便流式期间单条通知的序列化与传输成本与已流式内容总长度无关，长会话流式不再对子进程内存和管道带宽造成 O(n²) 压力。

**为什么是这个优先级**：移除 `messagesChange` 后通知条数已经与消息条数无关，但每个 `assistantContentUpdated` 通知仍携带与已流式内容等长的累积 `accumulated` payload（tool block 携带累积 `parameters`），节流只能减少条数、无法减少单条负载。改为纯 delta 后，`assistantContentUpdated`/`assistantReasoningUpdated` 只携带 `{messageId, chunk, stage}`（SDK 侧已计算 chunk = 新内容 − 旧内容），`toolBlockUpdated` 在 `stage="streaming"` 只携带 `parametersChunk`；消费端负责累积（追加），`getMessages` 全量响应作为权威对账通道自愈丢失的 delta。SDK 回调与 wire 通知统一为纯 delta（`accumulated` 已从 SDK 回调移除，2026-08-08），agentBridge 原样透传、无需剥离。压缩摘要的流式文本（`compactionContentUpdate`）同属这条口径：它的消费端只是一个尾部指示，此前携带累积全文，是同一类 O(n²) 残留（2026-10-27 收口）。

**独立测试**：构造长会话触发流式，在 CLI 侧记录 stdout 并测量通知负载大小：通知字节数随流式累积不增长（仅随 chunk 本身大小波动）；流式过程中 kill/重启任一宿主的 webview 转发层后，通过一次 `getMessages` 拉取即可恢复完整内容。

**从流到回调只有一份口径**：`aiService.callAgent` 的 `onContentDelta` / `onReasoningDelta` 只给**本帧新增的片段**，累积由消费端自己做（消息块自己 append）；不出快照版回调，因为同一个字节既写给块又发回调时，回调方要么拿快照重新算差分、要么和块抢着写，两种都是同一份状态的两个副本。对外 SDK 回调（`onAssistantContentUpdated` 的 `chunk`、`onCompactionContentUpdate`）同样是增量，于是「内层流 → 消息块 / 压缩尾部 / CLI / btw → wire → 手机」全程只传片段，没有任何一层需要差分。

**验收场景**：

1. **假设** 助手响应正在流式传输，**当** CLI 推送 `assistantContentUpdated`/`assistantReasoningUpdated` 通知时，**则** 通知负载为 `{messageId, chunk, stage}`，不含 `accumulated` 字段；内容由消费端按序追加累积
2. **假设** 工具参数正在流式传输，**当** CLI 推送 `toolBlockUpdated` 且 `stage="streaming"` 时，**则** 负载只含 `parametersChunk` 增量，不含累积 `parameters`；**当** `stage="end"` 时，**则** 负载携带全量 `parameters` + `result` 作为一次性权威值
3. **假设** 某条流结束，**当** CLI 推送最后一个增量通知时，**则** `chunk` 为空字符串（`chunk: ""`）且 `stage="end"`，消费端据此终结流式块
4. **假设** 宿主对增量通知做节流（如桌面 16ms / CLI 500ms 窗口），**当** 窗口内有多条 chunk 时，**则** 按到达顺序拼接为一个合并 delta 发送（window-concat），不丢弃中间值；进程内 CLI 同样使用 window-concat（SDK 回调已无 accumulated，不存在 last-value-wins 路径）
5. **假设** 增量通知中途丢失（传输异常），**当** 消费端发现内容不完整时，**则** 通过一次 `getMessages` 请求拉取全量权威快照整体替换自愈，管道 FIFO 保证拉取响应包含其之前发出的所有 chunk，不重复
6. **假设** 某个宿主需要将 delta 转发给 webview，**当** 转发时，**则** 透传 delta（消费端 webview reducer 追加），不在宿主侧重新累积为全量后再下发
7. **假设** 压缩摘要正在流式生成，**当** CLI 推送 `compactionContentUpdate` 时，**则** `content` 是本次新增的片段，不是累积全文（同一口径适用于 SDK 回调 `onCompactionContentUpdate`，消费端按序拼接、压缩结束清空）；单条通知的字节数与已输出长度无关

***

### 用户故事：工具图片的字节不下发 UI（优先级：P1）

作为 webview 与远控页，我希望 Read / MCP 工具读到的图片在跨进程下发时只留下「读到过图」这件事、不携带图片字节，以便消息下发与宿主侧缓存不随读图次数线性膨胀，同时模型上下文与磁盘转录保持完整。

**为什么是这个优先级**：工具块是目前唯一能单条就上 MB 的消息载荷（一张 1 MB 的截图，base64 约 1.33 MB）。而 UI 从来不渲染它：webview 里读工具块图片的那条分支永远走不到（它只在渲染 user 消息文本时被调用，工具块只出现在 assistant 消息里），CLI 只显示「有 N 张图」。远控页的单帧字节预算更是被它直接顶破（见 [remote-channel.md](../remote/remote-channel.md) 的连接生命周期故事场景 12 与白名单故事的场景 8）。裁掉它没有界面损失。

**独立测试**：让 Read 读一张 1 MB 的 PNG，在 CLI 侧记录 stdout：`toolBlockUpdated` 与 `getMessages` 的负载里检索不到该图的 base64；同一会话的下一次模型请求仍带完整图片。

**验收场景**：

1. **假设** Read 工具读到一张图片，**当** CLI 推送 `toolBlockUpdated`（`stage="end"`）时，**则** 该工具块不带 `images` 字段，其余字段（`name` / `parameters` / `result` / `shortResult` / `success` / `metadata`）原样下发
2. **假设** MCP 工具返回图片，**当** 工具块结束时，**则** 同样不带 `images` 字段
3. **假设** 宿主发起 `getMessages`（首屏、恢复、重连补差、远控页按 `limit` 取的尾段），**当** 返回消息列表时，**则** 列表里每个工具块都不带 `images` 字段
4. **假设** 一条携带工具块的消息经其它通知下发（如 `compactBlockAdded` 的 `message`），**当** 下发时，**则** 同样不带 `images` 字段
5. **假设** 用户自己粘贴 / 拖入图片，**当** user 消息下发时，**则** 不受影响：`image` 块仍带 data URL，webview 照常渲染缩略图与预览
6. **假设** 下一轮请求要发给模型，**当** 组装请求体时，**则** 上下文里的图片字节完整——裁剪只发生在 CLI 进程向外（宿主 / 远控）的那一跳，SDK 的内存消息与出站组装都不变
7. **假设** 会话被 compact / rewind / 恢复并回写转录，**当** 落盘时，**则** 磁盘上的转录仍完整（落盘由 SDK 负责，与下发裁剪无关）
8. **假设** 手机端从远控页拉取会话，**当** 消息下发时，**则** 同样不带图片字节；含工具图的消息不再因此被单帧预算截短或退化成「有更新」信号
9. **假设** 用户看 CLI 的 TUI，**当** 工具块渲染时，**则** 照旧显示「有图片」的标记（CLI 不经这条协议，不参与裁剪）

***

### 用户故事：插件端消息队列列表（优先级：P2）

作为插件端用户，我希望在 AI 处理期间发送的消息以队列列表形式展示，可以查看、立即发送、删除或重新编辑排队消息，以便在 AI 忙碌时管理我的待处理输入。

**为什么是这个优先级**：队列管理提升忙碌时的输入可控性，但消息仍可正常排队等待，属于体验增强。

**独立测试**：在 AI 响应期间发送两条消息，验证输入框上方出现"消息队列 (2)"列表；对其中一条点击删除，对另一条点击编辑修改后回车，验证队列内容相应更新。

**验收场景**：

1. **假设** AI 正在处理且用户发送了消息，**当**消息进入队列时，**则**输入框上方显示"消息队列 (N)"列表，N 为排队消息数。
2. **假设**队列列表已显示，**当**用户点击列表头部时，**则**在折叠（仅显示第一条）与展开（显示全部，超高可滚动）之间切换。
3. **假设**队列中某条为 shell 命令，**当**列表渲染时，**则**该条摘要带 `!` 前缀；所有条目单行截断显示，**仅文字被截断的条目**悬停时显示完整内容气泡（文字放得下、未被截断的条目不显示气泡）。
4. **假设**用户对某条排队消息点击"发送"，**当**操作发生时，**则**该消息立即发送（不等当前处理完成顺序出队）并从队列中移除；**队列里其余消息保留**，在当前回合结束后按原顺序继续发出——"立即发送"是一次中断（打断当前回合），不是一次 Stop（不清空排队消息）。
5. **假设** AI 处理中、队列里存在后台任务完成通知，且用户点击某条排队消息的"发送"，**当**操作发生时，**则**该消息立即发送并从队列移除；后台任务通知不触发额外的 AI 循环（不会出现两个并发的 agent loop），发送过程保持可中断，且被中断后通知仍保留在队列中不丢失。
6. **假设**用户对某条排队消息点击"删除"，**当**操作发生时，**则**该消息立即从列表消失。
7. **假设**用户对某条排队消息点击"编辑"，**当**操作发生时，**则**消息内容载入输入框并显示"编辑队列消息"标记；用户修改后按回车，**则**原队列消息被替换为新内容；用户删除该标记，**则**退出编辑状态。
8. **假设**用户通过键盘方向上键尝试召回队列消息，**当**在插件端输入框中操作时，**则**不支持该召回方式（与 CLI 的产品差异：插件端仅支持点击"编辑"按钮召回）。
9. **假设**有对话框打开或存在待处理的权限确认，**当**界面渲染时，**则**队列列表隐藏，避免遮挡。

***

### 边界情况

* **Windows spawn 方式**（JB 适用）：JB 插件以 `<runtime> <entry> --stdio` 方式执行内置 CLI（`<runtime>` = `~/.wave/runtime/node/` 里的托管运行时，入口是 `.js` 脚本，无需 shell），不再 spawn 任何 `npm.cmd`/`wave.cmd` shim，规避 Node.js CVE-2024-27980 补丁后无 shell 拒绝执行 `.cmd` 的限制；`getCliVersion` 探测同样以 `<runtime> <entry> -v` 执行。
* **`getCliVersion` 超时**（JB 适用）：`<runtime> <entry> -v` 执行有超时，超时返回 `null`（视为入口损坏，触发重新复制内置 CLI）。
* **内置 CLI 的宿主运行时**：VSCE 扩展宿主的 `process.execPath` 是有效的 Node 二进制（Electron 扩展宿主运行时），直接以 `process.execPath <bin/wave-code.js> --stdio` 方式 spawn，Windows 上无需 shell、无 `.cmd` 路径注入风险——这是宿主自带的运行时，不是「系统 Node」。JB 插件跑在 JVM 里、没有宿主 Node，运行时解析只有三步：`WAVE_NODE_PATH`（显式覆盖，设了就只用它）→ `~/.wave/runtime/node/`（marker 的版本与平台都匹配即直接使用，不额外执行探测命令）→ 缺失则从镜像取一份官方 Node 到该目录（固定版本，与 CLI 的 `engines.node >= 22` 对齐），校验通过后再以 `<runtime> <entry> --stdio` spawn，Windows 上同样无需 shell。**不读 PATH、不读 nvm/asdf/volta/fnm、不用 IDE 自带的 node**：这些来源的版本与可用性随用户环境与 IDE 升级变化，不是我们验证过的运行时，探测它们只会把环境差异变成现场问题（Windows 上 `where node` 还可能命中 0 字节的商店存根）。托管运行时只保留 `bin/node`（Windows 为 `bin/node.exe`），解压后丢弃 npm/headers/docs，磁盘占用与「下载一份完整 Node 发行包」不同量级。
* **托管运行时的版本固定与来源**：版本写死在插件里、不做「跟随镜像最新版」的探测——镜像上的最新版可能比插件验证过的版本新，跟内置 CLI 的兼容性没人验证过；升级运行时属于插件发版行为。来源默认与 rg/sharp 同一公共 npm 镜像（`https://registry.npmmirror.com/-/binary/node/<固定版本>/`），`WAVE_NODE_MIRROR` 可覆盖基址以适配私有化内部镜像；私有化客户的镜像可达性由客户自行保障（我们只保证失败时报出实际 URL 与配置项）。
* **运行时取用的原子性**：下载到临时目录（`node.new`）→ 用镜像的 `SHASUMS256.txt` 做 sha256 判断 + `<runtime> --version` 实跑校验 → 旧运行时挪到 `node.old`、`node.new` 原子 `rename` 到 `~/.wave/runtime/node/`。多条路径并发（多个插件窗口、VSCE/JB 同时初始化）时，先完成者写入、后到者复用；任何中途失败都不得在最终目录留下可被当成「已就绪」的部分文件（marker 只在全部校验通过后写入）。Windows 上正在被占用的 `node.exe` 会让替换 rename 失败，此时必须把 `node.old` 还原回去并报可操作错误，而不是留下半可用目录。
* **内置 CLI 布局**（VSCE/JB 通用）：内置内容为三件套——`bin/wave-code.js`（版本探测 shim，处理 `-v` 后 import `../dist/bundle/wave.mjs`）、`package.json`、`dist/bundle/wave.mjs`。wave.mjs 运行时经 `createRequire(import.meta.url)` 向上解析 `@vscode/ripgrep-<platform>-<arch>/bin/rg`，因此下载的 rg 必须放在 CLI 目录的 `node_modules/@vscode/` 下；该路径**每次使用时再解析**，不在模块求值期定死——依赖是 CLI 自己在启动期装进来的，晚于模块求值。
* **可写区复制**（VSCE/JB 通用）：插件安装目录只读，内置 CLI 在首次启动（或版本变更）时复制到用户目录 `~/.wave/cli/`；复制前只删除 `dist/`、入口与 `package.json`，保留 `node_modules/`（运行时依赖缓存，rg 与 sharp），升级插件不会强制重新下载。VSCE 与 JB 共享同一 runtime 目录。
* **运行时依赖自装与缓存**（rg 与 sharp 同一套机制）：宿主自带的那份 CLI 不带 `node_modules`，两个依赖都由 **CLI 在启动期**按「运行时依赖表」自装到共享 `~/.wave/cli/node_modules/`：按**运行时依赖表里每行携带的版本范围**（与 SDK 的 `package.json` 声明一致，由单测守住——不再运行时读任何 `package.json`，宿主副本只有 CLI 那份、读不到 SDK 的声明）从 npmmirror 取元数据、选范围内容最高版本（semver `maxSatisfying`）→ 逐个下载 tarball 并**校验 registry 的 sha512 完整性** → 解包到临时目录后原子 `rename` 到位（保留 tarball 内的可执行位，rg 二进制必须可执行）→ 全部成功才写 marker。rg 为 `@vscode/ripgrep`（JS 包装）+ `@vscode/ripgrep-<platform>-<arch>`（rg 二进制，无 musl 变体），落点与旧实现相同 ⇒ 已有缓存直接命中、不重复下载。**就绪判定先看文件系统、再看加载**：先在候选 `node_modules` 目录（**两个解析锚点**：CLI 自身位置优先、共享 `~/.wave/cli/node_modules` 兜底，`requireFn.resolve.paths(name)` 纯路径运算）里确认包目录存在，只有存在时才真正 `require` 加载校验——**绝不拿 `require` 去探测一个尚不存在的包**，因为那次失败的解析会按路径在 Node 内部留下「该包不存在」的进程级缓存，同一进程内包随后落盘也再解析不到（判定假阴性 → 清 marker → 该进程 grep 永久不可用，典型触发序：sharp 先装、其 `node_modules/` 建立后 rg 才被探测）。安装后的加载校验仍走真实 `require`（此时包已在磁盘上，安全），保留「已安装但无法加载」（如平台二进制不匹配）的降级判定。npm 安装的 `wave-code` 自带这两个依赖，第一步即短路、不发生任何下载。
* **运行时依赖下载失败只降级、不阻断启动**：CLI 在启动期等依赖就位（避免「刚启动就贴图/搜索」撞上还没装好），但安装失败不抛错——记一行警告后照常提供会话服务：Grep 报"ripgrep is not available"、超限图片按降级路径处理，宿主不弹初始化错误、不再代装（分界：旧行为是宿主在 spawn CLI 前代装 rg，并因它失败而拒绝启动）。失败结果在同一进程内不重试（避免每用一次就打一次网络），**下次启动**会再试。
* **进度文案的两段来源、一条阶段流**：宿主侧（连接主机、下载 / 安装运行时、同步 CLI）与 CLI 侧（下载工具依赖、启动会话）各自在阻塞点上报阶段，合并成同一条阶段流推给界面——同一时刻只显示一个阶段，后到的阶段覆盖先到的，不存在两个文案并列；阶段流不承担任何控制作用（没有「等用户确认」「取消」语义，也不影响步骤本身的成败）。
* **开发覆盖**（VSCE/JB 通用）：`WAVE_CLI_PATH` 环境变量指向工作区构建的 CLI 文件时优先使用，便于本地开发调试；生产环境不设置该变量。
* **CLI 版本同步**（VSCE/JB 通用）：内置 CLI 与插件版本由发布流程绑定（插件构建时复制对应版本的三件套进包），无运行期独立升级；用户升级插件即获得新 CLI。构建顺序必须先构建 `packages/code`（生成 `dist/bundle/wave.mjs`）再打包插件。
* **utility 请求无 session 上下文**：FileService（搜索文件）、SessionService（列出会话）、PluginService（管理插件）的请求不需要 sessionId，直接通过共享 StdioClient 发送。
* **`authUrl` 全局通知**：SSO 登录流程中 CLI 推送的 `authUrl` 通知不带 sessionId，通过 `router.registerGlobal` 注册的处理器接收，直接打开系统浏览器。
* **CLI 子进程的 env 传递**：StdioClient 构造函数接受可选的 `env` 参数，未传时子进程继承插件宿主的 `process.env`。
* **消息队列列表编辑无重复入队**：插件端中编辑排队消息时，将原消息删除并把内容载入输入框；若用户编辑后未发送，该消息不会自动回到队列。
* **消息队列列表无键盘召回**：插件端输入框的方向上键不召回队列消息（与 CLI 不同），仅支持通过列表中的"编辑"按钮召回；这是刻意的平台产品差异。
* **增量通知不携带累积值**：`assistantContentUpdated`/`assistantReasoningUpdated` 通知只携带 `chunk` 增量；`toolBlockUpdated` 在 `streaming` 阶段只携带 `parametersChunk`。任何依赖 `accumulated`/`parameters` 累积字段的旧消费端需改为本地累积或使用 `getMessages` 拉取全量。
* **end 通知是全量权威值**：`toolBlockUpdated` 的 `stage="end"` 通知携带完整 `parameters` + `result`，消费端应以该值为准终结工具块，不能仅凭 streaming 阶段的拼接结果。
* **工具图片的裁剪只到进程边界**：CLI 自己内存里的消息（`messages`）与转录文件仍持有完整图片字节，本规格不改变 SDK 的内存模型（见 [core/message-compact.md](../core/message-compact.md) 的压缩口径）。因此桌面端主进程的消息镜像也不再持有这些字节，但 CLI 子进程仍持有——两件事要分开算，别把本规格当成「工具图不进内存」的方案。
* **「有没有图」不靠字节表达**：Read 的 `shortResult`（`Image processed (<mime>)`）与 MCP 的（`Tool returned N image(s).`）已经把这件事写在文本里，因此不下发 `images` 不会让 UI 丢掉「这里读过图」的信息。
