diff --git a/.env.example b/.env.example
index 27fa7ebcd..299130bff 100644
--- a/.env.example
+++ b/.env.example
@@ -43,4 +43,5 @@ NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
MISE_INSTALL_URL=https://mise.run
-OPENCODE_INSTALL_URL=https://opencode.ai/install
+# opencode 版本(本 UI 只支持 V2,不要改成 v1.x)。改这里即可整体升级/降级后端版本。
+OPENCODE_VERSION=2.0.19
diff --git a/.prettierignore b/.prettierignore
index 7397700f6..6826b59d6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,4 +2,6 @@ dist
node_modules
public/material-icons
src-tauri/target
+# cargo check / tauri-build 生成的 schema(src-tauri/.gitignore 已忽略,非源码)
+src-tauri/gen/schemas
coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b1a1b372..2b38aee18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -364,7 +364,7 @@
- fix(settings): apply mobile viewport height to config editor (d442e66)
- fix(titlebar): preserve decorum window controls (1892621)
- fix: remove touchAction:none from FileTreeItem button that broke mobile scrolling (a619d72)
-- fix: replace 92vh with calc(var(--app-height) * 0.92) in mobile SettingsDialog to fix address bar hiding issue (f3558fc)
+- fix: replace 92vh with calc(var(--app-height) \* 0.92) in mobile SettingsDialog to fix address bar hiding issue (f3558fc)
## [v0.6.16] - 2026-06-07
@@ -630,7 +630,7 @@
## [v0.5.5] - 2026-04-18
-- fix: remove redundant *Single i18n keys — let i18next handle count=1 natively (9385ef8)
+- fix: remove redundant \*Single i18n keys — let i18next handle count=1 natively (9385ef8)
- style: match pane drop highlight radius to pane shell (rounded-lg) (f8acac2)
- feat: enable drag-to-split on active session list items (ac20b7b)
diff --git a/README.md b/README.md
index 7cc9e5a71..fab8cd2d2 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,10 @@
## 快速体验
+> ⚠️ **需要 OpenCode v2**(如 `v2.0.19`)。**本 UI 只支持 V2,不支持 v1.x** ——
+> 如果后端还是 v1,界面能打开但一发消息就会失败。
+> 用 `opencode --version` 确认;输出的版本号应以 `v2.` 开头。
+
无需部署,在本地启动 OpenCode 后端后直接访问托管版前端:
```bash
@@ -83,7 +87,9 @@ BACKEND_URL=your-server.com:4096 PORT=8080 docker compose -f docker-compose.stan
若你使用自定义 Caddyfile(或遇到 401 认证失败),在 `reverse_proxy` 块中添加:
```caddyfile
-handle_path /api/* {
+# 🔴 OpenCode V2:用 handle(不要用 handle_path)——V2 的端点本身带 /api 前缀,
+# handle_path 会削掉它,请求会打到不存在的路径上(后端只返回 SPA 兜底 HTML)。
+handle /api/* {
reverse_proxy your-opencode-serve:4096 {
header_up Host {upstream_hostport}
header_up Authorization {http.request.header.Authorization}
@@ -314,7 +320,8 @@ preview.example.com {
## 本地开发
-需要一个运行中的 [OpenCode](https://github.com/anomalyco/opencode) 后端。
+需要一个运行中的 [OpenCode](https://github.com/anomalyco/opencode) **v2** 后端
+(**本 UI 只支持 V2,不支持 v1.x**;用 `opencode --version` 确认版本号以 `v2.` 开头)。
```bash
opencode serve
diff --git a/README_EN.md b/README_EN.md
index 3ae97aed9..00ae8b004 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -43,6 +43,10 @@ A third-party Web frontend for [OpenCode](https://github.com/anomalyco/opencode)
## Quick Start
+> ⚠️ **OpenCode v2 required** (e.g. `v2.0.19`). **This UI only supports V2 and does not work with v1.x** —
+> if the backend is still v1, the UI will load but sending a message will fail.
+> Run `opencode --version` to check; the version number should start with `v2.`.
+
No deployment needed — after starting the OpenCode backend locally, access the hosted frontend directly:
```bash
@@ -83,7 +87,10 @@ After you enter the username/password in the frontend's server connection dialog
If you use a custom Caddyfile (or hit 401 auth failures), add this to the `reverse_proxy` block:
```caddyfile
-handle_path /api/* {
+# 🔴 OpenCode V2: use `handle` (NOT `handle_path`) — V2 endpoints already include
+# the /api prefix; handle_path strips it and requests hit non-existent paths
+# (the backend returns the SPA fallback HTML instead of JSON).
+handle /api/* {
reverse_proxy your-opencode-serve:4096 {
header_up Host {upstream_hostport}
header_up Authorization {http.request.header.Authorization}
@@ -263,7 +270,8 @@ preview.example.com {
## Local Development
-Requires a running [OpenCode](https://github.com/anomalyco/opencode) backend.
+Requires a running [OpenCode](https://github.com/anomalyco/opencode) **v2** backend
+(**this UI only supports V2 and does not work with v1.x**; run `opencode --version` and make sure it starts with `v2.`).
```bash
opencode serve
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
index 2ffdc0961..9d78f4f5e 100644
--- a/docker-compose.build.yml
+++ b/docker-compose.build.yml
@@ -28,4 +28,5 @@ services:
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
NODEJS_ORG_MIRROR: ${NODEJS_ORG_MIRROR:-https://npmmirror.com/mirrors/node}
MISE_INSTALL_URL: ${MISE_INSTALL_URL:-https://mise.run}
- OPENCODE_INSTALL_URL: ${OPENCODE_INSTALL_URL:-https://opencode.ai/install}
+ # opencode 版本(本 UI 只支持 V2)。Dockerfile 用它拼下载地址并校验哈希。
+ OPENCODE_VERSION: ${OPENCODE_VERSION:-2.0.19}
diff --git a/docker/Caddyfile.gateway b/docker/Caddyfile.gateway
index a73ee0ffe..01c2d1ef6 100644
--- a/docker/Caddyfile.gateway
+++ b/docker/Caddyfile.gateway
@@ -5,8 +5,11 @@
# ---- 主入口 :6658 — OpenCode 应用 ----
:6658 {
# API(SSE + WebSocket 自动处理)
- handle_path /api/* {
+ # 🔴 OpenCode V2:端点带 /api 前缀 → 原样透传,不能削(见 Caddyfile.standalone 注释)。
+ # 同时显式透传 Authorization:后端开启 OPENCODE_SERVER_PASSWORD 时必需(否则全部 401)。
+ handle /api/* {
reverse_proxy opencode-backend:4096 {
+ header_up Authorization {http.request.header.Authorization}
flush_interval -1
}
}
diff --git a/docker/Caddyfile.standalone b/docker/Caddyfile.standalone
index 20fee6142..fe8709a9f 100644
--- a/docker/Caddyfile.standalone
+++ b/docker/Caddyfile.standalone
@@ -2,8 +2,11 @@
# 静态文件 + API 反代到外部 opencode serve
:3000 {
- # API 反代(strip /api 前缀,SSE 需要 flush_interval -1)
- handle_path /api/* {
+ # API 反代(SSE 需要 flush_interval -1)
+ # 🔴 OpenCode V2:端点本身带 /api 前缀(/api/session、/api/info…)→ 必须原样透传。
+ # V1 时代用 `handle_path` 削掉 /api 才能命中后端;V2 这么做会把请求打到不存在的
+ # 路径上,后端只返回 SPA 兜底 HTML(前端拿 HTML 当 JSON 解析 → 整条链路坏)。
+ handle /api/* {
reverse_proxy {$BACKEND_URL:host.docker.internal:4096} {
header_up Host {upstream_hostport}
# 透传客户端 Authorization 头(后端开启 OPENCODE_SERVER_PASSWORD 认证时必需)
diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend
index 06cafc369..b0da5b3a3 100644
--- a/docker/Dockerfile.backend
+++ b/docker/Dockerfile.backend
@@ -14,6 +14,9 @@ ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
ARG NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
ARG MISE_INSTALL_URL=https://mise.run
+# 🔴 opencode 版本**必须钉死**:本 UI 只支持 V2,装成 V1 会直接跑不起来。
+# 升级步骤:改这里的默认值 + 重算下方 amd64/arm64 两个 OC_SHA256(见安装段的注释)。
+ARG OPENCODE_VERSION=2.0.19
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
@@ -89,17 +92,35 @@ RUN case "${TARGETARCH}" in \
| MISE_INSTALL_PATH=/usr/local/bin/mise MISE_INSTALL_FROM_GITHUB=1 MISE_INSTALL_ARCH="${MISE_ARCH}" sh \
&& mise --version
-# 安装 opencode。直接从 GitHub Releases 拉 tarball 解压,绕开 opencode.ai/install 脚本:
-# 该脚本为拼一个版本号字符串去调 api.github.com(未鉴权,60 次/小时/IP),
-# GitHub Actions 共享 runner IP 极易触发限流而 exit 1;而 releases/latest/download
-# 本身是 302 跳转,不需要版本号。tarball 内仅含单个 opencode 二进制。
+# 安装 opencode(**钉死版本 + 校验哈希**)。
+#
+# 🔴 为什么必须换渠道(阶段 4 实测结论):
+# GitHub Releases 的 `latest` 已经停在 **v1.18.33(V1)** —— 全仓库 500 个 release 里
+# 零个 v2,`v2.0.x` 也没有 release 资源(tarball 直链 404)。npm 的 `opencode-ai`
+# 同样没有 v2。也就是说:**照旧写法拉 `releases/latest/download` 一定装成 V1**,
+# 而本 UI 只支持 V2 → 容器起来后必然连不上/报错。
+#
+# V2 的真实分发渠道是官方更新服务 `update.opencode.ai` 指向的静态文件服务:
+# https://opencode.ai/files/bin/<版本>/opencode-linux-<架构>.tar.gz
+# (版本索引:`https://update.opencode.ai/api/dev`(v2 分支快照)/ `/api/beta`)
+# tarball 内仍是**单个 `opencode` 文件**,解压命令与旧 GitHub tarball 一致。
+#
+# ✅ 顺带收益:新渠道不经过 GitHub,**彻底摆脱 GitHub 限流**(旧注释里
+# 「共享 runner IP 极易触发 60 次/小时 限流」的问题不复存在)。
+#
+# 🔒 sha256 校验:防止上游文件被替换 / 下载被中间人篡改 / 半截包。
+# 升级版本时:① 改 ARG OPENCODE_VERSION;② 用
+# `curl -fsSL -o t.tar.gz <新URL> && sha256sum t.tar.gz` 重算两个架构的哈希。
RUN case "${TARGETARCH}" in \
- amd64) OC_ARCH=x64 ;; \
- arm64) OC_ARCH=arm64 ;; \
+ amd64) OC_ARCH=x64 \
+ OC_SHA256=1a9f7184292035a56b6cf22439762930b3a3ed13812f7360c33bb7ae07ef4075 ;; \
+ arm64) OC_ARCH=arm64 \
+ OC_SHA256=d0138dd9b43910c28166cfc4da4a53a1b99b07931a37afa749bde812cd02e5a4 ;; \
*) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/opencode.tar.gz \
- "https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-${OC_ARCH}.tar.gz" \
+ "https://opencode.ai/files/bin/${OPENCODE_VERSION}/opencode-linux-${OC_ARCH}.tar.gz" \
+ && echo "${OC_SHA256} /tmp/opencode.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/opencode.tar.gz -C /usr/local/bin opencode \
&& rm -f /tmp/opencode.tar.gz \
&& chmod +x /usr/local/bin/opencode \
@@ -114,6 +135,7 @@ ENV MISE_DATA_DIR=/root/.local/share/mise \
MISE_STATE_DIR=/root/.local/state/mise \
OPENCODE_DISABLE_AUTOUPDATE=true \
OPENCODE_DISABLE_TERMINAL_TITLE=true \
+ OPENCODE_VERSION=${OPENCODE_VERSION} \
WORKSPACE=/workspace
ENV PATH="/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:/root/.local/share/mise/bin:${PATH}"
diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend
index f932e5b32..888e25add 100644
--- a/docker/Dockerfile.frontend
+++ b/docker/Dockerfile.frontend
@@ -30,6 +30,8 @@ RUN set -eux; \
npm ci --ignore-scripts
COPY . .
RUN node scripts/copy-material-icons.mjs
+# 相对路径 = 「同源反代」语义。V2 下由前端解析为**当前页面 origin**
+# (见 src/constants/api.ts 的 V2 修正注释;V1 时代它靠反代削 /api 前缀,V2 不再削)。
ENV VITE_API_BASE_URL=/api
RUN npm run build
diff --git a/docker/backend-entrypoint.sh b/docker/backend-entrypoint.sh
index 1a83d4d9a..8abad772e 100644
--- a/docker/backend-entrypoint.sh
+++ b/docker/backend-entrypoint.sh
@@ -18,12 +18,17 @@ ensure_mise() {
}
ensure_opencode() {
+ # 镜像里已经装好了(Dockerfile 构建期安装 + sha256 校验),这里只是兜底:
+ # 只有当 /root 卷被换掉、二进制丢失时才重新下载。
if opencode --version >/dev/null 2>&1; then
return
fi
- # 直接从 GitHub Releases 拉 tarball,绕开 opencode.ai/install 脚本对
- # api.github.com 的依赖(共享 IP 易触发限流)。
+ # 🔴 必须走 opencode.ai/files 渠道,**不能用 GitHub Releases 的 latest**:
+ # GitHub 的 `latest` 已经停在 v1.18.33(V1),而本 UI 只支持 V2
+ # (V2 不在 GitHub Releases;npm 上的 v2 在 `@opencode/cli` 包,不是 `opencode-ai`)。
+ # 详见 docker/Dockerfile.backend 的注释。
+ # 版本号由 Dockerfile 的 ENV OPENCODE_VERSION 传进来。
case "$(uname -m)" in
x86_64) OC_ARCH=x64 ;;
aarch64|arm64) OC_ARCH=arm64 ;;
@@ -31,10 +36,12 @@ ensure_opencode() {
esac
curl -fsSL -o /tmp/opencode.tar.gz \
- "https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-${OC_ARCH}.tar.gz"
+ "https://opencode.ai/files/bin/${OPENCODE_VERSION:-2.0.19}/opencode-linux-${OC_ARCH}.tar.gz"
tar -xzf /tmp/opencode.tar.gz -C /usr/local/bin opencode
rm -f /tmp/opencode.tar.gz
chmod +x /usr/local/bin/opencode
+ # 兜底下载后做一次版本确认:装成 V1 时立刻失败,而不是等到界面连不上才排查。
+ opencode --version
}
ensure_package_mirrors() {
@@ -62,6 +69,19 @@ ensure_mise
ensure_opencode
ensure_package_mirrors
+# 🔴 版本守卫(阶段 4 新增):本 UI 只支持 opencode **V2**。
+# 如果容器里的二进制是 V1(典型场景:还在用改造前构建的旧镜像),
+# 表现是「界面能打开、一发消息就连不上」,排查成本极高 —— 所以在这里直接喊出来。
+# 注意是**警告不是退出**:万一 --version 输出格式变了,不应该让容器起不来。
+OC_VERSION_OUTPUT="$(opencode --version 2>/dev/null || echo unknown)"
+case "${OC_VERSION_OUTPUT}" in
+ *v2.*) ;;
+ *)
+ echo "WARNING: opencode 版本不是 V2(实测输出:${OC_VERSION_OUTPUT})。" >&2
+ echo "WARNING: 本 UI 只支持 V2。请更新或重建后端镜像(见 docker/Dockerfile.backend)。" >&2
+ ;;
+esac
+
if [ "$#" -eq 0 ]; then
set -- opencode serve --port 4096 --hostname 0.0.0.0
fi
diff --git a/docker/nginx.host.conf.example b/docker/nginx.host.conf.example
index 9727e9c36..015b5c94a 100644
--- a/docker/nginx.host.conf.example
+++ b/docker/nginx.host.conf.example
@@ -18,8 +18,10 @@ server {
}
# API 反向代理
+ # 🔴 OpenCode V2:端点本身带 /api 前缀 → proxy_pass 不能带尾斜杠
+ # (带尾斜杠会削掉 /api/,请求打到不存在的路径上,后端只返回 SPA 兜底 HTML)。
location /api/ {
- proxy_pass http://127.0.0.1:4096/;
+ proxy_pass http://127.0.0.1:4096;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -36,7 +38,8 @@ server {
# WebSocket(PTY 终端)
location /api/pty/ {
- proxy_pass http://127.0.0.1:4096/pty/;
+ # V2:同样不能带尾斜杠(保持 /api/pty/… 原样透传)
+ proxy_pass http://127.0.0.1:4096;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
diff --git a/docs/opencode-v2-migration-phase0.5.md b/docs/opencode-v2-migration-phase0.5.md
new file mode 100644
index 000000000..fd0a2ab80
--- /dev/null
+++ b/docs/opencode-v2-migration-phase0.5.md
@@ -0,0 +1,1463 @@
+# OpenCodeUI 迁移 V2 —— 阶段 0.5 报告(换 SDK + 迁移端点)
+
+> 状态:**已完成**
+> 执行环境:容器内 Linux,opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`),OpenCodeUI `0.6.46`
+> 参照文档:`docs/opencode-v2-migration.md`(下文简称「迁移文档」)
+> 日期:2026-09-30
+> 命名说明:本报告文件名沿用任务要求(`phase0.5`)。它对应迁移文档 §8 的**阶段 1**
+> (换 SDK + 迁移端点),不是「阶段 0 的补充」。
+
+## 结论摘要
+
+| 任务 | 结果 |
+| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| **1. 目录定位策略实测** | 🔴 **结论 B —— 头不够**,但缺口**只有 1 个端点**(`GET /api/session`,且它只认裸 `directory`)
已回填到迁移文档 §3.3 |
+| 2. 换客户端包 | ✅ `@opencode-ai/sdk ^1.16.0` → **`@opencode/client` 精确锁定 `2.0.19`**;20 个文件的 import 路径已改 |
+| 3. 端点迁移 | ✅ 21 个真实 SDK 调用点已迁移(含健康检查);**58 处**未迁移功能改为**显式报错**而非静默失败 |
+| 4. 类型检查 | ✅ **零报错**(换包后曾 **336 条** → 适配类型层后 **95 条** → 迁移完 **0 条**) |
+| 4. 单元测试 | ✅ 全绿(**97 文件 / 683 用例**,比阶段 0 的 96/667 **多 1 文件 16 用例**) |
+| 4. 冒烟(真实服务) | ✅ **12/12 通过** —— 会话列表 / 模型列表 / 配置读取三条验收标准全部达成 |
+| 5. 与文档不符 | ⚠️ 共 **10 处**,其中 **2 处会改变阶段 3 的做法**(见 §5 第 1、2 条) |
+
+**阶段 1 产出(迁移文档 §8 的验收标准)**:✅ 会话列表、✅ 模型列表、✅ 配置读取 —— 均已在真实 V2 服务上跑通。
+
+---
+
+## 1. 任务 1:目录定位策略实测(最重要)
+
+> 原始记录:`/tmp/opencode/v2test/TASK1-EVIDENCE.md`(含逐条 curl 输出)
+
+### 1.1 实测设置
+
+```
+服务:cd /tmp/opencode/v2test/cwd && OPENCODE_SERVER_PASSWORD=t1 \
+ opencode --log-level info serve --hostname 127.0.0.1 --port 4097
+服务进程 cwd = /tmp/opencode/v2test/cwd
+凭证:opencode:t1
+```
+
+夹具(三个目录,互相可区分):
+
+| 目录 | `opencode.json` | `.opencode/` 下的探针 |
+| ------ | -------------------------------- | ------------------------------------------------------------- |
+| `dirA` | `{"username":"MARK_FROM_DIR_A"}` | `agent/probe-a.md`、`command/probe-a.md`、`skill/probe-dirA/` |
+| `dirB` | `{"username":"MARK_FROM_DIR_B"}` | `agent/probe-b.md`、`command/probe-b.md`、`skill/probe-dirB/` |
+| `cwd` | (无) | (无) |
+
+> 📌 夹具最初用的是 `{"theme": ...}`,但实测**拿不到** —— 见 §5 第 4 条。
+> 改用 V2 schema 里真实存在的 `username` 才生效。
+
+### 1.2 逐项实测结果
+
+#### `GET /api/config`(locationRef 作用域)→ 用返回的 `Config.Entry[].path` 判定
+
+| 方式 | 返回的文档条目 | 结论 |
+| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------- |
+| a) 只带 `x-opencode-directory: %2Ftmp%2Fopencode%2Fv2test%2FdirA` | 全局 jsonc + `~/.config/opencode` + **`/tmp/opencode/v2test/dirA/opencode.json`** | ✅ **头生效** |
+| b) 只带 `?location[directory]=/tmp/opencode/v2test/dirB` | 全局 jsonc + `~/.config/opencode` + **`/tmp/opencode/v2test/dirB/opencode.json`** | ✅ 参数生效 |
+| c) 都不带 | 只有全局两条(cwd 下无 `opencode.json`) | ✅ 回落 cwd |
+
+#### `GET /api/agent` / `GET /api/command` / `GET /api/skill`(locationRef 作用域)→ 用探针文件判定
+
+| 方式 | `/api/agent` | `/api/command` | `/api/skill` |
+| ----------------------------------- | ---------------- | ---------------- | ------------------- |
+| a) 只带请求头(dirA) | 只多出 `probe-a` | 只多出 `probe-a` | 只多出 `probe-dirA` |
+| b) 只带 `location[directory]`(dirB) | 只多出 `probe-b` | 只多出 `probe-b` | 只多出 `probe-dirB` |
+| c) 都不带 | 两者都没有 | 两者都没有 | 两者都没有 |
+
+→ ✅ **头足够**(与阶段 0 的判断一致)
+
+#### 🔴 `GET /api/session`(会话列表)→ 头**不够**
+
+数据准备:dirA 1 个会话、dirB 1 个会话、cwd 2 个会话,另有本机其它项目的历史会话。
+
+| 方式 | 返回内容 |
+| ----------------------------------------------------- | ------------------------------------------------------- |
+| a) 只带请求头(dirA) | ❌ **未过滤** —— 返回**全局 20+ 条、跨 6 个项目**的会话 |
+| b) 只带 `?location[directory]=dirB` | ❌ **未过滤** —— 同上 |
+| **b2) 只带裸 `?directory=/tmp/opencode/v2test/dirB`** | ✅ **正确过滤** —— 只返回 dirB 的那 1 条 |
+| c) 都不带 | ❌ **未过滤** —— 返回全局所有会话 |
+| d) 请求头(dirA) + 裸 `directory`(dirB) 同时带 | ✅ 裸 `directory` 胜出,只返回 dirB |
+
+**源码依据**(tag `v2.0.19`):
+
+- `packages/server/src/handlers/session.ts:62-72` —— 把 `ctx.query` 原样交给 `session.list()`
+- `packages/core/src/session/store.ts:105` —— 只在 `"directory" in input` 时才
+ `eq(SessionTable.directory, input.directory)`
+- 该端点的 query schema(`SessionsQuery`,`packages/protocol/src/groups/session.ts:165`)
+ **只有裸 `directory`**,没有 `location[directory]`
+ → 头和 deepObject 参数都被**静默丢弃**
+
+> ⚠️ 后果比「没过滤」更严重:不带 `directory` 时会返回**跨全部项目**的会话列表,
+> 属于**跨目录数据泄露 + 列表错乱**,而且**不报错、不 4xx**。
+
+#### session 作用域端点(`/api/session/{id}/*`)→ 与目录无关
+
+| 请求 | 返回的 location |
+| ---------------------------------------------------- | ------------------------------------------------- |
+| `GET /api/session/{B}` 不带目录信息 | `/tmp/opencode/v2test/dirB` |
+| `GET /api/session/{B}` 带**冲突**请求头(指向 dirA) | `/tmp/opencode/v2test/dirB`(头被忽略,结果正确) |
+| `GET /api/session/{B}/message` 带 / 不带冲突头 | 两次返回**完全相同** |
+
+→ location 取自 **session 行本身**,请求里的目录信息**不应传、传了也被忽略**。
+
+### 1.3 🎯 结论:**结论 B(头不够)**,但缺口只有 1 个端点
+
+| 端点类别 | 请求头够吗 | 阶段 1 的处理 |
+| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------- |
+| **locationRef 作用域(57 个)**:`/api/config`、`/api/agent`、`/api/skill`、`/api/command`、`/api/location`、`/api/model`、`/api/provider` … | ✅ **够** | 但实现上**改为显式传 `location[directory]`**(见下方说明) |
+| **`GET /api/session`(会话列表)** | ❌ **不够** | 必须显式传**裸 `directory`** → `src/api/v2Convert.ts` 的 `sessionDirectory()` |
+| **session 作用域(`/api/session/{id}/*`)** | 不适用 | **不传任何目录信息**(V1 里硬塞 `directory` 的做法已删除) |
+
+**为什么「头够」却仍然选择显式传参数?**
+
+虽然结论是 B(有例外),但阶段 1 对 locationRef 端点统一采用了**显式传 `location[directory]`**,理由:
+
+1. 这正是**官方生成客户端自己用的方式** —— `@opencode/client` 的 `appendQuery()`
+ 会把 `{location:{directory}}` 摊平成 `location[directory]=`;
+2. 显式参数在**抓包 / 日志 / 断点**里可核对,请求头是隐式的、容易漏看;
+3. 请求头在 `OpenCode.make({ headers })` 里是 **client 级**配置 —— 目录一变就得重建 client,
+ 而按调用传参天然支持「一个 client 打多个目录」;
+4. 代码里可以直接加开发期告警(见下),请求头方式反而不好加断言。
+
+> 实现:`src/api/v2Convert.ts` 的 `locationInput()` / `sessionDirectory()`。
+
+### 1.4 🔴 额外发现:`POST /api/session` 忽略请求头**和**中间件(阶段 3 会踩)
+
+`packages/server/src/handlers/session.ts:136`:
+
+```ts
+location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
+```
+
+- **既不看 location 中间件,也不看 `x-opencode-directory` 请求头**,只认 **body 里的 `location`**
+- 实测:`POST /api/session` 只带 `x-opencode-directory: .../dirA` →
+ 建出来的会话 `location.directory` = **服务进程 cwd**,`projectID` 也是 cwd 项目的,**且不报错**
+- 必须显式在 body 里写 `{"location":{"directory":"..."}}` 才会落在目标目录
+
+→ 已写进 `src/api/session.ts` 的 `createSession()` 占位错误信息,阶段 3 实现时不会再踩。
+
+### 1.5 目录写错的防护(任务要求的「断言或开发期日志」)
+
+`src/api/v2Convert.ts` 里所有构造目录入参的函数,在目录缺失时都会在**开发环境**打印告警:
+
+```
+[OpenCode V2] GET /api/config 未指定目录(serverId=活动服务器)。服务端会回落到它自己的
+process.cwd(),可能导致「选了 A 目录却读到 B 目录」而不报错。
+```
+
+- 用 `import.meta.env.DEV` 判断 → **生产构建里会被 tree-shake 掉**,不产生运行时噪音
+- 冒烟测试里已实测触发(见 §4.3 第 ⑤ 项的 stderr)
+
+### 1.6 已回填到迁移文档
+
+`docs/opencode-v2-migration.md` §3.3 已新增小节
+**「🔴 阶段 1 实测结论(2026-09-30):结论 B —— 头不够,但缺口只有 1 个端点」**,
+含上表 + 三条结论 + `POST /api/session` 陷阱。
+
+---
+
+## 2. 任务 2:换客户端包
+
+### 2.1 依赖变更
+
+```diff
+ "dependencies": {
+- "@opencode-ai/sdk": "^1.16.0",
++ "@opencode/client": "2.0.19",
+```
+
+- **精确锁定 `2.0.19`(不带 `^`)**:客户端与服务端是**协议同版本**的生成代码,
+ 用 caret 范围可能在下次 `npm install` 时拉到 2.1.x 而静默不兼容
+- `npm install` 结果:`added 10 packages, and audited 504 packages`
+- 传递依赖变化:
+
+| 新增 | 说明 |
+| ------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
+| `@opencode/schema` / `@opencode/protocol` `2.0.19` | `@opencode/client` 的直接依赖 |
+| `effect` `4.0.0-rc.112` | `@opencode/protocol` 的依赖(peer 标记为 optional) |
+| `fast-check` / `pure-rand` / `msgpackr` / `msgpackr-extract` / `node-gyp-build-optional-packages` | `effect` 的依赖 |
+| `@msgpackr-extract/*` × 6 | 平台二进制(可选依赖) |
+
+| 移除 | 说明 |
+| ------------------ | ------ |
+| `@opencode-ai/sdk` | 被替换 |
+
+**✅ 已验证 `effect` 不会进浏览器 bundle**:
+`@opencode/client` 的 **promise 入口**(`dist/promise/index.js`)只引用
+`gc472t4a → h9cy5hnk / frbwqjmf / 1tbj6z39` 这几个 chunk;
+含 `effect` 的 chunk(`0fn08bwv`、`qonqb2tyr` 等)**不在 promise 入口的引用链上**
+(它们服务于 `./effect` 与 `./solid` 子入口)。
+实测 `vite build` 成功,主 bundle 里搜 `effect/unstable` **0 命中**。
+
+> ⚠️ 但那 10 个包**会装进 `node_modules`**(约多占磁盘)。这是 `@opencode/client` 的固有代价。
+
+### 2.2 import 路径改动清单(20 个文件)
+
+统一替换:`'@opencode-ai/sdk/v2/client'` → `'@opencode/client'`
+
+| # | 文件 | 改后来源 |
+| --- | ----------------------------- | --------------------------------------------------------------------------- |
+| 1 | `src/api/global.ts` | `@opencode/client`(后来改为 V2 原生 `ServerInfo`) |
+| 2 | `src/api/lsp.ts` | `../types/api/v1Model`(V2 无此类型) |
+| 3 | `src/api/sdk.test.ts` | `@opencode/client`(同时改 mock:`createOpencodeClient` → `OpenCode.make`) |
+| 4 | `src/api/todo.ts` | `../types/api/v1Model`(V2 无 `Todo`) |
+| 5 | `src/types/api/agent.ts` | `./v1Model` |
+| 6 | `src/types/api/common.ts` | `./v1Model` |
+| 7 | `src/types/api/config.ts` | `./v1Model` |
+| 8 | `src/types/api/event.ts` | `./v1Model` |
+| 9 | `src/types/api/file.ts` | `./v1Model` |
+| 10 | `src/types/api/mcp.ts` | `./v1Model` |
+| 11 | `src/types/api/message.ts` | `./v1Model` |
+| 12 | `src/types/api/model.ts` | `./v1Model` |
+| 13 | `src/types/api/permission.ts` | `./v1Model` |
+| 14 | `src/types/api/project.ts` | `./v1Model` |
+| 15 | `src/types/api/pty.ts` | `./v1Model` |
+| 16 | `src/types/api/session.ts` | `./v1Model` |
+| 17 | `src/types/api/skill.ts` | `./v1Model` |
+| 18 | `src/types/api/tool.ts` | `./v1Model` |
+| 19 | `src/types/api/vcs.ts` | `./v1Model` |
+| 20 | `src/types/api/worktree.ts` | `./v1Model` |
+
+**为什么不是「自动跟随」**(与迁移文档 §1.2 的预判一致):
+旧 import 路径里的 `v2` 指 **SDK 自身第二代**,打的仍是 **V1 端点**;
+而新包的 `@opencode/client` 的 102 个类型名里**只有 12 个与旧包同名**,
+其余 90 个(消息 / Part / 事件 / question / todo / tool / lsp / formatter …)**V2 已整体删除**。
+
+### 2.3 `src/api/sdk.ts` 重写
+
+| 项 | 改前 | 改后 |
+| ---------- | ------------------------------------------------ | -------------------------------------------------------- |
+| 包 | `@opencode-ai/sdk` | `@opencode/client` |
+| 工厂 | `createOpencodeClient({...})` | `OpenCode.make({ baseUrl, headers, fetch })` |
+| 返回类型 | `OpencodeClient` | `OpenCodeClient`(= `ReturnType`) |
+| 返回值约定 | `{data, error, request, response}` 需 `unwrap()` | **直接返回数据**,失败直接 throw(`ClientError`) |
+| `unwrap()` | 必需 | **已删除**(V2 不再有 envelope) |
+
+**保留的两项能力**:
+
+1. **Tauri `plugin-http` fetch**(绕 CORS)—— 原样保留,含:
+ - 懒加载 + 缓存(`getTauriFetch()`)
+ - **代次(generation)防串扰**:`abortInFlightApiRequests()` 会让旧 client 的新请求立刻抛 `AbortError`
+ - `trackedFetch()` 把外部 `AbortSignal` 与内部 `AbortController` 串起来
+2. **Basic Auth** —— 🔴 **用户名改为固定 `opencode`**(新增 `makeOpencodeBasicAuthHeader(password)`)
+
+**为什么用户名要固定**:V2 服务端把用户名**硬编码为 `"opencode"`**
+(`packages/server/src/auth.ts:20`),`OPENCODE_SERVER_USERNAME` 在 v2.0.19 中**零读取处**。
+实测:`custom:pw` → 401、`opencode:pw` → 200。
+所以 `sdk.ts` 现在**忽略** `serverStore` 里存的 `auth.username`。
+(`serverStore.makeBasicAuthHeader()` 保留未动,因为它是对外导出的 API;健康检查已改用新函数。)
+
+### 2.4 运行时验证
+
+```bash
+node -e "import('@opencode/client').then(m => console.log(typeof m.OpenCode.make))"
+# → function
+```
+
+✅ **不需要 `solid-js`、也不需要手动安装 `effect`** 即可 `import` 主入口(promise 版)。
+(阶段 0 遗留的 ❗「`@opencode/client` 在 Tauri 下的流式表现」**仍属阶段 2** —— 本次只用到 promise 方法,未用 `event.subscribe()`。)
+
+---
+
+## 3. 任务 3:端点迁移
+
+### 3.1 总览
+
+| 类别 | 数量 | 说明 |
+| ------------------------------ | -----: | -------------------------------------------------------------------------------------------- |
+| **真实 SDK 调用点(已迁移)** | **21** | 打的是 V2 端点 |
+| **未迁移功能(显式报错占位)** | **58** | 调用即抛「阶段 3 待办」错误,**不静默** |
+| 纯函数(不碰 SDK,原样保留) | 4 | `extractUserMessageContent`、`getPtyConnectUrl`、`createSseTextParser`、`normalizeTodoItems` |
+
+> 阶段 3 的待办清单可直接 `grep -rn "notMigratedYet" src/api/` 得到。
+
+### 3.2 端点迁移对照表(迁移文档 §4 逐行 → 实际改动位置)
+
+> 图例:✅ 已迁移(打 V2 端点)|⛔ 显式报错占位(阶段 2/3)|➖ V2 已删除且无替代
+
+#### §4.1 服务与事件
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ------------ | ----------------------------------------------- | --------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| **健康检查** | `GET /global/health`(Rust 先试 `/api/health`) | `GET /api/info` | ✅ | `src/api/global.ts:31`
`src-tauri/.../opencode.rs:66`(Rust)
`src/store/serverStore.ts:676`(前端) |
+| 事件流 | `GET /global/event` | `GET /api/event` | ⛔ 阶段 2(`src/api/events.ts` **未改动**) | — |
+| 断开实例 | `POST /global/dispose`、`/instance/dispose` | ➖ 删除 | ⛔ | `src/api/global.ts:40`、`:53` |
+| 当前路径 | `GET /path` | `GET /api/location` | ✅ | `src/api/client.ts:174`(`getPath`)、`:187`(`getLocation`) |
+| 重载配置 | — | `POST /api/location/reload` | ➖ 未接入(YAGNI) | — |
+
+#### §4.2 会话
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ---------------------------------------------- | --------------------------------- | ------------------------------------------ | --------------------------------- | -------------------------------- |
+| **列表** | `GET /session` | `GET /api/session` | ✅ **(裸 `directory`!)** | `src/api/session.ts:103` |
+| **详情** | `GET /session/{id}` | `GET /api/session/{id}` | ✅ | `src/api/session.ts:119` |
+| 创建 | `POST /session` | `POST /api/session` | ⛔(body 必须带 `location`) | `src/api/session.ts:138` |
+| 更新 | `PATCH /session/{id}` | `PATCH /api/session/{id}` | ⛔ | `src/api/session.ts:159` |
+| 删除 | `DELETE /session/{id}` | `DELETE /api/session/{id}` | ⛔ | `src/api/session.ts:178` |
+| **会话状态** | `GET /session/status` | `GET /api/session/active` | ✅(语义变为「只列活跃」) | `src/api/session.ts:45` |
+| 拉消息 | `GET /session/{id}/message` | `GET /api/session/{id}/message` | ⛔ 阶段 2(返回 `{data,cursor}`) | `src/api/message.ts:68` |
+| **发消息** | `POST /session/{id}/message` | `POST /api/session/{id}/prompt` | ⛔ 阶段 2 | `src/api/message.ts:160` |
+| **异步发消息** | `POST /session/{id}/prompt_async` | ➖ 删除(prompt 本身非阻塞) | ⛔ | `src/api/message.ts:181` |
+| 阻塞等待 | — | `POST /api/experimental/session/{id}/wait` | ➖ 未接入 | — |
+| 把阻塞工具转后台 | — | `POST /api/session/{id}/background` | ➖ 未接入 | — |
+| **停止生成** | `POST /session/{id}/abort` | `POST /api/session/{id}/interrupt` | ⛔ | `src/api/session.ts:195` |
+| 拷贝 | `POST /session/{id}/fork` | `POST /api/session/{id}/fork` | ⛔ | `src/api/session.ts:267` |
+| **回退** | `POST /session/{id}/revert` | 三段式 stage/commit/clear | ⛔ | `src/api/session.ts:208` |
+| 取消回退 | `POST /session/{id}/unrevert` | ➖ 删除 | ⛔ | `src/api/session.ts:228` |
+| **分享** | `POST/DELETE /session/{id}/share` | ➖ 删除 | ⛔ | `src/api/session.ts:241`、`:254` |
+| **摘要** | `POST /session/{id}/summarize` | ➖ 删除(用 `/compact`) | ⛔ | `src/api/session.ts:286` |
+| **子会话** | `GET /session/{id}/children` | ➖ 删除(用 `?parentID=`) | ⛔ | `src/api/session.ts:306` |
+| **待办** | `GET /session/{id}/todo` | ➖ 删除 | ⛔ | `src/api/session.ts:328` |
+| diff | `GET /session/{id}/diff` | `GET /api/session/{id}/diff` | ✅(字段完全兼容) | `src/api/session.ts:61` |
+| 新增(切 agent/model/move/view/context/inbox) | — | 若干 | ➖ 未接入(YAGNI) | — |
+
+#### §4.3 权限与提问
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ------------ | -------------------------------------- | --------------------------------- | ----------- | ----------------------------------------- |
+| 权限列表 | `GET /permission` | `GET /api/permission/request` | ⛔ | `src/api/permission.ts:31` |
+| 权限回复 | `POST /session/{id}/permissions/{pid}` | `POST .../permission/{rid}/reply` | ⛔ | `src/api/permission.ts:48` |
+| **回答提问** | `GET /question` + reply/reject | ➖ 删除 → **Form 表单体系** | ⛔ | `src/api/permission.ts:74`、`:92`、`:110` |
+| 待处理表单 | — | `GET /api/form` | ➖ 未接入 | — |
+
+#### §4.4 模型、配置、项目
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| --------------------- | ---------------------------- | ------------------------------------------ | ---------------------------------------- | ----------------------- |
+| **读配置** | `GET /config` | `GET /api/config`(返回 `Config.Entry[]`) | ✅(按优先级合并) | `src/api/config.ts:85` |
+| 读全局配置 | `GET /global/config` | ➖ 无独立端点 | ✅(从 Entry[] 里筛出非项目文档) | `src/api/config.ts:101` |
+| 改(location 级)配置 | `PATCH /config` | ➖ **不存在** | ⛔ | `src/api/config.ts:119` |
+| 改全局配置 | `PATCH /global/config` | `PATCH /api/experimental/config` | ⛔ **(只支持 `shell`,见 §5 第 2 条)** | `src/api/config.ts:137` |
+| **模型列表** | `GET /config/providers` | `GET /api/model` + `/api/provider` | ✅(前端 join) | `src/api/client.ts:50` |
+| 默认模型 | 同上 `default` 字段 | `GET /api/model/default` | ✅(形状变化,见 §5 第 5 条) | `src/api/client.ts:65` |
+| Provider 列表 | 同上 | `GET /api/provider` | ✅ | `src/api/client.ts:50` |
+| **可用 shell** | `GET /pty/shells` | `GET /api/config/shell` | ✅(类型完全一致) | `src/api/pty.ts:61` |
+| **Agent 列表** | `GET /agent` | `GET /api/agent` | ✅ | `src/api/agent.ts:16` |
+| **Skill 列表** | `GET /skill` | `GET /api/skill` | ✅ | `src/api/skill.ts:16` |
+| **命令列表** | `GET /command` | `GET /api/command` | ✅ | `src/api/command.ts:57` |
+| 执行命令 | `POST /session/{id}/command` | `POST /api/session/{id}/command` | ⛔(body 形态变了) | `src/api/command.ts:87` |
+| **项目列表** | `GET /project` | `GET /api/project` | ✅(去掉 directory 参数) | `src/api/client.ts:109` |
+| **当前项目/目录** | `GET /project/current` | `GET /api/location` | ✅(+ 补查 project.list) | `src/api/client.ts:87` |
+| 初始化 git | `POST /project/git/init` | ➖ 删除 | ⛔ | `src/api/client.ts:122` |
+| 更新项目 | `PATCH /project/{id}` | `PATCH /api/project/{id}` | ⛔ | `src/api/client.ts:137` |
+| **LSP 状态** | `GET /lsp` | ➖ **删除** | ⛔ | `src/api/lsp.ts:29` |
+| **格式化器状态** | `GET /formatter` | ➖ **删除** | ⛔ | `src/api/lsp.ts:46` |
+
+#### §4.5 文件与搜索
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| -------------- | ------------------- | ------------------------------- | ------------ | -------------------- |
+| 列目录 | `GET /file` | `GET /api/fs/list` | ⛔ | `src/api/file.ts:45` |
+| 读文件 | `GET /file/content` | `GET /api/fs/read/*` | ⛔ | `src/api/file.ts:62` |
+| 文件改动状态 | `GET /file/status` | ➖ 删除(用 `/api/vcs/status`) | ⛔ | `src/api/file.ts:74` |
+| 内容搜索 | `GET /find` | ➖ **无端点** | ⛔(待决策) | `src/api/file.ts:99` |
+| **文件名搜索** | `GET /find/file` | `GET /api/fs/find` | ⛔ | `src/api/file.ts:24` |
+| **符号搜索** | `GET /find/symbol` | ➖ 删除 | ⛔ | `src/api/file.ts:86` |
+
+#### §4.6 终端 PTY
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ---------- | ---------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------- |
+| 列表 | `GET /pty` | `GET /api/pty` | ⛔ | `src/api/pty.ts:42` |
+| 创建 | `POST /pty` | `POST /api/pty` | ⛔ | `src/api/pty.ts:70` |
+| 详情 | `GET /pty/{id}` | `GET /api/pty/{id}` | ⛔ | `src/api/pty.ts:82` |
+| 更新 | `PATCH /pty/{id}` | **`PUT /api/pty/{id}`** | ⛔ | `src/api/pty.ts:94` |
+| 删除 | `DELETE /pty/{id}` | `DELETE /api/pty/{id}` | ⛔ | `src/api/pty.ts:112` |
+| 连接 | `WS /pty/{id}/connect` | **两步**(connect-token → connect) | ⛔ | `src/api/pty.ts:136`(`getPtyConnectUrl` 保留但**V2 下连不上**,已注释说明) |
+| **shells** | `GET /pty/shells` | `GET /api/config/shell` | ✅ | `src/api/pty.ts:61` |
+
+#### §4.7 MCP、VCS、Worktree
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ----------------------- | ----------------------------------- | --------------------------------- | ----------- | ------------------------------------------- |
+| MCP 列表 | `GET /mcp` | `GET /api/mcp` | ⛔ | `src/api/mcp.ts:24` |
+| MCP 资源 | `GET /experimental/resource` | `GET /api/mcp/resource` | ⛔ | `src/api/mcp.ts:37` |
+| 增删 / 连接 / 断开 | `POST /mcp` 等 | `/api/experimental/mcp/{server}` | ⛔ | `src/api/mcp.ts:49`、`:63`、`:75` |
+| **MCP OAuth(4 端点)** | `/mcp/{name}/auth*` | ➖ 删除 → `/api/integration/*` | ⛔ | `src/api/mcp.ts:88`、`:101`、`:113`、`:125` |
+| VCS 信息 / diff | `GET /vcs`、`/vcs/diff` | `GET /api/vcs`、`/api/vcs/diff` | ⛔ | `src/api/vcs.ts:22`、`:35` |
+| Worktree 列表/新建/删除 | `/experimental/worktree` | `/api/worktree`(改用 projectID) | ⛔ | `src/api/worktree.ts:23`、`:36`、`:48` |
+| Worktree 重置 | `POST /experimental/worktree/reset` | ➖ 删除 | ⛔ | `src/api/worktree.ts:60` |
+
+#### §4.8 工具、插件、调试
+
+| 功能 | V1 | V2 | 阶段 1 处理 | 位置 |
+| ---------------------------------------------------- | ------------------------------ | ------------------------------- | ------------------ | --------------------------- |
+| 工具列表 | `GET /experimental/tool[/ids]` | ➖ 删除(用 `GET /api/plugin`) | ⛔ | `src/api/tool.ts:22`、`:34` |
+| 插件 / 调试 / 迁移状态 / 网页搜索 / Shell / 插件 RPC | — | 新增 | ➖ 未接入(YAGNI) | — |
+
+### 3.3 类型层适配(`src/types/api/*`)
+
+这是本次改动**最容易被低估**的一块。结论:**类型层必须从「转发层」变成「定义层」**。
+
+**问题**:`src/types/api/*` 原本 16 个文件几乎全是 `export type X = SDKX` 的**转发**。
+换包后旧包的 102 个类型名里**只剩 12 个**在新包中存在(`Project`、`Pty`、`UnknownError`、
+`McpResource`、`VcsInfo`、`PermissionRequest`、`WorktreeCreateInput/RemoveInput`、`SessionStatus`、
+`McpStatusConnected/Disabled/Failed/NeedsAuth`),其余 **90 个 V2 已整体删除**。
+
+**而下游的 UI / store / 渲染层仍然按 V1 模型编写,且阶段 1 禁止改动它们**
+→ 唯一出路是**让类型层自给自足**。
+
+**做法**:新增 `src/types/api/v1Model.ts`(**3286 行 / 189 个声明**)
+
+- 用脚本从 `@opencode-ai/sdk@1.16.0` 的 `dist/v2/gen/types.gen.d.ts` 里
+ **按「项目实际引用的 102 个类型名 + 传递闭包」逐字抽取**(未手写、未改写形状)
+- 文件头有醒目的中文说明:**这是阶段 1 的临时兼容层,阶段 2/3 完成迁移后应整体删除**
+- 各 `src/types/api/*.ts` 的 import 从 `@opencode/client` 改为 `./v1Model`
+
+**效果**:类型错误从 **336 → 95**(一次性消掉 241 条,含 `FileExplorer.tsx` 的 23 条、
+`QuestionDialog.tsx` 的 14 条等**全部下游渲染组件报错**,实现了「渲染层零改动」)。
+
+**新增的转换层**:`src/api/v2Convert.ts`(V2 响应 → 内部 V1 形状)
+
+| 函数 | 作用 | 关键差异 |
+| ------------------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------- |
+| `locationInput()` | 构造 `{location:{directory}}` | 缺目录时开发期告警 |
+| `sessionDirectory()` | 构造**裸 `directory`** | 🔴 `GET /api/session` 专用 |
+| `toInternalSession()` | `Session.Info` → `Session` | `directory` → **`location.directory`**;`slug`/`version`/`summary`/`share` 已删除 |
+| `toInternalSessionStatusMap()` | `/api/session/active` → `SessionStatusMap` | 语义变为「只列活跃」,缺失补 `idle` |
+| `toInternalAgent()` | `Agent.Info` → `Agent` | `permissions`→`permission`、`system`→`prompt`;**权限规则模型整个换了,填 `[]`** |
+| `toInternalSkill()` | `Skill.Info` → `Skill` | `path` → `location` |
+| `toInternalProject()` | `Project` → `Project` | **`canonical` → `worktree`** |
+| `toUiModelInfos()` | `Model.Info[]` + `Provider.Info[]` → `ModelInfo[]` | 两个平铺列表 join;`capabilities.input: string[]` → 4 个布尔 |
+
+### 3.4 未迁移项为什么用「显式报错」而不是留着编译错误
+
+新增 `src/api/notMigrated.ts`:
+
+```ts
+notMigratedYet('创建会话(POST /session)', 'POST /api/session', '⚠️ 必须把目录写进 body 的 location 字段…')
+// → throw new Error('[OpenCode V2 迁移 · 阶段 3 待办] 创建会话… 尚未迁移到 OpenCode V2。\nV2 替代方案:…')
+```
+
+**理由**:
+
+1. **类型检查必须通过**(阶段 1 验收要求),但 58 处功能确实没迁移
+2. **不能假装能用**:如果为了编译而 `as any`,运行时会静默 404 或拿到错数据
+3. **不能只是删掉**:删掉会让调用方直接 `undefined is not a function`,且丢失「这里曾经有什么功能」
+4. **显式抛错** → 编译过、失败**大声**、且**待办清单可 grep**
+ (`grep -rn "notMigratedYet" src/api/`)
+
+每条占位错误都写明了:**功能名** + **V2 替代端点** + **本次调用的实际参数**(便于复现)。
+
+### 3.5 健康检查改造(一并完成)
+
+#### 改前的问题:**静默假阳性**
+
+| 链路 | 改前逻辑 | V2 下的实际行为 |
+| -------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Rust**(`opencode.rs:42-76`) | 先试 `/api/health`,再试 `/global/health`,**只看状态码** `is_success()` | `/api/health` → 404(非 2xx)→ 继续;`/global/health` → **200 + SPA 兜底 HTML** → `is_success()` = **true** → **判「服务健康」**
🔴 于是**任何**在该路径返回 200 的 HTTP 服务都算「opencode 健康」,**校验能力归零且不报错** |
+| **前端**(`serverStore.ts:643`) | 打 `/global/health`,检查 `content-type: application/json` + `data.healthy === true` | 拿到 `text/html` → `status:'error'` → 界面显示服务异常 |
+
+#### 改后:单一 `GET /api/info` + **结构校验**
+
+**Rust**(`src-tauri/src/app/commands/opencode.rs`):
+
+```rust
+let response = client.get(format!("{}/api/info", base)) ... .send().await?;
+if !response.status().is_success() { return false; }
+// 关键:不能只看状态码
+serde_json::from_str::(&body).map(|v| is_opencode_info_body(&v))
+```
+
+`is_opencode_info_body()` 判据(与前端**同一套**):
+`version` 是非空字符串 + `pid` 是数字 + `urls` 是数组。
+
+> 📌 实现细节:Cargo.toml 里 `reqwest` 关了默认特性、**没启用 `json` feature**,
+> 所以用 `response.text()` + `serde_json::from_str` 手动解析,**零额外依赖**。
+
+**前端**(`src/store/serverStore.ts`):
+
+- `checkHealth()` 端点:`${server.url}/global/health` → **`${server.url}/api/info`**(第 676 行)
+- 新增 `isOpencodeInfoResponse()`,替换原来的 `data.healthy !== true` 判断
+- **删除了 `healthy` 字段依赖** —— V2 的 `/api/info` 没有这个字段
+
+**UI(3 处 version 展示 + 1 处诊断文案)**:
+
+| 位置 | 内容 | 是否需改 |
+| ------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------- |
+| `src/features/settings/components/ServerHealthButton.tsx:20` | `· OpenCode v${health.version}` | ✅ **无需改** —— `/api/info` 仍有 `version` |
+| `src/features/chat/sidebar/MultiServerFolderList.tsx:229` | `· v${health.version}` | ✅ **无需改** |
+| `src/features/chat/sidebar/SearchResults.tsx:232` | `· v${health.version}` | ✅ **无需改** |
+| `src/features/chat/ChatPane.tsx:371` | 硬编码 `'Expected /global/health to return OpenCode health JSON.'` | ✅ **已改为 `/api/info`** |
+
+> ⚠️ **透明说明**:`ChatPane.tsx` 属于聊天/渲染组件。这次只改了**1 行、且是健康检查的诊断文案**,
+> 依据是任务允许的「健康检查相关(…、对应 UI)」。如果认为越界,**单独 revert 这一行即可**,
+> 不影响其它任何改动(它只是错误提示里的一句话)。
+
+**新增/更新的测试**(`src/store/serverStore.test.ts`,+4 用例):
+
+1. ✅ V2 `/api/info` 响应 → `online`,`version` 正确
+2. ✅ **请求的 URL 是 `/api/info`**,且不含 `global/health`
+3. ✅ **旧 V1 形状 `{healthy:true,version}` 现在被拒绝**(防止回退)
+4. ✅ 非 OpenCode JSON(`{ok:true}`)仍被拒绝
+
+### 3.6 阶段 1 未做(按任务边界,属阶段 3)
+
+写操作一律未迁移:创建/更新/删除会话、发消息、中止、回退、权限回复、PTY 增删改、MCP 增删、
+worktree 增删、配置文件写入(`updateGlobalConfig` 的 `shell` 以外字段)。
+原因:任务明确「**只做 GET/读取类**,写操作和删除项留到阶段 3」。
+
+---
+
+## 4. 任务 4:验证
+
+### 4.1 类型检查
+
+```bash
+npx tsc -b --force # 退出码 0,零报错
+```
+
+**三个快照**(完整清单见附录 A / B):
+
+| 快照 | 状态 | 错误数 |
+| ----- | ------------------------------------------------------- | ------: |
+| **A** | 换包 + 改 import 路径 + 重写 `sdk.ts`,**类型层未适配** | **336** |
+| **B** | 再适配 `src/types/api/*`(引入 `v1Model.ts`) | **95** |
+| **C** | 端点迁移完成后(最终) | **0** |
+
+### 4.2 单元测试
+
+```bash
+timeout 180 npm run test:run # = vitest run(npm test 是 watch 模式,非交互环境下会挂住)
+```
+
+```
+Test Files 97 passed (97)
+ Tests 683 passed (683)
+ Duration 17.54s
+```
+
+- 基线(阶段 0):96 文件 / 667 用例
+- 现在:**97 文件 / 683 用例**(+1 文件 = 新增冒烟套件;+16 用例 = 4 条健康检查 + 12 条冒烟)
+- **服务不可达时**(把临时服务停掉后复跑):
+ ```
+ Test Files 96 passed | 1 skipped (97)
+ Tests 671 passed | 12 skipped (683)
+ ```
+ → 671 = 667(阶段 0 基线)+ 4(新增健康检查用例)✅;冒烟套件整体跳过,**不会让 `npm test` 变红**
+
+### 4.3 冒烟测试(真实 V2 服务 + 真实迁移代码)
+
+`src/api/phase1Smoke.test.ts` —— 12 项全部通过。**不是 mock**:打的是真实运行的 `opencode v2.0.19`。
+
+| # | 验证项 | 实测结果 |
+| --- | -------------------------------- | --------------------------------------------------------------------------------------------------- |
+| ① | 健康检查走 `/api/info` | `{"version":"2.0.19","pid":76002,"urls":["http://127.0.0.1:4097"],"paths":{"tmp":"/tmp/opencode"}}` |
+| ② | **会话列表按目录过滤** | dirA → `PAYLOAD_A@…/dirA`;dirB → `PAYLOAD_B@…/dirB`(**两条互不相同**) |
+| ③ | 会话详情 location 正确 | `ses_f11f1f19affed5wRJII4fsEl4e /tmp/opencode/v2test/dirB PAYLOAD_B` |
+| ④ | **模型列表** | 15 个模型,`providerName` 来自 `/api/provider` 的 join(未退化为 providerID) |
+| ⑤ | **配置读取按目录合并** | dirA → `username=MARK_FROM_DIR_A`;dirB → `MARK_FROM_DIR_B`;不带目录 → 无标记(回落 cwd) |
+| ⑥ | Agent 列表按目录过滤 | dirA 含 `probe-a` 不含 `probe-b`;dirB 反之 |
+| ⑦ | Skill 列表按目录过滤 | dirA 含 `probe-dirA`;dirB 含 `probe-dirB` |
+| ⑧ | 命令列表按目录过滤 | dirA 含 `probe-a`;dirB 含 `probe-b` |
+| ⑨ | 当前项目 / 路径 | `worktree=/tmp/opencode/v2test/dirA`(V2 `canonical` 正确映射);项目总数 44 |
+| ⑩ | Shell 列表走 `/api/config/shell` | 9 个 shell,`{path,name,acceptable}` 结构正确 |
+| ⑪ | 会话状态走 `/api/session/active` | `{}`(当前无活跃会话) |
+| ⑫ | **未迁移功能显式报错** | `searchText` / `getSessionTodos` 均抛「阶段 3 待办」错误,**不静默** |
+
+**复现方式**(已写进测试文件头部注释):
+
+```bash
+mkdir -p /tmp/opencode/v2test/{cwd,dirA,dirB}
+echo '{ "username": "MARK_FROM_DIR_A" }' > /tmp/opencode/v2test/dirA/opencode.json
+echo '{ "username": "MARK_FROM_DIR_B" }' > /tmp/opencode/v2test/dirB/opencode.json
+# …(agent/command/skill 探针见文件头)
+cd /tmp/opencode/v2test/cwd && \
+ OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097
+npx vitest run src/api/phase1Smoke.test.ts --reporter=verbose
+```
+
+### 4.4 其它检查
+
+| 检查 | 结果 |
+| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `npx eslint <我改动的文件>` | ✅ 零告警 |
+| `npx prettier --check <我改动的文件>` | ✅ 全部符合 |
+| `npm run format:check`(全仓库) | ⚠️ **失败,但属既有问题** —— 报告 160 个文件格式不符,**其中包含大量我从未触碰的文件**(`src/store/themeStore.ts`、`vite.config.ts` 等)。已逐一验证:**我改动的文件全部是干净的** |
+| `npx vite build` | ✅ 成功;主 bundle **不含 `effect`**(`effect/unstable` 0 命中) |
+| `cargo check`(Rust) | ❌ **未执行** —— 见 §6 |
+
+---
+
+## 5. 🔴 与文档预测不符之处(本节最重要)
+
+### ① 🔴 `GET /api/session` 只认裸 `directory`,头和 `location[directory]` 都被**静默忽略**(文档 §3.3 未覆盖)
+
+**文档原文**(§3.3):
+
+> **组合结论**:参数全可选 + 请求头是第二优先级回退 ⇒ **带 `x-opencode-directory` 头即可定位目录,端点参数可基本不传**。
+
+**实际**:这条对 **57 个 locationRef 端点成立**,但 **`GET /api/session` 是例外**:
+
+| 方式 | 结果 |
+| -------------------------- | -------------------------------------- |
+| 只带请求头 | ❌ 被忽略 → **返回全局所有项目的会话** |
+| 只带 `location[directory]` | ❌ 被忽略 → 同上 |
+| 只带**裸 `directory`** | ✅ 正确过滤 |
+
+**影响**:这是本次唯一**必须显式补参数**的端点,且失败方式是「**静默返回跨项目数据**」而非报错
+→ 若按文档「参数可基本不传」实现,会出现「会话列表混进别的项目」这种极难排查的 bug。
+已回填 §3.3,并在 `src/api/v2Convert.ts` 里用 `sessionDirectory()` 单独处理。
+
+**⚠️ 一个重要的补充(实现时才发现)**:「不带 `directory`」**本身是合法用法** ——
+多服务器模式的全局搜索(`SearchResults.tsx:121`)就是要跨目录搜整个服务器。
+所以 `sessionDirectory()` **不能抛错**,只能记一条开发期提示。
+这也意味着**这个端点无法用断言防呆**,只能靠日志 + code review。
+→ 阶段 3 若改动会话列表相关调用,请特别留意**目录是否漏传**。
+
+### ② 🔴 V2 **没有**「写任意配置字段」的 API —— 配置编辑器的保存功能无法迁移(文档 §4.4 判断错误)
+
+**文档原文**(§4.4):
+
+> | **配置编辑器** | 用 `updateConfig` + `updateGlobalConfig` | ✅ 有对应端点 | UI 实际用的是 `updateGlobalConfig` → **可迁移** |
+
+**实际**:`PATCH /api/experimental/config` 的 payload 类型是 `Config.Patch`,而
+`packages/schema/src/config.ts:112`(tag `v2.0.19`)里它**只有一个字段**:
+
+```ts
+export const Patch = Schema.Struct({ shell: Schema.NullOr(Schema.String) })
+```
+
+→ **V2 唯一能写的配置字段是 `shell`**。配置编辑器(`ConfigSettings.tsx:272` 调 `updateGlobalConfig`)
+的**保存功能无法迁移**,只能:
+① 保留读 + 改 `shell`;② 其余字段改为只读展示并引导用户直接编辑配置文件。
+
+**阶段 1 的处理**:`src/api/config.ts:137` 的 `updateGlobalConfig()` 只在 patch **仅含 `shell`** 时放行,
+其余字段一律**显式报错并列出不支持哪些字段**,避免「以为保存了其实被静默忽略」。
+文档 §4.4 对应行已修正。
+
+### ③ `GET /api/config` 的 `Config.Entry.info` 是 **schema 归一化后的视图**,未知字段被**静默丢弃**
+
+**文档原文**(§3.4):
+
+> 配置文件本身 V2 **能读 V1 格式并自动归一化**(不重写源文件),所以**用户配置不用改**。
+
+**实际**:这句话对**已知字段**成立,但 `GET /api/config` 返回的 `info` **不是原始文档**,
+而是**过了 V2 schema 的归一化结果** —— schema 里没有的字段**直接消失**。
+
+**实测**:夹具里写 `{"theme": "THEME_FROM_DIR_A"}` → API 返回的该条目 `info` 是 **`{}`**(空对象)。
+换成 V2 schema 里存在的 `username` 才拿得到值。
+
+**影响**:
+
+- 配置编辑器通过 API 读到的是**有损视图**,用户在文件里手写的未知/旧字段在界面上「看不见」
+- 阶段 1 的冒烟测试就因此**先失败了一次**(用 `theme` 做标记拿不到),换 `username` 后通过
+- 阶段 3 适配配置编辑器时必须考虑这一点(否则会出现「界面上没有 = 会被覆盖掉」的风险)
+
+### ④ 文档 §3.3 说「请求头仍然有效」是对的,但**没提它和中间件是两套东西**
+
+`POST /api/session`(创建会话)的 handler 是
+`ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }`
+(`packages/server/src/handlers/session.ts:136`)—— 它**既不看中间件也不看请求头**,
+只认 **body 里的 `location`**。
+
+**实测**:只带 `x-opencode-directory: .../dirA` 创建会话 → 建出来的会话在**服务进程 cwd**,
+`projectID` 也是 cwd 项目的,**且不报错**。
+
+→ 属阶段 3,但**提前记录**避免届时踩坑(已写进 `createSession()` 的占位错误信息)。
+
+### ⑤ `GET /api/model/default` 只返回**单个**模型,不是「每个 provider 的默认模型」映射
+
+**文档原文**(§4.4):
+
+> | 模型列表 | `GET /config/providers` | **`GET /api/model`** + `GET /api/model/default` | |
+
+**实际**:
+
+- V1 `config.providers()` 的 `default` 字段是 **`{ [providerID]: modelID }` 映射**
+- V2 `GET /api/model/default` 返回 `{ location, data: ModelInfo | null }` —— **单个模型**
+
+→ 阶段 1 把单个默认模型包装成「只含一个键」的映射以保持内部 `Record` 约定
+(`src/api/client.ts:65`)。该函数当前**全仓库无调用点**,属保留接口。
+
+### ⑥ `GET /api/session/active` 的语义是「**只列活跃会话**」,不是 V1 的「全量状态表」
+
+**文档原文**(§4.2):
+
+> | **会话状态** | `GET /session/status` | **`GET /api/session/active`** | 返回 `SessionActive` |
+
+**实际**:V1 的 `/session/status` 返回 **全部会话**的状态(4 态:`idle`/`busy`/`retry`/…);
+V2 的 `/api/session/active` **只返回活跃的那些**(`{type:'running'}`),**不在表里 = 空闲**。
+
+→ 阶段 1 在 `toInternalSessionStatusMap()` 里把缺失的会话补成 `idle`,让下游判断逻辑不变。
+文档没写这个语义差异,属**文档缺口**。
+
+### ⑦ `GET /path` → `GET /api/location` 有**字段缺失**,文档未提示
+
+**文档原文**(§4.1):
+
+> | 当前路径 | `GET /path` | **`GET /api/location`** | |
+
+**实际**:
+
+| | 字段 |
+| ------------------------ | ------------------------------------------------------ |
+| V1 `Path` | `{ home, state, config, worktree, directory }` |
+| V2 `Location.PublicInfo` | `{ directory, project: { id, directory, canonical } }` |
+
+V2 **没有任何端点能拿到 `home` / `state` / `config`**
+(`GET /api/info` 的 `paths` **只有 `tmp`**)。
+
+**影响**:`home` 被「目录选择器」当作默认起始路径(`ProjectDialog.tsx` 的 `path = p.home`),
+给空串会让它落到**文件系统根目录**,体验很差。
+→ 阶段 1 用**当前目录**兜底(`src/api/client.ts:174` 的 `getPath()`),并在代码里注明原因。
+文档 §4.1 应补上这条字段缺失。
+
+### ⑧ `GET /api/project` **不接受** `directory` 参数(文档表格未提)
+
+**文档原文**(§4.4):`GET /project` → `GET /api/project`(看起来只是路径改名)
+
+**实际**:V1 的 `project.list({ directory })` 接受目录参数;
+V2 的 `project.list()` **完全没有入参**(是**全局**项目表,实测返回 44 条跨所有项目)。
+传 `directory` 会被当成 `RequestOptions` 而类型报错。
+
+→ `getProjects()` 已去掉该参数(`src/api/client.ts:109`)。
+另外 `getCurrentProject()` 因为 V2 的 `location.get()` **不含 `vcs` / `time` / `sandboxes`**
+(而 `project.vcs` 是 UI 判断「要不要显示 git diff 选项」的依据),
+所以额外补查了一次 `project.list()` 按 id 取完整对象(`src/api/client.ts:87`,**2 次请求**)。
+
+### ⑨ `V1` 的 `Agent.permission` 与 V2 的 `permissions` **不是改名,是换了模型**
+
+**文档原文**(§3.4 提到过配置层的权限改动,但**没说 Agent 列表接口也受影响**)
+
+**实际**:
+
+| | 结构 |
+| ------------------- | ------------------------------------------------------------------------- |
+| V1 `PermissionRule` | `{ permission: string; pattern: string; action: 'allow'\|'deny'\|'ask' }` |
+| V2 `PermissionRule` | `{ action: string; resource: string; effect: 'allow'\|'deny' }` |
+
+字段与语义都不同 → **无法直接映射**。
+阶段 1 在 `toInternalAgent()` 里填空数组 `[]`,并已**全仓库核对:下游没有任何地方读取
+`agent.permission`**(只有 `request.permission`,那是 PermissionRequest 的字段,另一回事),
+所以不造成行为差异。已写进代码注释。
+
+### ⑩ 🔴 **新发现**:`GET /api/model` / `GET /api/provider` 在 location **首次被访问**时可能返回**空数组**
+
+**文档未提及**(§4.4 只说这两个端点替代了 `/config/providers`)。
+
+**实测**(opencode v2.0.19,同一台服务):
+
+| 场景 | `GET /api/model` 返回 |
+| --------------------------------------- | --------------------------------------------------- |
+| 全新目录 `dirC` 的**第 1 次**请求 | `{"location":…,"data":[]}` ← **空** |
+| `dirC` 的**第 2 次**请求 | 15 条 |
+| 全新目录 `dirD` 第 1 次 / 第 2 次 | **0 条** / 78 条 |
+| 全新目录 `dirE`:先打 `/api/provider` | `/api/provider` **也是 0 条** |
+| 全新目录 `dirF`:先打 `/api/agent` 预热 | 再打 `/api/model` → 正常 |
+| 服务刚启动时的第 1 次并发请求 | `/api/model` 与 `/api/provider` **都是 `data: []`** |
+| 同一 location 稳定后连打 6 次 | 全部 15 条(稳定) |
+
+→ **结论**:V2 的 location 是**惰性初始化**的,provider/model 目录在初始化完成前会返回**空数组**
+(而且**不报错、不 503**)。这正是「静默失效」的另一种形态。
+
+**影响**:
+
+1. **阶段 1 的冒烟测试曾因此失败一次** —— 服务刚起时 `getActiveModels()` 拿到 0 个模型。
+ 处理方式:测试里先做一次预热(并在注释里写明原因),把断言聚焦在「迁移后的 join 逻辑是否正确」。
+2. **产品层面需要处理**:UI 首次打开某个目录时,模型列表可能为空。
+ 阶段 2/3 应加**重试**(或等 location 就绪)—— 否则用户会看到「没有可用模型」。
+3. 也解释了为什么 `getActiveModels()` 用 `Promise.all([model.list, provider.list])` 是**有风险**的:
+ 两个请求都可能在 location 未就绪时返回空。
+
+> 补充观察:`dirD` 第 2 次返回的是 **78 条**(而不是稳定的 15 条),
+> 疑似「完整的 models.dev 目录」与「按配置过滤后的目录」之间的中间态。
+> 因为很快收敛到 15 条、且不影响阶段 1 的验收,**未深究**,仅记录现象供阶段 3 参考。
+
+---
+
+## 6. 未做 / 未验证事项(如实汇报)
+
+| 项 | 状态 | 原因 |
+| ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Rust 编译验证(`cargo check`)** | ❌ **未做** | `src-tauri/target/` 不存在(从未构建过),全量 `cargo check` 需拉整套 Tauri 依赖树,在 12G 内存约束下代价大。改动是**单个函数体 + 1 个私有辅助函数**,不涉及类型/借用/控制流以外的风险。**阶段 0 同样未做。** |
+| **Rust 健康检查的运行时验证** | ❌ 未做 | 需要 `cargo run`/真实 Tauri 运行时;逻辑已与前端**保持同一套判据**,前端那套已有 4 条单测覆盖 |
+| **WSL 真实链路** | ❌ 未做 | 容器内无 WSL(阶段 0 同样未做) |
+| **Tauri `plugin-http` 下的真实请求** | ❌ 未做 | 容器内无 Tauri 运行时。本次只验证了「注入自定义 fetch 的机制」在类型与 mock 层正确 |
+| **`client.event.subscribe()` 在 Tauri 下的流式表现** | ❌ 未做 | **属阶段 2**(本次未使用事件订阅)。阶段 0 遗留的 ❗ 仍然开放 |
+| **阶段 3 的全部写操作** | ❌ 未做 | 按任务边界(只做 GET/读取类) |
+| **配置编辑器适配 V2 字段** | ❌ 未做 | 属阶段 3(P2)。且发现 V2 根本没有通用写接口(见 §5 ②) |
+| **Docker 锁版本 / 三形态回归** | ❌ 未做 | 属阶段 3/4 |
+
+**清理说明**:
+
+- 冒烟测试启动的临时服务(端口 **4097**)**已停止**
+- 容器内原有的 **4096** 常驻服务(pid 8)**全程未受影响**
+- 临时夹具在 `/tmp/opencode/v2test/`(含 `TASK1-EVIDENCE.md`),**不在仓库内**
+- `dist/` 是 `.gitignore` 忽略的构建产物,`vite build` 的输出不影响仓库
+- 顺带修掉了容器文件系统给文件误加执行位的噪音(21 个文件 `chmod 644`),
+ **`git diff` 里已无 mode change**
+
+---
+
+## 7. 阶段 3 开工前的提醒(从本次实测沉淀)
+
+1. **`POST /api/session` 必须把目录写进 body**(`{location:{directory}}`),头与 query 都会被忽略(§5 ④)
+2. **配置写入只能改 `shell`**,其余字段要么引导用户编辑文件、要么放弃(§5 ②)
+3. **`GET /api/session` 必须传裸 `directory`**,否则静默返回全局数据(§5 ①)
+4. **`Config.Entry.info` 是有损视图**,未知字段会消失 → 配置编辑器要按「只读展示」设计(§5 ③)
+5. **`/api/session/active` 只列活跃会话**,缺省即空闲(§5 ⑥)
+6. `grep -rn "notMigratedYet" src/api/` 就是阶段 3 的待办清单(**58 处**)
+7. **模型/Provider 列表在 location 首次访问时可能是空的** → UI 要加重试(§5 ⑩)
+8. 阶段 2 开始前先决定:`src/types/api/v1Model.ts`(3286 行临时兼容层)里
+ **消息 / Part / 事件**那部分应优先删除,其余留到阶段 3
+
+---
+
+## 附录 A:类型报错全量清单(快照 A —— 换包后,类型层未适配)
+
+> 状态:已完成 `npm install @opencode/client@2.0.19` + 改 20 个文件的 import 路径 + 重写 `src/api/sdk.ts`,
+> **尚未**适配 `src/types/api/*`。
+> 命令:`npx tsc -b --force` **共 336 条**
+>
+> 错误码分布:`TS2339` × 136、`TS2305` × 94、`TS7006` × 36、`TS2353` × 31、
+> `TS2724` × 15、`TS2345` × 13、`TS2322` × 5、`TS2551` × 3、`TS2352` × 2、`TS2349` × 1
+
+按文件统计(完整逐条见下):
+
+| 文件 | 条数 | | 文件 | 条数 |
+| ---------------------------------------- | ---: | --- | ----------------------------------------------------- | ---: |
+| `src/components/FileExplorer.tsx` | 23 | | `src/api/lsp.ts` | 5 |
+| `src/types/api/message.ts` | 20 | | `src/api/global.ts` | 5 |
+| `src/api/session.ts` | 18 | | `src/types/api/common.ts` | 4 |
+| `src/hooks/useChatSession.ts` | 16 | | `src/features/message/parts/ToolPartView.tsx` | 4 |
+| `src/types/api/event.ts` | 14 | | `src/types/api/tool.ts` | 3 |
+| `src/features/chat/QuestionDialog.tsx` | 14 | | `src/features/chat/EmptyState.tsx` | 3 |
+| `src/features/chat/PermissionDialog.tsx` | 14 | | `src/api/vcs.ts` | 3 |
+| `src/features/chat/InlineQuestion.tsx` | 14 | | `src/api/tool.ts` | 3 |
+| `src/types/api/config.ts` | 13 | | `src/api/command.ts` | 3 |
+| `src/features/chat/InlinePermission.tsx` | 11 | | `src/types/api/worktree.ts` | 2 |
+| `src/hooks/useFileExplorer.test.tsx` | 10 | | `src/types/api/pty.ts` | 2 |
+| `src/api/mcp.ts` | 10 | | `src/types/api/project.ts` | 2 |
+| `src/api/file.ts` | 8 | | `src/hooks/usePermissionHandler.test.tsx` | 2 |
+| `src/api/client.ts` | 8 | | `src/hooks/useGitWorkspaceCatalog.ts` | 2 |
+| `src/hooks/useFileExplorer.ts` | 7 | | `src/features/settings/components/ConfigSettings.tsx` | 2 |
+| `src/api/permission.ts` | 7 | | `src/features/sessions/ProjectSelector.tsx` | 2 |
+| `src/types/api/mcp.ts` | 6 | | `src/features/sessions/ProjectSelector.test.tsx` | 2 |
+| `src/types/api/file.ts` | 6 | | `src/components/McpPanel.tsx` | 2 |
+| `src/hooks/useGlobalEvents.ts` | 6 | | `src/api/skill.ts` | 2 |
+| `src/components/WorktreePanel.tsx` | 6 | | `src/api/agent.ts` | 2 |
+| `src/api/pty.ts` | 6 | | `src/types/api/vcs.ts` | 1 |
+| `src/api/config.ts` | 6 | | `src/types/api/skill.ts` | 1 |
+| `src/types/api/session.ts` | 5 | | `src/types/api/agent.ts` | 1 |
+| `src/types/api/permission.ts` | 5 | | `src/features/chat/sidebar/FolderRecentList.tsx` | 1 |
+| `src/types/api/model.ts` | 5 | | `src/features/chat/InlineToolRequestContext.tsx` | 1 |
+| `src/components/SessionChangesPanel.tsx` | 5 | | `src/features/chat/InlineToolRequestContext.test.tsx` | 1 |
+| `src/api/worktree.ts` | 5 | | `src/features/chat/ChatPane.tsx` | 1 |
+| `src/api/message.ts` | 5 | | `src/api/todo.ts` | 1 |
+
+**关键观察**:这 336 条里**绝大多数(约 240 条)根本不在 `src/api/` 里**,
+而是散落在 **UI / hooks / 渲染组件**(`FileExplorer.tsx` 23 条、`QuestionDialog.tsx` 14 条、
+`useChatSession.ts` 16 条…)。
+→ 这直接证明了「**类型层必须自给自足**」这个判断:只要 `src/types/api/*` 一天还转发 V2 类型,
+下游就一天编译不过,而阶段 1 又禁止改动这些组件。
+
+### 快照 A 逐条清单(逐条,共 336 条)
+
+```text
+── src/api/agent.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 15:27 error TS2339 Property 'app' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+
+── src/api/client.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 56:29 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise; update: (input: ProjectUpdate...
+ 126:67 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'RequestOptions'.
+ 134:35 error TS2339 Property 'initGit' does not exist on type '{ list: (requestOptions?: RequestOptions | undefined) => Promise; update: (input: ProjectUpdate...
+ 152:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ProjectUpdateInput'.
+ 164:41 error TS2339 Property 'path' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/command.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 42:51 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'CommandListInput'.
+ 94:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'SessionCommandInput'.
+
+── src/api/config.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 15:40 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigGetInput'.
+ 23:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 31:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigUpdateInput'.
+ 40:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 48:34 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 100:45 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'FileReadInput'.
+ 108:32 error TS2339 Property 'status' does not exist on type '{ read: (input: FileReadInput, requestOptions?: RequestOptions | undefined) => Promise; list: (inpu...
+ 116:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 124:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/global.ts ──
+ 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalHealthResponse'.
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 22:20 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 31:20 error TS2339 Property 'instance' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+
+── src/api/lsp.ts ──
+ 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'FormatterStatus'.
+ 5:54 error TS2305 Module '"@opencode/client"' has no exported member 'LspStatus'.
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 20:51 error TS2339 Property 'lsp' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+ 42:57 error TS2339 Property 'formatter' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pr...
+
+── src/api/mcp.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:31 error TS2339 Property 'status' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ...
+ 22:40 error TS2339 Property 'resource' does not exist on type '{ persistentPty: { read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions | undefined)...
+ 30:30 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpAddInput'.
+ 38:34 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpConnectInput'.
+ 46:37 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpDisconnectInput'.
+ 54:39 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 64:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 72:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 80:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+
+── src/api/message.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 63:23 error TS2551 Property 'messages' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+ 97:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+ 112:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+
+── src/api/pty.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 40:38 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyListInput'.
+ 40:95 error TS7006 Parameter 'pty' implicitly has an 'any' type.
+ 50:31 error TS2339 Property 'shells' does not exist on type '{ list: (input?: PtyListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ...
+ 66:64 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyGetInput'.
+ 89:47 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyRemoveInput'.
+
+── src/api/session.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 31:35 error TS2551 Property 'status' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise Promise Promise Promise Promise Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+
+── src/api/todo.ts ──
+ 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'Todo'.
+
+── src/api/tool.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 22:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/vcs.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 17:39 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsGetInput'.
+ 29:63 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsDiffInput'.
+
+── src/api/worktree.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'WorktreeListInput'.
+ 22:85 error TS2353 Object literal may only specify known properties, and 'worktreeCreateInput' does not exist in type 'WorktreeCreateInput'.
+ 30:38 error TS2322 Type 'string | undefined' is not assignable to type 'string'.
+ 39:29 error TS2339 Property 'reset' does not exist on type '{ list: (input: WorktreeListInput, requestOptions?: RequestOptions | undefined) => Promise; create: (i...
+
+── src/components/FileExplorer.tsx ──
+ 109:10 error TS7006 Parameter 'submatch' implicitly has an 'any' type.
+ 113:13 error TS7006 Parameter 'range' implicitly has an 'any' type.
+ 202:16 error TS2339 Property 'type' does not exist on type 'FileTreeNode'.
+ 203:27 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 205:50 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 205:67 error TS2339 Property 'name' does not exist on type 'FileTreeNode'.
+ 486:29 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 734:45 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 735:28 error TS2339 Property 'type' does not exist on type 'FileTreeNode'.
+ 737:38 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 737:67 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 759:20 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 760:24 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 761:20 error TS2339 Property 'name' does not exist on type 'FileTreeNode'.
+ 765:11 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 765:22 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 765:37 error TS2339 Property 'name' does not exist on type 'FileTreeNode'.
+ 774:61 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 774:72 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 779:18 error TS2339 Property 'ignored' does not exist on type 'FileTreeNode'.
+ 794:40 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 808:72 error TS2339 Property 'name' does not exist on type 'FileTreeNode'.
+ 821:26 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+
+── src/components/McpPanel.tsx ──
+ 304:39 error TS2339 Property 'client' does not exist on type 'McpResource'.
+ 306:25 error TS2339 Property 'client' does not exist on type 'McpResource'.
+
+── src/components/SessionChangesPanel.tsx ──
+ 136:53 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'.
+ 136:98 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'.
+ 141:47 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'.
+ 152:77 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'.
+ 166:18 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'.
+
+── src/components/WorktreePanel.tsx ──
+ 60:43 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 61:44 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 166:41 error TS2345 Argument of type '{ name: string; }' is not assignable to parameter of type 'WorktreeCreateInput'.
+ 197:30 error TS2345 Argument of type '{ directory: string; }' is not assignable to parameter of type 'WorktreeRemoveInput'.
+ 275:91 error TS2322 Type 'VcsBranch' is not assignable to type 'string'.
+ 276:15 error TS2322 Type 'VcsBranch' is not assignable to type 'ReactI18NextChildren | Iterable'.
+
+── src/features/chat/ChatPane.tsx ──
+ 971:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+
+── src/features/chat/EmptyState.tsx ──
+ 68:106 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 96:41 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 97:17 error TS2339 Property 'worktree' does not exist on type 'Project'.
+
+── src/features/chat/InlinePermission.tsx ──
+ 55:30 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 55:63 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 56:31 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 56:51 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 57:46 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 57:59 error TS7006 Parameter 'p' implicitly has an 'any' type.
+ 61:41 error TS2339 Property 'always' does not exist on type 'PermissionRequest'.
+ 61:68 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 64:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 79:28 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 88:28 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+
+── src/features/chat/InlineQuestion.tsx ──
+ 30:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 30:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 36:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 36:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 42:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 42:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 100:61 error TS7006 Parameter 'q' implicitly has an 'any' type.
+ 100:64 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 112:46 error TS7006 Parameter '_q' implicitly has an 'any' type.
+ 112:50 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 142:33 error TS7006 Parameter 'question' implicitly has an 'any' type.
+ 142:43 error TS7006 Parameter 'qIdx' implicitly has an 'any' type.
+ 237:32 error TS7006 Parameter 'option' implicitly has an 'any' type.
+ 237:40 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+
+── src/features/chat/InlineToolRequestContext.test.tsx ──
+ 60:7 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'.
+
+── src/features/chat/InlineToolRequestContext.tsx ──
+ 69:49 error TS2339 Property 'tool' does not exist on type 'PermissionRequest'.
+
+── src/features/chat/PermissionDialog.tsx ──
+ 55:30 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 55:63 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 96:75 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 145:24 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 145:44 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 148:36 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 148:49 error TS7006 Parameter 'p' implicitly has an 'any' type.
+ 156:24 error TS2339 Property 'always' does not exist on type 'PermissionRequest'.
+ 156:42 error TS2339 Property 'always' does not exist on type 'PermissionRequest'.
+ 159:36 error TS2339 Property 'always' does not exist on type 'PermissionRequest'.
+ 184:55 error TS2339 Property 'always' does not exist on type 'PermissionRequest'.
+ 184:82 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 188:76 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 189:66 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+
+── src/features/chat/QuestionDialog.tsx ──
+ 34:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 34:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 41:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 41:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 48:32 error TS7006 Parameter '_' implicitly has an 'any' type.
+ 48:35 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 116:61 error TS7006 Parameter 'q' implicitly has an 'any' type.
+ 116:64 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 139:46 error TS7006 Parameter 'q' implicitly has an 'any' type.
+ 139:49 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+ 225:39 error TS7006 Parameter 'question' implicitly has an 'any' type.
+ 225:49 error TS7006 Parameter 'qIdx' implicitly has an 'any' type.
+ 330:32 error TS7006 Parameter 'option' implicitly has an 'any' type.
+ 330:40 error TS7006 Parameter 'idx' implicitly has an 'any' type.
+
+── src/features/chat/sidebar/FolderRecentList.tsx ──
+ 1018:15 error TS2322 Type 'string | VcsBranch' is not assignable to type 'ReactI18NextChildren | Iterable'.
+
+── src/features/message/parts/ToolPartView.tsx ──
+ 114:33 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 114:86 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 121:24 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 121:68 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+
+── src/features/sessions/ProjectSelector.test.tsx ──
+ 6:24 error TS2352 Conversion of type '{ id: string; name: string; worktree: string; }' to type 'Project' may be a mistake because neither type sufficiently overlaps with the o...
+ 12:21 error TS2352 Conversion of type '{ id: string; name: string; worktree: string; }' to type 'Project' may be a mistake because neither type sufficiently overlaps with the o...
+
+── src/features/sessions/ProjectSelector.tsx ──
+ 77:32 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 88:22 error TS2339 Property 'worktree' does not exist on type 'Project'.
+
+── src/features/settings/components/ConfigSettings.tsx ──
+ 191:26 error TS7006 Parameter 'shell' implicitly has an 'any' type.
+ 275:17 error TS7006 Parameter 'current' implicitly has an 'any' type.
+
+── src/hooks/useChatSession.ts ──
+ 402:73 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 402:93 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 415:34 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 415:64 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 415:87 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 415:111 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 687:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 691:44 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 695:65 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 702:13 error TS2322 Type 'string | null' is not assignable to type 'string'.
+ 713:35 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 720:54 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 725:30 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 1018:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 1030:13 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+ 1043:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'.
+
+── src/hooks/useFileExplorer.test.tsx ──
+ 116:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 122:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 123:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 130:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 132:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 165:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 175:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 183:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 192:36 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'.
+ 193:65 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+
+── src/hooks/useFileExplorer.ts ──
+ 247:28 error TS2339 Property 'type' does not exist on type 'FileTreeNode'.
+ 265:24 error TS2339 Property 'type' does not exist on type 'FileTreeNode'.
+ 422:14 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 437:14 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 455:16 error TS2339 Property 'type' does not exist on type 'FileTreeNode'.
+ 457:34 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+ 459:29 error TS2339 Property 'path' does not exist on type 'FileTreeNode'.
+
+── src/hooks/useGitWorkspaceCatalog.ts ──
+ 92:46 error TS2339 Property 'worktree' does not exist on type 'Project'.
+ 93:65 error TS2339 Property 'worktree' does not exist on type 'Project'.
+
+── src/hooks/useGlobalEvents.ts ──
+ 412:53 error TS2345 Argument of type 'PermissionRequest[]' is not assignable to parameter of type '{ id: string; sessionID: string; permission: string; patterns?: string[] | und...
+ 415:58 error TS2345 Argument of type 'PermissionRequest[]' is not assignable to parameter of type '{ id: string; sessionID: string; permission: string; patterns?: string[] | und...
+ 647:32 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 647:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+ 647:85 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'.
+ 647:109 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'.
+
+── src/hooks/usePermissionHandler.test.tsx ──
+ 49:11 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'.
+ 78:11 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'.
+
+── src/types/api/agent.ts ──
+ 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'Agent'.
+
+── src/types/api/common.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'ApiError'.
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'MessageAbortedError'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'MessageOutputLengthError'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthError'.
+
+── src/types/api/config.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'AgentConfig'.
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'Config'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'LayoutConfig'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'LogLevel'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpLocalConfig'.
+ 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpOAuthConfig'.
+ 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpRemoteConfig'.
+ 9:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionActionConfig'.
+ 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionConfig'.
+ 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionObjectConfig'.
+ 12:3 error TS2724 '"@opencode/client"' has no exported member named 'PermissionRuleConfig'. Did you mean 'PermissionRule'?
+ 13:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderConfig'.
+ 14:3 error TS2305 Module '"@opencode/client"' has no exported member 'ServerConfig'.
+
+── src/types/api/event.ts ──
+ 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventMessagePartDelta'.
+ 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventMessagePartRemoved'.
+ 9:3 error TS2724 '"@opencode/client"' has no exported member named 'EventPermissionReplied'. Did you mean 'PermissionReplied'?
+ 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventQuestionRejected'.
+ 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventQuestionReplied'.
+ 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventSessionDiff'.
+ 13:3 error TS2724 '"@opencode/client"' has no exported member named 'EventSessionIdle'. Did you mean 'SessionIdle'?
+ 14:3 error TS2724 '"@opencode/client"' has no exported member named 'EventSessionStatus'. Did you mean 'SessionStatus'?
+ 15:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventTodoUpdated'.
+ 16:3 error TS2724 '"@opencode/client"' has no exported member named 'EventVcsBranchUpdated'. Did you mean 'VcsBranchUpdated'?
+ 17:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventWorktreeFailed'.
+ 18:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventWorktreeReady'.
+ 19:3 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalEvent'.
+ 20:3 error TS2305 Module '"@opencode/client"' has no exported member 'Todo'.
+
+── src/types/api/file.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'File'.
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'FileContent'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'FileNode'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'SnapshotFileDiff'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'Symbol'.
+ 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'FindTextResponse'.
+
+── src/types/api/mcp.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpLocalConfig'.
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpOAuthConfig'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpRemoteConfig'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatusResponse'.
+ 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatus'.
+ 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatusNeedsClientRegistration'.
+
+── src/types/api/message.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'AgentPart'.
+ 3:3 error TS2724 '"@opencode/client"' has no exported member named 'AgentPartInput'. Did you mean 'AgentGetInput'?
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'AssistantMessage'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'CompactionPart'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'FilePart'.
+ 7:3 error TS2724 '"@opencode/client"' has no exported member named 'FilePartInput'. Did you mean 'FileWriteInput'?
+ 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'FilePartSource'.
+ 9:3 error TS2305 Module '"@opencode/client"' has no exported member 'PatchPart'.
+ 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'ReasoningPart'.
+ 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'RetryPart'.
+ 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'SnapshotPart'.
+ 13:3 error TS2305 Module '"@opencode/client"' has no exported member 'StepFinishPart'.
+ 14:3 error TS2305 Module '"@opencode/client"' has no exported member 'StepStartPart'.
+ 15:3 error TS2305 Module '"@opencode/client"' has no exported member 'SubtaskPart'.
+ 16:3 error TS2305 Module '"@opencode/client"' has no exported member 'SubtaskPartInput'.
+ 17:3 error TS2305 Module '"@opencode/client"' has no exported member 'TextPart'.
+ 18:3 error TS2305 Module '"@opencode/client"' has no exported member 'TextPartInput'.
+ 19:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolPart'.
+ 20:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolState'.
+ 21:3 error TS2305 Module '"@opencode/client"' has no exported member 'UserMessage'.
+
+── src/types/api/model.ts ──
+ 2:3 error TS2724 '"@opencode/client"' has no exported member named 'ConfigProvidersResponse'. Did you mean 'ConfigProviderSettings'?
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'Model'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'Provider'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthAuthorization'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthMethod'.
+
+── src/types/api/permission.ts ──
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionAnswer'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionInfo'.
+ 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionOption'.
+ 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionRequest'.
+ 9:67 error TS2339 Property 'tool' does not exist on type 'PermissionRequest'.
+
+── src/types/api/project.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Path'.
+ 4:3 error TS2724 '"@opencode/client"' has no exported member named 'ProjectUpdateData'. Did you mean 'ProjectUpdated'?
+
+── src/types/api/pty.ts ──
+ 3:3 error TS2724 '"@opencode/client"' has no exported member named 'PtyCreateData'. Did you mean 'PtyCreated'?
+ 4:3 error TS2724 '"@opencode/client"' has no exported member named 'PtyUpdateData'. Did you mean 'PtyUpdated'?
+
+── src/types/api/session.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Session'.
+ 3:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionCreateData'. Did you mean 'SessionCreated'?
+ 4:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionForkData'. Did you mean 'SessionForked'?
+ 5:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionListData'. Did you mean 'SessionMetadata'?
+ 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'SessionUpdateData'.
+
+── src/types/api/skill.ts ──
+ 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'AppSkillsResponse'.
+
+── src/types/api/tool.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolIds'.
+ 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolList'.
+ 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolListItem'.
+
+── src/types/api/vcs.ts ──
+ 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'VcsDiffData'.
+
+── src/types/api/worktree.ts ──
+ 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Worktree'.
+ 5:3 error TS2724 '"@opencode/client"' has no exported member named 'WorktreeResetInput'. Did you mean 'WorktreeListInput'?
+```
+
+## 附录 B:类型报错全量清单(快照 B —— 类型层适配后)
+
+> 状态:已新增 `src/types/api/v1Model.ts` 并把各类型文件指向它,**尚未**迁移端点。
+> 命令:`npx tsc -b --force` **共 95 条**,**全部集中在 `src/api/`**(下游 0 条 ✅)
+
+| 文件 | 条数 |
+| ----------------------- | ---: |
+| `src/api/session.ts` | 17 |
+| `src/api/mcp.ts` | 10 |
+| `src/api/pty.ts` | 8 |
+| `src/api/file.ts` | 8 |
+| `src/api/client.ts` | 8 |
+| `src/api/permission.ts` | 7 |
+| `src/api/config.ts` | 6 |
+| `src/api/worktree.ts` | 5 |
+| `src/api/message.ts` | 5 |
+| `src/api/global.ts` | 5 |
+| `src/api/vcs.ts` | 3 |
+| `src/api/tool.ts` | 3 |
+| `src/api/lsp.ts` | 3 |
+| `src/api/command.ts` | 3 |
+| `src/api/skill.ts` | 2 |
+| `src/api/agent.ts` | 2 |
+
+→ 这批就是任务 3 的待办清单,已全部处理(21 处迁移 + 58 处显式占位)。
+
+### 快照 B 逐条清单(逐条,共 95 条)
+
+```text
+── src/api/agent.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 15:27 error TS2339 Property 'app' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+
+── src/api/client.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 56:29 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise; update: (input: ProjectUpdate...
+ 126:67 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'RequestOptions'.
+ 134:35 error TS2339 Property 'initGit' does not exist on type '{ list: (requestOptions?: RequestOptions | undefined) => Promise; update: (input: ProjectUpdate...
+ 152:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ProjectUpdateInput'.
+ 164:41 error TS2339 Property 'path' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/command.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 42:51 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'CommandListInput'.
+ 94:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'SessionCommandInput'.
+
+── src/api/config.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 15:40 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigGetInput'.
+ 23:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 31:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigUpdateInput'.
+ 40:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 48:34 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 100:45 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'FileReadInput'.
+ 108:32 error TS2339 Property 'status' does not exist on type '{ read: (input: FileReadInput, requestOptions?: RequestOptions | undefined) => Promise; list: (inpu...
+ 116:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 124:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/global.ts ──
+ 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalHealthResponse'.
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 22:20 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi...
+ 31:20 error TS2339 Property 'instance' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+
+── src/api/lsp.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 20:51 error TS2339 Property 'lsp' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+ 42:57 error TS2339 Property 'formatter' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pr...
+
+── src/api/mcp.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:31 error TS2339 Property 'status' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ...
+ 22:40 error TS2339 Property 'resource' does not exist on type '{ persistentPty: { read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions | undefined)...
+ 30:36 error TS2322 Type 'McpServerConfig' is not assignable to type '{ readonly type: "local"; readonly command: readonly string[]; readonly cwd?: string | undefined; readonly ...
+ 38:34 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpConnectInput'.
+ 46:37 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpDisconnectInput'.
+ 54:39 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 64:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 72:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+ 80:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; .....
+
+── src/api/message.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 63:23 error TS2551 Property 'messages' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+ 97:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+ 112:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro...
+
+── src/api/pty.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 40:38 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyListInput'.
+ 40:95 error TS7006 Parameter 'pty' implicitly has an 'any' type.
+ 50:31 error TS2339 Property 'shells' does not exist on type '{ list: (input?: PtyListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ...
+ 58:53 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyCreateInput'.
+ 66:64 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyGetInput'.
+ 80:49 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyUpdateInput'.
+ 89:47 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyRemoveInput'.
+
+── src/api/session.ts ──
+ 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 31:35 error TS2551 Property 'status' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise Promise Promise Promise Promise Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<...
+
+── src/api/tool.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+ 22:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise...
+
+── src/api/vcs.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 17:39 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsGetInput'.
+ 29:57 error TS2322 Type '"git" | "branch"' is not assignable to type '"branch" | "working" | "committed"'.
+
+── src/api/worktree.ts ──
+ 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'.
+ 14:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'WorktreeListInput'.
+ 22:85 error TS2353 Object literal may only specify known properties, and 'worktreeCreateInput' does not exist in type 'WorktreeCreateInput'.
+ 30:38 error TS2322 Type 'string | undefined' is not assignable to type 'string'.
+ 39:29 error TS2339 Property 'reset' does not exist on type '{ list: (input: WorktreeListInput, requestOptions?: RequestOptions | undefined) => Promise; create: (i...
+```
diff --git a/docs/opencode-v2-migration-phase0.md b/docs/opencode-v2-migration-phase0.md
new file mode 100644
index 000000000..d6165e058
--- /dev/null
+++ b/docs/opencode-v2-migration-phase0.md
@@ -0,0 +1,644 @@
+# OpenCodeUI 迁移 V2 —— 阶段 0 报告
+
+> 状态:**已完成**(阶段 0:依赖修复 + WSL 启动修复 + 端到端冒烟 + §10.4 剩余项核对)
+> 执行环境:容器内 Linux,opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`),OpenCodeUI `0.6.46`
+> 参照文档:`docs/opencode-v2-migration.md`(下文简称「迁移文档」)
+> 日期:2026-09-30
+
+## 结论摘要
+
+| 任务 | 结果 |
+| --------------- | -------------------------------------------------------------------------------------------------- |
+| 1. 依赖不一致 | ✅ 已修复,`node_modules` 从 1.4.1 → **1.16.0**;`package.json` / `package-lock.json` **无需改动** |
+| 1. 类型检查 | ✅ **零报错**(467 个 `src` 文件参与检查)—— 与"记录一批报错留给后续阶段"的预期不同 |
+| 1. 单元测试 | ✅ 全绿(96 文件 / 667 用例),依赖变更无回归 |
+| 2. WSL 启动命令 | ✅ 已修复(1 个文件、2 个值 + 中文注释);`opencode.rs` 未动 |
+| 3. 端到端冒烟 | ✅ 服务正常起、鉴权正常、`/api/info` 与 `/api/location` 正常;V1 端点确认失效 |
+| 4. §10.4 剩余项 | ✅ `directory` vs `location[directory]` 已逐端点核对完(138 个端点);**发现 1 个文档结论错误** |
+| 5. 与文档不符 | ⚠️ 共 **8 处**,其中 1 处会影响阶段 1 的改造量估算(见 §5) |
+
+---
+
+## 1. 任务 1:依赖不一致修复
+
+### 1.1 修复前后版本对比
+
+| 项 | 修复前 | 修复后 |
+| --------------------------- | --------------------------- | ----------------------------------------- |
+| `package.json` 声明 | `@opencode-ai/sdk: ^1.16.0` | `^1.16.0`(**未改**) |
+| `package-lock.json` 锁定 | `1.16.0` | `1.16.0`(**未改**) |
+| `node_modules` 实装 | **`1.4.1`** ❌ | **`1.16.0`** ✅ |
+| `sdk.pty.shells()` 是否存在 | ❌ 不存在 | ✅ 存在(`dist/v2/gen/sdk.gen.d.ts:755`) |
+
+**执行的命令与结果**:
+
+```bash
+npm install
+# added 15 packages, removed 114 packages, changed 21 packages, and audited 495 packages in 8s
+# 14 vulnerabilities (1 low, 6 moderate, 7 high)
+```
+
+**关键事实**:`package-lock.json` 本来就已经正确锁定 `1.16.0`,只是 `node_modules` 与之不符。
+所以本次 `npm install` **只改了 `node_modules`,没有改任何仓库文件**(`git status` 可证:只有 `wsl_commands.rs` 被修改 + `docs/` 未跟踪)。
+
+> 因此「允许改 `package.json` / `package-lock.json`」这条授权**实际未使用** —— 没有需要改的地方。
+
+### 1.2 类型检查结果
+
+- `package.json` 自带脚本:`"typecheck": "tsc -b"`(另有别名 `type-check`),所以按脚本执行。
+- ⚠️ `tsc -b` 是**增量构建**(缓存 `node_modules/.tmp/*.tsbuildinfo`),可能直接命中缓存。
+ 因此额外跑了强制全量:`npx tsc -b --force`。
+- 另用 `npx tsc --noEmit -p tsconfig.app.json --listFiles` 确认 **467 个 `src` 文件**确实参与检查(不是空跑)。
+
+**结果:退出码 0,零报错。**
+
+```
+$ npm run typecheck
+> opencodeui@0.6.46 typecheck
+> tsc -b
+(无任何输出)
+
+$ npx tsc -b --force
+(无任何输出,退出码 0)
+```
+
+> 📌 **这是阶段 0 最重要的一条"输入缺失"**:迁移文档预期阶段 0 能产出一份「类型报错清单」供后续阶段使用,
+> 但依赖修好之后**一条报错都没有** —— 后续阶段的改造清单只能来自文档的端点对照表,不能指望类型检查报错来导航。
+
+### 1.3 修复前的坏调用:实际是 **2 处**,不是 1 处
+
+迁移文档 §2.3 只列了 `src/api/pty.ts:50` 的 `sdk.pty.shells()`。实际用 1.4.1 的类型复现后有 **2 处**:
+
+```
+src/api/pty.ts(50,31): error TS2339: Property 'shells' does not exist on type 'Pty'.
+src/store/childSessionStore.ts(83,22): error TS2339: Property 'agent' does not exist on type 'Session'.
+```
+
+- 第 1 处(`pty.shells`):与文档一致。已确认 1.4.1 的 `dist/` 里 `grep -r shells` **零命中**;1.16.0 中有。
+- 第 2 处(`Session.agent`):**文档未提及**。`src/store/childSessionStore.ts:83` 读取 `session.agent`,
+ 该字段在 1.4.1 的 `Session` 类型中不存在,1.16.0 的 `Session.Info` 中有 `agent?: string`。
+ → 也就是说 1.4.1 下**子会话(subtask)创建链路**在类型层面也是坏的,不只是 PTY shells。
+
+**复现方法(透明说明)**:项目当时的 `node_modules` 已经是修好之后的 1.16.0,无法直接回滚复现。
+所以采用**不改动项目任何文件**的旁路复现:
+把 npm 上的 `@opencode-ai/sdk@1.4.1` 解包到 `/tmp/opencode/sdk141/`,
+再写一个临时 tsconfig(`/tmp/opencode/repro-project/tsconfig.json`,`extends` 项目自己的 `tsconfig.app.json`),
+用 `compilerOptions.paths` 把 `@opencode-ai/sdk/*` 指向那份 1.4.1 副本,`include` 指向项目的 `src`,然后跑 `tsc`。
+
+> 局限:这是"用 1.4.1 的类型定义检查项目源码",能准确反映**类型层面**的坏调用;
+> 但它不等价于"在 1.4.1 运行时实际跑一遍",运行时的报错点可能更多(例如动态调用)。
+
+### 1.4 单元测试(依赖变更后的回归确认)
+
+依赖从 1.4.1 换到 1.16.0 属于运行时行为变更,因此补跑了一次单测:
+
+```bash
+timeout 180 npm run test:run # = vitest run(npm test 是 watch 模式,非交互环境下会挂住,故用 run 变体)
+```
+
+结果:**全绿** —— `Test Files 96 passed (96)` / `Tests 667 passed (667)`,耗时 16.96s,退出码 0。
+
+> ⚠️ 但**不要**据此认为"依赖问题会被单测发现"。经查:只有 `src/api/sdk.test.ts` 对
+> `@opencode-ai/sdk/v2/client` 做了 `vi.mock`;`ConfigSettings.search.test.tsx` mock 的是项目自己的
+> `api` 模块(`listAvailableShells: vi.fn()`),**从未真正调用到 `sdk.pty.shells()`**。
+> 两处坏调用都是"属性不存在",只有**真的调用到**才会在运行时抛 `not a function`。
+> → 推断(未实测):1.4.1 状态下这套单测很可能同样是绿的。**这类依赖不一致问题只能靠类型检查或人工比对发现。**
+
+---
+
+## 2. 任务 2:WSL 启动命令修复
+
+### 2.1 改动点(文件 : 行号 + 改动前后)
+
+**唯一改动文件**:`src-tauri/src/app/commands/wsl_commands.rs`
+
+| | 修改前 | 修改后 |
+| ------------------ | ---------------------------------------------------------- | ------------------------------ |
+| 行号 | 918–924 行 | 918–928 行 |
+| 值(debug 分支) | `"INFO"` | **`"info"`** |
+| 值(release 分支) | `"WARN"` | **`"warn"`** |
+| 注释 | `// 打包版 WARN / 开发版 INFO(官方 app.isPackaged 分支)` | 改写为小写 + 追加 4 行警示注释 |
+
+实际 diff:
+
+```diff
+- // 打包版 WARN / 开发版 INFO(官方 app.isPackaged 分支)
++ // 打包版 warn / 开发版 info(官方 app.isPackaged 分支)
++ // ⚠️ opencode V2 的 --log-level 只接受**小写**取值
++ // (all|trace|debug|info|warn|warning|error|fatal|none);
++ // 传大写 "INFO"/"WARN" 会被 effect/cli 判为非法值并直接报错退出,
++ // 导致 WSL 路径的 opencode serve 根本起不来(stdout 永远等不到监听 URL)。
+ format!(
+ "exec {} --print-logs --log-level {} serve --hostname 0.0.0.0 --port {}",
+ wsl_runtime::shell_escape(&opencode_path),
+- if cfg!(debug_assertions) { "INFO" } else { "WARN" },
++ if cfg!(debug_assertions) { "info" } else { "warn" },
+ port
+ ),
+```
+
+### 2.2 遵守的边界
+
+- ✅ **`opencode.rs` 未改动**(`git status` 证实:仅 `wsl_commands.rs` 被修改)。它只 spawn `["serve"]`,不带参数,无此问题。
+- ✅ **`OPENCODE_SERVER_USERNAME` 保留**(`wsl_commands.rs:915` 原样未动),符合迁移文档 §1.3③「保留是安全的」。
+- ✅ 未改任何 `src/` 下业务代码,未 commit / push / reset / checkout。
+
+### 2.3 验证(CLI 层面,实测)
+
+| 场景 | 结果 |
+| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--log-level INFO`(改前 debug 值) | ❌ 退出码 **1**,stderr:`~effect/cli/CliError/InvalidValue: Invalid value for flag --log-level: "INFO". Expected: "all" \| "trace" \| ... \| "none"` |
+| `--log-level WARN`(改前 release 值) | ❌ 退出码 **1**,同样报错(仅值不同) |
+| `--log-level info`(改后 debug 值) | ✅ 正常启动,stdout:`server listening on http://127.0.0.1:4097`,`/api/info` → 200 |
+| `--log-level warn`(改后 release 值) | ✅ 正常启动,stdout:`server listening on http://127.0.0.1:4101`,`/api/info` → 200 |
+
+### 2.4 未验证事项(如实说明)
+
+- ⚠️ **Rust 未编译验证**:`src-tauri/target/` 不存在(从未构建过),全量 `cargo check` 需要拉取整套 Tauri 依赖树,
+ 在 12G 内存约束下代价大且耗时长,故**未执行**。
+ 判断依据:本次改动仅为 **2 个字符串字面量**(`"INFO"`→`"info"`、`"WARN"`→`"warn"`)+ 注释,不涉及类型/借用/控制流,编译风险极低。
+- ⚠️ **WSL 真实路径未跑**:容器内没有 WSL。已验证的是「CLI 是否接受该参数值」这一**根因**,
+ 而不是「WSL 里整条启动链路能通」。后者需在 Windows 桌面环境下回归。
+
+---
+
+## 3. 任务 3:端到端冒烟
+
+### 3.1 启动
+
+```bash
+OPENCODE_SERVER_PASSWORD=test123 opencode --print-logs --log-level info serve --hostname 127.0.0.1 --port 4097
+```
+
+- **stdout 打印**:✅ `server listening on http://127.0.0.1:4097`(与迁移文档 §10.2 描述一致)
+- 端口按任务要求用 **4097**(容器内另有常驻服务占着 4096,pid 8,全程未打扰)
+
+### 3.2 逐项 curl 结果
+
+基准地址 `http://127.0.0.1:4097`。
+
+#### ① `GET /api/info`(带凭证 `opencode:test123`)→ 预期 200 + JSON
+
+```
+HTTP/1.1 200 OK
+content-type: application/json
+content-length: 97
+
+{"version":"2.0.19","pid":68513,"urls":["http://127.0.0.1:4097"],"paths":{"tmp":"/tmp/opencode"}}
+```
+
+✅ **通过**。注意响应**没有 `healthy` 字段**(与迁移文档 §4.1 描述一致),后续健康检查不能靠它判活。
+
+#### ② `GET /api/info`(无凭证)→ 预期 401
+
+```
+HTTP/1.1 401 Unauthorized
+www-authenticate: Basic realm="Secure Area"
+content-type: application/json
+content-length: 64
+
+{"_tag":"UnauthorizedError","message":"Authentication required"}
+```
+
+✅ **通过**。与迁移文档附录 B 记录完全一致(含 `www-authenticate` 头)。
+
+#### ③ `GET /global/health`(带凭证)→ 确认 V1 端点已失效
+
+```
+HTTP/1.1 200 OK ← ⚠️ 注意:是 200,不是 404
+content-type: text/html
+transfer-encoding: chunked
+
+
+
+ OpenCode
+ ...(完整的 SPA 首页 HTML)...
+
+```
+
+✅ **确认 V1 端点已失效** —— 返回的是 SPA 兜底页面而非 `{healthy, version}` JSON。
+⚠️ **但状态码是 `200` 而不是 `404`**,这一细节对判断"谁会坏"很关键,见 §5 第 3 条。
+
+#### ④ `GET /api/location`(带凭证)→ 预期 `{directory, project:{...}}`
+
+```
+HTTP/1.1 200 OK
+content-type: application/json
+content-length: 163
+
+{"directory":"/tmp/opencode/smoke","project":{"id":"d1892a84326cadabd9f919e9affd0922d9510e89","directory":"/tmp/opencode/smoke","canonical":"/tmp/opencode/smoke"}}
+```
+
+✅ **通过**,形状与预期一致。
+
+#### ⑤ 补充:`GET /api/health`(V1 的另一个健康端点,Rust 健康检查的第一顺位)
+
+```
+HTTP/1.1 404 Not Found (带凭证时)
+HTTP/1.1 401 Unauthorized (不带凭证时——鉴权中间件先于路由生效)
+```
+
+✅ 确认 `404`(**这个才是 404**,与 `/global/health` 的 200 形成对比)。
+
+### 3.3 `parse_listening_url()`(`opencode.rs:139`)兼容性验证
+
+验证方式:**把该函数逐字照抄**到 `/tmp/opencode/urlcheck/` 的一个独立 Rust 小程序里编译运行
+(唯一改动:`reqwest::Url` → `url::Url`,二者是同一个类型,`reqwest` 只是 re-export `url::Url`),
+喂入**实测抓到的真实 stdout/stderr 行**:
+
+```
+"server listening on http://127.0.0.1:4097" => Some("http://127.0.0.1:4097") ✅
+"server listening on http://127.0.0.1:4098" => Some("http://127.0.0.1:4098") ✅
+"server listening on http://0.0.0.0:4096" => Some("http://127.0.0.1:4096") ✅(0.0.0.0 归一化生效)
+"INFO server listening on http://127.0.0.1:4097" => Some("http://127.0.0.1:4097") ✅(前缀干扰不影响)
+[真实 stderr 日志行] => None ✅(不会误判)
+```
+
+✅ **结论:`parse_listening_url()` 能正确解析 V2 的 stdout 格式**,与迁移文档 §10.2 一致。
+
+> 补充:Rust 侧把 stdout 与 stderr **合并进同一个 channel**(`opencode.rs:112-118`),
+> 所以需要确认 stderr 里不会出现 `http://` 造成误判。实测带 `--print-logs` 的 stderr 中
+> `grep -c "http://"` = **0**,`grep "listening"` = **无** → 合并安全。
+
+### 3.4 stdout / stderr 日志分离记录
+
+| 场景 | stderr 行数 |
+| -------------------------------------------------------------------------- | -------------------------------------------------------------- |
+| 带 `--print-logs`,刚启动(未发请求) | **0** |
+| 带 `--print-logs`,发 3 个 `GET /api/info`(成功 200) | **0**(新增 0) |
+| 带 `--print-logs`,发 3 个 `GET /api/location`(首次,触发 location 启动) | **27** |
+| 带 `--print-logs`,location 预热后再发 3 个请求 | **0**(新增 0) |
+| 带 `--print-logs`,发 3 个**无凭证**请求(401) | **+3**(每请求 1 行 `Sent HTTP response ... http.status=401`) |
+| **不带** `--print-logs`,发 3 个请求 | **0** ✅ |
+
+✅ **核心结论成立**:日志走 stderr、**不污染 stdout**(stdout 只有那行监听 URL);
+不带 `--print-logs` 时 stderr 为 0 行。
+⚠️ 但日志**行数的量级与迁移文档描述不符**,详见 §5 第 5 条。
+
+stderr 日志格式示例:
+
+```
+timestamp=2026-09-29T16:30:59.777Z level=INFO run=18218ba4 message="watcher subscribe" path=/tmp type=entries ignores=0 http.span=75 role=server
+timestamp=2026-09-29T16:31:15.458Z level=INFO run=18218ba4 message="Sent HTTP response" http.span=0 role=server http.method=GET http.url=/api/info http.status=401
+```
+
+---
+
+## 4. 任务 4:§10.4 剩余项结论
+
+### 4.1 已关闭:`directory` vs `location[directory]` 逐端点核对
+
+**核对方法**(三重交叉,全部指定 tag `v2.0.19`,未读 HEAD):
+
+1. **源码**:`git -C /home/coder/project/opencode show v2.0.19:packages/protocol/src/groups/*.ts`
+ —— 逐个端点读 `query:` / `payload:` 的 schema 定义。
+2. **机器生成的契约**:`git show v2.0.19:packages/protocol/openapi.json`(113 paths / 136 operations),
+ 按参数的 `style: deepObject` 判定。
+3. **中间件挂载**:`packages/protocol/src/api.ts` 的 `makeApiFromGroup`,确认每个组用哪个 location 中间件。
+
+**交叉校验结果**:源码 136 个 operation 与 openapi.json **完全一致**(`:param` 与 `{param}` 仅记法差异),
+另加 spec 未收录的 2 个 pairing 端点 = **138 个端点**。
+
+#### 结论
+
+| 目录参数写法 | 端点数量 | 说明 |
+| ------------------------------------- | -------: | ----------------------------------------- |
+| `?location[directory]=`(deepObject) | **57** | location 作用域端点的标准写法 |
+| `?directory=`(裸参数) | **1** | **仅** `GET /api/session`(session 列表) |
+| 无任何目录参数 | **80** | 含全部 session 作用域端点 + 服务级端点 |
+| **合计** | **138** | |
+
+**四条关键规则**:
+
+1. **裸 `directory` 只有 1 个端点**:`GET /api/session`(`SessionsQuery`,`directory` 与 `project`+`subpath` 是互斥的两种筛选方式)。
+2. **其余 location 作用域端点一律用 `?location[directory]=`**(deepObject),共 57 个。
+3. **`/api/session/:sessionID/*` 这类 session 作用域端点完全不需要目录参数** ——
+ location 由 **session 行本身**决定(`SessionLocationMiddleware` → `sessionInfo()` → `instances.provide(session)`)。
+ 这对阶段 1/2 很省事:V1 里给这些调用硬塞 `directory` 的做法可以直接删掉。
+4. **`x-opencode-directory` 请求头在 v2.0.19 仍然有效**(优先级:`location[directory]` > 该请求头 > `process.cwd()`),
+ 依据 `packages/server/src/location.ts:40-47` 的 `requestRef()`。**这一点与迁移文档 §3.3 相悖,见 §5 第 1 条。**
+
+**补充实测**:裸 `directory` 传给 location 作用域端点会被**静默忽略**(不报错、不 400):
+
+```
+GET /api/location → directory = /tmp/opencode/smoke (cwd)
+GET /api/location?directory=/tmp → directory = /tmp/opencode/smoke (被忽略!)
+GET /api/location?location[directory]=/tmp→ directory = /tmp
+```
+
+⚠️ 这意味着**迁移期参数写错不会立刻报错**,而是静默回落到 `process.cwd()`,容易造成"看起来正常但数据不对"的隐蔽 bug。
+建议阶段 1 在 `directoryUtils.ts` 改造时配一个断言/日志。
+
+### 4.2 仍未关闭的项
+
+§10.4 共 11 条,10 条已 ✅,本次关闭 1 条(`directory` vs `location[directory]`)。**剩 1 条仍未关闭**:
+
+| 项 | 状态 | 原因 |
+| ------------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ❗ `@opencode/client` 的 `subscribe()` 在 Tauri 环境下的表现 | **仍未验证** | 该问题**无法靠读源码回答**:它取决于 Tauri `plugin-http` 的流式(streaming)行为与 WebView 的 `ReadableStream` 支持,必须在真实 Tauri 运行时里跑。且阶段 0 的授权范围不含引入 `@opencode/client`(那是阶段 1 的事),故留到阶段 1。 |
+
+> 另外,§10.4 之外仍标 ❗ 的还有:§10.1(`@opencode/client` 在浏览器/Tauri 下的流式表现)、
+> §10.5(Docker 锁版本、三形态回归)。这些按迁移文档归属阶段 1/3/4,不在阶段 0 范围。
+
+---
+
+## 5. 🔴 与文档预测不符之处(本节最重要)
+
+### ① `x-opencode-directory` 请求头在 V2 **仍然有效**(文档 §3.3 说法不准确)
+
+**文档原文**(§3.3 表格):`请求头 | x-opencode-directory | 变为 location 对象`
+
+**实际情况**:该请求头**在 v2.0.19 里依然被读取**,是 `location[directory]` 之后的第二优先级。
+
+- 源码依据 `packages/server/src/location.ts:40-47`:
+
+ ```ts
+ export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
+ const query = new URL(request.url, 'http://localhost').searchParams
+ const directory =
+ query.get('location[directory]') ||
+ (request.headers['x-opencode-directory'] ? decode(request.headers['x-opencode-directory']) : process.cwd())
+ return Location.Ref.make({ directory: AbsolutePath.make(directory) })
+ }
+ ```
+
+- **实测依据**(对运行中的 V2 服务):
+
+ ```
+ GET /api/location → {"directory":"/tmp/opencode/smoke", ...} (cwd)
+ GET /api/location -H "x-opencode-directory: /tmp/opencode"
+ → {"directory":"/tmp/opencode", ...} ✅ 头生效
+ GET /api/location?location[directory]=/tmp → {"directory":"/tmp", ...}
+ ```
+
+**影响(正面)**:阶段 1 中 `src/utils/directoryUtils.ts` 的改造量**可能远小于文档预估** ——
+现有基于请求头的 `formatPathForApi()` 逻辑对 57 个 location 作用域端点**可以继续工作**。
+是否要用新的 `location[directory]` 写法,可以按"更贴近官方 SDK 生成代码"的偏好来定,而不是被逼着改。
+
+### ② 依赖不一致造成的坏调用是 **2 处**,文档只列了 1 处(§2.3)
+
+文档只列 `src/api/pty.ts:50` 的 `sdk.pty.shells()`;实际还有:
+
+```
+src/store/childSessionStore.ts(83,22): error TS2339: Property 'agent' does not exist on type 'Session'.
+```
+
+→ 1.4.1 下**子会话(subtask)创建链路**在类型层面同样是坏的。虽然修依赖后两者都自愈,
+但这条说明「1.4.1 的损坏面比文档记录的大」,可作为阶段 3 排查 subtask 相关功能时的背景信息。
+
+### ③ `/global/health` 返回 **200 + HTML**,不是 404 —— 因此 Rust 健康检查会**假阳性**(文档 §1.3② 只覆盖了前端)
+
+**文档原文**(§1.3②):`→ 健康检查会拿到 HTML SPA 页面而非 {healthy, version} JSON,解析必然失败,界面会认为服务挂了。`
+
+**实际分两条链路,结论相反**:
+
+| 链路 | 代码位置 | 判活依据 | V2 下的实际行为 |
+| -------- | ------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| **前端** | `src/store/serverStore.ts:643`(打 `/global/health`)→ `:678-691` 检查 `content-type` | 必须 `application/json` | ✅ **文档成立**:拿到 `text/html` → `status: 'error'`,提示 "Server returned HTML instead of OpenCode health JSON" → 界面显示服务异常 |
+| **Rust** | `opencode.rs:42-76` `is_service_running_with_auth()` | **只看 HTTP 状态码** `r.status().is_success()` | ❌ **文档不成立**:`/api/health` → 401/404(非 2xx)→ 继续试 `/global/health` → **200** → `is_success()` = **true** → **返回"服务在运行"** |
+
+**这带来两个反直觉后果**:
+
+1. Rust 侧**不会**因为 V2 而判活失败(歪打正着地返回 `true`),所以
+ `start_opencode_service`(`opencode.rs:309/351`)的"服务已就绪"判定**不会被挡住**,桌面路径仍能起来。
+ → 文档"健康检查解析必然失败"的说法**只对前端成立**。
+2. 但它同时也**失去了校验能力**:以前能验证 `{healthy:true, version}`,现在只要**任何** HTTP 服务在该路径返回 200 就算"健康"。
+ → §9.3 里"改为单一 `GET /api/info`"的整改**仍然必要**,而且**优先级比文档暗示的更高**(它是静默失效,不是显式报错)。
+
+### ④ openapi 计数差异(115/247 vs 113/245)可**精确定位**到 2 个端点(文档仅注"计数差异")
+
+**文档原文**(文首计数说明):`本机 v2.0.19 二进制导出的权威 openapi 为 115 路径 / 247 schema,比 docs 站点快照多 2 条;端点逐一核对全部通过,仅计数差异,不影响结论。`
+
+**实际原因已查明**:差的 2 条正是 **ServerGroup 的 pairing 端点**:
+
+| 差异项 | 内容 |
+| ------------------ | --------------------------------------------------------------------------------- |
+| 多出的 2 条路径 | `POST /api/pair`(`server.pair`)、`GET /auth/connect/{code}`(`server.connect`) |
+| 多出的 2 个 schema | `PairingCode`、`PairingSession` |
+
+证据链:
+
+1. `packages/protocol/src/groups/server.ts`(tag `v2.0.19`)里这两个端点**存在**,但
+ 仓库内提交的 `packages/protocol/openapi.json` 里**没有**(`'/api/pair' in spec.paths === false`,
+ 且该 spec 中**没有任何非 `/api` 开头的 path**)。
+2. 该 spec 的 245 个 schema 中也**不含** `PairingCode` / `PairingSession` → 245 + 2 = **247**,113 + 2 = **115**,**严丝合缝**。
+3. 为什么 spec 缺?—— `openapi.json` 最后一次更新是 `53179daefa`,而这两个端点由 `eccf0b3b7b`
+ ("feat(server): pair with one-time connect links (#50970)")加入,且已验证 `53179daefa` 是 `eccf0b3b7b` 的**祖先**。
+ → **tag 里提交的 openapi.json 是过期的(生成后未随 pairing 功能重新生成)。**
+
+**影响**:结论不变(不影响任何端点的判定),但**阶段 1 用 openapi.json 做代码生成/对照时要当心这 2 条缺失**;
+同时说明「docs 站点快照 = 仓库里的 openapi.json = 113/245」三者一致,而**二进制才是权威的 115/247**。
+
+### ⑤ §10.2 的 stderr 行数描述不准确("发 3 个请求 → stderr 3 行")
+
+**文档原文**(§10.2):`实测:带它发 3 个请求 → stderr 3 行;不带 → 0 行`
+
+**实测真实行为**(见 §3.4 表):
+
+- 成功的请求(200)→ **0 行**(不是每请求 1 行)
+- **401 的请求 → 每请求 1 行**(`message="Sent HTTP response" ... http.status=401`)
+- **首个 location 作用域请求 → 约 27 行**(watcher/event 启动噪音,一次性)
+- location 预热后 → 又是 0 行
+
+→ 文档的 "3 行" 最可能来自**3 个鉴权失败(401)的请求**;把它当作"每请求 1 行"的通用规律会误判。
+**真正要记住的结论不变**:`--print-logs` 让日志走 **stderr**,stdout 干净,不带则为 0 行。
+
+### ⑥ 阶段 0 预期产出的「类型报错清单」实际为空
+
+文档 §8 阶段 0 把"确认 `sdk.pty.shells()` 缺失导致的当前故障状态"列为待办,隐含预期是能捞出一批类型错误。
+实际:**修好依赖后 `tsc` 零报错**。后续阶段**不能**依赖类型检查来发现需要改造的调用点,
+必须按 §4 的端点对照表逐个手工迁移。
+
+### ⑦ `npm install` 无需改动 `package.json` / `package-lock.json`
+
+任务描述把这两个文件列为"允许改",但实际上 `package-lock.json` 早已正确锁定 `1.16.0`,
+`node_modules` 只是**没按 lock 安装**。所以本次修复**零仓库文件改动**(只有 `wsl_commands.rs` 一处)。
+
+### ⑧ 新增发现:目录参数写错是**静默失效**,不会报错
+
+(文档未提及)裸 `directory` 传给 location 作用域端点会被**静默忽略**并回落到 `process.cwd()`,HTTP 仍是 200。
+迁移期这类错误不会暴露,建议阶段 1 显式加日志/断言。详见 §4.1 末的实测记录。
+
+---
+
+## 6. 未做 / 未验证事项(如实汇报)
+
+| 项 | 状态 | 原因 |
+| --------------------------------------------------- | --------- | ------------------------------------------------------------------------------- |
+| Rust 侧编译验证(`cargo check`) | ❌ 未做 | 无 `target/` 目录,全量构建代价大;改动仅为 2 个字符串字面量,风险极低 |
+| WSL 真实链路回归 | ❌ 未做 | 容器内无 WSL,仅验证了 CLI 层参数取值这一根因 |
+| `npm test`(vitest) | ✅ 已跑 | 见 §1.4:96 文件 / 667 用例全绿(用 `vitest run`,因 `npm test` 是 watch 模式) |
+| `@opencode/client` 的 `subscribe()` 在 Tauri 下表现 | ❌ 未验证 | 需真实 Tauri 运行时;属阶段 1 范围 |
+| Docker 锁版本 / 三形态回归(§10.5) | ❌ 未做 | 属阶段 3/4 范围 |
+
+**清理说明**:冒烟测试启动的 4 个临时服务(端口 4097 / 4098 / 4100 / 4101 / 4103)**已全部停止**;
+容器内原有的 4096 常驻服务(pid 8)**全程未受影响**。
+
+---
+
+## 附录 A:逐端点全表(`directory` vs `location[directory]`)
+
+> 数据来源:`packages/protocol/openapi.json`(tag `v2.0.19`,已验证与源码 136 operation 一致)
+>
+> - `packages/protocol/src/api.ts` 的中间件挂载 + 补录 spec 缺失的 2 个 pairing 端点。
+> 已验证 spec 与源码 136 个 operation 完全一致(`:param` 与 `{param}` 仅记法差异)。
+
+### 结论汇总
+
+| 写法 | 端点数量 |
+| ------------------------------------- | -------: |
+| `?location[directory]=`(deepObject) | **57** |
+| `?directory=`(裸参数) | **1** |
+| 无目录参数 | **80** |
+| 合计 | **138** |
+
+### 关键结论
+
+1. **裸 `directory` 只有 1 个端点**:`GET /api/session`(session 列表)。
+2. **`location[directory]` 是绝大多数 location 作用域端点的写法**,共 57 个。
+3. **session 作用域端点(`/api/session/:sessionID/*`)不需要任何目录参数** —— location 由 session 行决定(`SessionLocationMiddleware`)。
+4. **`x-opencode-directory` 请求头在 v2.0.19 仍然有效**(`packages/server/src/location.ts:40-47`),作为 `location[directory]` 之后的第二优先级,已实测确认。
+5. 裸 `directory` 传给 location 作用域端点会被**静默忽略**(实测 `GET /api/location?directory=/tmp` 仍返回 cwd),不报错。
+
+### 全表
+
+| 方法 | 路径 | 端点 ID | 组 | 作用域 | 目录参数写法 | query 参数 |
+| ------ | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | ----------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- |
+| GET | `/api/agent` | agent.list | agent | locationRef | `location[directory]` | location |
+| GET | `/api/agent/{agentID}` | agent.get | agent | locationRef | `location[directory]` | location |
+| GET | `/api/command` | command.list | command | locationRef | `location[directory]` | location |
+| GET | `/api/config` | config.get | config | locationRef | `location[directory]` | location |
+| GET | `/api/config/shell` | config.shells | config | locationRef | 无 | — |
+| DELETE | `/api/credential/{credentialID}` | credential.remove | credential | none(服务级) | 无 | — |
+| PATCH | `/api/credential/{credentialID}` | credential.update | credential | none(服务级) | 无 | — |
+| POST | `/api/credential/{credentialID}/activate` | credential.activate | credential | none(服务级) | 无 | — |
+| DELETE | `/api/debug/location` | debug.location.evict | debug | locationRef(handler 内 requestRef) | `location[directory]` | location |
+| GET | `/api/debug/location` | debug.location.list | debug | locationRef(handler 内 requestRef) | 无 | — |
+| GET | `/api/event` | event.subscribe | event | none(服务级全量) | 无 | — |
+| PATCH | `/api/experimental/config` | experimental.config.update | config | locationRef | 无 | — |
+| POST | `/api/experimental/fs/write` | experimental.fs.write | filesystem | locationRef | `location[directory]` | location, path |
+| POST | `/api/experimental/generate` | experimental.generate.text | generate | none(服务级) | 无 | — |
+| POST | `/api/experimental/integration/wellknown` | experimental.integration.wellknown.add | integration | locationRef | `location[directory]` | location |
+| DELETE | `/api/experimental/mcp/{server}` | experimental.mcp.remove | mcp | locationRef | `location[directory]` | location |
+| PUT | `/api/experimental/mcp/{server}` | experimental.mcp.add | mcp | locationRef | `location[directory]` | location |
+| POST | `/api/experimental/mcp/{server}/connect` | experimental.mcp.connect | mcp | locationRef | `location[directory]` | location |
+| POST | `/api/experimental/mcp/{server}/disconnect` | experimental.mcp.disconnect | mcp | locationRef | `location[directory]` | location |
+| GET | `/api/experimental/migration/v1` | experimental.migration.v1.status | migration | none(服务级) | 无 | — |
+| DELETE | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.remove | persistentPty | none(服务级) | 无 | — |
+| GET | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.get | persistentPty | none(服务级) | 无 | — |
+| PUT | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.update | persistentPty | none(服务级) | 无 | — |
+| GET | `/api/experimental/persistent-pty/{ptyID}/connect` | persistentPty.connect | persistentPty | none(服务级) | 无 | cursor, role, attachment_id, takeover, input_protocol, ticket |
+| POST | `/api/experimental/persistent-pty/{ptyID}/connect-token` | server.experimental.persistentPty.connectToken | persistentPty | none(服务级) | 无 | — |
+| GET | `/api/experimental/persistent-pty/{ptyID}/snapshot` | server.experimental.persistentPty.snapshot | persistentPty | none(服务级) | 无 | — |
+| POST | `/api/experimental/persistent-pty/handoff` | server.experimental.persistentPty.handoff | persistentPty | none(服务级) | 无 | — |
+| POST | `/api/experimental/persistent-pty/shutdown` | server.experimental.persistentPty.shutdown | persistentPty | none(服务级) | 无 | — |
+| GET | `/api/experimental/session/{sessionID}/export` | experimental.session.export | session | sessionRef(从 session 行取 location) | 无 | sanitize |
+| GET | `/api/experimental/session/{sessionID}/instructions/entries` | experimental.session.instructions.entry.list | session | sessionRef(从 session 行取 location) | 无 | — |
+| DELETE | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | experimental.session.instructions.entry.remove | session | sessionRef(从 session 行取 location) | 无 | — |
+| PUT | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | experimental.session.instructions.entry.put | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/experimental/session/{sessionID}/log` | session.log | session | sessionRef(从 session 行取 location) | 无 | after, follow |
+| POST | `/api/experimental/session/{sessionID}/skill` | experimental.session.skill | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/experimental/session/{sessionID}/terminal` | server.experimental.persistentPty.list | persistentPty | none(服务级) | 无 | — |
+| POST | `/api/experimental/session/{sessionID}/terminal` | server.experimental.persistentPty.create | persistentPty | none(服务级) | 无 | — |
+| GET | `/api/experimental/session/{sessionID}/terminal/read` | server.experimental.persistentPty.read | persistentPty | none(服务级) | 无 | lines |
+| POST | `/api/experimental/session/{sessionID}/wait` | experimental.session.wait | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/experimental/session/import` | experimental.session.import | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/experimental/session/stats` | experimental.session.stats | session | sessionRef(从 session 行取 location) | 无 | from, to, project, timezone, tools |
+| GET | `/api/form` | form.list | form | locationRef | `location[directory]` | location |
+| GET | `/api/fs/find` | fs.find | filesystem | locationRef | `location[directory]` | location, query, type, limit |
+| GET | `/api/fs/list` | fs.list | filesystem | locationRef | `location[directory]` | location, path |
+| GET | `/api/fs/read/*` | fs.read | filesystem | locationRef | `location[directory]` | location |
+| GET | `/api/info` | server.info | server | none(服务级) | 无 | — |
+| GET | `/api/integration` | integration.list | integration | locationRef | `location[directory]` | location |
+| GET | `/api/integration/{integrationID}` | integration.get | integration | locationRef | `location[directory]` | location |
+| POST | `/api/integration/{integrationID}/connect/command` | integration.command.connect | integration | locationRef | `location[directory]` | location |
+| DELETE | `/api/integration/{integrationID}/connect/command/{attemptID}` | integration.command.cancel | integration | locationRef | `location[directory]` | location |
+| GET | `/api/integration/{integrationID}/connect/command/{attemptID}` | integration.command.status | integration | locationRef | `location[directory]` | location |
+| POST | `/api/integration/{integrationID}/connect/key` | integration.connect.key | integration | locationRef | `location[directory]` | location |
+| POST | `/api/integration/{integrationID}/connect/oauth` | integration.oauth.connect | integration | locationRef | `location[directory]` | location |
+| DELETE | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | integration.oauth.cancel | integration | locationRef | `location[directory]` | location |
+| GET | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | integration.oauth.status | integration | locationRef | `location[directory]` | location |
+| POST | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | integration.oauth.complete | integration | locationRef | `location[directory]` | location |
+| GET | `/api/location` | location.get | location | locationRef | `location[directory]` | location |
+| POST | `/api/location/reload` | location.reload | location | locationRef | 无 | — |
+| GET | `/api/mcp` | mcp.list | mcp | locationRef | `location[directory]` | location |
+| GET | `/api/mcp/resource` | mcp.resource.catalog | mcp | locationRef | `location[directory]` | location |
+| GET | `/api/model` | model.list | model | locationRef | `location[directory]` | location |
+| GET | `/api/model/default` | model.default | model | locationRef | `location[directory]` | location |
+| POST | `/api/pair` | server.pair | server | none(服务级) | 无 | — |
+| GET | `/api/permission/request` | permission.request.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | `location[directory]` | location |
+| GET | `/api/permission/saved` | permission.saved.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | projectID |
+| DELETE | `/api/permission/saved/{id}` | permission.saved.remove | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — |
+| GET | `/api/plugin` | plugin.list | plugin | locationRef | `location[directory]` | location |
+| POST | `/api/plugin/check` | plugin.check | plugin | locationRef | `location[directory]` | location |
+| POST | `/api/plugin/update` | plugin.update | plugin | locationRef | `location[directory]` | location |
+| GET | `/api/project` | project.list | project | locationRef | 无 | — |
+| PATCH | `/api/project/{projectID}` | project.update | project | locationRef | 无 | — |
+| GET | `/api/provider` | provider.list | provider | locationRef | `location[directory]` | location |
+| GET | `/api/provider/{providerID}` | provider.get | provider | locationRef | `location[directory]` | location |
+| GET | `/api/pty` | pty.list | pty | locationRef | `location[directory]` | location |
+| POST | `/api/pty` | pty.create | pty | locationRef | `location[directory]` | location |
+| DELETE | `/api/pty/{ptyID}` | pty.remove | pty | locationRef | `location[directory]` | location |
+| GET | `/api/pty/{ptyID}` | pty.get | pty | locationRef | `location[directory]` | location |
+| PUT | `/api/pty/{ptyID}` | pty.update | pty | locationRef | `location[directory]` | location |
+| GET | `/api/pty/{ptyID}/connect` | pty.connect | pty | locationRef | 无 | location[directory], cursor, ticket |
+| POST | `/api/pty/{ptyID}/connect-token` | pty.connect.token | pty | locationRef | `location[directory]` | location |
+| GET | `/api/reference` | reference.list | reference | locationRef | `location[directory]` | location |
+| POST | `/api/rpc/{rpcID}/{method}` | rpc.call | rpc | locationRef | `location[directory]` | location |
+| GET | `/api/session` | session.list | session | sessionRef(从 session 行取 location) | **`directory`(裸)** | limit, order, search, parentID, directory, project, subpath, cursor |
+| POST | `/api/session` | session.create | session | sessionRef(从 session 行取 location) | 无 | — |
+| DELETE | `/api/session/{sessionID}` | session.remove | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}` | session.get | session | sessionRef(从 session 行取 location) | 无 | — |
+| PATCH | `/api/session/{sessionID}` | session.update | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/agent` | session.switchAgent | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/background` | session.background | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/command` | session.command | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/compact` | session.compact | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/context` | session.context | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/diff` | session.diff | session | sessionRef(从 session 行取 location) | 无 | from, to, context |
+| PUT | `/api/session/{sessionID}/environment` | session.environment | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/fork` | session.fork | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/form` | session.form.list | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/form` | session.form.create | session | sessionRef(从 session 行取 location) | 无 | — |
+| DELETE | `/api/session/{sessionID}/form/{formID}` | session.form.cancel | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/form/{formID}` | session.form.get | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/form/{formID}/reply` | session.form.reply | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/generate` | session.generate | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/inbox` | session.inbox.list | session | sessionRef(从 session 行取 location) | 无 | — |
+| DELETE | `/api/session/{sessionID}/inbox/{inboxID}` | session.inbox.cancel | session | sessionRef(从 session 行取 location) | 无 | — |
+| PATCH | `/api/session/{sessionID}/inbox/{inboxID}` | session.inbox.update | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/interrupt` | session.interrupt | session | sessionRef(从 session 行取 location) | 无 | resume |
+| GET | `/api/session/{sessionID}/message` | session.message.list | session | sessionRef(从 session 行取 location) | 无 | limit, order, cursor, type |
+| GET | `/api/session/{sessionID}/message/{messageID}` | session.message.get | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/model` | session.switchModel | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/move` | session.move | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/{sessionID}/permission` | session.permission.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — |
+| POST | `/api/session/{sessionID}/permission` | session.permission.create | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — |
+| GET | `/api/session/{sessionID}/permission/{requestID}` | session.permission.get | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — |
+| POST | `/api/session/{sessionID}/permission/{requestID}/reply` | session.permission.reply | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — |
+| POST | `/api/session/{sessionID}/prompt` | session.prompt | session | sessionRef(从 session 行取 location) | 无 | — |
+| DELETE | `/api/session/{sessionID}/revert` | session.revert.clear | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/revert/commit` | session.revert.commit | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/revert/stage` | session.revert.stage | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/shell` | session.shell | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/synthetic` | session.synthetic | session | sessionRef(从 session 行取 location) | 无 | — |
+| POST | `/api/session/{sessionID}/view` | session.view | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/session/active` | session.active | session | sessionRef(从 session 行取 location) | 无 | — |
+| GET | `/api/shell` | shell.list | shell | locationRef | `location[directory]` | location |
+| POST | `/api/shell` | shell.create | shell | locationRef | `location[directory]` | location |
+| DELETE | `/api/shell/{id}` | shell.remove | shell | locationRef | `location[directory]` | location |
+| GET | `/api/shell/{id}` | shell.get | shell | locationRef | `location[directory]` | location |
+| GET | `/api/shell/{id}/output` | shell.output | shell | locationRef | `location[directory]` | location, cursor, limit |
+| GET | `/api/skill` | skill.list | skill | locationRef | `location[directory]` | location |
+| GET | `/api/vcs` | vcs.get | vcs | locationRef | `location[directory]` | location |
+| GET | `/api/vcs/base` | vcs.base | vcs | locationRef | `location[directory]` | location |
+| GET | `/api/vcs/branch` | vcs.branch.list | vcs | locationRef | `location[directory]` | location, search, limit |
+| GET | `/api/vcs/diff` | vcs.diff | vcs | locationRef | `location[directory]` | location, mode, base, context |
+| GET | `/api/vcs/status` | vcs.status | vcs | locationRef | `location[directory]` | location |
+| POST | `/api/websearch` | websearch.query | websearch | locationRef | `location[directory]` | location |
+| GET | `/api/websearch/provider` | websearch.providers | websearch | locationRef | `location[directory]` | location |
+| DELETE | `/api/worktree` | worktree.remove | worktree | none(用 projectID) | 无 | — |
+| GET | `/api/worktree` | worktree.list | worktree | none(用 projectID) | 无 | projectID |
+| POST | `/api/worktree` | worktree.create | worktree | none(用 projectID) | 无 | — |
+| POST | `/api/worktree/refresh` | worktree.refresh | worktree | none(用 projectID) | 无 | — |
+| GET | `/auth/connect/{code}` | server.connect | server | none(服务级) | 无 | — |
diff --git a/docs/opencode-v2-migration-phase2a.md b/docs/opencode-v2-migration-phase2a.md
new file mode 100644
index 000000000..c6e1e7f45
--- /dev/null
+++ b/docs/opencode-v2-migration-phase2a.md
@@ -0,0 +1,778 @@
+# OpenCode V2 迁移 · 阶段 2a 报告(消息模型 + 历史加载,读侧)
+
+> 状态:**✅ 已完成**(2026-09-30)
+> 范围:**只做读侧** —— 历史消息加载、转换、渲染、游标分页。
+> 发消息、事件流、流式回复**本次未动**(归阶段 2b)。
+> 前置:阶段 0(`docs/opencode-v2-migration-phase0.md`)、阶段 1(`docs/opencode-v2-migration-phase0.5.md`)已完成。
+> **后续**:阶段 2b(事件流 + 发消息)**已完成**,报告见 `docs/opencode-v2-migration-phase2b.md`。
+> 实测环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19`
+> 主文档:`docs/opencode-v2-migration.md`(本阶段已按实测回填 §5.1 / §5.2 / §5.4 / §5.5 / §8)
+
+---
+
+## 0. 先说三件必须交代的事
+
+### 0.1 ⚠️ 工作目录:阶段 2a 的改动在**主仓库**,不在 worktree 里
+
+本次会话启动时的「工作目录」是一个**新建的 git worktree**
+(`/home/coder/.local/share/opencode/worktree/77e28b/crisp-lagoon`),
+但它**不包含前两个阶段的任何成果** —— 没有 `docs/`、没有 `src/api/notMigrated.ts`、
+没有 `src/api/v2Convert.ts`、`package.json` 里也没有 `@opencode/client`。
+
+原因:阶段 0/1 的全部产出都是**未提交的工作区改动**,而 git worktree 只签出已提交的
+HEAD(`8a6d4eae`),不会带过来。另外该 worktree 里 717 个文件被显示为「已修改」,
+是容器文件系统给新写入文件加执行位导致的权限位噪音,不是真实改动。
+
+→ **本阶段把会话切到真正的仓库目录 `/home/coder/project/OpenCodeUI` 上工作**,
+与阶段 0/1 的成果放在一起。全部改动都在这个目录里。
+
+### 0.2 ✅ 硬性约束逐条对照
+
+| 约束 | 结果 |
+| ------------------------------------------------------ | ------------------------------------------------------ |
+| 允许改:`src/types/api/message.ts` | ✅ 重写 |
+| 允许改:`src/types/api/v1Model.ts`(仅消息/Part 部分) | ⚠️ **未删任何导出**,原因见 §1.4(被冻结的事件层钉住) |
+| 允许改:`src/utils/messageConversion.ts` | ✅ 重写 |
+| 允许改:`src/api/message.ts` | ✅ 游标分页 |
+| 允许改:`messageStore` | ✅ 数据结构 + 合并 + 游标 |
+| 允许改:对应渲染组件 | ✅ `MessageRenderer` + 新增 `SessionMarkerPartView` |
+| 允许改:`docs/` | ✅ 主文档已回填 + 本报告 |
+| **禁止改**:`src/api/events.ts` | ✅ **零改动**(`git diff` 无此文件) |
+| **禁止改**:发消息链路 | ✅ `sendMessage` / `sendMessageAsync` 仍是显式报错占位 |
+| **禁止改**:`src-tauri/` | ✅ 零改动 |
+| **禁止改**:`src/api/notMigrated.ts` 报错语义 | ✅ 零改动(且新增 3 条用例把语义钉死) |
+| 禁止删除 `v1Model.ts` 非消息/Part 导出 | ✅ 一个都没删 |
+| 禁止 git commit/push/reset/checkout | ✅ 未执行 |
+| 禁止删除 `docs/` 下文件 | ✅ 未删除 |
+| 类型检查 0 报错 | ✅ `npx tsc -b` 无输出 |
+| `npm test` 现有用例不许挂 | ✅ 基线 683 例全部仍通过,**0 失败** |
+
+### 0.3 只做读侧 —— 本阶段**没有**做的事
+
+- ❌ `src/api/events.ts`(事件订阅/解析/重连)—— 一行没动
+- ❌ `coalesceEvents` delta 合并 —— 一行没动
+- ❌ `EventTypes` 常量表 —— 一行没动
+- ❌ 发消息链路(`POST /api/session/{id}/prompt`)—— 仍是 `notMigratedYet` 占位
+- ❌ `v1Model.ts` 的删除 —— 见 §1.4
+
+---
+
+## 1. 前置决策:`v1Model.ts` 分类结果
+
+`src/types/api/v1Model.ts`:**3502 行 / 189 个顶层 `export`**。
+按「本阶段要换掉 / 阶段 3 再动 / 跨组」分三桶:
+
+| 桶 | 数量 | 含义 |
+| ----------------------------- | ------: | ---------------------------------------------------------------------------------------------------------------------------- |
+| **A:消息 / Part / 消息事件** | **66** | 被 V2 消息模型推翻,属阶段 2 |
+| **B:阶段 3** | **104** | permission / session / config / agent / mcp / pty / file / vcs / worktree / lsp / todo / provider / model / project / auth … |
+| **C:跨组 / 不明确** | **19** | 同时被多组引用,单独删会连带打断别的组 |
+| 合计 | **189** | ✅ 与 `grep -c "^export "` 一致 |
+
+### 1.1 A 桶(66 个)—— 消息 / Part / 消息事件
+
+| 分组 | 类型 |
+| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 消息主体(4) | `UserMessage`、`AssistantMessage`、`Message`、`Prompt` |
+| Part 联合成员(12+) | `TextPart`、`ReasoningPart`、`ToolPart`、`FilePart`、`AgentPart`、`StepStartPart`、`StepFinishPart`、`SnapshotPart`、`PatchPart`、`SubtaskPart`、`RetryPart`、`CompactionPart`、`Part` |
+| 工具状态机(5) | `ToolStatePending`、`ToolStateRunning`、`ToolStateCompleted`、`ToolStateError`、`ToolState` |
+| Part 来源(5) | `FilePartSourceText`、`FileSource`、`SymbolSource`、`ResourceSource`、`FilePartSource` |
+| 用户消息的格式/摘要(4) | `OutputFormatText`、`JsonSchema`、`OutputFormatJsonSchema`、`OutputFormat` |
+| 消息错误联合(6) | `ProviderAuthError`、`UnknownError`、`MessageOutputLengthError`、`MessageAbortedError`、`StructuredOutputError`、`ContextOverflowError`、`ApiError` |
+| Part 输入(4) | `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput` |
+| `message.*` 同步事件(4) | `SyncEventMessageUpdated`、`SyncEventMessageRemoved`、`SyncEventMessagePartUpdated`、`SyncEventMessagePartRemoved` |
+| `session.next.*` 消息流事件(18) | `...Synthetic`、`...StepStarted/Ended/Failed`、`...TextStarted/Ended`、`...ReasoningStarted/Ended`、`...ToolInputStarted/Ended`、`...ToolCalled/Progress/Success/Failed`、`...Retried`、`...CompactionStarted/Delta/Ended` |
+| 事件载荷(2) | `EventMessagePartRemoved`、`EventMessagePartDelta` |
+
+### 1.2 C 桶(19 个)—— 跨组,建议随 `GlobalEvent` 一起处理
+
+`SnapshotFileDiff`(消息 summary ↔ file 组)、`Range`(Part 来源 ↔ Symbol)、
+**`GlobalEvent`(巨型联合,横跨全部组)**、`PromptSource` / `PromptFileAttachment` /
+`PromptAgentAttachment` / `PromptReferenceAttachment`(message ↔ file/agent/reference)、
+`SessionErrorUnknown`、`ToolTextContent` / `ToolFileContent`(V2 属消息模型,V1 仅用于工具事件)、
+`SessionNextRetryError`、`SyncEventSessionNextAgentSwitched` / `ModelSwitched` / `Moved` /
+`Prompted` / `PromptAdmitted` / `PromptPromoted` / `ShellStarted` / `ShellEnded`。
+
+### 1.3 B 桶(104 个)—— 阶段 3 再动,本阶段一律保留
+
+permission(`PermissionAction/Rule/Ruleset/Request`、`Question*`、`PermissionV2*`、`QuestionV2*`)、
+session(`Session`、`SessionStatus`、`SessionListData/CreateData/UpdateData/ForkData`、
+`SyncEventSessionCreated/Updated/Deleted`、`EventSessionDiff/Status/Idle`)、
+config(`Config`、`AgentConfig`、`ProviderConfig`、`PermissionConfig`、`LogLevel`、`ServerConfig`、
+`ReferenceConfig*`、`LayoutConfig`、`PolicyEffect`、`ConfigV2ExperimentalPolicy`…)、
+file(`File`、`FileNode`、`FileContent`、`Symbol`、`FindText*`)、
+mcp(`McpStatus*`、`McpResource`、`McpLocalConfig`…)、vcs / worktree / pty / tool / lsp /
+todo(`Todo`、`EventTodoUpdated`)/ project / agent / model(`Model`、`Provider`、
+`ProviderAuthMethod/Authorization`、`ModelV2Info`、`LocationRef`)/ auth(`Auth*`)/
+server(`EventServerInstanceDisposed`、`GlobalHealth*`)。
+
+### 1.4 🔴 本阶段**没有删除** `v1Model.ts` 的任何导出 —— 以及为什么
+
+任务要求「本阶段只删除/替换『消息与 Part』那一组」。实测后**无法在本阶段安全删除**,原因是
+**冻结的事件层把整条依赖链钉死了**:
+
+```
+src/api/events.ts (本阶段禁止改动)
+ └─ import type { GlobalEvent } from './types'
+ └─ GlobalEvent(v1Model.ts:595,巨型联合)
+ └─ SyncEventMessageUpdated → properties.info: Message
+ └─ SyncEventMessagePartUpdated → properties.part: Part
+ └─ EventMessagePartDelta / Removed → Part
+ └─ SyncEventSessionNext* → 大量引用 Message / Part / ToolState
+```
+
+`GlobalEvent` 里嵌着 `Message` / `Part` / `ToolState`,而 `events.ts` 又必须导入 `GlobalEvent`
+(用于 `coalesceEvents` / `parseGlobalEvent` / `isGlobalEvent` / `handleEventForSubscriber`)。
+**只要事件层不动,A 桶的这 66 个类型就一个都删不掉。**
+
+本阶段的实际处理:
+
+1. **`src/types/api/message.ts` 重写为 V2 模型**,V1 别名集中在文件末尾的
+ 「五、⚠️ 遗留 V1 别名(阶段 2b 删除)」一节,逐个标 `@deprecated` + 理由。
+2. **在 `message.ts` 的注释里给出「下游零引用、可在 2b 一并清理」的清单**:
+ `UserMessage`、`AssistantMessage`(仅 `types/index.ts` 的类型守卫在用)、
+ `ReasoningPart`、`ToolPart`、`ToolState`、`StepStartPart`、`StepFinishPart`、
+ `SnapshotPart`、`PatchPart`、`RetryPart`、`CompactionPart`、`SubtaskPart`、
+ `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput`
+ —— 保留它们只是为了让 `src/api/types.ts` / `src/types/api/index.ts` 这两个
+ **本阶段授权范围之外**的转发文件保持原样。
+3. **A 桶的 66 个定义原样留在 `v1Model.ts`**,留给 2b 随事件层一起删。
+
+> 📌 **对阶段 3 工作量判断的影响**:B 桶 104 个是阶段 3 的真实工作量;
+> A 桶 66 个 + C 桶 19 个(共 85 个)会**在 2b 一次性消失**,
+> 但**必须等 `events.ts` 重写完之后**。也就是说 2b 的收尾动作之一是
+> 「删 `events.ts` 对 `GlobalEvent` 的依赖 → 删 `GlobalEvent` → 删 A/C 两桶」。
+
+---
+
+## 2. 消息模型映射表:V1 字段 → V2 字段(逐字段)
+
+### 2.1 结构层
+
+| | V1 | V2 |
+| -------- | ------------------------------ | ------------------------------------------------------------------ |
+| 端点 | `GET /session/{id}/message` | **`GET /api/session/{id}/message`** |
+| 响应 | `MessageWithParts[]`(裸数组) | **`{ data: Session.Message.Info[], cursor: { previous, next } }`** |
+| 结构 | **两层**:`{ info, parts }` | **扁平联合**:消息自带 `content` |
+| 判别字段 | `role: 'user' \| 'assistant'` | **`type`**(11 种) |
+| 分页 | 无(一次全量) | **游标**(`limit` / `order` / `cursor` / `type`) |
+| SDK 入口 | `sdk.session.messages()` | **顶层** `sdk.message.list()`(不是 `session.message.list`) |
+
+### 2.2 11 种消息类型(`type` 判别)
+
+| TS 类型名 | 线上 `type` 值 | V1 对应物 |
+| ------------------ | ------------------------- | --------------------------------------------------- |
+| `User` | `"user"` | `UserMessage` |
+| `Assistant` | `"assistant"` | `AssistantMessage` |
+| `System` | `"system"` | ❌ 无(新增) |
+| `Skill` | `"skill"` | ❌ 无(新增) |
+| `Shell` | `"shell"` | ❌ 无(新增) |
+| `Synthetic` | `"synthetic"` | ❌ 无(新增,V1 只有 synthetic **part**) |
+| `Compaction` | `"compaction"` | `compaction` **part** |
+| `Idle` | `"idle"` | `step-start` / `step-finish` part(语义:一轮结束) |
+| `AgentSelected` | **`"agent-switched"`** | `session.updated` 事件 |
+| `ModelSelected` | **`"model-switched"`** | `session.updated` 事件 |
+| `LocationSwitched` | **`"location-switched"`** | ❌ 无(新增) |
+
+> ⚠️ 后三个**类型名与线上取值不一致**(源码 `Schema.tag("agent-switched")`)。
+
+### 2.3 `User` 消息逐字段
+
+| 概念 | V1 | V2 | 处理 |
+| --------------- | --------------------------------- | ------------------------------------------ | ---------------------- |
+| `id` | ✅ | ✅ `id` | 直接映射 |
+| `sessionID` | ✅ | ❌ **已删除** | 转换层由调用方补 |
+| `role` | `'user'` | → `type: 'user'` | 改名 |
+| 文本 | `parts[].type === 'text'` | **`text`(直接字段)** | 摊平成 1 个 text part |
+| 附件 | `parts[].type === 'file'` | **`files: PromptFileAttachment[]`** | 见 2.4 |
+| agent | `parts[].type === 'agent'` | **`agents: PromptAgentAttachment[]`** | 见 2.4 |
+| 技能 | ❌ | **`skills: PromptSkillAttachment[]`** | 新增 UI `skill` part |
+| `agent`(顶层) | ✅ `agent: string` | ❌ 已删除(实测在 `metadata.agent`) | 从 metadata 兜底 |
+| `model`(顶层) | ✅ `model: {providerID, modelID}` | ❌ 已删除(实测在 `metadata.model`) | 从 metadata 兜底 |
+| `summary` | ✅ `{title?, body?, diffs?}` | ❌ **已删除** | 留空,下游回退正文首行 |
+| `time` | `{created, completed?}` | `{created}`(**无 completed**) | 丢掉 completed |
+| `metadata` | ❌ | ✅ `Record`(**非契约**) | 仅用于取 agent/model |
+
+### 2.4 用户附件:`FilePart` → `PromptFileAttachment`
+
+| 概念 | V1 `FilePart` | V2 `PromptFileAttachment` | 处理 |
+| ---------- | --------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
+| `id` | ✅ | ❌ | 按 `消息id:file:下标` 合成 |
+| `filename` | ✅ `filename?` | → `name?` | 改名 |
+| `mime` | ✅ | ✅ | 直接映射 |
+| `url` | ✅ **可直接渲染** | ❌ 无;内容在 `data`(**base64**) | `uri` 源直接用;`inline` 源拼 `data:;base64,…` |
+| `source` | `FilePartSource`(file/symbol/resource 三态) | `{type:'inline'} \| {type:'uri',uri}`(两态,**语义不同**) | 只保留 uri → `path` |
+| 文本位置 | `source.text: {value,start,end}` | `mention?: {start,end,text}` | 映射成 V1 的 `source.text` |
+
+> 实测:本机 1637 条 user 消息里 **22 条带附件,全部是 inline base64 图片**。
+> V1 的 `url` 是直接塞给 `
` 的 → 不拼 data URL 就会显示裂图。
+
+### 2.5 `Assistant` 消息逐字段
+
+| 概念 | V1 | V2 | 处理 |
+| --------------------- | -------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------ |
+| `id` | ✅ | ✅ | 直接映射 |
+| `sessionID` | ✅ | ❌ **已删除** | 调用方补 |
+| `parentID` | ✅(指向 user 消息) | ❌ **已删除** | 填 `''`(实测 UI 只用 `Session.parentID`,不用消息的) |
+| `role` | `'assistant'` | → `type: 'assistant'` | 改名 |
+| 内容 | `parts: Part[]`(**独立数组**,靠 messageID 关联) | **`content: (Text\|Reasoning\|Tool)[]`(内嵌)** | 摊平成 UI parts |
+| 模型 | `modelID` + `providerID` 两个散字段 | **`model: ModelRef`(字段名是 `id`)** | 改名拆回 |
+| `mode` | ✅ | ❌ **已删除** | 填 `''` |
+| `path`(cwd/root) | ✅ | ❌ **已删除** | 填 `{cwd:'',root:''}` |
+| `summary`(是否摘要) | ✅ `summary?: boolean` | ❌ **已删除** | 填 `false` |
+| 成本 | `step-finish` part 的 `cost` | **`cost?: MoneyUSD`(顶层,可选)** | 上移 + 合成 step-finish part |
+| 用量 | `step-finish` part 的 `tokens` | **`tokens?: TokenUsageInfo`(顶层,可选)** | 同上;缺省补 0 |
+| 结束原因 | `step-finish` part 的 `reason: string` | **`finish?: 6 字面量联合`** | 收窄 |
+| `rawFinish` | ❌ | ✅ 新增 | 丢弃(渲染层零消费) |
+| 错误 | `error: MessageError`(`{name,data}` 5 元联合) | **`error: {type, message, status?}`(扁平开放字符串)** | 按关键字映射回 5 元 |
+| 重试 | `retry` **part** | **`retry` 字段**(内嵌 `{attempt, at, error}`) | 反向合成 `retry` part |
+| 快照 | `snapshot` / `patch` **part** | `snapshot` 字段(`{start,end,files?}`) | 丢弃(渲染层零消费) |
+| 步骤分隔 | `step-start` / `step-finish` **part** | ❌ **已删除**(改用 `Idle` 消息) | 合成 1 个尾部 step-finish |
+| `time` | `{created, completed?}` | `{created, streamed?, completed?}` | 新增 `streamed`,丢弃 |
+
+### 2.6 Assistant `content` 逐字段
+
+| 概念 | V1 | V2 | 处理 |
+| ------------------------------ | ------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------ |
+| text `id` | ✅ `TextPart.id` | ❌ **没有** | 按 `消息id:content:下标` 合成 |
+| text `sessionID` / `messageID` | ✅ | ❌ | 调用方补 |
+| text `synthetic` | ✅ `synthetic?: boolean` | ❌ **已删除**(改用独立 `system`/`synthetic` 消息类型) | 一律 `false` |
+| reasoning `time` | ✅ 必填 `{start, end?}` | `{created, completed?}` **可选** | 缺省用消息 `created` 兜底 |
+| reasoning `state` | ❌ | ✅ `providerState` | 丢弃 |
+| tool `id` | `callID` | **`id`** | 改名 |
+| tool 名 | `tool` | **`name`** | 改名 |
+| tool `executed` | ❌ | ✅ 新增 | 丢弃 |
+| tool `time` | `state.time: {start,end}` | **`time: {created,ran?,completed?}`(工具层)** | 下移到 `state.time` |
+| tool 状态 | `pending\|running\|completed\|error` | **`streaming\|running\|completed\|error`** | `streaming` → `pending`(`input` 是**字符串** → 放进 `state.raw`) |
+| tool 产出 | `state.output: string` | **`state.content: ToolContent[]`** | 只取 `text` 项用 `\n` join |
+| tool 标题 | `state.title: string` | ❌ **已删除** | 从 `metadata.title` 兜底,否则空串 |
+| tool 附件 | `state.attachments?: FilePart[]` | ❌ 删除(改用 `content` 里的 `file` 项) | **不映射**(渲染层零消费,见 §7.2) |
+| tool 错误 | `state.error: string` | `state.error: {type,message,status?}` | 取 `message` |
+
+### 2.7 其余 9 种消息 → UI 模型
+
+| V2 类型 | UI 归宿 | 说明 |
+| --------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------- |
+| `system` | `role:'system'` + `session-marker` part | 实测是「指令/上下文更新」通知,`description` 是给人看的摘要 |
+| `synthetic` | 同上 | 实测官方用它把 shell 作业产出插进转录 |
+| `skill` | 同上 | 技能激活记录 |
+| `shell` | 同上 | 会话级 shell 命令消息(与工具里的 `shell` **不是一回事**) |
+| `idle` | 同上(**不渲染**) | 一轮结束边界;可见耗时已由 assistant 的 step-finish/footer 展示 |
+| `agent-switched` / `model-switched` / `location-switched` | 同上 | 一行提示 |
+| `compaction` | **复用已有的 `compaction` part** | 渲染层本来就有 `CompactionPartView`;增补 `status`/`reason`/`summary` 字段 |
+
+---
+
+## 3. 分页参数与行为说明
+
+### 3.1 参数口径(照 openapi + v2.0.19 源码核实)
+
+| 参数 | 类型 | 约束 | 说明 |
+| ----------- | ----- | --------------------------------------------- | -------------------------------------- |
+| `sessionID` | path | `^ses` | |
+| `limit` | query | **`NumberFromString`,1..200**,省略时 **50** | 超过 200 → 400 |
+| `order` | query | `asc` \| `desc`,默认 `desc` | **只对首页有效** |
+| `cursor` | query | 不透明字符串 | **与 `order` 互斥** |
+| `type` | query | **10 个字面量(无 `idle`)** | 在分页**之前**过滤;翻页时必须原样带上 |
+
+### 3.2 🔴 游标方向(本阶段最重要的实测结论)
+
+游标是 base64url 的 `{id, order, direction}`。实测解出:
+`{"id":"msg_0ee2…","order":"desc","direction":"previous"}`。
+
+**`previous` / `next` 是相对于「本次排序」的,不是绝对时间方向。**
+服务端默认 `order=desc`(新→旧)时:
+
+| 请求 | 实测结果 |
+| ------------------------ | ----------------------------------------- |
+| `?limit=3`(不传 order) | 返回**最新 3 条**(新→旧) |
+| 跟 **`cursor.next`** | ✅ 拿到**更旧**的一页(50 条) |
+| 跟 `cursor.previous` | ❌ **空数组**(最新那条之后没有更新的了) |
+
+源码依据:
+
+- `packages/server/src/handlers/message.ts` —— `previous` 锚定 `messages[0]`、`next` 锚定 `messages.at(-1)`;
+ 两者都把 `order` 编码进游标;且 `cursor` + `order` 同时传 → 400 `InvalidCursorError`。
+- `packages/core/src/session/store.ts` `messages()` ——
+ `order = direction === 'previous' ? 反转(requestedOrder) : requestedOrder`。
+
+→ **「滚动到顶部加载更早历史」必须用 `cursor.next`。**
+(迁移文档 §5.5 原文写的是 `cursor.previous`,**已在主文档修正**。)
+
+### 3.3 🔴 游标**不表示**「还有没有更多」
+
+只要本页非空,`previous` / `next` **都会返回一个值**,哪怕那个方向已经没有数据
+(跟过去只会拿到空数组)。因此:
+
+**「还有更多」用 `limit + 1` 溢出法判断**:多要一条,多出来就说明还有。
+
+⚠️ **多要的那条必须保留,不能丢弃** —— 因为 `cursor.next` 锚定在本页**最后一条**上,
+丢掉它就等于让下一页跳过它,会**永久丢消息**。
+
+实现细节(`src/api/message.ts`):
+
+- `limit` 先钳制到 1..200,再请求 `limit + 1`;
+- 返回的 `messages` **全部保留**(可能比调用方要的多 1 条,这是刻意的);
+- `hasMore = data.length > limit`;
+- `limit === 200` 时无法再 +1 → 退化为 `data.length >= 200`(已在注释里写明)。
+
+### 3.4 本项目的用法
+
+```
+首页:getSessionMessages(sid, { limit: 50 })
+ → 服务端给最新的 51 条(desc)→ API 层重排成「旧→新」交给 store
+ → store 记下 cursor.next 作为 historyCursor,hasMoreHistory = page.hasMore
+
+更旧:getSessionMessages(sid, { limit: 50, cursor: historyCursor })
+ → 同样重排成「旧→新」→ store.prependMessages() 前置
+ → 更新 historyCursor / hasMoreHistory
+```
+
+- 向前翻页游标存在 **`messageStore` 的 `SessionState.historyCursor`** —— 随 session 一起被
+ LRU 淘汰,不会像原来的 `cursorRef`(`useSessionManager` 里的 `Map`)那样跨 session 泄漏。
+- `useSessionManager` 里加了**并发保护**(`loadingMoreRef`),避免滚动事件高频触发重复加载。
+- `historyCursor` 为 `null` 但 `hasMoreHistory` 为真时,视为状态不一致 → 纠正为「已到最早」,
+ 避免死循环。
+
+### 3.5 滚动位置保持
+
+**本阶段未改动滚动逻辑**,确认它仍然成立:
+
+- `ChatArea.tsx` 的 prepend 锚点(`prependAnchor` / `capturePrepend` / `restorePrepend`,
+ L614-673)按 `data-timeline-key`(= 消息 id)记录锚点元素的视口偏移,
+ prepend 后按差值补偿 `scrollTop`;
+- 触发条件:`onScroll` 里 `userScrolled && scrollTop < 200 && !loadingMore && hasMoreHistory`
+ → `loadMore()` → `capturePrepend()` → `onLoadMore()`(= `loadMoreHistory`)→ `restorePrepend(true)`;
+- 本阶段只改了 `loadMoreHistory` **内部怎么拿数据**(游标 vs limit 递增),
+ 以及 `hasMoreHistory` **怎么算**(`limit+1` 溢出 vs `length >= limit` 近似);
+- 锚点 key 是消息 id,而 `prependMessages` 只前置、不改已有消息的 id → 锚点仍能命中。
+
+> ✅ 顺带修好一个老问题:V1 的 `hasMoreHistory = apiMessages.length >= limit` 是**近似**,
+> 到底了还会多发一次注定为空的请求;现在用溢出法**精确**判断,到底即停。
+
+---
+
+## 4. 测试清单与覆盖率
+
+### 4.1 总体结果
+
+| 场景 | 文件 | 用例 | 结果 |
+| ---------------------- | --------------------- | --------------------------: | ------------- |
+| **默认(无真实服务)** | 98 passed / 2 skipped | **741 passed + 20 skipped** | ✅ **0 失败** |
+| **起真实 V2 服务后** | 99 passed / 1 skipped | **753 passed + 8 skipped** | ✅ **0 失败** |
+
+跳过的 20 例 = 阶段 1 冒烟 12 例 + 阶段 2a 冒烟 8 例(两者都在服务不可达时**自跳过**,不是失败)。
+
+- **基线 683 例全部仍通过**(其中含阶段 1 的 12 例冒烟,它们在本阶段起了真实服务后也跑通)
+- **本阶段新增 70 例**:`messageConversion.test.ts` 35 + `api/message.test.ts` 23 +
+ `messageStore.test.ts` 净增 12
+
+### 4.2 新增测试文件
+
+| 文件 | 用例 | 覆盖的关键逻辑 |
+| --------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/utils/messageConversion.test.ts` | 35 | user 文本/附件/agent/skill 映射;metadata 兜底(含 V2 命名 `id`);inline base64 → data URL;uri 源 + mention;assistant 内容摊平与**下标 id**;model/provider 改名;step-finish 合成(含「流式中不合成」);retry 反向合成;工具 4 态映射(`streaming→pending`、content→output、time 下移、error 取 message、metadata.title 兜底);**错误类型映射 5 例参数化**;system/compaction/idle marker;**「11 种类型一条都不能丢」**;批量转换与 role 守卫互斥 |
+| `src/api/message.test.ts` | 23 | **重排成旧→新**;不传 cursor 时**不发 order**;带 cursor 时**也不发 order**(互斥);`limit+1` 溢出探测;短页 `hasMore=false`;**长页 `hasMore=true` 且保留溢出项**;游标归一化为 `string\|null`;空页;默认页大小;limit 钳制(9999 / 0 / 负数);200 上限的退化;`type` 透传与省略;`extractUserMessageContent` 6 例(含 synthetic 过滤、folder、textRange、agent);**`sendMessage`/`sendMessageAsync` 报错语义 3 例** |
+| `src/test/fixtures/v2Messages.ts` | — | V2 消息夹具(刻意只填必填字段,V2 加必填项时会立刻报错) |
+| `src/features/message/phase2a.smoke.test.tsx` | 8 | 见 §5(默认 skip,`VITE_OPENCODE_SMOKE=1` 开启) |
+
+### 4.3 改写的测试文件
+
+| 文件 | 改动 |
+| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/store/messageStore.test.ts` | 17 → **29** 例。夹具从 V1 `{info,parts}` 换成 V2 扁平消息;**明确区分两条通路**(V2 读侧用 `v2*` 夹具 / V1 事件侧仍用 V1 夹具);新增游标(存/取/不被元数据刷新清掉)、prepend 去重与「不按时间重排」、`upsertMessages`(不清空历史 / 就地更新)、`keepLocalOnly`(保留 / 不保留)等用例 |
+| `src/store/messageStoreHooks.test.tsx` | 6 例,夹具换 V2;`handlePartUpdated` 的 part id 换成转换层合成规则 |
+| `src/hooks/useSessionStats.test.tsx` | 2 例,夹具换 V2;压缩场景改用**独立的 compaction 消息**(V2 语义) |
+
+### 4.4 覆盖率
+
+> ⚠️ **测量方式与善后(如实交代)**:仓库未安装覆盖率工具。为拿到数字,本次用
+> `npm install --no-save --no-package-lock @vitest/coverage-v8@4` 临时装了一个副本
+> (`--no-save --no-package-lock` 保证 **`package.json` / `package-lock.json` 零改动**,已 diff 核对)。
+> 但这个命令**顺带把 node_modules 里的 vitest 4.1.2→4.1.11、shiki 4.0.2→4.4.3 升上去了**
+> (npm 会重算依赖树),并因此让 `src/workers/shikiWorker.ts` 冒出一个**与本次改动无关**的
+> `tsc` 类型报错。**已用 `npm ci` 把 node_modules 恢复到 package-lock 的精确版本**
+> (vitest 4.1.2 / shiki 4.0.2,覆盖率包已移除),恢复后 `tsc -b` 重新为 **0 报错**、
+> 测试重新全绿。仓库文件自始至终零改动。
+
+| 文件 | Stmts | Branch | Funcs | Lines | 未覆盖部分说明 |
+| --------------------------------- | ---------: | ---------: | ---------: | ---------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/utils/messageConversion.ts` | **92.38%** | **85.22%** | **91.66%** | **92.92%** | 仅剩 8 处分支(`toFilePartSource` 的空 mention 分支、`toAgentPart` 的无 mention 分支、compaction failed 的 model 分支等) |
+| `src/api/message.ts` | **100%** | **88.63%** | **100%** | **100%** | 未覆盖分支:`clampLimit` 的非有限数、`hasMore` 的 200 上限退化等 |
+| `src/store/messageStore.ts` | 64.95% | 52.32% | 68.18% | 71.25% | 未覆盖部分**全部是既有代码**:通知/rAF 管线、session LRU 淘汰与保护、`revertState` 撤销重做(**属阶段 3**)、V1 事件处理器(**属阶段 2b**)、`extractUserText` 私有辅助 |
+| `src/types/message.ts` | 83.33% | 90.47% | 92.3% | 83.33% | 未覆盖:`getMessageText` 尾部、部分守卫分支 |
+| `src/test/fixtures/v2Messages.ts` | 88.88% | 100% | 87.5% | 88.88% | `v2Tool` 的默认参数分支 |
+
+**核心结论**:本阶段**新写的逻辑**(转换层、分页层)覆盖率 92% / 100%;
+`messageStore` 的缺口集中在**本阶段明确不碰**的三块(事件层、撤销重做、通知管线)。
+
+---
+
+## 5. 冒烟结果
+
+### 5.1 环境
+
+```
+OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097
+```
+
+- 真实服务 `v2.0.19`,`GET /api/info` → `{"version":"2.0.19","pid":…,"urls":["http://127.0.0.1:4097"]}`
+- 数据:**本机真实数据目录**(588 个会话 / **17,288 条真实 V2 消息**),
+ 目标目录 `/home/coder/project/OpenCodeUI`(25 个会话)
+- 入口:`VITE_OPENCODE_SMOKE=1 npx vitest run src/features/message/phase2a.smoke.test.tsx`
+
+### 5.2 结果:8/8 通过(跑过两次,数字不同是因为目标会话本身还在增长)
+
+| 用例 | 实测输出(第 1 次 / 第 2 次) |
+| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 首页拉到历史、重排成旧→新、给出游标 | 6 条 = limit 5 + 溢出 1;`hasMore=true`;`cursor.next` 有值(两次一致) |
+| 跟 `cursor.next` 拉到更早一页、与首页无重叠 | 第 2 页 6 条,全部早于首页最早那条,**零重叠**(两次一致) |
+| 连续翻页直到尽头 | 第 1 次:**9 页 / 181 条去重**;第 2 次:**11+ 页 / 231+ 条去重**(目标会话在这两次之间又写入了新消息)。两次都:页大小 21(=20+1),`hasMore=false` 后**正常终止**,**零重复** |
+| 转换后渲染层依赖的不变量 | 角色分布 `{assistant:21}`;part 分布 `{reasoning:9, tool:21, step-finish:20, text:11}`;每条消息 `sessionID`/`id`/`time.created` 齐备;**每个 part 有 id 且消息内唯一** |
+| 真实工具调用映射 | 样例 `{"tool":"write","status":"completed"}`,状态落在 UI 认识的 4 态内 |
+| 真实渲染(用户 / 助手) | 用户消息渲染 **2502** 字符;助手消息渲染 **118** 字符(均非空 DOM) |
+| 系统类消息渲染 | 找到真实 `system` 消息,渲染 **38** 字符,不抛错 |
+| 整页渲染不抛错 | **51 条消息渲染 49721 字符**,含工具卡片、推理、系统提示 |
+
+### 5.3 原始分页语义(curl 直打,独立于本项目代码)
+
+| 检查 | 结果 |
+| ----------------------- | --------------------------------------------------------------------- |
+| 首页 `?limit=3` | 最新 3 条(desc)✅ |
+| 跟 `cursor.next` | 50 条更旧 ✅ |
+| 跟 `cursor.previous` | **空数组** ✅(证明 `previous` = 更新方向) |
+| 游标解码 | `{"id":"msg_0ee2…","order":"desc","direction":"previous"}` ✅ |
+| `?type=idle` | **400**(过滤枚举只有 10 个,无 idle)✅ |
+| `?type=user` | 200 ✅ |
+| `?limit=201` | **400** `Expected a value less than or equal to 200` ✅ |
+| `?cursor=abc&order=asc` | **400** `InvalidCursorError: Cursor cannot be combined with order` ✅ |
+
+### 5.4 ❌ 没做到的:浏览器级滚动验证
+
+**没有做**浏览器里的真实滚动加载验证,原因如实说明:
+
+- 本会话**没有连接桌面浏览器**(`browser.tabs.open` 返回
+ _"No desktop browser is connected to this session"_),浏览器工具不可用;
+- 备选路径(vite dev server + `VITE_API_BASE_URL=http://127.0.0.1:4097` + `--cors`)能起来,
+ 但**没有浏览器可以打开它**;顺带发现 `vite.config.ts` 的 dev proxy 是 V1 遗留
+ (`rewrite: path => path.replace(/^\/api/, '')` 会把 V2 必需的 `/api` 前缀削掉),
+ 该文件不在本阶段授权范围内,未改动。
+
+**替代验证**(已做):
+
+1. 游标分页的**真实数据**端到端(9 页 181 条、无重复、正确终止);
+2. 真实数据的**真实 React 渲染**(51 条消息 / 49721 字符,含工具卡片);
+3. 滚动保持逻辑所在的 `ChatArea.tsx` **本阶段零改动**,
+ 且其触发条件(`hasMoreHistory`)与依赖(消息 id 稳定)都由单测覆盖。
+
+→ 结论:**数据链路已充分验证;「滚动触发 + 锚点补偿」这段 UI 行为未在浏览器里实跑**,
+建议在能开浏览器的环境里补一次人工回归(步骤见 §9)。
+
+---
+
+## 6. 🔴 与文档预测不符之处
+
+> 本节是本次报告最重要的部分。前两轮都是这么做的。
+
+### 6.1 🔴🔴 文档 §5.5 写错了:加载更早历史要用 `cursor.next`,不是 `cursor.previous`
+
+| | 内容 |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | 「滚动到顶部时用 **`cursor.previous`** 向前加载」 |
+| **实测** | 服务端默认 `order=desc`(新→旧)。跟 `cursor.previous` 拿到的是**更新**的一页(在最新那条之后 → **空数组**);跟 **`cursor.next`** 才拿到**更旧**的一页(实测 50 条) |
+| **为什么** | 游标把 `order` 和 `direction` 一起编码;`previous`/`next` 是**相对于本次排序**的。源码 `store.messages()`:`order = direction === 'previous' ? 反转(requestedOrder) : requestedOrder` |
+| **影响** | 若照文档实现,**滚动到顶部永远加载不出历史**,而且**不报错**(只是空数组)—— 属于最难查的一类 bug |
+| **处理** | ✅ **已在主文档 §5.5 修正并标注「阶段 2a 实测修正」** |
+
+### 6.2 🔴 文档 §5.1 的类型名 ≠ 线上 `type` 取值
+
+文档列的是 `AgentSelected | ModelSelected | LocationSwitched`(TS 类型名),
+**线上 `type` 实际是 `"agent-switched"` / `"model-switched"` / `"location-switched"`**。
+按类型名去比较会永远匹配不上。→ ✅ 已回填 §5.1(新增对照表)。
+
+### 6.3 🔴 文档 §5.2 遗漏:**所有 V2 消息都没有 `sessionID`**
+
+V1 每条消息都带 `sessionID`,V2 **一律没有**(会话上下文由请求路径给出)。
+文档只写了「`sessionID` 在 Assistant 上被删除」,实际是**11 种消息全都没有**。
+→ 转换层必须由调用方补,否则 store 里所有按 session 分组的逻辑失效。
+✅ 已回填 §5.2 ①。
+
+### 6.4 🔴 文档 §5.2 遗漏:`User` 的 `agent` / `model` 是「移到 metadata」而不是「删除」
+
+文档写 `User.agent` / `User.model` 被删除。实测**官方 TUI 会把它们写进 `metadata`**:
+
+```json
+"metadata": { "displayText": "…", "agent": "build",
+ "model": { "modelID": "deepseek-v4.1-flash", "providerID": "aether2", "variant": "max" } }
+```
+
+⚠️ 两个坑:① `metadata` 在 schema 里是 `Record`,**不是契约字段**,
+第三方写入方可以不写;② `metadata.model` 用的是 **V1 命名 `modelID`**(不是 V2 的 `id`)。
+→ 处理:**只做「有就取」的兜底**,取不到留空,绝不假设存在(转换层同时兼容 `modelID` 和 `id`)。
+✅ 已回填 §5.2 ②。
+
+### 6.5 🔴 文档 §5.2 遗漏:`text` / `reasoning` **没有 `id`**
+
+文档只说「步骤分隔被删除」。实测 `assistant.content[]` 里**只有 tool 有 `id`**,
+`text` / `reasoning` 完全没有 id;而 UI 用 id 做 React key 与折叠状态。
+→ 转换层按 `消息id:content:下标` 合成。
+⚠️ 下标必须按 **content 数组下标**算,不能按同类型计数(否则插入新块会让已有 id 漂移)。
+✅ 已回填 §5.2 ④。
+
+### 6.6 🔴 文档 §5.2 遗漏:工具状态多了 `streaming`、少了 `pending`,且 `title`/`attachments` 被删
+
+| 文档说法 | 实际 |
+| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 「工具调用:`parts[].type === 'tool'`(内嵌 `state`)」→ V2 同构 | ❌ 状态机**变了**:`pending` → **`streaming`**(且此时 `input` 是**未解析的字符串**);`callID` → **`id`**;`tool` → **`name`**;`state.time` → **工具层 `time`**;`state.output: string` → **`state.content: ToolContent[]`**;`state.title` 与 `state.attachments` **被删除** |
+
+→ UI 只认 `pending|running|completed|error`,转换层必须把 `streaming` 翻译回 `pending`。
+✅ 已回填 §5.2 ⑤。
+
+### 6.7 🔴 文档 §5.2 表述不准:错误不是「`Assistant.error` + `Session.StructuredError`」这么简单
+
+V1 的 `Message.error` 是 `{name, data}` 的 **5 元判别联合**;V2 是 `{type, message, status?}`,
+`type` 是**开放字符串**。实测出现过的取值:`aborted`、`unknown`、`provider.error`、
+`provider.invalid-output`、`tool.execution` —— **一个都不叫 `ProviderAuthError` / `APIError`**。
+→ 只能按关键字启发式映射。最关键的一条:**`aborted` → `MessageAbortedError`**,
+UI 靠它显示「已中止」(本机库里有 **57 条** assistant 是这个类型)。
+✅ 已回填 §5.2 ⑥。
+
+### 6.8 🔴 文档 §5.5 未提:**游标不代表「还有没有更多」**
+
+只要本页非空,`previous`/`next` 都会返回值,哪怕该方向已经没数据。
+→ 必须用 `limit + 1` 溢出法;且**溢出的那条不能丢**(游标锚定本页最后一条,
+丢了就永久丢消息)。这是照文档实现一定会踩的坑。✅ 已回填 §5.5。
+
+### 6.9 🔴 文档 §5.5 未提:`limit` 是 `NumberFromString` 且限定 1..200
+
+`?limit=201` → 400 `Expected a value less than or equal to 200`。
+V1 时代前端习惯用 `limit` 递增(`Math.max(INITIAL_MESSAGE_LIMIT, 200)`),
+迁移时必须加钳制。✅ 已回填 §5.5。
+
+### 6.10 🔴 V2 自身的不一致:`idle` **能返回但不能过滤**
+
+`SessionMessagesQuery.type` 的枚举只有 **10 个值(没有 `idle`)**,
+但响应联合 `PublicSessionMessage` **包含** `SessionMessage.Idle`。
+实测 `?type=idle` → **400**,而 `idle` 消息在不过滤时会正常返回。
+→ 不是本项目的 bug,是 V2 的枚举不一致;实现里用 `SessionMessageFilterType = Exclude`
+把它编码进类型。✅ 已回填 §5.5。
+
+### 6.11 🟡 文档 §5.1 的端点写法容易误读
+
+文档写 `GET /api/session/{id}/message`(对的),但 SDK 入口是**顶层**
+`client.message.list()`,**不是** `client.session.message.list()`
+(`session.message` 只有 `get` 单条)。任务描述里的「`GET /api/message`」经 openapi 核实
+**不存在** —— 正确路径就是 `/api/session/{sessionID}/message`。
+
+### 6.12 🟡 文档 §5.4 的文件清单与实际改动有出入
+
+- 文档列了 `src/api/types.ts`(兼容别名)要改 —— 实际**没改**(该文件不在本阶段授权范围内,
+ 且 V1 别名保留在 `src/types/api/message.ts` 里就够用)。
+- 文档**没列**实际必须新增的文件:`SessionMarkerPartView.tsx`(V2 新增消息类型的渲染)、
+ `src/test/fixtures/v2Messages.ts`(V2 夹具)、两个新测试文件。
+- ✅ 已回填 §5.4 的「阶段 2a 实际改动的文件」表。
+
+### 6.13 🟡 文档 §5.4 说 `src/types/message.ts` 要「UI 侧模型调整」——实际是**新增**而非推翻
+
+按「保留现有函数签名与导出名、调用方最小改动」的要求,本阶段**没有推翻 UI 模型**,
+而是把它当**转换目标**(渲染层 5,900 行因此几乎零改动),只**新增**了:
+`SystemMessageInfo`(`role:'system'`)、`SessionMarkerPart`、`SkillPart`,
+并给 `CompactionPart` 增补 V2 字段。这是本阶段最关键的架构决策,见 §7.1。
+
+### 6.14 🟡 阶段 0/1 报告与实际的偏差(本次核对发现)
+
+- 阶段 1 报告称「683 单测全绿」—— 实测该数字**包含**阶段 1 冒烟的 12 例
+ (当时服务在跑);服务不在时它们会**自跳过**。本阶段报告统一按「有无服务」两种口径给出。
+- 容器里 `npx eslint .` 有 **42 个 warning / 0 error**(全是既有的 React refs 与
+ react-refresh 告警);`npx prettier --check .` 有 **167 个文件不达标**(版本漂移,
+ 与本次改动无关,本阶段未做全仓库格式化以免产生无关 diff)。
+ 本阶段**新增/改动的文件 eslint 零告警**,格式化风格与所在文件保持一致。
+
+---
+
+## 7. 设计决策与取舍
+
+### 7.1 ✅ 核心决策:**不推翻 UI 模型,而是把它当转换目标**
+
+任务要求「保留现有函数签名与导出名(调用方最小改动)」。据此选择:
+
+```
+V2 扁平消息(Session.Message.Info)
+ │ ← 新增的全部复杂度都在这一层
+ ▼
+src/utils/messageConversion.ts
+ │
+ ▼
+UI 模型 src/types/message.ts({info, parts, isStreaming} 基本不变)
+ │
+ ▼
+渲染层 src/features/message/**(≈5,900 行,几乎零改动)
+```
+
+**收益**:`MessageRenderer.tsx` 只加了 37 行(一个 `role==='system'` 分支 + 一个轻量视图),
+其余 5,900 行渲染代码一行没动。
+**代价**:转换层要「造」三样 V2 没有的东西(见 7.2),并且这是**有损**的
+(V2 独有的字段如 `providerState` / `rawFinish` / `providerResultState` 被丢弃)。
+
+**为什么值得**:这些被丢的字段经全仓库核对**渲染层零消费**;
+而如果反过来让渲染层直接用 V2 模型,等于重写 5,900 行 + 全部相关测试。
+
+### 7.2 转换层「造」出来的三样东西(都是为了喂饱既有渲染层)
+
+| 造的东西 | 为什么必须造 | 风险与对策 |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| **`sessionID`** | V2 消息里没有,但 store 按 session 分组、revert 按 id 定位都要它 | 入参**强制要求**(`toUIMessage(msg, sessionID)`),不给默认值,避免静默传空串 |
+| **part `id`** | V2 的 text/reasoning 没有 id,UI 要它做 React key 与折叠状态 | 规则固定为 `消息id:content:下标`(**下标而非同类型计数**),保证「插入新块不导致已有 id 漂移」 |
+| **尾部 `step-finish` part** | V2 把 cost/tokens/finish 上移到 assistant 顶层,但渲染层的「过程/最终内容拆分」(`splitProcessRenderItems`)与工具组配对(`groupPartsForRender`)**依赖 step-finish** | **只在 `finish` 已存在时**合成(流式中不合成),否则工具组会提前挂上未完成的用量 |
+
+### 7.3 V2 新增的 9 种消息:统一用一个 `session-marker` part 承载
+
+**没有**给 9 种消息各造一个 UI part 类型,而是:
+
+```ts
+interface SessionMarkerPart extends PartBase {
+ type: 'session-marker'
+ marker: SessionMarkerMessage // 保留 V2 原始消息(强类型判别联合)
+}
+```
+
+**理由**:① 渲染上它们都是「一行提示」,共用一个视图即可;
+② 保留原始消息的完整类型,渲染层按 `marker.type` 拿到强类型字段,**转换层不做有损摊平**;
+③ 将来 V2 加新消息类型时只需扩联合,渲染层 switch 会提示补分支。
+`compaction` 例外 —— 渲染层本来就有 `CompactionPartView`,直接复用。
+
+**`idle` 故意不渲染**:它只是「一轮结束」边界,可见耗时已由 assistant 的 step-finish 与 footer
+展示,再画一条分隔线只会让信息流变吵(本机有 81 条 idle)。
+
+### 7.4 分页:**不暴露 `order` 参数**
+
+`getSessionMessages()` 刻意**不提供 `order` 选项**,一律用服务端默认 `desc` 起手,
+再把结果重排成「旧→新」交给上层。理由:
+① `cursor` 与 `order` 互斥,暴露 `order` 会引入「传了 cursor 又传 order → 400」的脚枪;
+② 固定 desc 意味着**所有游标都是 desc 语义**,不会有方向歧义;
+③ 上层(store)本来就按升序存。
+
+### 7.5 `hasMoreHistory` 从「近似」改成「精确」
+
+V1:`apiMessages.length >= limit` —— 到底了还会多发一次注定为空的请求。
+V2:`limit + 1` 溢出法 —— 到底即停。
+(顺带:`historyCursor` 为 `null` 但 `hasMoreHistory` 为真时会被纠正,避免死循环。)
+
+### 7.6 没有做的事(YAGNI)
+
+- ❌ 没有为「用户技能附件」写专门的渲染分支(实测 1637 条 user 消息里 **0 条**带 skills)
+ —— 但**类型与转换都做了**(`SkillPart`),只是渲染走 `session-marker` 之外的路径时
+ 会被 `UserMessageView` 的 filter 忽略。**如实记录:用户消息上的 skill 附件当前不显示。**
+- ❌ 没有把 V2 `state.content` 里的 **file 类型产出**映射成附件(V1 的 `state.attachments`
+ 在渲染层**零消费**,映射了也不会显示)。
+- ❌ 没有处理 `Assistant.snapshot`(渲染层零消费)。
+- ❌ 没有改 `MAX_CACHED_SESSIONS = 10` 的 LRU 策略(游标已并入 `SessionState`,无需额外缓存层)。
+
+---
+
+## 8. 未做 / 已知缺口(如实)
+
+| # | 项 | 说明 |
+| --- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | **浏览器级滚动验证未做** | 本会话无桌面浏览器连接,见 §5.4。数据链路已验证;UI 滚动行为建议人工补测 |
+| 2 | ~~**`v1Model.ts` 一个导出都没删**~~ | ✅ **阶段 2b 已完成**:189 → 104 个顶层导出(A 桶 66 删 + C 桶 17 删 + 2 降级) |
+| 3 | ~~**事件层零改动**~~ | ✅ **阶段 2b 已重写** `events.ts` / `EventTypes` / `EventCallbacks` |
+| 4 | ~~**流式回复未适配**~~ | ✅ **阶段 2b 已适配**(真实 V2 服务冒烟 11/11) |
+| 5 | ~~**发消息仍不可用**~~ | ✅ **阶段 2b 已实现**(`prompt` / `prompt` + `wait`) |
+| 6 | **撤销/重做(undo/redo)仍不可用** | 依赖 V2 已删的 `revert` / `unrevert` 端点(V2 改成三段式),属**阶段 3**。`useRevertState` 只做了类型适配,运行时仍会抛错(与阶段 1 行为一致) |
+| 7 | **用户消息上的 skill 附件不显示** | 见 §7.6(实测 0 条,未做渲染分支) |
+| 8 | **`limit === 200` 时 `hasMore` 退化** | 无法再 +1 探测,退化为「满页即视为还有」;已在代码注释与报告中写明。本项目实际只用 50 |
+| 9 | **Rust / Tauri / WSL 未跑** | 与阶段 1 一致(本阶段完全没碰 `src-tauri/`) |
+| 10 | **prettier 全仓库不达标** | 167 个文件(既有版本漂移),本阶段未做全仓库格式化以免产生无关 diff |
+| 11 | ~~**阶段 1 的 dev proxy 是 V1 遗留**~~ | ✅ **阶段 2b 已修正**(删掉会削掉 V2 必需 `/api` 前缀的 `rewrite`) |
+
+> ⬆️ 上表第 2/3/4/5/11 项已由 **阶段 2b** 完成,详见 `docs/opencode-v2-migration-phase2b.md`。
+> 阶段 2b 还额外发现:本文件的冒烟测试选会话的启发式(「最新 12 个里第一个非空的」)过于脆弱,
+> 已被阶段 2b 改成「按新→旧体检、取消息数最多的那个」。
+
+---
+
+## 9. 建议的后续动作
+
+### 9.1 立刻可做(人工回归,约 5 分钟)
+
+```bash
+# 1) 起真实 V2 服务
+OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097
+
+# 2) 起前端(浏览器能直连 4097;若走 dev proxy 需先修 vite.config.ts 的 /api 重写)
+npm run dev
+
+# 3) 在界面里:
+# - 打开一个历史很长的会话(如阶段 1 的会话 ses_f11f3943…,181+ 条)
+# - 确认历史消息正确渲染(工具卡片、推理、系统提示、耗时/用量)
+# - 滚到顶部,确认能持续加载更早历史,且**滚动位置不跳**
+# - 一直滚到最早,确认加载停止(不再发空请求)
+```
+
+### 9.2 阶段 2b 的开工清单
+
+> ✅ **阶段 2b 已全部完成**(2026-09-30),报告:`docs/opencode-v2-migration-phase2b.md`。
+> 下面保留原始清单以便对照;每条的落地情况见 2b 报告。
+
+1. 重写 `src/api/events.ts`(改用 `client.event.subscribe()` 或手写改造)→ ✅ 用了官方 subscribe
+2. 重写 `EventTypes` 常量表 + `handleEventForSubscriber`(21 个事件里只有 4 个属消息族)→ ✅ 45 个 V2 常量
+3. 实现「断线后重订阅 + 重拉全量」(V2 事件流是**易失**的,见 §6.2)→ ✅ `markAllSessionsStale()` + 强制重载
+4. 重写 `messageStore` 的 4 个事件处理器为 V2 形状(`session.text.*` / `session.tool.*` / `session.message.content.updated`)→ ✅
+5. 发消息链路:`POST /api/session/{id}/prompt` → ✅ 含 `prompt` + `wait` 两条链路
+6. **收尾删除**:先摘掉 `events.ts` 对 `GlobalEvent` 的依赖 → 删 `GlobalEvent` →
+ 删 A 桶 66 个 + C 桶 19 个 → ✅ 实际是 66 + 17 删、2 个降级(原因见 2b 报告 §5.3)
+7. 拍板两个决策(§9.5):内容搜索怎么办、等待语义怎么走 → ✅ 内容搜索移除、等待用 `wait`
+
+> ⚠️ 清单第 2 条的「21 个事件里只有 4 个属消息族」**口径需修正**:
+> 21 个是 `EventTypes` 常量,其中消息族是 4 个(`message.part.updated/delta/removed` + `message.updated`),
+> 但**替换它们的 V2 事件有 17 个**(13 个 part 类 + 3 个 delta + 1 个 content.updated),
+> 加上本阶段新增接线的 `session.execution.*` 等,最终 `EventTypes` 表是 **45 个常量**。
+
+### 9.3 阶段 3 会用到的东西
+
+- B 桶 104 个类型(清单见 §1.3)
+- `useRevertState` / `revertMessage` / `unrevertSession`:V2 三段式 `revert/stage → commit → delete`
+- `TaskRenderer` 的子会话加载(V2 已删 `GET /session/{id}/children`,改用 `GET /api/session?parentID=`)
+
+---
+
+## 10. 附:本阶段改动清单
+
+### 10.1 修改(17 个文件,+2070 / −553 行)
+
+| 文件 | 改动 |
+| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| `src/types/api/message.ts` | 456 行 —— V2 消息模型(11 种)+ 分页类型;V1 别名集中标注 `@deprecated` |
+| `src/utils/messageConversion.ts` | 684 行 —— V2 扁平 → UI 模型的完整转换 |
+| `src/api/message.ts` | 341 行 —— 游标分页 `getSessionMessages()`;`extractUserMessageContent` 改吃 UI 模型 |
+| `src/store/messageStore.ts` | 133 行 —— `setMessages`/`prependMessages` 改吃 V2;`historyCursor`;`upsertMessages`;`keepLocalOnly` |
+| `src/store/messageStoreTypes.ts` | 9 行 —— `SessionState.historyCursor` |
+| `src/types/message.ts` | 145 行 —— `SystemMessageInfo` / `SessionMarkerPart` / `SkillPart` / `CompactionPart` 增补 |
+| `src/hooks/useSessionManager.ts` | 180 行 —— 历史加载改游标 + 并发保护 |
+| `src/hooks/useRevertState.ts` | 45 行 —— 类型适配 |
+| `src/hooks/useChatSession.ts` | 19 行 —— 发消息兜底改用 `upsertMessages` |
+| `src/features/message/MessageRenderer.tsx` | 37 行 —— `role:'system'` 分支 + `SystemMessageView` |
+| `src/features/message/parts/index.ts` | 1 行 —— 导出新视图 |
+| `src/features/message/tools/renderers/TaskRenderer.tsx` | 11 行 —— 子会话加载适配 |
+| `src/locales/{zh-CN,en}/message.json` | 各 9 行 —— `system.marker.*` 文案 |
+| `src/store/messageStore.test.ts` | 372 行 —— 17 → 29 例 |
+| `src/store/messageStoreHooks.test.tsx` | 69 行 —— 夹具换 V2 |
+| `src/hooks/useSessionStats.test.tsx` | 103 行 —— 夹具换 V2 |
+
+### 10.2 新增(5 个文件,1309 行)
+
+| 文件 | 行数 | 说明 |
+| ------------------------------------------------------ | ---: | ----------------------------------- |
+| `src/utils/messageConversion.test.ts` | 439 | 转换层单测(35 例) |
+| `src/api/message.test.ts` | 303 | 分页 + 提取 + 报错语义单测(23 例) |
+| `src/features/message/phase2a.smoke.test.tsx` | 293 | 真实服务冒烟(8 例,默认 skip) |
+| `src/features/message/parts/SessionMarkerPartView.tsx` | 155 | V2 新增消息类型的渲染 |
+| `src/test/fixtures/v2Messages.ts` | 119 | V2 消息测试夹具 |
+
+### 10.3 文档
+
+| 文件 | 改动 |
+| --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| `docs/opencode-v2-migration.md` | §5.1 类型名对照表;§5.2 八条实测补充;§5.4 阶段 2a 文件表;**§5.5 游标方向修正**;§8 阶段 2 拆成 2a/2b |
+| `docs/opencode-v2-migration-phase2a.md` | 本报告(新增) |
diff --git a/docs/opencode-v2-migration-phase2b.md b/docs/opencode-v2-migration-phase2b.md
new file mode 100644
index 000000000..30924bfc6
--- /dev/null
+++ b/docs/opencode-v2-migration-phase2b.md
@@ -0,0 +1,696 @@
+# OpenCode V2 迁移 · 阶段 2b 报告(事件流 + 发消息,写侧)
+
+> 状态:**✅ 已完成**(2026-09-30)
+> 范围:**写侧** —— 事件订阅层重写、`EventTypes` / `EventCallbacks` 重做、`messageStore` 的 4 个事件处理器、
+> 发消息两条链路、`v1Model.ts` 收尾删除、内容搜索移除、dev proxy 修正。
+> 前置:阶段 0(`docs/opencode-v2-migration-phase0.md`)、阶段 1(`docs/opencode-v2-migration-phase0.5.md`)、
+> 阶段 2a(`docs/opencode-v2-migration-phase2a.md`)
+> 实测环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19`
+> 主文档:`docs/opencode-v2-migration.md`(本阶段已按实测回填 §6.1 / §6.3 / §6.4 / §8 / §9.3 / §9.5)
+
+---
+
+## 0. 先说三件必须交代的事
+
+### 0.1 ⚠️ 硬性约束逐条对照
+
+| 约束 | 结果 |
+| ---------------------------------------------------- | ------------------------------------------------------------------------ |
+| 允许改:`src/api/events.ts` | ✅ 整体重写(1060 → 1378 行) |
+| 允许改:`src/api/message.ts` | ✅ 发消息两条链路落地 |
+| 允许改:`src/api/file.ts`(仅搜索相关) | ✅ 移除 `searchText` / `searchSymbols`,实现 `searchFiles` |
+| 允许改:`EventTypes` 常量表 | ✅ `src/types/api/event.ts` 整体重写(177 → 506 行) |
+| 允许改:`messageStore` | ✅ 4 个事件处理器改成 V2 形状 |
+| 允许改:`messageConversion.ts`(事件侧复用) | ✅ 新增 `toUIPartFromContent` / `toStepFinishPart`,删除 V1 形状遗留函数 |
+| 允许改:`v1Model.ts`(仅删 A/C 桶) | ✅ 189 → **104** 个顶层导出(详见 §5) |
+| 允许改:发消息链路 | ✅ 含 `createSession`(见 §4.4,任务 4 明确点名) |
+| 允许改:`FileExplorer.tsx`(仅搜索 UI) | ✅ 内容搜索 UI 与请求分支移除,不留置灰按钮 |
+| 允许改:`vite.config.ts` | ✅ 删掉会削 `/api` 前缀的 V1 rewrite |
+| 允许改:`docs/` | ✅ 主文档回填 + 本报告 |
+| **禁止改**:`src-tauri/`(整个 Rust 层) | ✅ **零改动** |
+| **禁止改**:`src/api/notMigrated.ts` 既有语义 | ✅ **零改动** |
+| **禁止改**:`v1Model.ts` 的 B 桶 104 个导出 | ✅ 104 个一个不少(脚本逐个核对,见 §5.4) |
+| 禁止 git commit / push / reset / checkout / worktree | ✅ 未执行 |
+| 禁止删除 `docs/` 下任何文件 | ✅ 未删除 |
+| 类型检查 0 报错 | ✅ `npx tsc -b --force` 无输出 |
+| `npm test` 现有用例不许挂 | ✅ 阶段 2a 基线 741 例全部仍通过 |
+| 全程简体中文注释与报告 | ✅ |
+| 失败项如实汇报、不编造 | ✅ 见 §7、§8 |
+| 只用单测 + API 冒烟证明,不做浏览器人工验证 | ✅ 未做浏览器验证 |
+
+**超出授权范围但不得不改的文件**(各 1~3 行,全部是「不改就编译不过」,逐条说明):
+
+| 文件 | 为什么必须动 | 改动量 |
+| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------- |
+| `src/hooks/useGlobalEvents.ts` | 它是 `EventCallbacks` 的**唯一生产消费者**。事件载荷从 V1 变 V2,这里的适配代码不可能不动 | ~137 行(含注释) |
+| `src/contexts/SessionContext.tsx` | 同上(订阅 `onSessionCreated/Updated/Deleted`,且要删掉 `onTodoUpdated`) | ~52 行 |
+| `src/hooks/useSessions.ts` | 同上 | ~48 行 |
+| `src/components/WorktreePanel.tsx`、`src/hooks/useGitWorkspaceCatalog.ts` | `worktree.ready/failed` → `worktree.updated/resolved` | 各 4~10 行 |
+| `src/api/session.ts` | 任务 4 点名「创建会话必须写进 body 的 `location`」 | ~30 行 |
+| `src/api/v2Convert.ts` | 新增 `toInternalPermissionRequest()`(V2 权限载荷 → 内部模型) | +45 行 |
+| `src/types/api/{common,file,index,message}.ts`、`src/types/index.ts`、`src/types/ui.ts`、`src/api/types.ts` | A/C 桶删除后的转发清理(不清理就 `tsc` 报错) | 各 2~110 行 |
+| `src/types/api/todo.ts` | **新增**:`TodoItem` 原来挂在 `event.ts` 上,V2 没有 `todo.updated` 事件,类型得搬家 | 新文件 28 行 |
+
+### 0.2 本阶段**没有**做的事
+
+- ❌ **Tauri 真机验证**:容器内无 Tauri 运行时,`plugin-http` 的流式表现**未实测**(见 §8#1)
+- ❌ **浏览器人工验证**:按用户决定,只用单测 + API 冒烟(见 §6)
+- ❌ **Form 表单渲染器**:V2 的 `form.created/replied/cancelled` 事件已接线,但渲染器属阶段 3(见 §7#11)
+- ❌ **权限回复 API**:`src/api/permission.ts` 仍是 `notMigratedYet`(阶段 3)
+- ❌ **「换模型 → 切会话模型」的 UI 联动**:V2 的模型是**会话级**的,`prompt` 不接受 model 参数(见 §7#7)
+- ❌ **撤销/重做**:依赖 V2 三段式 revert(阶段 3),行为与阶段 1/2a 一致(仍抛错)
+
+---
+
+## 1. 事件映射表(V1 → V2,实际实现的)
+
+> 口径:阶段 2a 报告 §1.4 记录的「`events.ts` 实际引用的 21 个 `EventTypes` 常量」逐个对照。
+
+### 1.1 消息族(本阶段核心)
+
+| V1 事件 | V2 实际实现 | 说明 |
+| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| `message.part.updated` | **`session.text.started/ended`**、**`session.reasoning.started/ended`**、**`session.tool.input.started/ended`**、**`session.tool.called/progress/success/failed`**、**`session.step.started/ended/failed`** | 拆成 13 个事件;统一走 `onPartUpdated` 的判别联合(`kind: 'content' \| 'step' \| 'step-start'`) |
+| `message.part.delta` | **`session.text.delta`**、**`session.reasoning.delta`**、**`session.tool.input.delta`** | 统一走 `onPartDelta`(`kind: 'text' \| 'reasoning' \| 'input'`) |
+| `message.part.removed` | ❌ **无对应事件** → 改为「**重拉消息**」 | 触发源是 `session.revert.staged/committed/cleared` 与 `session.execution.interrupted`,回调名 `onMessagesInvalidated` |
+| `message.updated` | **`session.message.content.updated`** | ⚠️ 已接线,但**实测从不下发**(见 §7#2)→ 实际是「有分支、无生产者」 |
+| `session.error` | **`session.execution.failed`** | 载荷从 V1 的 `{name, data}` 换成 V2 的 `{type, message, status?}` |
+| `session.updated` | **`session.renamed`**、**`session.metadata.updated`**、**`session.agent.selected`**、**`session.model.selected`**、**`session.moved`** | 统一合成 `SessionInfoPatch`(部分字段补丁) |
+| `question.asked` | **`form.created`** | 载荷结构完全不同(`FormInfo` vs `QuestionRequest`)→ 本阶段只做 pending 登记 + 通知 |
+| `question.replied` | **`form.replied`** | 同上 |
+| `question.rejected` | **`form.cancelled`** | 同上 |
+| `worktree.ready` | **`worktree.updated`** | 载荷从 `{}` 变成 `{projectID}` |
+| `worktree.failed` | **`worktree.resolved`** | ⚠️ **语义变了**:不是「失败」而是「目录被解析/采用」(见 §7#10) |
+| `session.created` | ✅ `session.created` | 载荷字段与 REST 的 `Session.Info` **不一致**(见 §7#5) |
+| `session.deleted` | ✅ `session.deleted` | 载荷从裸 `sessionID` 变成 `{sessionID}` |
+| `session.idle` | ⚠️ **保留分支,但实测从不下发** | schema 里已标 `// deprecated` → 实际改用 `session.execution.succeeded`(见 §7#1) |
+| `session.status` | ⚠️ **保留分支,但实测从不下发** | 实际改用 `session.execution.started/succeeded/failed/interrupted`(见 §7#1) |
+| `project.updated` | ✅ `project.updated` | 原样可用 |
+| `permission.asked` | ✅ `permission.asked` | 载荷字段名变了:`permission/patterns/always` → `action/resources/save`(见 §7#6) |
+| `permission.replied` | ✅ `permission.replied` | 载荷 `{sessionID, requestID, reply}` |
+| `vcs.branch.updated` | ✅ `vcs.branch.updated` | 原样可用 |
+| `server.connected` | ✅ `server.connected` | ⚠️ V2 的 `data` 是**空对象**,V1 的 `properties.timestamp` 没有了(见 §7#3) |
+| `todo.updated` | ❌ **已移除** | 源码中不存在该事件 → 常量、回调、`todoStore` 写入分支全部删除 |
+| `lsp.updated` | ❌ **已移除** | 不在 `ServerDefinitions`(V2 不跑 LSP);原本 `events.ts` 也没有处理分支,只删常量 |
+
+### 1.2 本阶段**新增接线**的 V2 事件(V1 没有对应物)
+
+| V2 事件 | 回调 | 用途 |
+| ----------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------- |
+| `session.execution.started` | `onSessionStatus({type:'busy'})` | **替代 `session.status`**:一轮开始 |
+| `session.execution.succeeded` | `onSessionStatus({type:'idle'})` + `onSessionIdle` | **替代 `session.idle`**:一轮成功结束 |
+| `session.execution.failed` | `onSessionStatus({type:'idle'})` + `onSessionError` | 一轮失败 |
+| `session.execution.interrupted` | `onSessionStatus({type:'idle'})` + `onSessionIdle` + `onMessagesInvalidated` | 用户中断(转录被截断 + 必须让 `isStreaming` 落回 false) |
+| `session.usage.updated` | `onSessionUsage` | 会话级累计成本/用量(一轮内会下发 2 次:步中 + 步末) |
+| `session.retry.scheduled` | `onSessionRetry` | V1 是 `retry` part,V2 是 assistant 的字段 |
+| `session.revert.staged/committed/cleared` | `onMessagesInvalidated` | 回退三段式 → 重拉 |
+| `session.step.started` | `onPartUpdated({kind:'step-start'})` | **新建 assistant 消息的权威信号**,带 `agent` / `model` |
+
+### 1.3 真实服务实际下发的事件(三次抓包汇总,去重 42 种)
+
+```
+server.connected
+session.created
+session.renamed
+session.execution.started / succeeded / interrupted
+session.instructions.updated
+session.inbox.enqueued / delivered
+session.step.started / streamed / ended / failed
+session.text.started / delta / ended
+session.reasoning.started / delta / ended
+session.tool.input.started / delta / ended
+session.tool.called / progress / success / failed
+session.usage.updated
+project.updated vcs.branch.updated
+provider.updated model.updated agent.updated command.updated skill.updated
+integration.updated reference.updated plugin.updated websearch.updated
+shell.created shell.exited
+```
+
+**不在这份名单里的**:`session.idle`、`session.status`、`session.message.content.updated`
+(前两个 deprecated/无生产者,第三个不在 `V2Event` 联合里)—— 详见 §7。
+
+---
+
+## 2. 连接管理方案:**用了官方 `client.event.subscribe()`**
+
+### 2.1 为什么
+
+官方实现(`@opencode/client` 的 `SharedEvents.make`,源码逐行读过)已经做好了本项目 V1 手写 1060 行里的绝大部分事情:
+
+| 能力 | 官方是否已做 | 说明 |
+| ------------------------------------------------- | ------------ | ------------------------------------------------------------------------- |
+| 共享一条懒连接 | ✅ | 多订阅者复用同一流,最后一个退出才 `controller.abort()` |
+| 每订阅者独立队列 | ✅ | 容量 **4096**,溢出报 `Event subscriber exceeded its 4096-event capacity` |
+| SSE 文本解析 | ✅ | 多行 `data:` 合并、`\r\n`/`\r` 归一、增量 UTF-8 解码、单事件字节上限 |
+| 心跳信号 | ✅ | `onActivity` 回调:**含心跳在内的任何传输活动**都触发 |
+| 自动重连 | ❌ | 由本项目负责(`RECONNECT_DELAYS` 退避) |
+| 连接状态机 / 代次防串扰 / 后台保活 / 生命周期监听 | ❌ | 由本项目负责(**从 V1 原样保留**) |
+
+### 2.2 传输层被刻意隔离成一个函数
+
+```ts
+function createEventTransport(serverId, signal, onActivity): AsyncIterable {
+ return getSDKClient(serverId).event.subscribe({ signal, onActivity })
+}
+```
+
+**取舍理由**:Tauri 下 `plugin-http` 的流式表现本容器**无法验证**(见 §8#1)。
+把它隔离成一个函数,真机若发现问题,只需把这一行换成手写
+`fetch('/api/event') + ReadableStream`,**其余(分发 / 合并 / 重连 / 状态机)一行都不用改**。
+
+### 2.3 从 V1 原样保留的部分
+
+- 每服务器独立连接 + 订阅者集合 + 连接状态广播(`useSyncExternalStore` 友好)
+- **代次(generation)防串扰**:重连后旧连接的事件自动失效(实测用例覆盖)
+- `RECONNECT_DELAYS = [1s,2s,3s,5s,10s,30s]` 指数退避;后台另有一套更激进的 `[0.5s…10s]`
+- 心跳超时判定(前台 60s / 后台 120s)+ 后台 keepalive 轮询(30s)
+- 可见性变化 / `online` / `offline` 生命周期监听
+- `onReconnected` 广播(带 2s cooldown,防止快速重连密集触发数据拉取)
+- **`coalesceEvents()` 批量合并**(4096 队列溢出是真实风险,必须保留)
+
+### 2.4 批量合并(V2 字段路径重写)
+
+```
+同一批内相同 (sessionID, messageID, partID, kind) 的 delta → 字符串拼接成一个
+某个 part 的整块更新(started/ended/called/success/failed)到达 → 丢弃该 part 在途的 delta
+```
+
+关键点:合并键里的 `partID` 是**已经算好的 UI part id**,与 store 的定位规则完全一致:
+
+- text / reasoning → `` `${messageID}:content:${ordinal}` ``
+- tool → 工具自身的 `id`
+
+**实测证明**:一次带工具调用的完整回合里,`session.tool.progress` 连续两帧、
+`session.reasoning.delta` / `session.text.delta` 紧随各自的 `*.started` —— 合并逻辑有真实用武之地。
+
+---
+
+## 3. 恢复策略:**重订阅 + 重拉**
+
+### 3.1 触发条件(三个入口)
+
+| 触发 | 位置 | 动作 |
+| ---------------------------------------------- | ------------------------------- | ------------------------------------------------------ |
+| **流正常结束**(服务端关闭 / 网络断开) | `runEventStream` 的 `done` 分支 | `state='disconnected'` → `scheduleReconnect` |
+| **流出错**(fetch 失败 / 队列溢出 / 解析失败) | `catch` 分支 | `state='error'` + `onError(err)` → `scheduleReconnect` |
+| **心跳超时**(60s 无任何传输活动) | `resetHeartbeat` 的定时器 | `state='disconnected'` → `scheduleReconnect` |
+
+另有两个**立即重连**入口(不走退避):页面从后台恢复前台、网络 `online`;以及显式的
+`reconnectServerSSE(serverId)`(切换服务器 / WSL sidecar 换端口)。
+
+### 3.2 「重拉」怎么落地
+
+V2 的流是**易失的**(官方契约原文:_"Volatile by contract: a slow consumer overflows and
+fails the stream, and events during disconnection are missed."_)——**不回放**。
+所以只重订阅会丢消息,必须补一次全量拉取。实现分两层:
+
+1. **`events.ts`**:连接成功(含首次连接)→ `broadcastReconnected(conn, reason)`,
+ `reason` 为 `'network'` 或 `'server-switch'`(带 2s cooldown)。
+2. **`useGlobalEvents.onReconnected`**(每个服务器一份):
+ - `refreshServerHealth(serverId)`
+ - `fetchAndInitialize(serverId)` —— 重拉 session 状态 + pending 权限/表单
+ - **`messageStore.markAllSessionsStale()`** —— 把所有已缓存会话标记为 stale,
+ 下次读取时 `useSessionManager` 的 `canUseCached` 失效 → **强制重拉**
+ - 逐个通知会话消费者(`consumer.callbacks.onReconnected?.(reason)`)
+3. **`useChatSession.onReconnected`**(每个 pane 一份):`markAllSessionsStale()` +
+ `loadSession(routeSessionId, { force: true })` → 当前打开的会话**立刻重拉**
+ (`force` 模式下跳过「SSE 推送比 API 多就不覆盖」的短路,无条件用服务端数据覆盖)。
+
+> `SessionContext` / `useSessions` / `useAutoRefresh` / `useGitWorkspaceCatalog` 也各自订阅
+> `onReconnected`,但它们在 `reason === 'server-switch'` 时**跳过**(避免切换服务器时
+> 新旧数据打架)—— 与 V1 行为一致。
+
+### 3.3 代次(generation)防串扰
+
+每次 `teardownConnectionTransport` / `forceReconnectNow` / `disconnectServerConnection`
+都会 `conn.generation++`。`runEventStream` 捕获建立时的 `myGeneration`,
+在**每次 `await iterator.next()` 前后**都比对;不一致就立即 `break` 并丢弃整批。
+实测用例「代次防串扰:reconnect 之后旧流的事件被丢弃」覆盖。
+
+---
+
+## 4. 发消息链路
+
+### 4.1 两条链路的实现
+
+```ts
+// 只投递,不等这轮跑完(UI 常规发送路径)
+sendMessageAsync(params) → sdk.session.prompt(buildPromptParams(...))
+
+// 投递 + 等这轮跑完
+sendMessage(params) → sdk.session.prompt(...) → sdk.session.wait({ sessionID })
+```
+
+依据(照 `v2.0.19` 源码核实,不是推测):
+
+- `packages/protocol/dist/groups/session.js:278` ——
+ `POST /api/session/:sessionID/prompt`,payload `{id?, ...PromptInput.Prompt.fields, metadata?, delivery?, resume?}`,
+ description 原文 _"Durably admit one session input and schedule agent-loop execution unless resume is false."_
+ → **prompt 本身就是非阻塞的**,返回 `Session.Inbox.User`。
+- `POST /api/experimental/session/{id}/wait`(SDK `session.wait`)—— _"Wait for a session agent loop to become idle"_。
+- ⚠️ `POST /api/session/{id}/background` **不是** `prompt_async` 的替代品
+ (它是 _"Move active foreground backgroundable tools into background observation"_)—— 已在代码注释里写明。
+
+### 4.2 `buildPromptParams()`(唯一构造入口,纯函数,已导出供单测)
+
+`PromptInput.Prompt` 只有 4 个字段:`{text, files?, agents?, skills?}`。映射规则:
+
+| UI 输入 | V2 参数 |
+| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| `params.text` | `text` |
+| 附件 `type: 'file' \| 'folder'` | `files: [{ uri, name?, mention? }]`(`uri` 取 `attachment.url`,缺失回落到 `file://${relativePath}`;`mention` 由 `textRange` 展开) |
+| 附件 `type: 'agent'` | `agents: [{ name: agentName, mention? }]` |
+| 附件 `type: 'text' \| 'command'` | **跳过**(command 走 `session.command` 端点) |
+| `params.agent` | `metadata.agent` |
+| `params.model` + `params.variant` | `metadata.model = {providerID, modelID, variant}` |
+
+空的 `files` / `agents` / `metadata` 键**整体省略**(不传 `[]`)。
+
+### 4.3 ⚠️ 模型:V2 的 `prompt` **不接受 model 参数**
+
+`PromptInput.Prompt` 的 4 个字段里没有 model —— V2 的模型是**会话级**的
+(`POST /api/session/{id}/model` + `session.model.selected` 事件)。
+本阶段的处理:
+
+- 把调用方给的模型写进 `metadata.model`(**与官方 TUI 完全一致**:阶段 2a 实测 TUI 写的就是
+ `metadata.model`,且用的是 V1 命名 `modelID`)→ **历史渲染能显示正确的模型**
+- **没有**额外发 `switchModel`:那会往转录里插一条 `model-switched` 记录,每次发送都插一次显然不对
+- 「用户换模型 → 切会话模型」的联动属**阶段 3**(需要 `session.switchModel` + 记住当前会话模型)
+
+### 4.4 `createSession()`(任务 4 点名)
+
+```ts
+const created = await sdk.session.create({
+ title,
+ agent,
+ model,
+ ...(directory ? { location: { directory } } : {}), // 🔴 只认 body 里的 location
+})
+```
+
+🔴 依据 `packages/server/src/handlers/session.ts:136`:
+`location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }`
+—— 它**既不看中间件也不看请求头**,不传就静默落到服务进程的 cwd(阶段 0/1 已实测)。
+另:V2 的 `SessionCreateInput` **没有 `parentID`**,形参保留但**不发送**(已注释说明)。
+
+### 4.5 `sendMessage` 的返回值是**入队记录**,不是 AI 回复
+
+V2 没有「一次请求拿回复」的接口(回复只出现在转录里)。
+`SendMessageResponse` 的 `info` 是入队记录、`parts` 恒为 `[]` —— **刻意的**,
+已在 `src/api/types.ts` 与 `src/api/message.ts` 双处注明。全仓库只有 `sendMessageAsync` 有生产调用方。
+
+---
+
+## 5. `v1Model.ts` 删除清单
+
+### 5.1 总数
+
+| | 阶段 2a 末 | 阶段 2b 末 |
+| ------------- | ---------: | ---------: |
+| 顶层 `export` | **189** | **104** |
+| 行数 | 3502 | 1492 |
+
+### 5.2 A 桶 66 个(消息 / Part / 消息事件)—— 全部删除
+
+| 分组 | 数量 | 类型 |
+| --------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 消息主体 | 4 | `UserMessage`、`AssistantMessage`、`Message`、`Prompt` |
+| Part 联合成员 | 13 | `TextPart`、`ReasoningPart`、`ToolPart`、`FilePart`、`AgentPart`、`StepStartPart`、`StepFinishPart`、`SnapshotPart`、`PatchPart`、`SubtaskPart`、`RetryPart`、`CompactionPart`、`Part` |
+| 工具状态机 | 5 | `ToolStatePending`、`ToolStateRunning`、`ToolStateCompleted`、`ToolStateError`、`ToolState` |
+| Part 来源 | 5 | `FilePartSourceText`、`FileSource`、`SymbolSource`、`ResourceSource`、`FilePartSource` |
+| 输出格式 | 4 | `OutputFormatText`、`JsonSchema`、`OutputFormatJsonSchema`、`OutputFormat` |
+| 消息错误联合 | 7 | `ProviderAuthError`、`UnknownError`、`MessageOutputLengthError`、`MessageAbortedError`、`StructuredOutputError`、`ContextOverflowError`、`ApiError` |
+| Part 输入 | 4 | `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput` |
+| `message.*` 同步事件 | 4 | `SyncEventMessageUpdated`、`SyncEventMessageRemoved`、`SyncEventMessagePartUpdated`、`SyncEventMessagePartRemoved` |
+| `session.next.*` 消息流事件 | 18 | `Synthetic`、`StepStarted/Ended/Failed`、`TextStarted/Ended`、`ReasoningStarted/Ended`、`ToolInputStarted/Ended`、`ToolCalled/Progress/Success/Failed`、`Retried`、`CompactionStarted/Delta/Ended` |
+| 事件载荷 | 2 | `EventMessagePartRemoved`、`EventMessagePartDelta` |
+
+### 5.3 C 桶 19 个(跨组)—— 17 删 + 2 降级
+
+**删除(17)**:`GlobalEvent`、`PromptSource`、`PromptFileAttachment`、`PromptAgentAttachment`、
+`PromptReferenceAttachment`、`SessionErrorUnknown`、`ToolTextContent`、`ToolFileContent`、
+`SessionNextRetryError`、`SyncEventSessionNextAgentSwitched`、`ModelSwitched`、`Moved`、`Prompted`、
+`PromptAdmitted`、`PromptPromoted`、`ShellStarted`、`ShellEnded`
+
+**降级为非导出(2)**:`SnapshotFileDiff`、`Range`
+—— 它们被 **B 桶**的 `Session.summary.diffs` / `EventSessionDiff.diff` / `Symbol.location.range`
+结构性地引用,删定义会让 B 桶编译不过。处理方式是**只去掉 `export` 关键字,结构一字未改**,
+这样「顶层导出」计数照样从 189 降到 104,而 B 桶一个都没动。
+
+> ⚠️ `Range` 必须显式定义:不定义的话它会**静默解析到 DOM 的 `Range`**(`lib.dom.d.ts`),
+> `tsc` 照样零报错,但 `Symbol.location.range` 的类型就完全错了 —— 已在文件里加注释警示。
+> `src/types/api/file.ts` 的 `FileDiff` 原先是 `Omit`,
+> 已就地定义等价形状(`file`/`patch`/`status` 仍是可选,**结构零变化**)。
+
+### 5.4 🔴 如实交代:批量删除脚本误删了 3 个 B 桶类型(已补回)
+
+删除用的是一个「从 `export type X = {` 扫到首个大括号配平行」的脚本。
+`Pty` / `Todo` / `QuestionOption` 这三个 B 桶类型的内层字段带 `/** … */` 文档注释,
+脚本在「向前吞注释」时吃掉了外层注释的起始行、又把结束行算错,于是**把定义删掉了、留下残片**。
+
+**发现方式**:用「原始 189 个名字」与「删后剩余名字」做集合差(不是靠 `tsc` —— 因为残片仍能编译)。
+**补回方式**:按仓库内**实际读取的字段**逐一核对后重写,并加注释说明:
+
+| 类型 | 字段依据 |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `QuestionOption` | `{label, description}`;依据 V2 自带 V1 schema `@opencode/schema/dist/v1/question.d.ts` 的 `Option` |
+| `Pty` | `{id, title, command, args, cwd, status: 'running'\|'exited', pid}`;依据 `BottomPanel.tsx:87-89`、`RightPanel.tsx:83`、`App.tsx:585` 实际读的字段 + V2 `Pty` 同构 |
+| `Todo` | `{content, status, priority}`;依据 `src/api/todo.ts` 的 `normalizeTodoItems()`,且 `TodoItem = Todo & {id}` 说明服务端无 id |
+
+**最终核对结果**(脚本 + `tsc --force` + 未定义标识符扫描三重验证):
+
+```
+orig 189 B 104 remaining 106(= 104 导出 + 2 降级)
+MISSING (B 桶里被误删的): []
+EXTRA (本应删掉却还在): [ 'Range', 'SnapshotFileDiff' ] ← 预期的 2 个降级项
+未在本文件定义、也未列白名单的标识符: (空)
+顶层 export 计数: 104
+```
+
+---
+
+## 6. 测试与冒烟结果
+
+### 6.1 总体
+
+| 场景 | 文件 | 用例 | 结果 |
+| ------------------------------ | --------------------- | --------------------------- | ---------------------------------------- |
+| **默认(无真实服务开关)** | 99 passed / 2 skipped | **803 passed + 19 skipped** | ✅ **0 失败** |
+| **开 `VITE_OPENCODE_SMOKE=1`** | 101 passed | **822 passed + 0 skipped** | ✅ **0 失败**(连续 3 轮复跑均 822/822) |
+
+- 阶段 2a 基线 **741 例全部仍通过**(0 回归)
+- 跳过的 19 例 = 阶段 2a 冒烟 8 + 阶段 2b 冒烟 11(两者都在开关关闭时**自跳过**,不是失败)
+- `npx tsc -b --force`:**0 报错**
+- `npx eslint <所有改动文件>`:**0 告警**
+- `npx prettier --check <所有改动文件>`:**全部通过**(顺带把改动文件里既有的格式漂移一并修正)
+
+### 6.2 新增 / 改写的测试
+
+| 文件 | 用例数 | 覆盖的关键逻辑 |
+| --------------------------------------------- | -------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `src/api/events.test.ts` | **43**(原 5,重写) | 真实帧解析(A 组)、`coalesceEvents` 合并/作废(B 组)、事件分发(C 组)、连接管理(D 组) |
+| `src/api/message.test.ts` | 29(原 23) | `buildPromptParams` 7 例(附件/agent/跳过/省略空键/metadata)+ 发送链路 2 例(prompt 顺序、wait 参数)+ 原有分页 20 例 |
+| `src/store/messageStore.test.ts` | 35(原 29) | V2 载荷下的 part upsert、text/reasoning/input 三种 delta、step-finish 只在 finish 存在时合成、`handleSessionInvalidated` 置 stale、`handleMessageUpdated` 全量替换 |
+| `src/store/messageStoreHooks.test.tsx` | 6 | 夹具换 V2 载荷 |
+| `src/hooks/useGlobalEvents.test.tsx` | 25 | 回调载荷换 V2;`handlePartRemoved` → `handleSessionInvalidated`;`onTodoUpdated` 用例删除;question → form |
+| `src/contexts/SessionContext.test.tsx` | 7(原 5) | 新增 2 例锁住「部分补丁必须合并、不得覆盖成 undefined」与「本地没有该会话时交给服务端重查」 |
+| `src/api/phase1Smoke.test.ts` | 13(原 12) | 删掉 `searchText` 报错断言;新增「文件名搜索在真实服务上能命中」 |
+| `src/components/FileExplorer.test.tsx` | 4(原 3) | 新增「只剩文件名搜索」:调用 `searchFiles`、渲染在 Files 分组、Content 分组不存在 |
+| `src/features/message/phase2b.smoke.test.tsx` | **11**(新增) | 真实服务冒烟,见 §6.4 |
+
+### 6.3 真实事件帧夹具(任务 8 的硬性要求)
+
+**新增 `src/test/fixtures/v2EventFrames.ts`**:把三次真实抓包得到的 **30 个原始帧**(含心跳注释行)
+逐字节存成夹具,来源与抓包命令写在文件头。用途:
+
+1. `A. 真实事件帧解析` 组用它验证 **wire 格式**:
+ - 只有 `data:` 行,**没有** `event:` / `id:` 行
+ - `data` 后面是**一层** JSON,事件是**扁平**结构(`type` 与 `data` 同级,不是 V1 的 `{directory, payload:{type, properties}}`)
+ - 心跳是**注释行**(解析不出事件)
+ - 首帧是 `server.connected`,`data` 为空对象且**没有 `created`**
+2. `C. 事件分发` 组用它验证 **字段路径**:把真实帧喂进 `subscribeToEvents`,断言
+ - `session.text.*` 的 `assistantMessageID` / `ordinal` / `delta` / `text`
+ - **合成 part id = `消息id:content:下标`**(不是 V2 的任何字段)
+ - `session.tool.*` 用**工具自身的 `id`** 作 part id;`input.ended` **不带 `name`**
+ - `session.step.started` 带 `agent`/`model`;`session.step.ended` 带 `finish`/`cost`/`tokens`
+ - `session.created` 的 `sessionID`/`slug`/`version`/`location.directory`,且**没有 `time`**
+ - `session.renamed` / `session.moved` → `SessionInfoPatch` 部分补丁
+
+> 真实帧一旦被 V2 改字段,这组测试会立刻红掉 —— 这就是它存在的意义。
+
+### 6.4 API 冒烟(真实 V2 服务,11/11 通过)
+
+环境:`OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097`
+入口:`VITE_OPENCODE_SMOKE=1 npx vitest run src/features/message/phase2b.smoke.test.tsx`
+
+| 用例 | 实测结果 |
+| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| 订阅 `/api/event` 能收到 `server.connected` **首帧** | ✅ 首帧 `data: {"id":"evt_…","type":"server.connected","data":{}}`(**无 `created`**) |
+| **心跳**正常 | ✅ 连接后 **≈0.01s 立刻一次**,之后严格 **15.00s / 30.00s / 45.00s**;全是注释行 |
+| 发 `prompt` 后事件流真的有数据 | ✅ `session.execution.started` → `step.started` → `text.started` → `text.delta` → `text.ended` → `step.ended` → `execution.succeeded` |
+| 🔴 `session.idle` / `session.status` 从未下发 | ✅ 22~100 秒窗口、三次抓包、含一次带工具调用的完整回合与一次 interrupt,**一帧都没有** |
+| 🔴 `session.message.content.updated` 也不下发 | ✅ 同上 |
+| `execution.*` → busy/idle 映射生效 | ✅ 事件层回调实测收到 `['busy','idle']` + `onSessionIdle` |
+| delta 的 part id 合成正确 | ✅ `partID === `${assistantMessageID}:content:${ordinal}`` |
+| `step.started` / `step.ended` 载荷正确 | ✅ `messageID` 与真实 `assistantMessageID` 一致 |
+| `sendMessageAsync` 走 `prompt` 且非阻塞 | ✅ 返回 < 15s;随后转录里出现 `user` 消息 |
+| `sendMessage` 走 `prompt` + `wait` | ✅ `wait` 返回后转录里已有 assistant 消息且有 parts |
+| 附件 / agent 参数形状 | ✅ `buildPromptParams` 纯函数断言(`files[].uri` + `mention`、`metadata.agent`、`metadata.model`) |
+
+**顺带修好两个老问题**(都属测试自身质量,**不是被测代码回归**):
+
+1. **阶段 2a 冒烟选会话的启发式太脆**。原来是「最新 12 个会话里第一个非空的」——
+ 本阶段的冒烟在仓库目录里新建会话,于是它选中了一个只有 1~2 条消息的小会话,
+ 导致「hasMore 必须为真」「必须能找到工具调用」等 6 条断言失败。
+ 已改成「按新→旧逐个体检、**取消息数最多的那个**,探针页填满即提前收工」,
+ 并把 `GET /api/session` 的 `limit` 显式提到 200(**服务端默认只给最新 50 个**,
+ 不显式要就会被截断 —— 这是踩到的第二个坑)。
+2. **冒烟测试会往数据目录里堆会话**。每次全量冒烟都会新建 2~3 个会话;
+ 跑几轮后仓库目录攒到 97 个会话,把老的长会话挤到列表很后面(正是问题 1 的诱因)。
+ 已给 `phase2b.smoke.test.tsx` 加 `afterAll` **自清理**:只删本次运行自己建的 id,
+ 不碰任何既有会话。实测连续 3 次全量冒烟后会话总数稳定不变(97 → 97)。
+
+> ⚠️ **遗留**:本阶段早期(未加自清理之前)的冒烟运行已在
+> `/home/coder/project/OpenCodeUI` 目录留下约 40 个 1~4 条消息的测试会话。
+> **未做删除**(对用户数据的破坏性操作需明确授权)。清理命令见 §8#10。
+
+---
+
+## 7. 🔴 与文档预测不符之处
+
+> 本节是本次报告最重要的部分。前三轮都是这么做的。
+
+### 7.1 🔴🔴 文档 §6.4 把 `session.idle` / `session.status` 判为「✅ 原样可用」——**实测二者从不下发**
+
+| | 内容 |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | `session.idle` ✅(`session-status-event.ts`)、`session.status` ✅ |
+| **实测** | v2.0.19 **一帧都没有**。22~100 秒窗口、三次抓包、含带工具调用的完整回合 + 一次 interrupt,逐帧核对 |
+| **源码依据** | 两者**确实在** `ServerDefinitions` 里(`event-manifest.js` 的 `...SessionStatusEvent.Definitions`),所以类型与常量都在;但 `SessionStatusEvent.Idle` 上明确标了 **`// deprecated`** → **没有生产者** |
+| **后果(严重)** | `useGlobalEvents.onSessionIdle` 负责 `messageStore.handleSessionIdle()`(把 `isStreaming` 落回 false、给流式消息补 `completed`)与 `childSessionStore.markIdle`;`onSessionStatus` 负责 `activeSessionStore.updateStatus`("Working" 列表 + busy→idle 通知)。**这两个回调永不触发 → 界面会一直停在「生成中」,侧栏永远有「工作中」** |
+| **处理** | ✅ 改用**确实会下发**的 `session.execution.*` 做映射:`started`→busy、`succeeded`→idle+`onSessionIdle`、`failed`→idle+`onSessionError`、`interrupted`→idle+`onSessionIdle`+`onMessagesInvalidated`。`session.idle`/`session.status` 的常量与分支**保留**(将来恢复了也能用),但代码注释明确写了「不要依赖它」 |
+| **回填** | ✅ 主文档 §6.4 已加「阶段 2b 实测修正」标注 |
+
+> ⚠️ `execution.interrupted` 的映射里**必须**带 `onSessionIdle`:
+> 实测用户中断后不会有 `execution.succeeded`,只 reset 状态码是不够的,
+> `isStreaming` 会一直停在 true。这一条是本阶段自查时发现的(已补测试)。
+
+### 7.2 🔴 `session.message.content.updated` 有 schema 类型、但**不在 `V2Event` 联合里**,且实测不下发
+
+| | 内容 |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | `message.updated` → `session.message.content.updated`(§6.4) |
+| **实测 1(类型层)** | `@opencode/client` 里**有** `SessionMessageContentUpdated` 类型(`types.d.ts:3257`),也出现在事件日志联合 `SessionEventDurable` 里,但 **`V2Event`(`types.d.ts:3310`)的联合里没有它** → `client.event.subscribe()` 的静态类型不包含该事件,直接 `satisfies Record` 会**编译失败** |
+| **实测 2(运行时)** | 三次抓包**一帧都没有** |
+| **处理** | ① `V2EventUnion = V2Event \| SessionMessageContentUpdated`,把 SDK 漏掉的这一支手工并回来并加注释;② `satisfies` 放宽到 `V2Event['type'] \| SessionMessageContentUpdated['type']`;③ **保留** `handleMessageUpdated` 分支(API 完整),但在注释与报告里都注明「有分支、无生产者」 |
+| **性质** | V2 自身的**枚举/联合不一致**(与阶段 2a 发现的「`idle` 能返回但不能过滤」同一类),不是本项目的问题 |
+
+### 7.3 🔴 心跳:**连接后立刻有一次**,之后才是 15 秒间隔
+
+| | 内容 |
+| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | §6.1「心跳:每 **15 秒**一行注释 `: heartbeat`」 |
+| **实测** | `0.00s server.connected` / **`0.01s HEARTBEAT`** / `15.00s` / `30.00s` / `45.00s` —— 首帧心跳**几乎立刻**到达 |
+| **影响** | 若照文档实现「15 秒才该有心跳」的判定,会误判;反过来,若用「收到心跳」当连接成功信号,会**过早**认为连接就绪 |
+| **处理** | ✅ 本项目用的是 `onActivity`(任何传输活动都刷新心跳超时),两种行为都能正确覆盖;冒烟测试的断言已按实测改为「首帧心跳 < 5s,之后间隔 12~20s」 |
+| **回填** | ✅ 主文档 §6.1 已加标注 |
+
+### 7.4 🟡 `server.connected` 的 `data` 是空对象,V1 的 `properties.timestamp` 没有了
+
+| | 内容 |
+| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | §6.1「首帧:连接即刻收到 `{id, type:"server.connected", data:{}}`」(文档这里是对的) |
+| **本项目受影响点** | `useGlobalEvents.onServerConnected` → `serverStore.applyServerConnectedTimestamp(serverId, data.timestamp)` 做**时钟校准** |
+| **实测** | `server.connected` 既没有 `data.timestamp`,也**没有 `created`** |
+| **处理** | ✅ 事件层尽力而为:传事件的 `created`(有就给、没有就是 `undefined`);`applyServerConnectedTimestamp` 对非数字直接 `return false`,**静默跳过校准**。即:V2 下**服务器时钟校准能力丢失**,如实记录(未见 UI 依赖它的硬需求) |
+
+### 7.5 🟡 `session.created` **事件**的字段与 REST 的 `Session.Info` 不一致
+
+| 概念 | 事件 `session.created.data` | REST `Session.Info` |
+| -------------- | --------------------------- | ----------------------------------------------- |
+| id | **`sessionID`** | `id` |
+| 目录 | `location.directory` | `location.directory` |
+| slug / version | **有** | ❌ 没有(`toInternalSession` 用 id / 空串兜底) |
+| **时间** | ❌ **完全没有 `time`** | `time.created/updated` |
+| 成本 / 用量 | ❌ 没有 | `cost` / `tokens` |
+
+→ 事件层新增 `sessionFromCreatedEvent()` 逐字段映射,**时间用事件自身的 `created` 兜底**(语义正确)。
+文档 §5.2 只讲了 REST 侧,没提事件侧字段不同。
+
+### 7.6 🔴 权限载荷:不是「多了 `patterns`」,而是 `permission→action` + `patterns→resources`
+
+| | 内容 |
+| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **阶段 1 注释原文** | 「V2 的 `PermissionRequest` 结构变了(**少了 permission 字段、多了 patterns**)」 |
+| **实测(生成类型逐字段核对)** | V2 是 `{id, sessionID, action, resources, save?, source?, message?}` —— **`patterns` 是 V1 的名字**,V2 叫 `resources`;`permission` 改叫 `action`;`always` 改叫 `save`;`tool:{messageID,callID}` 改叫 `source:{type:'tool',messageID,id}` |
+| **处理** | ✅ 新增 `v2Convert.toInternalPermissionRequest()` 做映射,权限 UI 与 `SessionEventCallbacks` 本阶段零改动;阶段 3 换权限链路时一并清理 |
+
+### 7.7 🔴 V2 的 `prompt` **不接受 model 参数**(文档 §4.2 的请求体列表里没有它,但没点明后果)
+
+`PromptInput.Prompt`(`@opencode/schema/dist/prompt-input.js`)只有 `{text, files?, agents?, skills?}`。
+V2 的模型是**会话级**的。文档 §4.2 写的是「请求体 `{text, files, agents, skills, metadata, delivery, resume}`」
+—— 列的是对的,但没说明「**V1 的逐条消息指定模型在 V2 不存在了**」,这是个容易踩的坑。
+→ 处理见 §4.3(写 `metadata.model` 与官方 TUI 一致;切模型属阶段 3)。
+
+### 7.8 🟡 `session.step.started` 是「新建 assistant 消息」的权威信号,文档没提
+
+文档 §6.4 的流式事件族表里列了 `session.step.started/streamed/ended/failed` 对应
+「`step-start` / `step-finish`」,但没说它**带 `agent` 与 `model`**。
+V2 的 `session.text.started` 只给 `assistantMessageID` + `ordinal`,**拿不到 agent/model**;
+而 UI 的助手页脚要显示模型名 → 本阶段据此新增了 `PartUpdatedPayload` 的 `kind: 'step-start'` 分支,
+流式期间就能把 `modelID`/`providerID`/`agent`/`time.created` 填对。
+
+### 7.9 🟡 工具事件的三个「载荷不全」之处(文档未提)
+
+| 事件 | 实测现象 | 处理 |
+| -------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
+| `session.tool.input.ended` | **不带 `name`**(只有 `input.started` 带) | store 合并时**保留已有的 name**,否则工具卡片会变空白标题 |
+| `session.tool.called` | 实测帧里**没有 `state`** 字段(类型上是可选的) | 按可选处理(`providerState: undefined`) |
+| `session.tool.success` | 实测帧里**没有 `resultState`** | 同上 |
+
+### 7.10 🟡 `worktree.resolved` 不是「失败」
+
+文档 §6.4 写 `worktree.failed` → `worktree.resolved`(名字对应关系是对的),
+但 `worktree.resolved` 的语义是「目录被解析/采用」(`{projectID, directory, previous, adopted?}`),
+**不是失败**。原代码在 `onWorktreeFailed` 里把 `data.message` 塞进错误提示 —— V2 没有 `message` 字段。
+→ 处理:`WorktreePanel` 改成「只刷新列表 + 清 loading」,不再弹错误(并加注释说明语义变化)。
+
+### 7.11 🟡 Form 体系:**不是 question 的等价替换**
+
+文档 §4.3 已经点明「Form 是全新 UI 能力」,本阶段实测补充:`form.created` 的载荷是
+`{form: {id, sessionID, title, fields, metadata?}}`,与 V1 `QuestionRequest` 的
+`{questions: [{question, header, options, multiple}]}` **结构完全不兼容**。
+→ 处理:事件层按 V2 真实事件名接线(`form.created/replied/cancelled`),
+`useGlobalEvents` 只做 **pending 登记 + 通知**(沿用原来的通知/提示音路径),
+**不**向会话消费者分发;Form 渲染器属阶段 3。
+
+### 7.12 🟡 `session.step.failed` 会带 `aborted` 错误(用户中断时)
+
+实测一次 interrupt 的帧序列:`session.execution.interrupted`(`reason:"user"`)+ `session.step.failed`
+(`error: {type:"aborted", message:"Step interrupted"}`,**无 `finish` / `cost` / `tokens`**)。
+→ 阶段 2a 的 `toMessageError()` 会把 `aborted` 映射成 `MessageAbortedError`,
+UI 的 `isAbortedMessage()` 因此能正确显示中止态;`finish` 缺失时**不合成** step-finish part,
+与读侧规则一致。
+
+### 7.13 🟡 阶段 0/1 报告的一处小偏差
+
+阶段 0 报告说 `--log-level` 只影响 WSL 路径 —— 本阶段复核:**桌面路径 `opencode.rs:88` 不带任何参数**,
+确实不受影响(结论不变)。此条仅作记录,无新增差异。
+
+### 7.14 🟡 V2 对 location 的**惰性初始化**(本阶段再次复现)
+
+首次访问某个 location 时,第一批请求可能返回**不完整**列表(模型 / skill / 文件索引都复现过),
+紧接着的第二次请求就正常。阶段 0.5 报告 §5⑩ 已记录;本阶段在
+`phase1Smoke` 的 `getSkills(DIR_B)` 与 `searchFiles` 上**再次复现**,已在测试里用「预热一次」绕过。
+**UI 侧任何新增的 per-location 调用都应有重试或「等 location 就绪」的处理。**
+
+---
+
+## 8. 未做 / 已知缺口(如实)
+
+| # | 项 | 说明 |
+| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| 1 | **Tauri 下 `plugin-http` 的流式未实测** | 容器内无 Tauri 运行时。传输层已隔离成 `createEventTransport()` 一个函数,真机若异常只需换掉它,其余一行不动 |
+| 2 | **浏览器人工验证未做** | 按用户决定只用单测 + API 冒烟(阶段 2a 的同类缺口沿用) |
+| 3 | **`handleMessageUpdated` 有分支、无生产者** | 依赖 `session.message.content.updated`,实测不下发(见 §7.2)。保留是为了 API 完整 |
+| 4 | **Form 渲染器缺失** | 事件已接线,但 UI 还是 V1 的 question 组件(且 `getPendingQuestions` 仍抛 `notMigratedYet`)→ 表单实际不可用(阶段 3) |
+| 5 | **权限回复不可用** | `replyPermission` 仍抛 `notMigratedYet`(阶段 3) |
+| 6 | **换模型不生效** | `prompt` 不接受 model;只写进 `metadata.model` 供历史显示(见 §4.3 / §7.7) |
+| 7 | **`sendMessage`(阻塞版)无生产调用方** | 返回值是拼出来的入队记录,`parts` 恒空;若将来有人渲染它会显示空 AI 消息(已在两处注释警示) |
+| 8 | **`useGlobalEvents` 的 `pendingQuestions` 队列成了死代码** | V1 由 `onQuestionAsked` 写入;V2 的 `onFormCreated` 只做 pending 登记,不再写入该队列,但 `onSessionCreated` 仍 `drainPending`、`onFormReplied/Cancelled` 仍 `removePendingByRequestId` —— 永远空转。建议阶段 3 做表单分发时一并清理或补齐 |
+| 9 | **`sendMessageAsync` 的 1.5s 兜底拉取仍在** | `useChatSession` 里那段「SSE 没推就主动拉一次」的逻辑未动,作为断流兜底保留 |
+| 10 | **本机数据目录残留约 40 个测试会话** | 阶段 2b **早期**的冒烟运行(自清理机制加入之前)在 `/home/coder/project/OpenCodeUI` 目录建了约 40 个 1~4 条消息的会话。**未做删除**(属对用户数据的破坏性操作,需明确授权)。已加自清理,后续运行不再增长(实测 3 轮 97 → 97)。清理命令:
`curl -u opencode:t1 "http://127.0.0.1:4097/api/session?directory=<目录>&limit=200"` 找到测试会话后 `curl -u opencode:t1 -X DELETE http://127.0.0.1:4097/api/session/` |
+| 11 | **`session.usage.updated` 未接入 UI** | 事件已分发到 `onSessionUsage`,但 `useGlobalEvents` 没有消费者(用量已由 assistant 的 step-finish 展示)。属 YAGNI,留作阶段 3 可选 |
+| 12 | **`session.shell.*` / `shell.created` / `shell.exited` 未接线** | V2 新增的会话级 shell 消息与 shell 作业事件;本阶段范围外(`shell` 消息类型阶段 2a 已能渲染) |
+| 13 | **Rust / Tauri / WSL 未跑** | 与阶段 1/2a 一致(本阶段完全没碰 `src-tauri/`) |
+| 14 | **prettier 全仓库仍有漂移** | 本阶段把**所有改动过的文件**格式化到合规,未做全仓库格式化(避免无关 diff) |
+
+> **修订(2026-09-30)**:上表 #6「**换模型不生效**」已在真实使用暴露并修复 ——
+> 发送前调用 `session.switchModel`(幂等)+ 建会话时带上所选模型,见主文档 §10.6。
+
+---
+
+## 9. 建议的后续动作
+
+### 9.1 立刻可做(人工回归,约 10 分钟)
+
+```bash
+# 1) 起真实 V2 服务
+OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097
+
+# 2) 起前端(dev proxy 已在阶段 2b 修好,浏览器可直连)
+npm run dev
+
+# 3) 界面里:
+# - 新建会话 → 发一条消息 → 确认「流式逐字输出」+ 推理折叠 + 工具卡片
+# - 确认一轮结束后「生成中」指示消失(依赖 execution.succeeded 的映射,见 §7.1)
+# - 中途按停止 → 确认立即回到可输入状态(依赖 execution.interrupted 的映射)
+# - 打开历史长会话 → 滚到顶部继续加载(阶段 2a 的能力)
+# - 文件浏览器搜索框:只应搜到文件名,**没有**内容匹配分组
+```
+
+### 9.2 阶段 3 待办交接
+
+1. **Form 表单渲染器**(替代 question):`GET /api/session/{id}/form` + `POST .../form/{formID}/reply` +
+ `DELETE .../form/{formID}`;六种字段类型 + `when` 条件;事件侧已接线(`form.created/replied/cancelled`)
+2. **权限链路**:`GET /api/permission/request` + `POST .../permission/{rid}/reply`(`{decision}`)+
+ `GET/DELETE /api/permission/saved`;载荷映射已在 `v2Convert.toInternalPermissionRequest()`
+3. **换模型联动**:`POST /api/session/{id}/model`(`session.switchModel`)+ 记住当前会话模型,
+ 避免每次发送都写 `metadata.model`
+4. **回退三段式**:`revert/stage` → `revert/commit` → `DELETE revert`;事件侧已接
+ `session.revert.staged/committed/cleared` → `onMessagesInvalidated`
+5. **中止**:`abort` → `POST /api/session/{id}/interrupt`(事件侧已接 `execution.interrupted`)
+6. **`v1Model.ts` 的 B 桶 104 个**(清单见阶段 2a 报告 §1.3):permission / session / config / file /
+ mcp / vcs / worktree / pty / tool / lsp / todo / provider / model / project / auth
+7. **`pendingQuestions` 死代码清理**(见 §8#8)
+8. **B 桶收尾时的连带项**:`src/types/api/{common,file}.ts` 里为「A/C 桶删除」做的就地定义
+ (`SnapshotFileDiffShape`、`v1Model` 的非导出 `SnapshotFileDiff`/`Range`)可以一起收敛
+9. **Tauri 真机验证** `plugin-http` 的流式(见 §8#1)
+10. **`session.usage.updated` / `session.shell.*` / `session.skill.activated` / `session.synthetic` /
+ `session.instructions.updated` 的可选接入**(事件层已能分发,只差 UI 消费)
+
+---
+
+## 10. 附:本阶段改动清单
+
+### 10.1 重写 / 大改
+
+| 文件 | 阶段 2a 末 | 阶段 2b 末 | 说明 |
+| -------------------------------- | -----------------: | ---------------------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/api/events.ts` | 1060 行 | **1384 行** | 传输层换官方 `subscribe()`;V2 事件分发;`coalesceEvents` 字段路径重写;删掉 Tauri bridge / 手写 SSE 解析 / `normalizeSessionError` / `normalizeTodoItems` 依赖 |
+| `src/types/api/event.ts` | 177 行 | **506 行** | V2 `EventTypes`(45 个常量)+ V2 `EventCallbacks`(24 个回调)+ 全部载荷类型 |
+| `src/types/api/v1Model.ts` | 3502 行 / 189 导出 | **1492 行 / 104 导出** | A 桶 66 删 + C 桶 17 删 + 2 降级 |
+| `src/store/messageStore.ts` | 968 行 | **1156 行** | 4 个事件处理器改 V2 形状;新增 `applyStepStarted` / `applyStepEnded` / `upsertPart` / `buildContentParts` / `createStreamingAssistantInfo` |
+| `src/utils/messageConversion.ts` | 704 行 | 720 行 | 新增 `toUIPartFromContent` / `toStepFinishPart` / `isTextLikePart`;导出 `toTokenUsage` / `toMessageError`;删除 4 个 V1 形状遗留函数 |
+| `src/api/message.ts` | 259 行 | **381 行** | `sendMessage` / `sendMessageAsync` / `buildPromptParams` 实现 |
+| `src/api/file.ts` | 118 行 | 128 行 | 实现 `searchFiles`(`GET /api/fs/find`);删除 `searchText` / `searchSymbols` |
+
+### 10.2 新增
+
+| 文件 | 行数 | 说明 |
+| --------------------------------------------- | ---: | ------------------------------------------------------------- |
+| `src/api/events.test.ts` | 908 | 事件层单测(**43 例**),重写自原 359 行的 V1 版本(原 5 例) |
+| `src/test/fixtures/v2EventFrames.ts` | 59 | **真实抓包**的 30 个事件帧夹具 |
+| `src/features/message/phase2b.smoke.test.tsx` | 416 | 真实服务冒烟(**11 例**,默认 skip) |
+| `src/types/api/todo.ts` | 28 | `TodoItem` 从 `event.ts` 搬家(V2 无 `todo.updated`) |
+
+### 10.3 小改(适配性)
+
+`src/hooks/useGlobalEvents.ts`、`src/hooks/useSessions.ts`、`src/contexts/SessionContext.tsx`、
+`src/components/WorktreePanel.tsx`、`src/hooks/useGitWorkspaceCatalog.ts`、`src/components/FileExplorer.tsx`、
+`src/api/session.ts`、`src/api/v2Convert.ts`、`src/api/types.ts`、`src/types/index.ts`、`src/types/ui.ts`、
+`src/types/api/{index,message,common,file}.ts`、`src/locales/{zh-CN,en}/components.json`、`vite.config.ts`
+
+### 10.4 测试改动
+
+`src/store/messageStore.test.ts`、`src/store/messageStoreHooks.test.tsx`、`src/hooks/useGlobalEvents.test.tsx`、
+`src/contexts/SessionContext.test.tsx`、`src/api/message.test.ts`、`src/api/phase1Smoke.test.ts`、
+`src/components/FileExplorer.test.tsx`、`src/features/message/phase2a.smoke.test.tsx`
+
+### 10.5 文档
+
+| 文件 | 改动 |
+| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `docs/opencode-v2-migration.md` | §6.1 心跳修正;§6.3 传输层取舍;§6.4 事件存活判定 4 条实测修正;§8 阶段 2 标注 2b 完成;§9.3 删除已完成项;§9.5 两个决策落地 |
+| `docs/opencode-v2-migration-phase2b.md` | 本报告(新增) |
diff --git a/docs/opencode-v2-migration-phase3a.md b/docs/opencode-v2-migration-phase3a.md
new file mode 100644
index 000000000..010537a37
--- /dev/null
+++ b/docs/opencode-v2-migration-phase3a.md
@@ -0,0 +1,609 @@
+# OpenCodeUI 迁移 V2 —— 阶段 3a 报告(功能补齐)
+
+> 状态:**阶段 3a 已完成**(2026-09-30)
+> 目标环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19`
+> 验收:`tsc` **0 报错**;全量测试 **967 passed / 38 skipped / 0 failed**(服务未启动时);
+> 真实服务冒烟 **6/6**(中断、回退三段式、权限回复、表单、PTY、MCP 各至少一次)
+> 前置报告:`docs/opencode-v2-migration-phase0.5.md`、`-phase2a.md`、`-phase2b.md`
+> **计数口径**:任务书写「51 处 `notMigratedYet`」,逐文件核对后确认为 **51 处**(见 §1.1 逐条表)。
+
+---
+
+## 0. 三件必须先交代的事
+
+### 0.1 硬性约束逐条对照
+
+| 约束 | 实际执行 |
+| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| **允许改** `src/api/**`、`src/features/**`、`src/store/**`、`src/utils/`、测试、`docs/` | ✅ 改动集中在这几处(另有少量 `src/components/**`、`src/hooks/**` 的**必要**适配,见 §5.2) |
+| **禁止改 `src-tauri/`** | ✅ 一行未动(`git diff --stat src-tauri/` 里的 2 个文件是**阶段 0** 的改动,非本阶段) |
+| **禁止改 `v1Model.ts` 的 B 桶 104 个导出** | ✅ 一个导出都没删;本阶段新类型一律「就地重定义」在各自模块里(`mcp.ts` / `pty.ts` / `vcs.ts` / `worktree.ts` / `form.ts`) |
+| **禁止删除 UI** | ✅ 未删除任何组件。V2 删掉的能力一律用 `removedInV2()` 标记,UI 清理归 3b |
+| **禁止 git commit / push / reset / checkout / worktree** | ✅ 只用了 `git status` / `git diff` / `git ls-files`(只读) |
+| **禁止删除 `docs/` 下任何文件** | ✅ 只新增 + 追加修订 |
+| 测试服务用完必须关闭 | ✅ 4097 上的临时服务已 `kill` 并确认端口无响应;**用户自己的 4096 服务未触碰** |
+| 不得在用户数据目录留下测试会话 | ✅ 冒烟自建会话在 `afterAll` 全删;实测 `/home/coder/project/OpenCodeUI` 下**零** `[phase3a-smoke]` 残留 |
+| 全程简体中文注释与报告 | ✅ |
+
+### 0.2 本阶段**没有**做的事
+
+1. **没有删除任何 UI 入口**(LSP/格式化器状态、share、归档、todo、worktree reset、git init、配置保存…)
+ —— 按硬性约束只做 `removedInV2()` 标记,清单见 §7。
+2. **没有接入** V2 新增的 `persistent-pty`、`plugin RPC`、`/api/shell`、`/api/websearch`、`/api/rpc`、
+ `session.inbox`、`session.instructions`、`/api/vcs/base`、`/api/vcs/branch`、`fs/write`(YAGNI,与 §9.2 一致)。
+3. **Tauri / WSL / Docker 未跑**(容器内无 Tauri 运行时、无 WSL)—— 与阶段 1/2a/2b 一致。
+ ⚠️ 这直接影响 `ptyBridge.ts`(先取 ticket 再 `bridge_connect`)—— **真机未验证**,如实记录。
+4. **没有改配置编辑器**(`src/features/settings/**`):V2 的写入口只有 `shell`,属于 3b 的「改只读 + 引导编辑文件」。
+5. **没有做全仓库 prettier 格式化**(避免无关 diff);只格式化本阶段改动过的文件。
+
+### 0.3 结论摘要
+
+- **`notMigratedYet` 调用点在 `src/api/` 已清零**(仅剩定义本身):`grep -rn "notMigratedYet(" src/api/` → 0 命中。
+- 新增第二个标记函数 `removedInV2()`,把「V2 已删能力」和「还没迁移」区分开
+ → `grep -rn "removedInV2(" src/api/` 就是 3b 的移除清单(**13 处**)。
+- 新增 **Form 表单渲染器**(六种字段类型 + `when` 联动 + `external` 确认位),替代 V1 的 question。
+- 回退改造为 **`stage` → `commit` → `clear`** 三段式;**运行时不再抛错**(阶段 2a 只做了类型适配)。
+- 全量测试 **967 passed / 0 failed**(服务未启动);服务启动时 **980 passed**。
+ 新增单测约 **164 个**(`file`/`vcs`/`worktree`/`config`/`global`/`client`/`tool`/`lsp`/`command`/`mcp`/`pty`/`form`/`permission`/`session`)。
+
+---
+
+## 1. 51 处 `notMigratedYet` 逐个处置表
+
+### 1.1 总表
+
+图例:**迁移** = 改成真实 V2 调用;**3b** = `removedInV2()` 标记(V2 已删能力,UI 清理归 3b)。
+
+| # | 文件:函数 | 处置 | V2 端点 / SDK |
+| --: | ----------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | `session.ts::getLastTurnDiff` | 迁移 | `GET /api/session/{id}/diff`(**不传 `from` 时服务端默认就是「最新一条 user 消息所在轮次」**)→ `sdk.session.diff({sessionID})` |
+| 2 | `session.ts::updateSession` | 迁移 | `PATCH /api/session/{id}` → `sdk.session.update` + **回读一次**(V2 返回 void) |
+| 3 | `session.ts::deleteSession` | 迁移 | `DELETE /api/session/{id}` → `sdk.session.remove`(方法名从 `delete` 改名) |
+| 4 | `session.ts::abortSession` | 迁移 | `POST /api/session/{id}/interrupt` → `sdk.session.interrupt`,返回 `interrupted` |
+| 5 | `session.ts::revertMessage` | 迁移(**改名** `stageRevert`) | `POST /api/session/{id}/revert/stage` |
+| 6 | `session.ts::unrevertSession` | 迁移(**改名** `clearRevert`) | `DELETE /api/session/{id}/revert` |
+| 7 | `session.ts::shareSession` | **3b** | V2 删除了分享端点(仅配置层 `share:"auto"`,无 API) |
+| 8 | `session.ts::unshareSession` | **3b** | 同上 |
+| 9 | `session.ts::forkSession` | 迁移 | `POST /api/session/{id}/fork`,body **`{before}`**(不是 `messageID`) |
+| 10 | `session.ts::summarizeSession` | 迁移 | `POST /api/session/{id}/compact` → `sdk.session.compact`(**不接受模型参数**) |
+| 11 | `session.ts::getSessionChildren` | 迁移 | `GET /api/session?parentID=` → `sdk.session.list({parentID})` |
+| 12 | `session.ts::getSessionTodos` | **3b** | V2 删除了 todo 端点,事件侧亦无 `todo.updated` |
+| 13 | `permission.ts::getPendingPermissions` | 迁移 | `GET /api/permission/request`(location 作用域) |
+| 14 | `permission.ts::replyPermission` | 迁移 | `POST /api/session/{id}/permission/{rid}/reply`,body `{decision, message?}` |
+| 15 | `permission.ts::getPendingQuestions` | 迁移(**改名** `listPendingForms`,搬到 `form.ts`) | `GET /api/form`(位置级)/ `GET /api/session/{id}/form`(会话级) |
+| 16 | `permission.ts::replyQuestion` | 迁移(**改名** `replyForm`) | `POST /api/session/{id}/form/{formID}/reply` |
+| 17 | `permission.ts::rejectQuestion` | 迁移(**改名** `cancelForm`) | `DELETE /api/session/{id}/form/{formID}` |
+| 18 | `pty.ts::listPtySessions` | 迁移 | `GET /api/pty`(location 作用域,解包 `{location,data}`) |
+| 19 | `pty.ts::createPtySession` | 迁移 | `POST /api/pty` |
+| 20 | `pty.ts::getPtySession` | 迁移 | `GET /api/pty/{ptyID}` |
+| 21 | `pty.ts::updatePtySession` | 迁移 | **`PUT`** `/api/pty/{ptyID}`(方法从 PATCH 改 PUT) |
+| 22 | `pty.ts::removePtySession` | 迁移 | `DELETE /api/pty/{ptyID}` |
+| 23 | `pty.ts::getPtyConnectUrl`(非 `notMigratedYet`,但同属连接协议) | 迁移(**改签名**) | 新增 `createPtyConnectTicket()` → `POST /api/pty/{ptyID}/connect-token`;URL 改为「带 ticket」 |
+| 24 | `mcp.ts::getMcpStatus` | 迁移 | `GET /api/mcp`(**数组**,不再是 `Record`) |
+| 25 | `mcp.ts::getMcpResources` | 迁移 | `GET /api/mcp/resource`(新增 `templates`) |
+| 26 | `mcp.ts::addMcpServer` | 迁移 | `PUT /api/experimental/mcp/{server}`,body `{config}` |
+| 27 | `mcp.ts::connectMcpServer` | 迁移 | `POST /api/experimental/mcp/{server}/connect` |
+| 28 | `mcp.ts::disconnectMcpServer` | 迁移 | `POST /api/experimental/mcp/{server}/disconnect` |
+| 29 | `mcp.ts::startMcpAuth` | 迁移 | `/api/integration/*`:`GET /api/integration/{id}` 找 oauth method → `POST .../connect/oauth` |
+| 30 | `mcp.ts::removeMcpAuth` | 迁移 | `GET /api/integration/{id}` 找 credential connection → `DELETE /api/credential/{credentialID}` |
+| 31 | `mcp.ts::completeMcpAuth` | 迁移 | `POST /api/integration/{id}/connect/oauth/{attemptID}/complete` |
+| 32 | `mcp.ts::authenticateMcp` | 迁移 | 发起 + **轮询** `GET .../connect/oauth/{attemptID}`(仅 `mode:'auto'`) |
+| 33 | `file.ts::listDirectory` | 迁移 | `GET /api/fs/list` → `sdk.file.list` |
+| 34 | `file.ts::getFileContent` | 迁移 | `GET /api/fs/read/*` → `sdk.file.read`(**裸 `Uint8Array`**) |
+| 35 | `file.ts::getFileStatus` | 迁移 | `GET /api/vcs/status`(V1 的 `/file/status` 已删) |
+| 36 | `vcs.ts::getVcsInfo` | 迁移 | `GET /api/vcs` |
+| 37 | `vcs.ts::getVcsDiff` | 迁移 | `GET /api/vcs/diff`(`mode` 枚举**变了**,见 §6.6) |
+| 38 | `worktree.ts::listWorktrees` | 迁移 | `GET /api/worktree?projectID=`(**作用域从 directory 改为 projectID**) |
+| 39 | `worktree.ts::createWorktree` | 迁移 | `POST /api/worktree` |
+| 40 | `worktree.ts::removeWorktree` | 迁移 | `DELETE /api/worktree`(`{projectID, directory, force}` 三字段必填) |
+| 41 | `worktree.ts::resetWorktree` | **3b** | V2 删除了 `POST /experimental/worktree/reset`,无替代 |
+| 42 | `tool.ts::getToolIds` | **3b** | V2 删除 `/experimental/tool/ids`;本仓库**零调用点** |
+| 43 | `tool.ts::getTools` | **3b** | V2 删除 `/experimental/tool`;本仓库**零调用点** |
+| 44 | `lsp.ts::getLspStatus` | **3b** | V2 不再运行语言服务器,端点已删;零调用点 |
+| 45 | `lsp.ts::getFormatterStatus` | **3b** | 同上;零调用点 |
+| 46 | `global.ts::disposeGlobal` | **3b** | V2 删除 `POST /global/dispose`,**无等价物**;零调用点 |
+| 47 | `global.ts::disposeInstance` | 迁移 | `DELETE /api/debug/location` → `sdk.debug.location.evict` |
+| 48 | `config.ts::updateConfig` | **3b** | V2 的 `/api/config` **只有 GET**,无 location 级写入口 |
+| 49 | `config.ts::updateGlobalConfig`(「含其它字段」分支) | **3b** | `PATCH /api/experimental/config` 的 payload **只有 `shell`** |
+| 50 | `client.ts::initGitProject` | **3b** | V2 删除 `POST /project/git/init`(改为 location 首次使用时自动初始化) |
+| 51 | `client.ts::updateProject` | 迁移 | `PATCH /api/project/{projectID}` → `sdk.project.update` |
+| 52 | `command.ts::executeCommand` | 迁移 | `POST /api/session/{id}/command`,body `{name, text}`(V1 是 `{command, arguments}`) |
+
+> **说明**:表里 52 行是因为把「同属连接协议但不是 `notMigratedYet`」的 `getPtyConnectUrl` 也列进来了。
+> 真正的 `notMigratedYet` 调用点 = **51 处**(第 23 行不计入)。
+
+### 1.2 处置结果统计
+
+| 处置 | 数量 | 明细 |
+| ------------------------------ | -----: | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| **迁移到 V2 端点** | **38** | 见上表 |
+| **标记 `removedInV2()` 给 3b** | **13** | share / unshare / todos / 归档 / worktree reset / tool ×2 / lsp ×2 / disposeGlobal / updateConfig / updateGlobalConfig 其它字段 / initGit |
+| 合计 | **51** | ✅ 全部有明确去向 |
+
+### 1.3 「改名」的函数(避免后来者找不到)
+
+| V1 名字 | V2 新名字 | 为什么改名 |
+| --------------------- | --------------------------------------- | ------------------------------------------------------------ |
+| `revertMessage` | `stageRevert` | 三段式的第一段,旧名会误导「一次调用完成回退」 |
+| `unrevertSession` | `clearRevert` | V2 官方术语是 clear(清暂存),且新增了 `commitRevert` |
+| `getPendingQuestions` | `listPendingForms` / `listSessionForms` | Form ≠ question,且新增了会话级/位置级两个入口 |
+| `replyQuestion` | `replyForm` | 载荷从 `answers: QuestionAnswer[]` 变成 `answer: FormAnswer` |
+| `rejectQuestion` | `cancelForm` | V2 官方术语是 cancel |
+
+---
+
+## 2. 三段式回退:实现说明与端点口径
+
+### 2.1 V1 vs V2 语义对照(源码核实,tag `v2.0.19`)
+
+| 阶段 | 端点 | 源码行为(`packages/core/src/session/revert.ts`) |
+| ---------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **stage** | `POST /api/session/{id}/revert/stage` | 只写 `session.revert = {messageID, snapshot, files}` 并把**文件快照**恢复到该边界(`files:false` 可只挪边界)。**不删任何消息**。返回 `Session.Revert`。 |
+| **commit** | `POST /api/session/{id}/revert/commit` | 只发 `RevertEvent.Committed`;由 projector(`projector.ts:739`)**真正 `DELETE FROM session_message WHERE seq >= boundary.seq`** + 清空 revert 标记 + `InstructionState.reset`。**不可逆**。 |
+| **clear** | `DELETE /api/session/{id}/revert` | 把 stage 时改动的文件快照恢复回去 + 清空 revert 标记。消息从未被删,所以清掉标记后它们**重新可见**(= V1 的 `unrevert`)。 |
+
+### 2.2 🔴 两条必须知道的行为(否则 UI 会错)
+
+1. **stage 之后消息仍然留在服务端**,`GET /api/session/{id}/message` 依然会返回它们。
+ → 「回退后消息消失」**必须由前端按 `session.revert.messageID` 过滤** ——
+ 这正是 `messageStore` 的 `revertState` 在做的事(阶段 2a 已实现),本阶段沿用。
+2. **发新消息(`prompt`)会自动 commit**:
+ 源码 `packages/core/src/session/session.ts:165` 的注释原文
+ _"Commit a staged revert only after preparation succeeds, before admitting new work."_
+ → 所以「回退 → 改一下 → 重新发送」这条自然流**不需要前端显式 commit**。
+ `session.compact` 同样会先 commit(`session.ts:252`)。
+
+### 2.3 UI 映射(`useSessionManager` / `useRevertState`)
+
+| UI 动作 | 调用 | 说明 |
+| -------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| 撤销(undo) | `stageRevert(sessionId, userMessageId)` | 边界 = 被点击的用户消息 |
+| 重做(redo) | 还有更早的撤销历史 → `stageRevert`(边界往前挪一条 = 恢复一条)
没有历史了 → `clearRevert` | 与 V1 的 redo 语义等价 |
+| 全部重做(redo all) | `clearRevert` | 等价 V1 的 `unrevert` |
+| —— | **不主动调用 `commitRevert`** | 交给服务端在 `prompt`/`compact` 时自动 commit |
+
+> `commitRevert()` 仍然**导出**(API 完整 + 冒烟测试用它验证「真删」语义),但 UI 不调用。
+> 理由:`commit` 不可逆,而 UI 的「撤销/重做」是可逆交互,主动 commit 会毁掉 redo 能力。
+
+### 2.4 阶段 2a 遗留的「运行时会抛错」已修复
+
+阶段 2a 只做了**类型适配**,`revertMessage` / `unrevertSession` 仍是 `notMigratedYet` 占位。
+本阶段:
+
+- `src/hooks/useSessionManager.ts`:`handleUndo` / `handleRedo` / `handleRedoAll` 改走 `stageRevert` / `clearRevertApi`
+ (导入时**别名**成 `clearRevertApi`,因为本文件有一个同名的本地回调 `clearRevert`,不别名会遮蔽)。
+- `src/hooks/useRevertState.ts`:整体改写到三段式。
+ ⚠️ **如实说明**:该 hook 全仓库**零消费点**(真正在跑的是 `useSessionManager`),
+ 它只是 `src/hooks/index.ts` 的公共导出。本阶段按任务要求把它迁移到可用状态,**是否删除归 3b 判断**。
+
+---
+
+## 3. Form 表单:字段类型清单与渲染方案
+
+### 3.1 六种字段类型(照 openapi `Form.*` + 服务端 `packages/core/src/form.ts` 核实)
+
+| type | 专有字段 | 渲染控件(本项目 `FormDialog.tsx`) | 提交值 |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
+| `string` | `format?`(email/uri/date/date-time)、`minLength?`、`maxLength?`、`pattern?`、`placeholder?`、`default?`、`options?`、`custom?` | 有 `options` → **选项按钮组**(`custom:true` 时另给自由输入框);无 → 单行 ``,按 `format` 用原生 `email`/`url`/`date`/`datetime-local` | `string`(选项用 **option.value**,不是 label) |
+| `number` | `minimum?`、`maximum?`、`default?`(可能是 `'Infinity'`/`'-Infinity'`/`'NaN'`) | `` | `number` |
+| `integer` | 同 number | `` | `number`(整数) |
+| `boolean` | `default?` | 勾选按钮 | `boolean`(**`false` 也要提交**) |
+| `multiselect` | `options[]`(必填)、`minItems?`、`maxItems?`、`custom?`、`default?: string[]` | 多选按钮组(`custom:true` 时另给「回车追加」输入框) | `string[]` |
+| `external` | `url`(必填) | **链接 + 「我已打开并完成」确认按钮** | **`true`**(见 §3.3) |
+
+所有非 external 字段还都可带:`title?` `description?` `required?` `hidden?` `when?: FormWhen[]`。
+
+### 3.2 渲染方案的三个关键决策
+
+1. **`when` 求值放在前端,且逐字对齐服务端 `isActive`/`matches`**
+ (`src/api/form.ts` 的 `resolveVisibility()`):
+ - 服务端在**创建期**就强制「`when` 只能引用**前面**的字段」
+ (`Form field condition must reference an earlier field`)→ 前端按 fields 顺序自上而下求值即可,无循环依赖。
+ - **条件引用的字段「未作答」时,`eq` 与 `neq` 都判 false**(不是「neq 取反」)。源码注释原文:
+ _"An unanswered referenced field makes the condition false for both ops."_
+ - 多选字段参与比较时是「**任一项命中**」(`value.some(item => item === when.value)`)。
+ - 比对用的是**转换后的值**(number 字段的条件值是数字、boolean 是布尔),所以 `resolveVisibility` 边遍历边转换。
+2. **`external` 字段是「永远必填的确认位」**:服务端 `validateAnswer` 里
+ `if (field.type === 'external') { if (value !== true) return 'External form field must be acknowledged' }`
+ —— 字段里**没有** `required` 也一样。渲染器因此必须给一个显式的确认动作(不能只渲染链接)。
+3. **answer 只提交「可见」字段,且空值不提交**(`buildFormAnswer()`):
+ 服务端会拒「未知 key」(`Unknown form field`)与「条件不成立却带了值」(`Form field is not active`)。
+ 留空的非必填字段直接**不发该 key**(服务端把「缺 key」当「未作答」,合法);
+ 而 `''` / `[]` 会触发字段级约束(如 `minLength`、`minItems`),所以必须丢掉。
+ 例外:`boolean` 的 `false` 是合法值,必须保留。
+
+### 3.3 校验(`validateForm()`)—— 与服务端 `validateAnswer`/`validateField` 对齐
+
+覆盖:`external` 确认位、`required`、string 的 min/maxLength、`pattern`、`format`(email/uri/date/date-time)、
+**闭集选项**(有 `options` 且 `custom!==true` 时值必须是某个 option 的 value)、
+number/integer 的 `Number.isFinite` + 整数性 + min/max、multiselect 的 min/maxItems + 闭集。
+
+刻意与服务的两处差异(都在注释里写明):
+
+- `pattern` 是**非法正则**时本地不阻塞(本地判不了,交给服务端报 `invalid pattern`)。
+- 非必填字段留空时**不跑** minLength/minItems(因为我们会把空值丢掉,服务端看到的是 `undefined`,
+ 它的字段级约束不会执行)。这**不是疏漏**,是服务端语义。
+- 额外加了一条**服务端没有**的保护:数字字段填了内容但转不出有效值(`abc` / `Infinity`)时报
+ 「请输入数字」,而不是静默丢掉用户的输入。
+
+### 3.4 接线(事件 → 状态 → 渲染)
+
+| 环节 | 文件 | 说明 |
+| ---- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 事件 | `src/hooks/useGlobalEvents.ts` | `form.created` 载荷是 **`{form: FormInfo}`(包了一层)**,`form.replied`/`form.cancelled` 是**扁平**的 `{id, sessionID, …}` —— 这个不一致是 V2 自身的,别「顺手统一」 |
+| 分发 | 同上 | **阶段 3a 起真正向会话消费者分发** `onFormCreated/Replied/Cancelled`(2b 只做 pending 登记,因为当时没有渲染器) |
+| 状态 | `src/hooks/usePermissionHandler.ts` | `pendingQuestionRequests: ApiQuestionRequest[]` → **`pendingForms: FormInfo[]`**;新增 `handleFormReply` / `handleFormCancel` |
+| 渲染 | `src/features/chat/FormDialog.tsx`(**新增**) | 六种字段类型 + `when` 联动 + external 确认位;Escape 取消 / send 键提交 |
+| 挂载 | `src/features/chat/ChatPane.tsx` | 底部 `QuestionDialog` → **`FormDialog`**(与 PermissionDialog 同一套视觉与弹入动画) |
+
+> ⚠️ **内联通道(`InlineQuestion`)保持原样但恒空**:V2 的 `Form.Info` 只有
+> `{id, sessionID, title, fields}`,**没有 tool 关联字段**,无法按 `callID` 内嵌到工具卡片里。
+> 所以 `InlineToolRequestContext.pendingQuestions` 恒为 `[]`,`InlineQuestion` 的渲染路径保留不删,归 3b。
+
+---
+
+## 4. PTY 两步连接说明
+
+### 4.1 协议
+
+```
+① POST /api/pty/{ptyID}/connect-token → {location, data:{ticket, expires_in:60}}
+ ⚠️ 必须带请求头 `x-opencode-ticket: "1"`(见 §6.4)
+② GET /api/pty/{ptyID}/connect?ticket=…&cursor=…&location[directory]=…
+ → 101 Switching Protocols
+```
+
+### 4.2 实现
+
+| 位置 | 改动 |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/api/pty.ts` | 新增 `createPtyConnectTicket(ptyId, directory?, serverId?)`;`getPtyConnectUrl` 改为 `(ptyId, ticket, directory?, options?, serverId?)` |
+| `src/api/ptyBridge.ts` | 拼 URL 前先 `await createPtyConnectTicket()`;ticket 走 **query**(Rust `bridge_connect` 不支持自定义 header,注释已写明不要挪到 header) |
+| `src/components/Terminal.tsx` | 浏览器分支改为异步 IIFE:先取 ticket 再拼 URL;**重连天然重新申请**(指数退避逻辑未动) |
+
+**ticket 是一次性的**:同一 ticket 第二次连接 → **403**(真机实测)。所以 URL **不能缓存复用**。
+
+---
+
+## 5. 改动清单
+
+### 5.1 新增文件(15 个)
+
+| 文件 | 行数级别 | 说明 |
+| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- |
+| `src/api/form.ts` | 大 | Form API + 纯函数(可见性/校验/answer 组装) |
+| `src/types/api/form.ts` | 小 | Form 类型(复用 SDK 生成类型) |
+| `src/features/chat/FormDialog.tsx` | 大 | **表单渲染器**(新增 UI 能力) |
+| `src/api/phase3a.smoke.test.ts` | 中 | 真实服务冒烟(6 例,默认 skip) |
+| `src/features/chat/FormDialog.test.tsx` | 中 | 渲染器单测(6 例) |
+| `src/api/{session,permission,form,file,vcs,worktree,config,global,client,tool,lsp,command,mcp,pty}.test.ts` | —— | 本阶段新增/扩展的单测(共 14 个测试文件) |
+
+### 5.2 修改文件(关键项)
+
+| 文件 | 改动 |
+| --------------------------------------------- | ----------------------------------------------------------------------------------- |
+| `src/api/notMigrated.ts` | 新增 `removedInV2()` + `V2_REMOVED_PENDING_CLEANUP` 前缀;文件头写清两种去向 |
+| `src/api/session.ts` | 51 处中的 12 处;三段式;`getSessionDiff` 修全量语义(见 §6.1) |
+| `src/api/permission.ts` | 权限 2 处 + 新增 saved 规则管理(`listSavedPermissions` / `removeSavedPermission`) |
+| `src/api/v2Convert.ts` | 新增 `toInternalRevert()`(V2 的 `files` 字段映射) |
+| `src/types/api/session.ts` | `SessionRevert` 用**交叉类型**追加 V2 的 `files`(不动 v1Model) |
+| `src/hooks/useGlobalEvents.ts` | `pendingQuestions` → `pendingForms`;form 事件**开始向消费者分发** |
+| `src/hooks/usePermissionHandler.ts` | 表单链路(`pendingForms` / `handleFormReply` / `handleFormCancel`) |
+| `src/hooks/useSessionManager.ts` | undo/redo 改三段式(导入别名避免遮蔽) |
+| `src/hooks/useRevertState.ts` | 整体改写到三段式(该 hook 零消费点,如实记录) |
+| `src/hooks/useChatSession.ts` | form 事件回调、session family 拉取改 `listPendingForms` |
+| `src/features/chat/ChatPane.tsx` | `QuestionDialog` → `FormDialog`;内联通道 `pendingQuestions: []` |
+| `src/components/SessionChangesPanel.test.tsx` | 补 `toVcsDiffMode` 替身(3 行,见 §6.6) |
+
+> ⚠️ **越界说明(如实)**:硬性约束写的是「允许改 `src/api/**`、`src/features/**`、`src/store/**`、`src/utils/`、测试、`docs/`」,
+> 但为了让迁移**真的可用**,本阶段还动了 `src/hooks/**`(表单/回退链路所在)与
+> `src/components/{McpPanel,Terminal,WorktreePanel,SessionChangesPanel}.tsx`(消费方适配)。
+> 这些都是「调用方必须跟着改」的最小改动,没有删任何 UI。
+
+---
+
+## 6. 🔴 与文档预测不符之处(本节最重要)
+
+> 共 **16 条**。前 8 条是本阶段新发现,后 8 条是 4 个并行子任务的实测补充。
+> 已回填主文档的部分在 §6.17 列出。
+
+### 6.1 🔴🔴 `GET /api/session/{id}/diff` **是「按轮次」的,不是全量** —— 阶段 1 的迁移悄悄退化了它
+
+| | 内容 |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **文档原文** | §4.2:`diff \| GET /session/{id}/diff \| GET /api/session/{id}/diff \|`(暗示等价) |
+| **openapi 原文** | _"Structured per-file diffs of the files **a turn** changed… `from`: User message whose turn to diff. **Defaults to the turn of the newest user message.**"_ |
+| **后果** | 阶段 1 把 V1 的 `getSessionDiff`(全量)直接映成不传参数的 `session.diff()` → **它只显示最后一轮**。UI 的「会话变更」模式与 V1 行为不一致,而且**不报错**。 |
+| **处理** | ✅ 本阶段修掉:先查**最早/最新一条 user 消息**(`limit=1&type=user&order=asc\|desc`,两个轻请求),把它们当 `from`/`to` 传进去覆盖整段历史;拿不到边界时退化为 V2 默认。`getLastTurnDiff` 则**不需要**任何参数(默认就是最新一轮)。 |
+
+### 6.2 🔴 `fork` 的请求体字段是 **`before`**,不是 `messageID`;`Session.ForkBoundary` 是只读字段
+
+| | 内容 |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **文档原文** | §4.2:`POST /api/session/{id}/fork \| 新增 ForkBoundary` |
+| **实测** | openapi 的请求体是 `{before?: msg_id}`;`Session.ForkBoundary = {type:'before'\|'through', messageID}` 是 `Session.Info.fork.boundary` 这个**只读字段**的形状,**不是请求体**。 |
+| **处理** | ✅ `forkSession(sessionId, messageId)` → `sdk.session.fork({sessionID, before: messageId})`。语义与 V1 一致(都不含该消息)。 |
+
+### 6.3 🔴 **V2 删除了「归档会话」能力**(文档未提)
+
+| | 内容 |
+| -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| **实测** | `SessionUpdateInput` 只有 `{title?, metadata?, permissions?}`;v2.0.19 的 server handler / protocol / core 里 `grep archiv` **零命中**。 |
+| **后果** | UI 的「归档」(`useChatSession.handleArchiveSession`)在 V2 **没有实现手段**。 |
+| **处理** | ✅ `updateSession(..., {time:{archived}})` 显式抛 `removedInV2()`(不静默忽略)→ 归 3b 决定移除或改「删除」。 |
+
+### 6.4 🔴 PTY 连接协议的四条实测细节(openapi 看不出来)
+
+| # | 发现 | 证据 |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
+| ① | **`connect-token` 必须带请求头 `x-opencode-ticket: "1"`**(值是**固定字面量 `"1"`**,不是票据本身)。openapi 标它 optional,实际不带 → **403** `Invalid PTY connect token request` | 真机握手 |
+| ② | **`pty.connect.token` 的返回值没有被 SDK 解包** —— 返回的是完整信封 `{location, data:{ticket, expires_in}}`(只有 `experimental.persistentPty.*` 等少数端点被 SDK `.then(v => v.data)` 处理) | 生成代码 + 真机 |
+| ③ | **WS connect 的目录参数名是 `location[directory]`**:`?ticket=X` → 404、`?ticket=X&directory=/tmp` → **404(裸参数被静默忽略)**、`?ticket=X&location[directory]=/tmp` → 101 | 真机(pty 在 /tmp、服务端 cwd 在别处) |
+| ④ | **手拼 URL 必须带 `/api` 前缀**。`getApiBaseUrl()` 返回**裸**地址(SDK 内部自己拼 `/api`)。漏了会打到 SPA 兜底路由拿到 **200 + text/html**(不是 404,极具迷惑性),WS 握手失败 | 真机 |
+
+> 另外实测:ticket **一次性**(同 ticket 第二次连接 → 403)、**错目录 → 404**、
+> **带 ticket 时服务端跳过 Basic 认证**(所以浏览器形态可以不带 `auth_token`/userinfo)。
+
+### 6.5 🔴 `fs/list` 的目录条目 **path 带尾斜杠**(文档未提)
+
+实测 `GET /api/fs/list` 返回 `src/`、`.git/`、`src/components/`;`fs/find` 同样。
+必须剥掉,否则:① `name` 变空串(UI 目录名消失);② `node.path` 与 `/api/vcs/status` 的裸文件路径对不上 →
+`computeDirectoryStatus` 生成的父目录键是 `src` 而不是 `src/` → **目录改动颜色整片失效**。
+
+另外三条 fs 细节:
+
+- **`path=''` / `path='.'` / 不传 `path` 三者等价**(都返回 location 根),文档担心的「空串会报错」不成立。
+- **`fs/read` 返回裸 `Uint8Array`**(丢掉 content-type),且**不接受绝对路径**(传了返回 **500**,不在 SDK 声明的状态码里)。
+ → mimeType 必须**前端按扩展名推断**(正好也避开了服务端把 `a.ts` 认成 `video/mp2t` 的错判)。
+- **`fs/list` 会列出 `.git/` 与 gitignore 命中的条目**,且**没有任何 ignored 标记**
+ → 内部 `ignored` 只能恒 `false`,V1 的「gitignore 文件半透明」能力**丢失**(3b 需决定是否前端过滤)。
+
+### 6.6 🔴 `Vcs.Mode` 枚举与 UI **不兼容**(文档 §4.7 只写了「路径加 `/api`」)
+
+UI 的变更范围是 `git | branch | session | turn`,而 V2 只认 **`working | branch | committed`**。
+实测传 `mode=git` → **400 `Expected Vcs.Mode`**。
+→ 新增 `toVcsDiffMode()` 做 `git → working` 翻译(`session`/`turn` 不走 VCS 端点,不受影响)。
+**这是本阶段最容易被漏掉的破坏性变更。**
+
+附带:**非 git 目录的 `/api/vcs` 是 `200 + {"branch":{}}`**,不是 4xx/5xx。
+只靠 try/catch 会把「没有 VCS」当成「有 VCS 但分支为空」放过去 → 必须再判 `branch.current`。
+
+### 6.7 🔴 worktree 的**作用域参数从 `directory` 改成 `projectID`**(文档 §4.7 只写「转正」)
+
+解析错项目时 HTTP 全 200 → 静默失效。实现里先用 `GET /api/location` 拿 `location.project.id`。
+附带行为变化(3b 需知道):V2 的 create 用 `git worktree add --detach`(**游离 HEAD,不建分支**),
+remove **只做 `git worktree remove [--force]`**(不再删 `opencode/` 分支、不再 `rm -rf`)。
+
+### 6.8 🔴 `disposeGlobal` 在 V2 **没有等价物**(文档 §4.1 说「可用 `DELETE /api/debug/location` 替代」)
+
+`debug/location` 一次只能驱逐**一个** location;`global/dispose`(释放整个进程资源)**没有替代品**。
+→ 分开处理:`disposeInstance` 迁移,`disposeGlobal` 标记 3b。
+
+### 6.9 🟡 MCP 的四条 schema 变化(文档未提)
+
+1. `Mcp.Status` **新增 `pending`**(服务器正在启动/握手);`NeedsAuth.error` 在 V2 是**必填**。
+2. `GET /api/mcp/resource` 比 V1 多出 **`templates`**(V1 只有 resources);资源来源字段是 `server`(V1 叫 `client`)。
+3. OAuth attempt 有 **`mode: 'auto' | 'code'`** 两种,直接决定 `authenticateMcp` 能否「一步到位」:
+ `code` 模式下永远轮询不到 complete,必须走 `completeMcpAuth`(实现里对 code 直接抛错并带出链接)。
+4. **OAuth method 可能带必填 `form`**(如 `github-copilot` 的 `deploymentType`)→ 不带 `answer` 会被服务端拒。
+ 当前实现按「MCP 自动生成的 integration 通常不需要」处理,属**潜在风险点**(已在报告与注释里标注)。
+
+### 6.10 🟡 全新 location 的**第一次 `GET /api/mcp` 返回空数组**(惰性初始化的又一处)
+
+配置里明明有 MCP 服务器,但首次调用拿到 `data: []`,第二次(或等几秒)才有。
+MCP 服务器是随 location **异步**连接的。→ UI 首次打开 MCP 面板可能显示「尚未配置」,需要刷新。
+(与阶段 0.5 §5⑩、阶段 2b §7.14 是同一类问题。)
+
+### 6.11 🟡 `GET /api/session/active` 的**线缆信封是 `{data:{…}}`**
+
+SDK 的 `session.active()` 会**自动解包**(声明类型是裸 Record),所以 `getSessionStatus()` **是对的**。
+但**裸 fetch 必须自己取 `.data`** —— 本阶段冒烟一开始直接读顶层,于是 `waitForBusy()` 永远看不到活跃会话。
+这条值得记一笔:V2 的「是否解包」是**逐端点**由 codegen 决定的,不能凭直觉。
+
+### 6.12 🟡 `revert/stage` 对 **busy 会话返回 `SessionBusyError`**
+
+所以「回退」必须在执行结束**之后**才允许(`stage` 不是无脑幂等的)。冒烟里因此要先等会话真正空闲。
+另:`prompt` 是**非阻塞入队**,刚发完时 `/api/session/active` 可能还是空的 →
+「先等它忙起来再等它闲下来」是必须的两步(否则 `waitForIdle` 会立刻返回 true,然后撞上 `SessionBusyError`)。
+
+### 6.13 🟡 Form 的三条硬性语义(文档只写了「六种字段类型 + when 条件」)
+
+| # | 语义 | 源码位置 |
+| --- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| ① | **`external` 字段必须被确认为 `true`**(哪怕没有 `required`) | `validateAnswer`:`if (value !== true) return 'External form field must be acknowledged'` |
+| ② | **`when` 只能引用「前面」的字段**(创建期强制) | `validateFields`:`Form field condition must reference an earlier field` |
+| ③ | **引用字段未作答时 `eq`/`neq` 都判 false**;多选是「任一项命中」 | `matches`:`if (value === undefined) return false` |
+
+另:`GET /api/form`(位置级)与 `GET /api/session/{id}/form`(会话级)**只列 `state.status === 'pending'`** 的表单。
+
+### 6.14 🟡 权限:`decision` 枚举**没变**,变的是「必须带 sessionID」
+
+任务提醒「`decision` 的枚举值必须以 openapi 为准,别照 V1 惯性」——
+核实结果:openapi 的 `Permission.Reply` = **`"once" | "always" | "reject"`**,**与 V1 完全一致**。
+真正会踩的是:V2 的 `sessionID` 是**路径参数**(`POST /api/session/{sessionID}/permission/{requestID}/reply`),
+V1 有「无 sessionID」的分支 → V2 下缺 sessionID 会**必然失败**。
+→ 实现里缺 sessionID 时**显式抛中文错误**,不发必错请求。
+
+另:`GET /api/permission/request` **没有 sessionId 过滤参数**(拉全量后前端过滤);
+`POST /api/session/{id}/permission`(主动创建请求)在**审批不需要时直接返回 `{effect:'allow'}` 而不产生 pending 请求**
+—— 冒烟要造 pending 必须先建一个带 `permissions:[{action:'bash',resource:'*',effect:'ask'}]` 的会话。
+
+### 6.15 🟡 各模块的「零调用点」核查(3b 可直接删)
+
+| 函数 | 调用点 | 3b 动作 |
+| ------------------------------------- | -------------------------------- | -------------------------------------------------------------------- |
+| `getToolIds` / `getTools` | **0** | 可删 `src/api/tool.ts` + `src/types/api/tool.ts` |
+| `getLspStatus` / `getFormatterStatus` | **0** | 可删 `src/api/lsp.ts` |
+| `disposeGlobal` | **0** | 可删函数 |
+| `updateConfig`(API) | **0** | 可删函数(注意 `ConfigSettings.tsx` 里有个**同名局部函数**,别误删) |
+| `updateProject` | 0 | 保留(已按 V2 契约实现 + 测试) |
+| `getPtySession` | 0 | 保留(已迁移) |
+| `removeMcpAuth` / `completeMcpAuth` | 0(由 `authenticateMcp` 间接走) | 保留 |
+
+### 6.16 🟡 任务书的两处计数/描述偏差
+
+1. **本组实际是 15 处而不是 12 处**:worktree 4 + tool 2 + lsp 2 + global 2 + config 2 + client 2 + command 1 = **15**。
+ (任务书 §9 写「`api/tool.ts` / `api/lsp.ts` / `api/global.ts` / `api/config.ts` / `api/client.ts` / `api/command.ts` 的 12 处」,
+ 漏算了 worktree 的 4 处。)51 处的总数不受影响(worktree 4 已在别处计入)。
+2. **任务书说「lsp.ts 2 处 → 标记给 3b,不删 UI」,但 UI 侧本来就没有 LSP 状态展示**:
+ 全仓库 `getLspStatus|getFormatterStatus|LSPStatus|FormatterStatus` 只有定义处,**零调用点、零 UI**。
+ 设置面板里的 `lsp`/`formatters` 区块是**配置字段编辑器**,而且 **V2 的 config schema 里这两个字段仍在**
+ (`packages/schema/src/config.ts` 有 `ConfigLSP` / `ConfigFormatter`)→ 它们属于「配置编辑器」那条线,不是「LSP 状态展示」。
+3. **`docs/opencode-v2-migration.md` §9.3 的 `src/api/pty.ts:27 normalizePty()` 已过期** —— 该函数早已不存在。
+
+### 6.17 ✅ 已回填主文档
+
+| 位置 | 内容 |
+| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| §3.3 | 新增「**「SDK 是否解包 `{data}` 信封」是逐端点决定的**」实测表 |
+| §4.1 | `global/dispose` **无替代**的修正 |
+| §4.2 | `update`(**V2 删除归档会话** + 返回 void)、`abort`(空闲返回 false 不是错误)、`fork`(字段是 `before`)、**`diff` 是按轮次的**(阶段 1 的退化已修)、**回退三条实测语义** |
+| §4.3 | 权限(枚举没变、**sessionID 变路径参数**、列表无 sessionId 过滤)+ **Form 三条硬性语义**(external 确认位 / when 只能引用前面的字段 / 未作答两 op 皆 false) |
+| §4.4 | 配置写入只能改 `shell` 的复核结论 |
+| §4.5 | `fs/list`(**尾斜杠**、`.git/` 与 gitignore 无标记)、`fs/read`(**裸字节**、**不收绝对路径**)、`file/status → vcs/status` |
+| §4.6 | PTY **四条连接细节**(`x-opencode-ticket:"1"`、不解包、`location[directory]`、`/api` 前缀)+ ticket 一次性 |
+| §4.7 | MCP(**数组**、新增 `pending`、`NeedsAuth.error` 必填、`templates`、`mode:auto\|code`、惰性初始化)、VCS(**`Vcs.Mode` 枚举变了**、非 git 返回 200)、worktree(**作用域改 `projectID`**、reset 已标记、行为变化) |
+| §5.6 | **新增章节**:回退三段式的实现口径(含三条必须知道的语义 + UI 映射表) |
+| §8 | 阶段 3 **拆成 3a(已完成)/ 3b(未开始)**,逐条勾选 + 产出说明 |
+| §9.3 | 标注 `normalizePty()` 那条**已过期**;`SessionChangesPanel` 的 patch 回退分支**已成死代码**;新增阶段 3a 引入的兼容物(`SessionRevert` 交叉类型) |
+| §9.4 | **逐项复核原「要移除的功能」表**:`子会话列表`/`手动摘要`/`unrevert` **其实是可迁移项**(已迁移);**漏列了「归档会话」** |
+| §10.3 | 新增 14 条阶段 3a 实测结论表 |
+
+合计:**13 处「实测修正/补充」标注 + 16 处「已迁移/已实现/已标记」标注 + 1 个新章节(§5.6)+ §8 的阶段拆分**。
+
+---
+
+## 7. 阶段 3b 待办交接(移除清单最终版)
+
+### 7.1 `removedInV2()` 标记的 13 处(API 层)
+
+```
+src/api/session.ts shareSession / unshareSession / getSessionTodos
+ + updateSession 的 time.archived 分支(归档会话)
+src/api/worktree.ts resetWorktree
+src/api/tool.ts getToolIds / getTools
+src/api/lsp.ts getLspStatus / getFormatterStatus
+src/api/global.ts disposeGlobal
+src/api/config.ts updateConfig / updateGlobalConfig 的「含其它字段」分支
+src/api/client.ts initGitProject
+```
+
+### 7.2 对应的 UI 入口(本阶段**只标记未删**)
+
+| UI 入口 | 文件 | 归属 |
+| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
+| 分享按钮 / ShareDialog | `src/features/chat/{Header,ShareDialog}.tsx` | share |
+| 归档按钮 | `useChatSession.handleArchiveSession`(侧栏) | 归档 |
+| 待办展示 | `src/features/chat/input/InputFooter.tsx` | todo |
+| worktree「重置」 | `src/components/WorktreePanel.tsx`(`handleReset`/`resetConfirm`/确认弹窗/按钮) | worktree reset |
+| 「初始化 git」按钮 | `src/components/SessionChangesPanel.tsx` | project/git/init |
+| 配置保存链路 | `src/features/settings/components/ConfigSettings.tsx` → 改「只读 + 引导编辑 `opencode.json`」 | config 写 |
+| `InlineQuestion` 渲染路径 | `src/features/message/parts/ToolPartView.tsx` / `MessageRenderer.tsx` / `InlineQuestion.tsx` / `QuestionRenderer.tsx` / `QuestionDialog.tsx` | question 体系 |
+| MCP `needs_client_registration` 分支 | `src/components/McpPanel.tsx` | V2 不再产生 |
+| `gitignore` 灰显(`ignored`) | `src/components/FileExplorer.tsx` | V2 无此信息 |
+
+### 7.3 其它待办(非「移除」类)
+
+1. **Tauri 真机验证**:`ptyBridge.ts` 的「先取 ticket 再 `bridge_connect`」只做了编译级 + 逻辑级验证,
+ Rust 侧是否原样转发 query **未实测**(容器内无 Tauri 运行时)。
+2. **`@opencode/client` 的 `subscribe()` 在 `plugin-http` 下的流式** 仍未验证(阶段 2b 遗留)。
+3. **Form 的可选增强**:`metadata` 未做特殊渲染;`getFormDetail` 已可用但打开历史会话时**尚未回填已回答的表单**(只显示 pending)。
+4. **MCP 的可选增强**:`templates` 已取回但 UI 未展示;`needs_auth.error` 未展示;`mcp.remove` 未封装(本项目 V1 也没有)。
+5. **`src/hooks/useRevertState.ts` 是零消费点的公共导出** —— 建议 3b 直接删除。
+6. **`useFileExplorer` 的根目录 10s TTL 缓存**(V1 原有)会让 `softRefresh()` 在 10s 内滞后,3b 可评估。
+7. **prettier 全仓库仍有漂移**(本阶段只格式化改动过的文件,避免无关 diff)。
+8. **`v1Model.ts` 的 B 桶 104 个导出** 仍在(本阶段一个未删,按要求留给 3b 收尾)。
+9. **`docs/opencode-v2-migration.md` §4.7 建议补一行**:worktree 的作用域参数从 `directory` 改成 `projectID`(已回填)。
+10. **Rust / WSL / Docker 三形态回归**(阶段 4)。
+
+---
+
+## 8. 测试与冒烟结果
+
+### 8.1 类型检查
+
+```
+npx tsc -b --force → 0 报错(全仓库)
+```
+
+### 8.2 全量单测
+
+```
+# 服务未启动(4097 关闭)—— 默认状态
+npx vitest run --reporter=dot
+→ Test Files 112 passed | 4 skipped (116)
+ Tests 967 passed | 38 skipped (1005)
+ Duration 20.02s
+
+# 服务启动时(phase1Smoke 也会跑)
+→ Test Files 113 passed | 3 skipped (116)
+ Tests 980 passed | 25 skipped (1005)
+```
+
+**基线对比**:阶段 2b 结束时是 **803 passed + 19 skipped**。
+本阶段新增约 **164 个通过用例**(14 个测试文件),**零失败、零回归**。
+4 个 skip 的文件是 4 个「需真实服务」的冒烟文件(默认跳过)。
+
+### 8.3 真实服务冒烟(`src/api/phase3a.smoke.test.ts`)
+
+```
+OPENCODE_SERVER_PASSWORD=t1 opencode --log-level warn serve --hostname 127.0.0.1 --port 4097
+VITE_OPENCODE_SMOKE=1 VITE_OPENCODE_SMOKE_DIRECTORY=/tmp/opencode/p3a/ws \
+ npx vitest run src/api/phase3a.smoke.test.ts
+→ Test Files 1 passed (1) | Tests 6 passed (6)
+```
+
+| # | 用例 | 结果 | 关键断言 |
+| --- | ---------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ① | 中止 | ✅ | 空闲会话 `interrupted=false`(不报错);**观察到 busy 后 `interrupted=true`**,且随后回到 idle |
+| ② | 回退三段式 | ✅ | stage 后**消息仍在**且 `session.revert.messageID` 正确;clear 后标记消失且消息仍在;commit 后**边界消息真被删除** |
+| ③ | 权限回复 | ✅ | `POST .../permission` 造 pending → 列表能查到(`action→permission`/`resources→patterns` 映射正确)→ `reply('reject')` → 从 pending 消失;`/api/permission/saved` 可读 |
+| ④ | 表单 | ✅ | 六种字段 create → 会话级 + 位置级列表都能查到 → reply(含 `external: true`)→ `state=answered`;再建一张 → cancel → `state=cancelled` |
+| ⑤ | PTY | ✅ | create(`status` 形状)→ list → **connect-token 拿到 ticket** → remove |
+| ⑥ | MCP | ✅ | list(数组形状)→ resource catalog(`{resources,templates}`)→ runtime add → connect → remove |
+
+**数据卫生**:冒烟在专用 scratch 目录 `/tmp/opencode/p3a/ws` 建会话,`afterAll` 全部 `DELETE`。
+实测跑完后该目录会话数 = **0**;`/home/coder/project/OpenCodeUI` 下 **零** `[phase3a-smoke]` 残留。
+**测试服务已 `kill`**(端口 4097 无响应),**用户自己的 4096 服务未被触碰**(仍返回 401 = 存活)。
+
+### 8.4 额外的真机验证(子任务做的,比要求更进一步)
+
+| 模块 | 内容 | 结果 |
+| ------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
+| PTY | 真实 WS 握手(Tauri 形态 + 浏览器形态)、ticket 一次性(403)、错目录(404) | ✅ 101 / 403 / 404 |
+| MCP | 起了假 MCP stdio 服务器,验证 `PUT /api/experimental/mcp/{server}` 的 `{config}` 包一层 + V2 字段名 + `connected/failed/disabled` 三态 | ✅ 204 + 状态正确 |
+| fs/vcs | 尾斜杠、`absolute` 拼接、`.ts` mime、200KB base64、status 字段、非 git→null、三种 diff mode | ✅ 8/8 |
+
+---
+
+## 9. 未做 / 已知缺口(如实)
+
+| # | 项 | 说明 |
+| --- | --------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| 1 | **Tauri 真机未验证** | 容器内无 Tauri 运行时。受影响最大的是 `ptyBridge.ts`(先取 ticket 再 `bridge_connect`) |
+| 2 | **WSL / Docker 未跑** | 与阶段 1/2a/2b 一致 |
+| 3 | **浏览器人工回归未做** | 按前几轮的既定做法,只用单测 + API 冒烟(**表单渲染器**额外补了组件级单测覆盖六种字段类型) |
+| 4 | **配置编辑器未改** | V2 的写入口只有 `shell` → 3b 改「只读 + 引导编辑文件」 |
+| 5 | **打开历史会话时不回填已回答的表单** | 只显示 pending 表单(`getFormDetail` 已实现,未接线到历史渲染) |
+| 6 | **Form 的 `metadata` 未特殊渲染** | 目前只渲染 `title` + `fields` |
+| 7 | **`fs/list` 的 gitignore 灰显能力丢失** | V2 不返回 ignored 标记;未加前端过滤(3b 决策) |
+| 8 | **base64 是全量内存操作** | V2 的 `fs/read` 必须整包读文件,超大文件(>50MB)可能卡顿;未加大小上限 |
+| 9 | **`authenticateMcp` 的 `mode='code'` 只抛错** | 不自动轮询(轮询也拿不到 complete);错误信息里带出授权链接供用户改走 `completeMcpAuth` |
+| 10 | **OAuth method 的必填 `form` 未处理** | 需要 `answer` 的 integration 会被服务端拒(潜在风险,已在注释标注) |
+| 11 | **MCP 面板未绑定 serverId** | 签名仍是 `(name, directory?)`;attempt 缓存 Map 的 key 已预留 serverId 前缀 |
+| 12 | **prettier 全仓库仍有漂移** | 本阶段只格式化改动过的文件 |
+| 13 | **`getSessionDiff` 多两次轻请求** | 为了拿 from/to 边界(`limit=1`);服务端是内存查询,当前可接受 |
+
+---
+
+## 10. 附:本阶段改动规模
+
+```
+git diff --stat(含未提交的 0/1/2a/2b/3a 全部改动)
+ 65 files changed, 6671 insertions(+), 2650 deletions(-) ← 仅 src/api + src/types/api + src/hooks + src/features + src/components
+
+本阶段新增测试文件(14)
+ src/api/{session,permission,form,file,vcs,worktree,config,global,client,tool,lsp,command,mcp,pty}.test.ts
+ src/features/chat/FormDialog.test.tsx
+ src/api/phase3a.smoke.test.ts
+```
diff --git a/docs/opencode-v2-migration-phase3b.md b/docs/opencode-v2-migration-phase3b.md
new file mode 100644
index 000000000..99c4c354a
--- /dev/null
+++ b/docs/opencode-v2-migration-phase3b.md
@@ -0,0 +1,726 @@
+# OpenCodeUI 迁移 V2 —— 阶段 3b 报告(功能裁撤 + 配置编辑器 + Rust)
+
+> 状态:**阶段 3b 已完成**(2026-09-30)
+> 目标环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19`
+> 验收:`tsc` **0 报错**;`prettier --check` **全仓库通过**;`eslint` **0 error**;
+> 全量测试 **958 passed / 44 skipped / 0 failed**(服务未启动时);
+> 真实服务冒烟 **6/6**(配置读、shell 写、静默丢弃实证、客户端防线、API 下架、数据卫生)
+> 前置报告:`docs/opencode-v2-migration-phase{0,0.5,2a,2b,3a}.md`
+> **计数口径**:任务书说「13 处 `removedInV2()`」→ 复核为 **13 处**(见 §2.1)。
+> `v1Model.ts` B 桶:**104 个导出 → 42 个**(见 §4)。
+
+---
+
+## 0. 三件必须先交代的事
+
+### 0.1 硬性约束逐条对照
+
+| 约束 | 实际执行 |
+| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| **允许改** `src/**`(含删 UI)、`src-tauri/**`、测试、`docs/` | ✅ 改动集中在这几处 |
+| **禁止 git commit / push / reset / checkout / worktree** | ✅ 只用了 `git status` / `git diff` / `git ls-files` / `git show` / `git grep`(只读) |
+| **禁止删除 `docs/` 下任何文件** | ✅ 只新增 + 追加修订(`docs/*.md` 的 6 个文件被 prettier 重排了表格对齐,**内容一字未改**,见 §6.9) |
+| 测试服务用完必须关闭 | ✅ 4097 的临时服务已 `kill` 并**确认无残留进程**;**用户自己的 4096 服务未触碰**(仍 401 = 存活) |
+| 不得留下测试会话 | ✅ 冒烟自建会话全部删除;sqlite 实测 `session_v2` 里 **`phase3b` 特征 = 0**、`directory like '/tmp/opencode/p3b%'` = 0 |
+| 测试必须设 120–180s 超时 | ✅ 全量 `timeout 180 npx vitest run` |
+| 全程简体中文注释与报告 | ✅ |
+| **🔴 误改用户全局配置(已按用户许可恢复)** | ⚠️ **发生过**,见 §0.3 —— 如实交代 |
+
+### 0.2 本阶段**没有**做的事
+
+1. **没有提交任何 git 事务**(整个迁移自阶段 0 起就未提交,本次沿用)。
+2. **没有删** `openapi_doc.json` / `openapi_formatted.json` 两个孤儿文件 —— 见 §8.2 的交待(属主文档 §8 3b 清单项,但**不在本轮任务书 A–G 范围内**,且它们是仓库根目录的 git 跟踪文件,按「删除文件需确认」的规则留待阶段 4)。
+3. **没有接入** V2 新增的 `persistent-pty` / `plugin RPC` / `/api/shell` / `/api/websearch` / `/api/rpc` / `session.inbox` / `session.instructions` / `/api/vcs/base` / `/api/vcs/branch` / `fs/write`(YAGNI,与 3a §9.2 一致)。
+4. **没有做浏览器人工回归**(沿用前几轮做法:单测 + API 冒烟 + **本轮新增的「裁撤守卫」静态断言**,见 §6.3)。
+5. **Rust 未编译验证**(容器缺 GTK 系统库,如实标注,见 §5.6)—— 但改动**几乎全是注释 + 一行字符串**。
+
+### 0.3 🔴 必须交代的事故:冒烟测试写进了用户的全局配置文件
+
+| 项 | 内容 |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **发生时机** | 第一次跑 `phase3b.smoke.test.ts` 的 ②③ 两项(都要真实写 `PATCH /api/experimental/config`) |
+| **发生了什么** | `PATCH /api/experimental/config` 写的是「**最高优先级的全局配置文档**」,默认落在 `$XDG_CONFIG_HOME/opencode/opencode.jsonc` → 即 **`/home/coder/.config/opencode/opencode.jsonc`**(宿主机映射目录)。服务端把 `"shell": "/bin/sh"` 写进了这个文件。 |
+| **怎么发现的** | 我的测试里「回读」断言一直失败(读到 `undefined`),排查时看到 `GET /api/config` 的 `Entry[].path` 指向真实用户目录,才意识到没隔离 |
+| **影响** | 用户的 `shell` 从「未设置 = 自动探测 `/usr/bin/bash`」变成「固定 `/bin/sh`」(不支持 bash 语法) |
+| **证据** | opencode 日志里 **235 条** `shell tool using shell` 记录**全部**是 `/usr/bin/bash`、**零** `/bin/sh`,最后一条在 2026-09-24 → 说明该键**原本不存在** |
+| **处理** | ① 立即停止测试服务;② 把当时文件备份到 `/tmp/opencode/p3b/opencode.jsonc.after-smoke`;③ **向用户说明并取得明确许可**;④ 按用户选择**删除 `shell` 键**,恢复原状(仅该行差异,已 `diff` 复核);⑤ **整改测试**:把 `XDG_CONFIG_HOME` 指向 scratch 目录写成**运行前置条件**(红字),启动命令与文件头注释同步更新 |
+| **教训** | 「PATCH 全局配置」这类写操作,**必须先隔离 `XDG_CONFIG_HOME`**;`afterAll` 恢复「值」兜不住「文件被写过」 |
+
+> 相关发现(有价值):写完 `PATCH` 后**立刻** `GET /api/config` 拿到的 `info` 是 `{}`(旧值),
+> 约 1 秒后才是 `{shell: …}`;`POST /api/location/reload` 也**不能**立刻修好。
+> → 这条已写进冒烟测试(轮询)与配置编辑器(保存后重试回读),见 §3.4。
+
+---
+
+## 1. 任务 A:13 处 `removedInV2()` 对应的 UI 清理
+
+### 1.1 UI 移除对照表(对着 3a 报告 §7.2 的 9 类)
+
+| # | 3a §7.2 的 UI 入口 | 本轮实际动作 | 涉及文件 | 状态 |
+| --: | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
+| 1 | 分享按钮 / ShareDialog | 删除组件 + Header 的分享按钮 + SidebarFooter 的「分享对话」菜单项 + store 的 `shareUrl` 全链路 + 3 组 locale 键 | delete `src/features/chat/ShareDialog.tsx`;`Header.tsx`、`sidebar/SidebarFooter.tsx`、`store/messageStore*.ts`、`store/index.ts`、`hooks/useSessionManager.ts`、`locales/{zh-CN,en}/chat.json` | ✅ 全清 |
+| 2 | 归档按钮(侧栏) | 删除 `handleArchiveSession` + **命令面板项** + **快捷键 `Alt+Backspace`** + 控制器动作 `archiveSession` + 2 个 locale 键 | `hooks/useChatSession.ts`、`features/chat/ChatPane.tsx`、`App.tsx`、`store/paneControllerStore.ts`、`store/keybindingStore.ts`、`features/settings/KeybindingsSection.tsx`、`features/settings/settingsSearchCatalog.ts`、`locales/{zh-CN,en}/commands.json` | ✅ 全清(**入口比任务书列的多 4 处**,见 §6.2) |
+| 3 | 待办展示(InputFooter) | 删除会话级待办面板(进度环 + 任务列表 + `TodoSwapPanel`)+ `todoStore` + `api/todo.ts` + `types/api/todo.ts` + `sessionLifecycle` 的清理调用 | `features/chat/input/InputFooter.tsx`(重写)、delete `store/todoStore.ts`、`api/todo.ts`、`types/api/todo.ts`;`store/index.ts`、`types/api/index.ts`、`utils/sessionLifecycle.ts` | ✅ 全清(**`TodoRenderer` 特意保留**,见 §1.2) |
+| 4 | worktree「重置」 | 删除 `handleReset` + `resetConfirm` 确认弹窗 + 列表项按钮 + `WorktreeItem.onReset` + `resetWorktree()` + `WorktreeResetInput` + 3 组 locale 键 | `components/WorktreePanel.tsx`、`api/worktree.ts`、`types/api/worktree.ts`、`types/api/index.ts`、`locales/{zh-CN,en}/components.json` | ✅ 全清 |
+| 5 | 「初始化 git」按钮 | 删除 `handleInitGit` + 按钮 + `initializingGit` 状态 + `initGitProject()` + locale 键标注 | `components/SessionChangesPanel.tsx`、`api/client.ts` | ✅ 全清 |
+| 6 | 配置保存链路 | 见 §3(降级为「只读 + 仅 shell 可写 + 复制 JSON」) | `features/settings/components/**`、`api/config.ts` | ✅ 全清 |
+| 7 | `InlineQuestion` 旧渲染路径 | 删除 `InlineQuestion.tsx`、`QuestionDialog.tsx`、`pendingQuestions`、`findQuestionRequestForTool`、`onQuestionReply`/`onQuestionReject`、`ToolPartView`/`MessageRenderer` 的内联分支、`types/api/permission.ts` 的 4 个 Question 类型 | delete 2 文件;`InlineToolRequestContext.tsx`(重写)、`message/parts/ToolPartView.tsx`、`message/MessageRenderer.tsx`、`chat/ChatPane.tsx`、`chat/index.ts`、`types/api/permission.ts`、`types/api/index.ts`、`api/types.ts` | ⚠️ **部分清**:`QuestionRenderer.tsx` **特意保留**,见 §1.2 |
+| 8 | MCP `needs_client_registration` 分支 | 删除 4 处 switch 分支 + `getErrorMessage` 分支 + `MCPStatusNeedsClientRegistration` 类型 + locale 键 | `components/McpPanel.tsx`、`types/api/mcp.ts`、`types/api/index.ts`、`locales/{zh-CN,en}/components.json` | ✅ 全清 |
+| 9 | `gitignore` 灰显(`ignored`) | 删除 `FileNode.ignored` 字段(改为就地定义 V2 形状)+ FileExplorer 的 `opacity-50` 灰显 + `api/file.ts` 的 `ignored: false` | `types/api/file.ts`(重写)、`api/file.ts`、`components/FileExplorer.tsx`、`api/file.test.ts` | ✅ 全清(决策见 §1.3) |
+
+> **9 类全部处置完毕**:8 类「全清」,1 类(question)按 §1.2 的证据**只清了交互侧、保留只读侧**。
+
+### 1.2 🔴 两处**特意保留**:`QuestionRenderer` 与 `TodoRenderer`
+
+3a 报告把这两个渲染器一并归入「V1 残留、归 3b 删除」。**本轮实测证明该前提不成立**,
+删掉会造成**功能倒退** —— 所以保留,并在此交代依据。
+
+#### ① `QuestionRenderer.tsx` —— V2 的 `question` 工具**还在**
+
+| 证据 | 内容 |
+| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| v2.0.19 源码 | `packages/core/src/tool/plugin/question.ts`:`export const name = "question"`,描述与 V1 一致。它内部改调 `Form.Service.ask(...)` 弹表单,但**工具调用本身照旧落进消息历史** |
+| 实测载荷形状 | `state.input` = `{questions:[{question, header, options:[{label,description}], multiple?}]}`
`state.content` = `[{type:'text', text:'User has answered your questions: "Q"="A". …'}]`(`toModelContent()` 生成)
`state.metadata` = `{answers: [["A"]], truncated: false}` |
+| 本地库统计 | **54 条**这样的 `question` 工具调用,且它们所在会话**全部在 `session_v2` 表里**(即在 V2 的会话列表里可见) |
+| 结论 | `QuestionRenderer` 是**活代码** → 保留(并把头部注释改写成上面这套依据,避免后来者再误删) |
+
+> **顺带一条对 3a 报告的事实修正**:3a §3 写「V2 的 `Form.Info` 只有 `{id, sessionID, title, fields}`,**没有 tool 关联字段**」——
+> **不准确**。v2.0.19 的 `packages/schema/src/form.ts` 里 `InfoBase` 明确含
+> `metadata: Metadata.pipe(optional)`,而 `question` 工具正是靠它把表单绑回工具调用:
+> `metadata: { kind: "question", tool: { messageID, id: <工具调用 id> } }`。
+> → **「按 callID 内联渲染表单」在 V2 技术上可行**;本项目**有意不做**(YAGNI:底部 `FormDialog` 已覆盖全部待处理表单)。
+> 将来若要做,匹配键是 `form.metadata.tool.id === part.callID`。这段说明已写进 `InlineToolRequestContext.tsx` 顶部。
+
+#### ② `TodoRenderer.tsx`(+ `todoUtils.ts`)—— 历史 `todowrite` 卡片仍要渲染
+
+| 证据 | 内容 |
+| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| V2 无待办能力 | `GET /session/{id}/todo` 已删;事件侧无 `todo.updated`(2b 已确认源码中不存在);V2 工具集里**没有 todo 工具**(V1 的 `todowrite` 在 `packages/core/src/database/v1-migration.bun.ts:901` 的 `REMOVED_TOOLS` 里) |
+| 但历史里有 | 本地 `session_message` 里 **328 条** `todowrite` 工具调用(最后一条 2026-09-24,即 V1 时代的历史被迁移进了 `session_v2` 可见的会话) |
+| 本轮处置 | **会话级待办 UI 全删**(面板 / store / API);**只读渲染器保留**(历史工具卡片) |
+| 本地库旁证 | `todo` 表 287 行,**最后写入时间 2026-09-24** → V2 时代**零写入**,印证「V2 不产生待办」 |
+
+### 1.3 `gitignore` 灰显能力丢失的决策(任务 E-4)
+
+**决策:接受丢失,删掉灰显逻辑与 `ignored` 字段,不自己实现 `.gitignore` 解析(KISS)。**
+
+依据:
+
+- V2 的 `fs/list`(`packages/schema` 的 `FileSystemEntry`)只有 `{path, type}`,**没有任何 ignored 标记**;
+ 且实测**原样列出** gitignore 命中的条目(`.gitignore`、`secret.log`、`ignored-dir/`、`.git/` 都在列表里)。
+- 前端自己解析 `.gitignore` 的代价:需要实现完整规则集(嵌套 `.gitignore`、取反 `!`、`**` 通配、目录继承、
+ `.git/info/exclude`、全局 excludesfile…),远高于「灰显」这点收益,而且**做不对反而误导用户**。
+- 落地:`FileNode` 改成**就地定义的 V2 形状**(去掉 `ignored`),`FileExplorer` 去掉 `${node.ignored ? 'opacity-50' : ''}`,
+ `api/file.ts` 去掉 `ignored: false`。**代码里保留了说明注释**,避免后来者以为是漏了。
+
+> 同批还就地重定义了 `FileContent`(去掉 V1 的 `diff`/`patch`)与 `FileStatusItem`,
+> 并删掉了只服务已移除搜索功能的 `Symbol` / `SymbolRange` / `SymbolLocation` / `TextSearchMatch` / `FindTextResponse`。
+
+---
+
+## 2. `removedInV2` 清零证明 + 13 处 API 函数的最终去向
+
+### 2.1 清零证明(命令 + 实测输出)
+
+```bash
+# 生产代码(排除所有 .test. 文件)—— 这是「清零」的正式口径
+$ grep -rn "removedInV2(" src/ --include='*.ts' --include='*.tsx' | grep -v '\.test\.' | wc -l
+0
+$ grep -rn "notMigratedYet(" src/ --include='*.ts' --include='*.tsx' | grep -v '\.test\.' | wc -l
+0
+$ ls src/api/notMigrated.ts
+ls: cannot access 'src/api/notMigrated.ts': No such file or directory
+```
+
+⚠️ **口径说明(避免误读)**:如果**不带** `grep -v '\.test\.'`,会命中 **2 处** ——
+它们全部来自**裁撤守卫测试自己的断言字符串**(`expect(findInSource('removedInV2('))`):
+
+```
+src/features/phase3b.removal.test.ts:110: it('`removedInV2(` 全仓库 0 命中(定义与调用都清掉)', () => {
+src/features/phase3b.removal.test.ts:111: expect(findInSource('removedInV2(')).toEqual([])
+(notMigratedYet( 同理,另 2 处)
+```
+
+该守卫在扫描时会**主动排除测试文件自身**,所以「0 命中」的断言是真通过,不是自欺。
+
+**并且做成了自动化断言**:`src/features/phase3b.removal.test.ts`(**21 个用例**)
+(下面说的「零命中」都是**排除测试文件自身**后的口径)
+在**全部源码(先剥注释)** 里断言 `removedInV2(` / `notMigratedYet(` / 各类被删标识符**零命中**,
+并额外断言「该保留的还在」(`QuestionRenderer` / `TodoRenderer` 必须存在)。
+→ 以后有人把它们加回来,**测试会红**。
+
+> ⚠️ 为什么「先剥注释」是必须的:3b 的代码里到处是「⛔ 阶段 3b 已删除 xxx」这类说明注释,
+> 它们**故意提到**被删的名字(否则后来者看不懂为什么这里空了一块)。
+> 该测试还带**扫描器自检**(3 个用例),防止「断言恒真」的空跑。
+
+### 2.2 13 处 `removedInV2()` 的最终去向
+
+图例:**删函数** = 函数与定义整体删除;**删文件** = 文件整体删除。
+
+| # | 原标记(阶段 3a) | 最终去向 | 备注 |
+| --: | ---------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| 1 | `session.ts::shareSession` | **删函数** | V2 删分享端点;`session_share` 表 0 行、`session_v2.share_url` 全 NULL(实测) |
+| 2 | `session.ts::unshareSession` | **删函数** | 同上 |
+| 3 | `session.ts::getSessionTodos` + `ApiTodo` | **删函数** | V2 无待办端点/事件/工具 |
+| 4 | `session.ts::updateSession` 的 `time.archived` 分支 | **删分支 + 删入参** | `params.time` 整个移除 → 现在传 `time` **连编译都过不了**(比运行时抛错更早拦住) |
+| 5 | `worktree.ts::resetWorktree` + `WorktreeResetInput` | **删函数 + 删类型** | V2 删端点且无替代能力;UI 按钮 + 确认弹窗一并删 |
+| 6 | `tool.ts::getToolIds` | **删文件**(`api/tool.ts` + `types/api/tool.ts`) | 零调用点;连带删 `types/api/index.ts` 的转发 |
+| 7 | `tool.ts::getTools` | **删文件**(同上) | 同上 |
+| 8 | `lsp.ts::getLspStatus` | **删文件**(`api/lsp.ts`) | 零调用点、零 UI |
+| 9 | `lsp.ts::getFormatterStatus` | **删文件**(同上) | 同上 |
+| 10 | `global.ts::disposeGlobal` | **删函数** | V2 无等价物(`debug/location` 只能驱逐单个 location) |
+| 11 | `config.ts::updateConfig` | **删函数** | V2 的 `/api/config` 只有 GET |
+| 12 | `config.ts::updateGlobalConfig` 的「含其它字段」分支 | **改为 `throw new Error(...)`** | 从「迁移占位符」变成**前端防线**:含其它字段时**前端就报错、且一个字段都不写**(③ 有冒烟实证) |
+| 13 | `client.ts::initGitProject` | **删函数** | V2 改为 location 首次使用时自动初始化 git;UI 按钮一并删 |
+
+**连带删除的「不再被引用的函数」**(任务 A 要求):
+
+| 函数 | 为什么变成零引用 |
+| ------------------------------- | -------------------------------------------------------------------- |
+| `config.ts::getProviderConfigs` | 唯一消费方是配置编辑器的 provider 下拉;编辑器降级后那些组件整体删除 |
+| `notMigrated.ts`(整个文件) | 两个占位符(`notMigratedYet` / `removedInV2`)都已退役 |
+
+> **API 层另外做的一次「零消费点」扫描**(26 个函数),结论是**其余 24 个都是「有意保留」**,
+> 不属死代码:`getAgents` / `getDefaultModels` / `updateProject` / `getLocation` / `getPtySession` /
+> `completeMcpAuth` / `removeSavedPermission` / `commitRevert` / `coalesceEvents` / `disconnectSSE` …
+> —— 3a 报告 §6.15 已逐个说明「保留(API 完整性 + 有测试)」,本阶段**没有**顺手删它们(避免越界)。
+
+---
+
+## 3. 任务 B:配置编辑器降级(已按用户拍板落地)
+
+### 3.1 降级后的形态
+
+设置页「配置」标签 → 一句摘要 + **「打开配置查看器」**按钮 → 弹窗(`min(97vw,880px)` × `min(90vh,820px)`):
+
+| 区块 | 内容 | 按钮 |
+| --------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------ |
+| 头部 | 标题「配置查看器」 | 关闭 |
+| **说明 1**(警告底) | 为什么其它字段改不了 | — |
+| **说明 2**(浅底) | 数据来源是**有损视图** | — |
+| **shell 区块**(accent 边框 +「可编辑」徽章) | `