---
url: /wave-agent/specs/remote/remote-web.md
description: 远控页这一端：作为第五种宿主怎么接上本机、怎么保持不失真，页面上能真正执行的动作（审批、宿主命令、`/btw`、看原图、往上翻更早），以及这一页上的会话
---

# 功能规格说明：Web 端适配

**创建日期**：2026-10-13（2026-10-30 从 `ui/remote-control.md` 拆出）

本文只讲 **Web 端这一页**：它作为第五种宿主怎么接上本机、怎么保持不失真，页面上能真正执行哪些动作（审批、宿主命令、`/btw`、看原图、往上翻更早），以及这一页上的会话（发起、列表与删除、前台 CLI 会话被接管）。触控设备的尺寸与字号见 `remote-mobile.md`，装到主屏与锁屏推送见 `remote-pwa.md`。

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

### 用户故事：Web 页作为第五种宿主（页面产物，优先级：P1）

作为离开电脑的用户，我希望用手机扫一下二维码（或在任意一台设备的浏览器里直接打开这个地址）就能看到正在跑的会话并完成审批，以便不必回到电脑前推进任务。

**为什么是 P1**：页面本身不是目标，审批闭环才是；但它决定了 Web 页能不能用——页面产物是「同一份 webview + 一层浏览器 shim」这一复用策略的交付物，其边界（单会话、只拉尾页、不支持的命令要明说）需要在规格里钉住。

**独立测试**：从企业端部署的页面用带通道键的地址打开，能列出本机会话并显示最后一段消息与待审批请求；在页面上批准后本机继续执行；关掉页面再打开，待审批请求仍在；断开链路之后让本机进入一次待审批，重连时该审批卡（带选项）出现在页面上；页面上 `ExitPlanMode` 的卡片里能读到**计划全文**；消息里的文本路径与图片路径点开都能看到内容（两条按需读取通道，见后面两个同名故事；Write 块的内容预览照旧）；页面上不出现「看着能点、点了没反应」的入口；**重命名会话 / 压缩上下文 / 斜杠命令菜单**在页面上真的能用；电脑端会话处在非 `default` 档位时，页面上显示的权限档与它一致（不是「修改前询问」）；电脑端这条会话挂着待办清单、正在自动压缩、或有排队未发的消息时，页面（冷启动或重连后）看到的是这些状态的**当前值**——待办清单不为空、「正在压缩对话」指示在场、待发清单不缺条目；电脑端构建比页面旧（`getSessionInfo` 回包缺新字段）时，页面保持现值、不把清单写空。

移动端形态（整屏、字号档、触控目标）见 `remote-mobile.md` 的「触控设备上的尺寸与字号」故事。

**验收场景**：

1. **假设** 用带通道键的地址打开 Web 页，**则** 页面先把「去哪、用什么说」两件补全：中继地址由页面**同源**推导（页面就部署在中继同一个域下的 `/remote/`，`?ws=` 只留给本地调试覆盖），通道键直接取地址里的 `host=`；然后连上订阅地址、以 `{"type":"auth","credential":{"type":"sso","token":"<登录 JWT>"},"version":"…"}` 完成握手（拿到 `role:"subscriber"`）。**这一份 JWT 就是企业端登录态本身**（页面与登录页同源，读的是同一份已存下的令牌），页面不引入第二套账号、也没有单独的登录步骤。**地址里那个通道键不是凭证**：中继在订阅握手时校验「这行 host 属于当前账号、且账号是该企业的活跃成员」，别人的通道键换不来任何东西——所以它可以坦然印在二维码上。

2. **假设** 页面已连上，**则** 首屏先列**本进程内此刻持有的活会话**（主数据源是 `listDaemonSessions`，每条给 `sessionId` / `sessionCwd` / `title` / `lastActiveAt` / `isLoading`，按 `lastActiveAt` 倒序；title 与 lastActiveAt 由机器侧从该会话的显示消息派生），未指定就选**最近活跃**的一条；然后只拉它的**最后一页**（默认 60 条，且整页序列化后不超过单帧字节预算）与待审批请求，并重读一次会话状态（场景 23），以一份完整初始状态回答 webview 的就绪消息（用户、会话、消息、待确认请求、会话状态一次给全）。磁盘上的历史会话不进这个列表——它们是 `/resume` 这条独立入口的取数（见「手机端也能接着磁盘上的历史会话聊」故事），首屏与「会话」整页列表仍只报进程内活会话，见边界情况。页大小按**预算**定而不是按「条数看起来够用」定：均值 5.7 KB/条时 100 条 ≈ 0.57 MB **已经越过** 512 KiB 预算（常态被裁掉十来条），60 条 ≈ 0.34 MB 落在预算之内，所以常态是**整页整页**地到（见连接生命周期故事的场景 10 / 11）。

3. **假设** 中继发来「列表变了」的信号、或页面重新连上，**则** 页面只按本地最后一条消息 id 走 `since` 补差；补不上时（锚点已消失）回落为重拉最后一页。

4. **假设** 本机触发了一次需要审批的工具调用，**则** 页面弹出审批卡（工具名、参数等）；**`ExitPlanMode` 的完整计划内容就渲染在这张卡里**（markdown、独立滚动区、不截断）—— 桌面端把它放在右栏计划面板、插件端由宿主另开一个计划标签页，**页面这两处都没有**，所以页面这一处**刻意**用卡片承载（这是与其余各端不同的例外，见 `core/plan-mode.md` 的同名故事与其场景 11）；用户批准或拒绝后以 `permissionResponse` 回执给本机（关闭卡片时的拒绝语义见场景 24）；关掉页面再打开，该待审批请求仍在（**含计划正文**：正文随权限请求的 context 一起来到页面，冷启动快照与断线补读两条路都带）。

5. **假设** 页面渲染的是与桌面 / IDE 相同的那份聊天 UI，**则** 它是**一次看一条**的单会话视图（没有 pane、没有工作目录管理），会话之间用顶栏的「会话」入口切换（见第 10 条）；顶栏**不提供**「历史对话」入口（切换收在「会话」面板），顶栏的「新建对话」图标则**重绑**为「发起新会话」的入口（插件端同位同图标，见「Web 端也能发起新会话」故事场景 1；插件端那个 `clearChat`——开一条继承旧目录的新会话、不回传新 id——语义不在页面上提供）。并且**不支持**的命令（打开外链、上传文件、单独登录/登出等）必须给出明确原因或显式提示，绝不能静默无响应把用户晾在一个灰着的界面上。

6. **假设** 用户点「远程控制」项里的「在浏览器中打开」，**则** Web 页把它列为不支持（页面自己就运行在浏览器里），不产生未处理的动作。

7. **假设** 用户在 Web 页上看或改权限模式，**则** 口径是钉死的：**Web 端默认就提供四档**（含「跳过权限确认」），不设「会话创建时是否已授权」那道门；**默认模式保守** —— 会话初始仍是 `default`，**连接这个动作本身不改模式**；**「不自动继承」指的是连接动作不改模式**，不是「Web 页看不到当前模式」（同一个会话只有一份模式状态）。**模式是状态、不是过渡**：页面必须显示这条会话**当前**的模式，所以冷启动 / 页面重挂（刷新）/ 从列表切到这条会话 / 断线重连 / 回前台这几条路都要把它**重读一次**（`getSessionInfo` 是载体，与适配器推的 `permissionModeChange` 同源；同一次读还带待办清单 / 压缩中 / 命令运行 / 待发队列，见场景 23）。宿主推的 `permissionModeChange` 是**转换通知**——只在页面挂着的那段时间里发生的那次切换才会到——所以只听它的页面会停在自己 reducer 的初始值 `default`：那是这条会话**并没有**的一个权限，用户看到的是一个比实际更保守、却不是实际生效的档位（场景 14 的忙闲、场景 17 的待审批、场景 23 的其余会话状态是同一类缺口）。

8. **假设** 页面手上的**访问令牌**不可用——本地读不到 `token`（这台设备没登录过）、本地令牌已过期（同源存储的 `expiresAt` 已到）、或中继在握手时回了 `invalid_token`——**但**企业端的刷新令牌（同源存储的 `refreshToken`）仍在，**则** 页面**先就地静默续期**：对同源的 `POST /api/auth/refresh` 发一次请求，成功后把新凭证写回存储、用**新令牌**继续原来的连接流程（握手与后续请求都用新的那一个），**全程不发生任何导航**。续期在页内**单飞**（同一时刻只发一个请求，其余调用复用同一份结果）且**每次会话只自动尝试一次**；本地没有 `expiresAt` 时按「令牌仍可用」处理、直接连接，过期与否交给握手说话。这是「每小时被赶去登录一次」与「一个用户看不见的请求」之间的取舍，见边界情况那条耦合清单。

9. **假设** 静默续期没拿到可用凭证——没有刷新令牌、刷新令牌也已失效、响应不合形状（缺 `token`），或请求本身发不出去（网络异常、存储不可用）——**则** 页面才走原来那条路：**自动导航到企业端登录页**，并把**当前页面地址（原样带着 `host=` 等参数）**作为回跳目标一并带上；用户在企业端完成登录（密码与 SSO 两条路都算）后**自动回到这个地址**，页面重新读一次登录态、继续原来的连接流程。回跳目标**只接受同源站内路径**，跨站地址一律拒绝——这是这条路上唯一真实的攻击面（开放重定向）；页面只负责带参数，不解析登录结果，也不接管登录表单。这条路的其余硬约束不变：每次会话只自动跳一次、只在企业端域名上跳（本地调试页照旧只提示）。

10. **假设** 用户点开顶栏的「会话」入口，**则** 页面列出**本进程内此刻持有的活会话**（跨工作目录：每条带自己的 `sessionCwd`，它就是这条会话的身份），当前正在看的那条有选中态；选中另一条后，页面只切**自己**的视图 —— 改本地 `sessionId`、清空消息列表、重拉该会话的尾页、它的待审批请求与会话状态（场景 7 / 23）—— 并且**不调用 `restoreSession`**：电脑端那条会话本身、以及电脑端用户正看着的界面都不受任何影响。**切换只改手机视图，这是「控制面」的含义**。这条切换也**不套用 IDE 那条忙闲守卫**（当前会话正在生成 / 压缩中 / 有正在运行的后台任务）：守卫防的是「**原地**恢复替换当前会话」，而页面上的「恢复」只改自己这份 `sessionId` 与视图，电脑侧那条会话照旧跑着——所以页面上选中另一条会话**始终有效**，不得在忙时被静默忽略（依据与同一口径的「新建对话」入口见「Web 端也能发起新会话」故事场景 10）。

11. **假设** 页面收到一条通知，其信封 `sessionId` 与页面当前会话不同（设备级通道下这是常态：同一进程里别的会话也在产出），**则** 直接丢弃、不落进当前视图；只有信封 `sessionId` 与当前会话一致（或该通知不带 `sessionId`，如状态信号与机器信息）才处理。否则多个会话的消息与流式增量会混进同一个列表。**审批与提问没有例外**：跨会话的 `permissionRequest` 同样丢弃——2026-10-15 收严的一处（此前把它当「进程级事实、用户最不能漏」放行，代价是页面会在当前对话里弹别条对话的卡，见「待确认只在它自己的对话里弹，对话状态在会话列表标出来」故事场景 1；别处等在确认这件事改由会话列表的标记承接）。

12. **假设** 用户把消息列表滚到顶部并继续上拉，**则** 页面按同一套协议再要一页更早的内容（`loadEarlierMessages` → `earlierMessages`，与其余三端相同），接在已加载内容之前；判据是宿主回包的 `hasMore`，翻到「没有更早」为止，具体场景见「手机上往上翻更早的消息」（2026-11-06 起；此前这一侧是「顶部即终点」，那条取舍的由来见该故事的「为什么是 P1」）。

13. **假设** 页面首连或切到一条超过 `60` 条消息的会话，**则** 首屏只取最近 `60` 条（条数与 512 KiB 单帧预算谁先到停谁），并把「还有更早」随快照带给页面（`hasEarlier`）——它是上翻开关的来源，**不按条数猜**（一页被裁短不代表到底）。

14. **假设** 电脑侧这条会话**此刻正在生成**（之后任何时刻，只要不重启就成立），**则** 页面打开后输入区必须呈现**停止**入口，而不是「发送」——**不论页面是什么时候到位的**：冷启动 / 刷新、从列表切到这条会话、断线重连、中继丢帧后的重拉，都算。**依据**：手机上**没有 ESC 键**，页面上的这枚按钮是中断唯一可达的入口（「前台 CLI 会话也能被手机接管」场景 4 已把「手机上能中断」写成硬要求，缺的只是它可达）。宿主推的 `loadingChange` 是**转换通知**——只在页面挂着的那段时间里发生的那次开始/结束才会到——所以「只听通知」的页面必然漏掉它到位之前就已开始的那一轮：**那一整轮都没有停止入口**，用户只能等它跑完。真值在机器的会话注册表里（`listDaemonSessions` 的 `isLoading`，与 `loadingChange` 同源）：页面应在每次重新读取注册表后按它对齐自己呈现的忙闲，而**不是把快照里的忙闲写死成「空闲」**；重连那条路只补消息增量、不带状态，所以它也要单独重读一次注册表。

15. **假设** 用户在手机上发出一条消息（`sendMessage`），**则** 页面**不给这条请求设任何时限**，也不把「回执有没有回来」当成发送是否成功的判据：它的回执是**整个回合结束时**才回的（桥接层要 `await agent.sendMessage()` 到生成循环走完，而循环会停在一次等人作答的审批上——在手机上作答的往往正是刚发出这条消息的人），而**回合长度没有上界**——工具循环想跑多久就跑多久，几小时也行。因此：(a) **回合级方法（`sendMessage` / `compact` / `askBtw`，即 shim 的 `TURN_METHODS`）不挂超时**，而不是挂一个更大的数字：回执是一次性帧，链路一断就永久丢（两端都不重投），所以任何时限都分不清「还在跑」与「回执丢了」，唯一可能产出的结果是假的「没发出去」——用户据此重发，机器就多收一条一样的消息；(b) 用户在页面上能看到的失败**不依赖这个回执**，而且都到得很快：白名单拒绝 / 会话已不存在 / 宿主不支持是秒级错误回执，链路断是连接状态条（场景 13）的事，断连时发出的那条由发件箱在 `auth_ok` 时补发；(c) 回合真的卡住时，出路是输入区那枚**停止**入口（场景 14 的忙闲口径保证它一定在位），不是在对话里插一条红字。**依据**：桌面 / 插件端是同进程调用、根本没有这层超时，所以这条按 Web 页收口，不改共享聊天 UI；而「读接口与普通控制写仍按 20 秒」那档不受影响——它们本来就是机器立刻回答的东西（场景 19）。

16. **假设** 用户在本页触发**控制指令**（暂停本轮生成 / 切换权限档 / 压缩上下文），**则** 指令的失败**必须有回显**：这三件事的结果都会从消息流自己长出来（忙闲、模式、压缩后的列表），所以成功不需要额外提示，但失败此前被空 `catch` 吞掉——屏幕上什么都没有，而「点了暂停但没反应」在这台设备上无处追问（没有 ESC 键，也没有终端可看）。失败时页面给出可见状态（例如「暂停指令没有回执：…」），不静默。

17. **假设** 链路断过一段（手机锁屏 / 切走、信号抖动，或「电脑侧不在线」的 4001 重试期间）**且** 断开期间本机有工具调用进入待审批，**则** 页面重连成功时必须**重新读取待审批请求**（`listPendingPermissions`）并把页面上还没有的审批卡补上——只补消息增量不够。**依据**：链路断开时本机**主动丢弃**通知（`RemoteControlService` 在无订阅端时不缓冲，理由正是「手机重连会用 `getMessages({ since })` 补齐」），而那句话只对**消息**成立：待审批是一次**进程级事实**（`listPendingPermissions` 报告全部会话的请求，不限于页面正在看的那条——页面只把**属于当前会话**的那些补成卡，其余喂会话列表的「待确认」标记，见「待确认只在它自己的对话里弹，对话状态在会话列表标出来」故事），它不在消息流里，所以断线期间到达的审批在页面上**永远不出现**——列表里只剩那个工具块（工具名与图标），选项一个都渲染不出来（选项只由审批卡渲染），页面看起来像卡住了。**冷启动是另一条路**：首屏快照本就带待审批（场景 2），所以「杀掉 App 重开就有选项」——这正是用户 2026-10-14 报的现象，也是它区别于「页面卡住」的判据。补读与场景 14 同源（重连只补增量、状态要单独重读），并必须**按请求 id 去重**：同一条审批可能先由通知送达、又在这次重读里出现，而页面的 reducer 是**追加**——不去重就会为一次审批出两张卡。用户在页面上作答过的那条若在机器侧**仍待审批**（作答那一帧在断线时丢了，`permissionResponse` 是无回执的通知），下一次重读应当**再次出现**：它确实还没有被处理。

18. **假设** 页面从后台回到前台（`visibilitychange` 变成 visible，或从 bfcache 恢复时的 `pageshow`），**则** 页面必须**重新对齐一次**：补消息增量、重读忙闲（场景 14）、重读待审批（场景 17）、重读权限模式（场景 7）与待办清单 / 压缩中 / 命令运行 / 待发队列（场景 23）——即把「重连成功」那几件事再做一遍。**依据**：页面在后台被冻结期间不会收到任何通知（iOS 直接挂起整个页面，帧也不排队），所以睡过去的那段时间与断线等价：醒来时消息停在旧位置、忙闲可能早已翻转、睡着的过程中到达的审批一张都没有；而这一页此前**没有任何回前台动作**（`visibilitychange` 与 `pageshow` 都不监听），从后台回来是一张静止的旧画面。这四处重读只在链路可用（已完成握手且 socket 是 OPEN）时发起：链路不可用时这些请求只会压在发件箱里、最后以一条**误报**的超时收场，而重连成功那条路（场景 17）本来就会补做同样的事。（场景 23 的状态同走这几处重读，回前台对它们同样生效。）

19. **假设** 本机在计划模式下请求退出（`ExitPlanMode` 待审批），**则** 页面这张审批卡里渲染**计划全文**（markdown、可滚动的独立区域、不截断），并且**两条到达路径都带正文**——实时通知与冷启动/断线补读取回的卡片没有区别（正文随权限请求的 context 一起来，页面不额外去取）；用户只看到卡片就能决定批准还是拒绝。**依据**：计划内容是批准流程的核心信息，而页面**没有**桌面端右栏面板、也**没有** 插件端那个由宿主另开的计划标签页——不放进卡片，手机上就只能盲批准。

20. **假设** 用户在页面上遇到**文件路径**（消息正文里的路径、工具块头部的路径、Write 工具块里的「打开预览」），**则** 点开它**就有内容可看**：文本路径按需读那份文件并渲染（见「点消息里的文件路径看内容」故事），图片路径按需取那张图（见「点消息里的图片路径看原图」故事）。**两条都不落进 `openFile`**，所以「页面上没有编辑器」这个理由不再成立——它挡住的是「开编辑器」，不是「读得到那份文件」：宿主读得到、页面显示得了，缺的只是一条按需读取的通道。**Write 块的内容预览仍然保留**：那是消息里现成的内容，不需要任何请求。仍不可点的是**已发送粘贴图的 `[imageN]` 标签**（字节只留在宿主那台机器上，下发帧已剥掉，见「白名单」故事场景 10）与**其余工具的原始输出**（逐行纯文本，不走链接化）。**依据**：同一份 webview 在插件端把这些路径点成 `openFile`（宿主开编辑器），页面点了只会落进未实现分支、连回执都没有——所以这一端换的是**动作**（就地看内容），不是把 `openFile` 接出来。

21. **假设** 用户浏览页面上的各入口，**则** 凡在页面上**必然无效**的入口都**不渲染**（不出现「看着能点、点了没反应」）。2026-10-17 的第二批解禁之后，**仍属此列**的是：**设置页及其各导航**（`/config` `/plugin` `/mcp` `/agents` `/skills` `/hooks`）与插件市场、**登录与退出登录**、**上传文件**、**已发送图片的标签**（粘贴图的字节只留在宿主那台机器上——下发远控页的帧会把图片字节剥掉，见白名单故事的场景 10；标签本身照旧显示、顺序与正文的 `[imageN]` 一一对应；输入框里还没发送的图片不受影响，那些字节就在浏览器里，照常可点开预览）、**顶栏「历史对话」**（磁盘历史会话的入口只有 `/resume`，见「手机端也能接着磁盘上的历史会话聊」故事）、`/plan`、`/reload-plugins`、`/remote-control`（在控制页上是自我指涉）。**已解禁、不再是「不渲染」**：回滚（`/rewind` 与消息上的回滚按钮）、`/clear`、`/resume`（只读枚举 + 一条新建会话路径，见「手机端也能接着磁盘上的历史会话聊」故事）、底栏模型选择器、`/tasks` 与 `/workflows`、排队消息的三个动作、`@` 文件建议、历史提示词——见下面的「页面能执行的那批宿主命令」故事；**点路径看内容**不在这一列——文本走按需读、图片走按需取图，两条都不需要编辑器，见前面两个同名故事；`/btw` 同样已解禁（它不属于「不渲染」那一列：面板长在页面自己的输入区上方，缺的只是转发），见「Web 页也能用 `/btw` 旁路提问」故事。toast 及其动作按钮**不需要守卫**：页面**根本收不到** `showToast` —— shim 不转发这条命令，弹窗与按钮都没有出现的路径。**企业控制台与帮助文档例外**：它们改成**真链接**（浏览器原生打开，不经宿主），所以保留且可用。**依据**：页面渲染的是 IDE 那份 UI，不做守卫就会把一批宿主专属入口带出来；留在清单里的这些，背后要么是宿主的**界面**能力（开编辑器、开设置页、装插件、登录流程），要么只有宿主侧才有意义（磁盘上的历史会话），而不是「方法还没接」——那条分界正是第二批解禁的判据。

22. **假设** 用户在页面上**重命名会话**、触发**压缩上下文**、或打开**斜杠命令菜单**，**则** 这三件事在页面上**真的能用**（远端的白名单放行 `renameSession` / `compact` / `getSlashCommands`，宿主侧各自实现），而不是留一条「未实现」的提示。**弹窗里的清单也跟着补齐**：宿主本地命令（`/btw` `/clear` `/resume` `/rewind` `/tasks` `/workflows` `/status` `/compact`）由同一次回包带回，页面上没有的能力则不在其中——过滤在服务端按「入口 = 远控页」做，页面不存名单（见 `ui/slash-commands.md` 的「弹窗里的宿主命令只有一份表」故事）。**`/resume` 是这张表第一次出现「入口一致、宿主再收窄」**：同一张表给「远控页」这一个入口的集合，由**前台 CLI 宿主**在回包前删掉这一条——它只有一条会话槽位，恢复磁盘会话等于把用户终端里正看着的那条换掉（见「前台 CLI 会话也能被手机接管」场景 9）。页面照旧不存名单，收窄发生在宿主侧。**依据**：这三条与页面已有能力同源（都作用于当前会话、不需要编辑器或文件系统），是「先按隐藏处理」还是「补成能用」的分界；后台任务与工作流不在此列（它们要的是宿主的进程与输出流，本轮仍按场景 21 隐藏）。

23. **假设** 页面在五条重新对齐路径中的任何一条上到位——冷启动 / 从列表切到这条会话 / 断线重连 / 页面重挂（刷新）/ 回前台，**则** 页面上由**会话状态**驱动的呈现都按**当前值**对齐，而不是停在 reducer 初值或只等下一次过渡：**待办清单**（`TaskCreate` / `TaskUpdate` 的任务列表，`TaskList` 挂在输入区上方——它与场景 21 隐藏的 `/tasks` **后台任务弹窗**不是一回事，后者是 `backgroundTasks`）、**压缩中**（「正在压缩对话」指示；压缩不只会由页面自己发起——自动压缩在每次 API 请求前由机器侧触发，页面根本不在场）、**命令运行中**、**待发队列**（场景 21 已把它定为只读清单——只读不等于可以显示成空）与**上下文占用**（输入区的百分比环）。**依据**：与场景 7 / 14 / 17 同一族——首屏快照是唯一一次无过渡的初始化，没写进它的字段就只能靠后续过渡补；「页面到位之前就是那个值」「断线期间变了」「重挂后回到初值」三种情况全都照不到。载体是 `getSessionInfo` 扩的四个字段（`tasks` / `isCompacting` / `isCommandRunning` / `queuedMessages`）：该方法已过远端白名单、两个宿主都已实现，扩字段是增量改动，不动白名单；上下文占用**不新增读路径**——`getMessages` 回包本就带 `contextUsagePercent`，每次（重）拉消息顺手对齐。**版本错配的容忍**：页面比机器新是常态（机器在跑哪份构建，页面管不到）——回包里缺哪个字段就**不写**哪个字段（保持现值），绝不把 `undefined` 落成空清单或「空闲」。

24. **假设** 用户在页面上关掉一张确认卡（提问弹窗右上角的关闭键、或任意确认卡的 Esc / 关闭），**且**没有填写反馈，**则** 页面发出的拒绝**同时意味着本轮在机器侧被中止**——与桌面端 / VS Code / JetBrains 关掉同一张卡的结果完全相同：会话回到空闲，模型不会带着「用户拒绝了操作」这条结果继续往下做。**依据**：Claude Code 的取消（Esc / Ctrl+C / 无反馈关闭）走 `cancelAndAbort` 直接 `abortController.abort()` 结束本轮，带 feedback 的拒绝才把结果交回模型；其 VS Code 插件把 `interrupt: true` 放进取消帧、由 CLI 侧执行中断，插件自己不动手。**要收口的正是这处偏差**：此前远控链路只把 `permissionResponse {behavior:"deny"}` 转发给宿主，两个宿主（`RemoteControlCliHost` / `AgentBridge`）都只 resolve、不中断，于是手机上关掉提问弹窗后模型继续编、桌面端关掉就停。判据落在宿主侧（看这条拒绝带不带 `message`：不带 = 关闭 = 中止；带 = 用户给了反馈 = 只拒该工具），页面不多发一条消息、也不新增协议方法。**带反馈的拒绝行为不变**（只拒绝这一次工具调用，模型据反馈继续或再次提问）。

***

### 用户故事：首屏读失败能自己恢复——有限重试、回前台重跑（优先级：P1）

作为页面停在空白上的用户，我希望「读不到电脑侧的会话列表」这类失败能自己再试，而不是只能杀页面重进。

**为什么是这个优先级**：这条读是**首屏的开关**——页面要先知道这台机器有哪些会话、显示哪一条，才能画消息流与待审批；它失败时页面既没有会话也没有内容。而它今天**没有任何恢复路径**：失败只把原因画在状态条上，之后的每一次对齐（回前台、页面重挂、断线重连）走的都是增量补差，没读到的那份列表不会被重读，用户唯一的出路是重载页面。

**独立测试**：断网打开页面，超时后出现「读不到电脑侧的会话列表：…」并按节拍自动重试；恢复网络后重试成功、首屏正常上屏、状态条消失；三次用完仍失败时状态条只剩原因和关闭键，切成后台再回前台页面重跑整份快照；对一台不认识该方法的机器（回 `-32601`）一次都不重试。

**验收场景**：

1. **假设** 冷启动那一次列表读失败（超时、中继丢帧、电脑侧暂时不回话），**则** 页面按固定节拍自动重试（2s / 4s / 8s，共三次），期间状态条如实说明原因；任意一次成功就照常上屏并把状态条清掉。
2. **假设** 三次都用完仍失败，**则** 停止自动重试，状态条上只留原因和关闭键——**没有**「重试」按钮（2026-10-29 移除，见「状态只在一条流内状态条上播报」场景 4）。恢复靠场景 4 的三条路径：回前台、页面重挂、断线重连。
3. **假设** 失败是**永久**的（`-32601`：这台机器的远端白名单里没有这个方法；或宿主明说「这台机器的会话不支持」），**则** 一次都不自动重试，只把原因留在状态条上。**依据**：重试改变不了「这台机器不会答」，只会把真实原因埋在不停的重试下面。这与「断线自愈」的无限退避不矛盾——那里等的是「机器还没上线」，是有意义的等待。
4. **假设** 首屏从没成功过（列表读一直没成功），**则** 回前台、页面重挂、断线重连这三条既有对齐路径都**重跑整份快照**，而不是走增量补差。判据是「首屏是否成功过」这个**状态**，不是「刚刚失败过」这个事件。
5. **假设** 首屏已经成功过，**则** 这三条路径恢复成今天的增量行为（补新消息、重读忙闲 / 待审批 / 权限档），不做整份重拉。**依据**：整份重拉要占手机带宽与中继缓冲，而已经上屏的页面只需要补齐差额。

**代价照实说**：一次失败最多多打三次读，每次在电脑侧都是一次 `listDaemonSessions`（多会话进程里每条会话都要做一次 fs 推导）；节拍取短值是因为失败多半是瞬时，但要接受「电脑侧一直不回话时，页面会多问三次」。

***

### 用户故事：点消息里的图片路径看原图（优先级：P1）

作为用手机看 AI 真机验证结果的用户，我希望点消息里出现的截图路径就能看到那张图，以便不必回到电脑前确认效果。

**为什么是 P1**：这是页面上唯一一个「界面缺口」与「能力缺口」不等价的入口。场景 23 / 24 把「点路径打开本地文件」整条关掉，判据是「页面上没有编辑器可以打开它」——但**看图不需要编辑器**：宿主读得到那个文件，页面也显示得了图片，缺的只是一条按需取图的通道。而真机验证是 AI 会话里最常见的截图场景之一（截图落在宿主机器的一个绝对路径上，正文里只出现路径），手机上只能看到路径、看不到效果，这个场景就只能等回到电脑前。

**独立测试**：在远控页上点一条消息正文里以图片扩展名结尾的绝对路径（如 `/tmp/shot.png`），弹层里出现该图片；同一段文本里的 `.ts` / `.log` 路径仍是纯文本、不可点；点一张远超单帧预算的大图仍能看到（宿主压到预算内，必要时标注已缩小）；把一张文本文件改名为 `.png` 后点它，得到「不是图片」的可见说明而不是乱码；宿主是不认识这条方法的旧构建时，得到可见的「这台电脑还不支持」而不是一直转圈。

**验收场景**：

1. **假设** 消息正文、行内代码或工具块头的路径**既是绝对路径、扩展名又落在图片白名单内**（`png` / `jpg` / `jpeg` / `gif` / `webp`），**当** 用户点它时，**则** 页面向宿主请求这张图并用全屏覆盖层显示。**这个入口只对图片扩展名开放**：远控页的 `linkFilePaths`（正文 / 行内代码 / 围栏代码块 / bash 输出四条通道的路径链接化）**仍然是关闭的**，场景 23 对它继续成立——打开的是另一个更窄的开关，而不是把「能打开本地文件」整条解禁。白名单取的是 `readTool` 的 `SUPPORTED_IMAGE_EXTENSIONS`（agent 的 Read 工具能看到的就这五种），**不是**桌面文件面板那八个（后者多 `svg` / `bmp` / `ico`）：`svg` 是 XML、既不是位图也没有「压到字节预算内」的同一套语义，先不放进这个对外网的入口；`bmp` / `ico` 在真机截图里不出现。

2. **假设** 页面要显示这张图，**则** 宿主先把它压到**单帧字节预算**（512 KiB，见白名单故事场景 8 与「连接生命周期」场景 12）以内再回包，压缩顺序是\*\*「PNG palette（无损，UI 截图最优）→ 降采样 → JPEG 降质量」**，走的是 `imageRewrite.ts` 已有的那套阶梯（`SCALES` × `JPEG_QUALITIES`），不为这条通道另写一套；压到最小档仍超预算时回**更小的缩略图并标注「已缩小」\*\*，而不是把超预算的帧发出去。**格式次序不是随手定的**：这类图是 UI 截图（大片纯色 + 文字），Guacamole 官方文档与 GUACAMOLE-668 都记着同一条经验——非照片画面用有损 JPEG 会因文字振铃反而更差、主观响应也更慢，所以 `PNG palette` 在前、JPEG 只在 PNG 压不下去时兜底。

3. **假设** 宿主收到这条请求，**则** 「这是不是图片」由**文件内容的真实类型判断，不是只看扩展名**：扩展名只是页面侧「给不给点击入口」的判据，宿主侧还要按 magic 判一次真的图片 mime，判不过就回「不是图片」的可见说明、不返回任何内容。**依据**：路径来自对话内容而不是用户手输，但一条 `/tmp/x.png` 仍可能是指向别处的符号链接——只信扩展名等于把「读宿主任意文件」开了一个形状很窄但真实的口子。

4. **假设** 用户在页面上点了一张图，**则** 弹层沿用页面的图片预览形态（全屏覆盖层、点遮罩或右上按钮关闭、Esc 关闭），与输入框里未发送图片的预览**同形**；关闭后回到消息流原来的位置，不丢滚动位置。

5. **假设** 页面手里那份构建比电脑侧新、宿主不认识这条按需取图方法，**则** 页面给出可见的「这台电脑还不支持」文案，图片路径退回**纯文本不可点**的形态，不静默无响应、也不留一个一直转圈的弹层。这是本规格已有的「缺哪个能力就哪条功能不可用」口径（见「连接生命周期」场景 3）。

6. **假设** 用户点的是**已发送粘贴图**的 `[imageN]` 标签（那批字节留在宿主机器上、下发帧已剥掉，见白名单故事场景 10），**则** 这条通道**不覆盖**它，标签仍按场景 24 渲染成不可点。两者数据来源不同：图片路径是「宿主上有个文件，按路径取」，粘贴图是「消息块里曾经有字节，现已剥离」——把它们并成一条通道会给后者凭空造一个反查路径，本轮不做。

7. **假设** 这条会话由**前台 CLI 进程**服务（而不是 daemon 或 GUI 子进程），**则** 按需取图**同样可用**。**依据**：本规格已经踩过一次「白名单放行 ≠ 宿主实现」的坑（见「Web 页也能发起新会话」那条记录：四条新会话方法只落到 `AgentBridge`、`RemoteControlCliHost` 一条没实现，页面在那类会话上必报错）——所以这条能力在规格里就把两个宿主一起写上。

8. **假设** 取图失败（文件已不存在、读盘报错、不是图片、超出大小上限），**则** 页面上给出可见说明并收尾，**不静默**——与场景 19「控制指令的失败必须有回显」同一条判据，手机上没有终端可以追问。这条请求按**普通请求档**（20 秒）计时，不进 `TURN_METHODS` 那一类（那一类不设时限，因为它的回执要等整轮跑完）：取图不产生 LLM 回合，超时基本意味着图太大而非「还在跑」，此时应回缩略图或报错，而不是让用户对着转圈等。

***

### 用户故事：点消息里的文件路径看内容（优先级：P1）

作为用手机看 AI 干活结果的用户，我希望点消息里出现的文件路径就能读到那份文件的内容，以便不必回到电脑前确认它到底写了什么、日志到底报了什么。

**为什么是 P1**：图片那条通道落地之后，「页面上没有编辑器」这条禁令就只剩一半成立——它挡住的是 `openFile`，不是「读得到那份文件」。而手机上最高频的其实不是截图，是「改动在 `src/foo.ts`」「构建输出在 `/tmp/build.log`」这类**文本**路径：AI 说改好了，人要读的是那几行；测试挂了，人要读的是那段报错。这条通道与取图共用同一形态（页面发 → 宿主读 → 页面显示），增量只在两处——宿主怎么判「这是文本」、入口放到哪一层。不做的代价很具体：手机上永远只看到一串路径字符串。

**独立测试**：在远控页点消息正文里的 `/tmp/build.log`，弹层里出现它的内容；点 Read 工具块头里那个相对路径 `src/foo.ts` 同样能打开（按会话工作根解析成绝对路径）；点一个二进制文件得到「这不是文本文件」的可见说明；点一个不存在的路径得到可见说明；点一张图片仍然弹图、不被文本通道抢走；把宿主的 `getTextPreview` 去掉（模拟旧构建）后点文本路径得到可见的「这台电脑还不支持」、路径退回纯文本；打开一份 5 MB 的日志，头部写着「只显示前 256 KiB」；打开一份带相对链接的 markdown 后点那个链接，弹层就地换成链接指向的文件（不刷新页面、不导航）；点页内锚点与外链则什么都不发。

**验收场景**：

1. **假设** 用户走到页面上的任一条**路径通道**（消息正文纯文本、行内代码、围栏代码块、Bash 输出、工具块头 Read / Write / Edit、Write 块的「打开预览」按钮），**则** 这些路径**全部**变成可点入口，点了就地看内容，**不设扩展名白名单**：判据不是「名字像不像文本」，而是「宿主读出来是不是文本」（场景 4）。这比图片那条宽得多，理由写在边界情况——**文本没有等价于「解码出来必须是位图」的那道天然闸门**，所以它不靠名字收窄，靠内容判型 + 大小上限 + 只读这三条。带 `:12` / `:12-30` 行号后缀的路径照旧按链接化时的解析剥掉后缀再发。

2. **假设** 用户点的是**相对路径**（工具块头显示的就是相对路径）或以 `~/` 开头的路径，**则** 页面按**会话工作根**（`workspaceRoot`，不是会随 Bash 里 `cd` 变化的那份 `workdir`）把它拼成绝对路径再发给宿主。**依据**：路径来自会话内容，AI 中途 `cd` 过也不该让「`src/foo.ts`」指向别处；工具块头这份显示文本本来就是宿主相对化过的，页面只有工作根这一个可信锚点。

3. **假设** 页面要显示这份内容，**则** 走一条**新方法**（`getTextPreview`），**不并进取图那条**。两条的授权判据不可互换（图靠「解码出来必须是 png/jpeg/gif/webp」，文本靠「能按 UTF-8 解出且不含 NUL」），合成一个方法会让「方法名」与「判据」错位。页面侧可以共用同一张按 `requestId` 关联的待办表与同一个超时档（形态相同），但宿主侧必须是两个实现。

4. **假设** 宿主收到请求，**则** 「这是不是文本」**由内容判**，不看扩展名：读出的字节里含 `NUL`（`0x00`）一律当二进制拒绝；按 UTF-8 解码后替换字符（`U+FFFD`）占比超过一成也当二进制拒绝。判不过就回「这不是文本文件」的可见说明、不返回任何内容。**依据**：入口既然不设扩展名白名单，判型就是这条通道唯一的实质闸门——没有它，点一个可执行文件会得到满屏乱码，点一个 500 MB 的二进制会先把宿主内存吃满。

5. **假设** 文件很大，**则** 宿主先 `stat`、超过 **256 KiB** 只读**前** 256 KiB（不 `readFile` 全量），回包里带 `truncated` 与文件真实大小，页面头部写「只显示前 256 KiB，完整文件 5.2 MB」。截断按**字节**做，落在多字节字符中间就退回最近的字符边界（宁可少几个字节，不要吐出半个字）。**256 KiB 不是帧预算的极限**（单帧 512 KiB，文本走 JSON 字符串不像 base64 膨胀 4/3，理论上能放更多）：一屏手机文本只有几 KB，再大只是让弹层渲染变慢、让弱网下的一次点击更久——取「够看」与「别拖慢」的交点。

6. **假设** 文件**大部分能解、零星解不通**（GBK 日志里混了几行、二进制与文本混排的构建输出），**则** 照常显示，解不通的部分用替换字符降级，并在头部提示「内容里可能有编码问题」——**不整个报错**。这与场景 4 是两件事：那里是「整份不像文本」（拒），这里是「是文本但编码不干净」（显示并提示）。

7. **假设** 用户在手机上点开一份内容，**则** 弹层沿用**图片预览那套全屏覆盖层**（点遮罩关闭、右上 ✕、`Esc` 关闭、关闭后回到消息流原来的滚动位置），**正文排版沿用文件面板那套**（`renderFileMarkdown`：markdown + 代码高亮 + 表格滚动容器，已有消毒）——不是裸 `<pre>`：`.md` 计划文件与代码文件在手机上按原始文本铺开等于不可读。头部一行给「文件名 + 完整路径 + 截断/编码标注」。**不新写渲染器**：远控页跑的就是同一份 webview 产物，这套渲染器本来就在包里。

8. **假设** 读取失败（文件已不存在、不是文本文件、读盘报错、超出大小上限），**则** 弹层里给出宿主那句原因并收尾，**不静默**（与图片故事场景 8 同一条判据：手机上没有终端可以追问）。链路没回音时按取图那一档计时（25 秒，比 shim 普通请求档长 5 秒，让宿主的明确拒绝先到），**不用回合级那档**——取文件不产生 LLM 回合。

9. **假设** 页面手里的构建比电脑侧新、宿主不认识 `getTextPreview`，**则** 给出可见的「这台电脑还不支持」，路径退回**纯文本不可点**（与图片故事场景 5 同一条口径：「缺哪个能力就哪条功能不可用」）。

10. **假设** 用户点的是**图片**路径，**则** 它仍然走取图那条（弹图），文本通道只接其余路径。**扩展名这里只是页面侧的呈现分流，不是授权**：宿主侧两条通道各自判各自的内容（场景 4 与图片故事场景 3）。

11. **假设** 用户点开的是一份 markdown 文档，而正文里带指向**本地文件**的链接（相对路径如 `[规格](020-mini-app/spec.md)`、`[上文](../core/a.md)`，也含绝对路径与 `file:` 形式，可带 `#锚点`、`?query` 与百分号编码），**当** 用户点该链接，**则** 按**被预览文件所在目录**解析成绝对路径，并沿用场景 1 的同一条按需读取链路**在同一个弹层里换一份**（标题、截断 / 编码标注一并换成新文件的），**且页面不得发生任何导航**：正文是渲染进页面文档的，一个没被拦截的 `<a href>` 会被浏览器按**页面自己的地址**解析，点一下就把整页换成一个静态对象请求的结果（2026-11-02 实测：本页部署在 `<站点>/remote/`，相对链接被解析成同站一个不存在的对象，整页显示静态站点的 `NoSuchKey` 报错）。外链（`http:` / `https:` / `mailto:` 等非本地文件协议）与页内 `#锚点` 不归弹层处理，保持各自默认行为。**依据**：与文件面板（`desktop-file-panel.md` 场景 22）是同一条判据，并复用同一个解析器（`utils/markdownLinkTarget.ts`）——两端对「点 markdown 里的链接」只该有一个答案。

***

### 用户故事：手机上往上翻更早的消息（优先级：P1）

作为用手机回看一条长会话的用户，我希望把列表滚到顶就继续往前翻、一直翻到这条会话的第一条（压缩之前的历史也在），以便手机上不再是「只能看最后 60 条」。

**为什么是 P1**：远控页最常见的形态就是「同一条会话跑了一整天、人离开工位后接着盯」——用户要看的是「昨天它到底是怎么改的」「那个报错之前它试了什么」，而这些恰好都落在最近 60 条之外。上翻分页在本机三端 2026-10-29 就落地了（`ui/message-rendering-system.md`「首屏一页，上翻按需取更早」），远控页当时单独留在更窄的形态上（首屏封顶 100 条、没有更早可翻），理由是这一侧独有的硬约束：中继每订阅者 1 MiB 缓冲，超限的帧**被静默丢弃**。2026-11-06 拍板补上，因为那条约束只决定**一帧能装多少**，不决定**能不能翻**——机器侧对超预算的 `getMessages` 回包本来就裁到最新的能装下的那一段（`remote-channel.md` 白名单故事场景 8），而实测约 5.7 KB/条 ⇒ 一页（60 条）就是 0.34 MB 量级，常态整页到。缺了它，一条长会话的前半段在手机上永远读不到；而 `before` 是 `getMessages` 已有的参数、白名单本来就放行 `getMessages`，页面与宿主之间只多了一个 `hasMore` 的消费方。

**独立测试**：给一条 250 条消息的会话打开远控页，断言首屏只取最近 60 条且快照带「还有更早」；把列表滚到顶，断言发出一次 `getMessages({ before, limit })`、新页接在已加载内容之前、视口停在原来那一行；再滚到顶直到收到「没有更早」后不再发请求；把锚点换成一个已被 rewind 截断的 id，断言按 `anchorLost` 换下一个已知 id 重试而不是判定到底；翻到第 3 页时让电脑侧产生一条新消息，断言已翻出的三页仍在列表里、开关没被关掉；构造一条每条都带长工具输出的会话，断言被宿主裁短后的页与已加载内容首尾相接、不漏条也不重复，且回包的 `hasMore` 被强制成 `true`。

**验收场景**：

1. **假设** 用户打开一条消息数超过首屏的会话，**则** 首屏仍是最近 `60` 条（远控页自己的取值不变），快照带上「还有更早」；这一页与其余各端一样按条数填满、可以跨过压缩边界。
2. **假设** 用户把列表滚到顶部并继续上拉，**则** 页面发一次上翻请求（锚点＝当前已加载的第一条，与其余三端同一条协议：`loadEarlierMessages` → `earlierMessages`），取回的一页**接在已加载内容之前**而不是替换，视口仍停在用户刚才看的那一行。
3. **假设** 一页已经取回、用户又滚到顶，**则** 再取一页；同一时刻只有一个请求在飞（重复滚到顶不叠加），在顶部连续翻页时晚到的旧页按 `requestId` 丢弃。
4. **假设** 回包说「没有更早了」，**则** 顶部就是这条会话的第一条，页面不再发请求、也不渲染加载占位或空白占位行。
5. **假设** 回包说「这一页之前还有更多」，**则** 上翻的开关保持打开——判据是回包的 `hasMore`（与首屏那次读同一个信号），**不按条数猜**：一页被裁短不代表到底。**且裁短本身也由机器侧改这个字段**：一页超出单帧预算时裁掉的是**最早**那几条，所以「更早的还在」必然成立，机器侧因此把回包的 `hasMore` 强制成 `true`，哪怕宿主那一页原本说的是 `false`（上翻正好取到这条会话最早那一页时就是这种情形）；不改这一处，页面会照着 `false` 把开关关掉，被裁掉的几条再也翻不回来。
6. **假设** 取更早失败（链路断开、宿主拒绝、或那一页连一条都装不进单帧预算），**则** 已渲染内容不动、开关关掉、**不新增页内提示条**（与其余三端「失败回空页、停止再要」的兜底同形），下一次滚到顶再试。
7. **假设** 锚点在转录里已经不在了（rewind / 清空 / 换会话），**则** 按回包的 `anchorLost` 换成已加载列表里的下一个 id 重试，几次之后收手——**不**当成「没有更早」，否则这段历史再也翻不出来。
8. **假设** 用户翻出了更早的几页后，电脑侧发生一次结构变化（新消息、工具块结束、重连补差、重挂），**则** 已翻出来的那几页**不消失**：宿主这一侧的影子列表把上翻取回的页一并接住，后续整表重发带的就是「已翻出来的那一整份」，滚动位置不动。
9. **假设** 用户切到另一条会话、或页面重载，**则** 回看深度丢弃，从首屏一页重新开始（与其余三端同口径：回看深度不跨会话累积）。
10. **假设** 电脑侧的构建还不认 `before`（版本错配），**则** 首屏与其余功能照常，上翻不出现或白要一次（回来的那段按 id 去重后列表不变）——降级方向是「翻不动」，绝不出现空白页或串会话。
11. **假设** 这条会话由**前台 CLI 进程**服务（而不是 daemon 或 GUI 子进程），**则** 上翻**同样可用**：`before` 必须由两个服务端宿主（多会话的 `AgentBridge` 与前台 CLI 的 `RemoteControlCliHost`）**都**透传给 `getMessages`。**依据**：白名单放行 `getMessages` 只说明通道上能问，宿主少传一个 `before` 就变成「再读一次尾部」——页面拿回同一段尾段、按 id 去重后列表不变，而 `hasMore` 仍是 true，于是每次滚到顶都白要一次、永远翻不动（正是同族故事场景 7「白名单放行 ≠ 宿主实现」的形态）。

**代价照实说**：一页能装多少最终由字节预算决定而不是条数。页大小取 60 是为了让常态落在预算内（60 条 ≈ 0.34 MB），但遇到每条都带长工具输出的会话，同一页仍可能被裁短（60 条只剩三十来条），页面侧也拿不到「这一页被裁过」的标记——用户只会感到「又得上拉一次」。换来的是一条长会话在手机上真能从尾读到头，而单帧预算、白名单与 `getMessages` 的参数形状一个字都不用改；裁短也不漏条（机器侧同时把 `hasMore` 强制成 `true`，见场景 5）。

***

### 用户故事：待确认只在它自己的对话里弹，对话状态在会话列表标出来（优先级：P1）

作为用手机盯着好几条对话的用户，我希望屏幕上弹出的提问 / 审批卡**一定属于我正在看的这条对话**，而「谁在等我确认」「谁还在跑」这两件事在「会话」列表里一眼可见，以便不会在别人的上下文里替它作答、也不会漏掉真正在等我的那条、更不必逐条点进去才知道哪条在跑。

**为什么是这个优先级**：这不是性能或观感问题，而是**归属错误**。现状里 `permissionRequest` 是通知过滤唯一的例外（shim 按信封 `sessionId` 丢掉别的会话的帧，唯独放行审批，依据是「审批是进程级事实、用户最不能漏」），而 `AskUserQuestion` 走的正是这条链路——于是别条对话的提问会落到当前对话里；卡片本身又不带来源（`confirmationOf` 只映射工具名与参数，丢掉请求自带的 `sessionId`），客户端只有「这是本对话在问」这一个解释；再叠加「弹窗只渲染队列第一条」与「`listPendingPermissions` 返回全部会话」，当前对话自己的待确认还可能被别条更早的请求挤到队列后面（用户 2026-10-15 报的就是这个：「有多个对话，非当前对话的 ask user question 弹窗有时候会出现在当前对话里」）。它本来与场景 11「跨会话通知一律丢弃」是同一条口径，开口子的理由（审批不能漏）成立，但**承接方式不该是「混进当前视图」**：标记是「哪里在等我」的正确载体，回答则必须发生在那条对话里。后半（状态标记）是同一件事的另一半——待确认一旦不再进当前视图，**列表就必须接得住**，否则「不会误答」会变成「会漏答」；而列表既然要承载状态，运行中（正在生成）是同性质、同数据源的一条，一并给出才有意义（用户 2026-10-15 追加：「把运行中也标记上，类似桌面端左侧对话列表的效果」）。

**独立测试**：两条对话同时挂着待确认（A 一条工具审批、B 一个 `AskUserQuestion`），另有一条正在生成，页面停在 A——A 弹自己的卡、B 一张都不弹；「会话」列表里 B 带「待确认」标记（A 不重复标：它本来就弹着卡）、正在生成的那条带「运行中」标记（哪怕它就是 A 也照样标）；切到 B 才弹 B 的提问卡；在电脑侧把 B 的请求作答掉，页面（无论停在哪条）下次对齐后该标记消失。

**验收场景**：

1. **假设** 页面正在看会话 A，另一条会话 B 进入待确认（`AskUserQuestion` 或工具审批），**则** B 的卡**不进当前视图**——不弹、也不进待确认队列；A 自己的待确认照旧弹。B 的等待在「会话」列表里以「待确认」标记呈现，**当前正在看的那条不标**（它本来就弹着卡，重复提醒没有增量；与桌面端侧栏「打开后消失」同款取舍）。
2. **假设** 用户从列表切到会话 B，或冷启动就落在 B，**则** 这次重读把 B **自己**的待确认带进来并弹卡——切会话是「这条对话的待确认第一次进入视图」的时刻，与冷启动同一条路，且**不新增读路径**：`listPendingPermissions` 本就报告全部会话、每条带 `sessionId`，页面按该字段筛出属于当前会话的那些弹卡，其余留着喂列表标记。
3. **假设** 同一条对话有多个待确认，**则** 卡按到达顺序弹；队列里不再混进别的对话的请求，于是「弹窗只渲染第一条」这个既有约束不再意味着「当前对话的卡可能被别条挤在看不见的位置」。
4. **假设** 某条对话的待确认在电脑侧（终端 / 桌面端）被作答、或随会话结束消失，**则** 该标记在下一次重读后消失——标记是 `listPendingPermissions` 的**当前事实**、不是历史累积，与场景 20 的补卡共用同一次读。
5. **假设** 某条对话正在生成（进程内这条会话有活 turn / 正在压缩），**则** 它在列表里带「**运行中**」标记，**含当前正在看的那条**——与桌面端「选中的对话也应显示 loading」同款（那里是拍板过的例外），因为「我正在看的这条还在不在跑」同样是列表要回答的问题（页面输入区另有一份忙闲呈现，见场景 17；两者同源、不冲突）。判据是会话注册表 `listDaemonSessions` 的 `isLoading`：与场景 17 的重读同一次，零新增读路径。
6. **假设** 一条对话同时满足「待确认」与「运行中」，**则** 只呈现**一个**标记，优先级 **待确认 > 运行中**（被审批卡住的对话不算「在跑」，它更需要被看见）——与桌面端侧栏的优先级同款。**「已完成未读」（桌面端绿点）不做**：那是宿主的本地已读跟踪（打开即清），页面每次切视图都重读消息，没有「未读」这个语义，照搬只会给出一个永远亮着或永远不亮的点。
7. **假设** 用户看到某条对话带标记，**则** 标记只**指路**：它不替用户切视图、也不自动跳到那条对话（切视图只改手机自己，进那条对话才弹卡，见场景 10 / 场景 2）。**标记本身不发系统通知**——页面内的标记不弹横幅、也不申请通知权限；「别处有东西等你」在锁屏 / 后台时的那条通道是 **Web Push**（见「锁屏也能知道」故事，那里同时规定页面可见时不弹横幅），与桌面端 SSH 后台会话同款口径（见 `desktop-app.md` 的「SSH 远程后台会话」故事）。
8. **假设** 页面在任何一条重对齐路径上到位（冷启动 / 切会话 / 断线重连 / 页面重挂 / 回前台），**则** 两类标记都按**当前值**对齐，与场景 7 / 17 / 20 / 26 同族——待确认与忙闲都是**状态**（进程级事实），不是只发生一次的过渡。数据源就是既有那两次重读（`listPendingPermissions` 与会话注册表 `listDaemonSessions`）：本故事**不加读路径、也不加通知**。

**代价照实说**：手机不再在**页面内**主动把「别处有东西在等你」推到眼前（页面不跳、标记不弹横幅），用户若既不看着页面、也不看会话列表，就等同于不知道那条对话卡着；**锁屏 / 后台那一段由 Web Push 承接**（见「锁屏也能知道」故事），但推送是进程级的、只按「哪个会话在等」送达，不含内容、也不替用户切视图。换来的是**每一次作答都发生在正确的上下文里**。这正是把「不能漏」交给列表标记 + 推送，而不是交给「跨会话也弹一张卡」的原因。

**依据（与场景 11 / 20 的关系）**：本故事把场景 11 那条口径收严到「审批与提问没有例外」，同时保留场景 20 那条**读**的口径（重连 / 回前台要重读待审批，因为它不在消息流里）——读是进程级的，弹是会话级的，两者不矛盾。

***

### 用户故事：页面能执行的那批宿主命令——回滚、清空、模型、后台任务与队列动作（优先级：P1）

作为用手机接管会话的用户，我希望页面上的**回滚、清空、切模型、看后台任务/工作流、改排队消息、`@` 文件搜索、历史提示词**都**真的能用**，而不是被藏起来或者点了没反应；其中**会动电脑侧文件**的那几个（回滚、清空）在手机上执行前必须先问一次，并且说清它到底动了什么。

**为什么是这个优先级**：第一批把「页面能承载的」与「只有宿主才有的」混成了一张清单——回滚、`/clear`、切换模型、`/tasks`、`/workflows`、队列三动作、`@` 建议、历史提示词被一起判成「宿主才持有」，理由是「宿主才持有的数据」。逐条查过之后，这个理由只对其中的**界面**类成立（设置页、插件市场、编辑器、磁盘历史会话）：上面这些的能力都在 `AgentBridge` 上早就实现了，缺的只是**白名单一行 + shim 一个 case + 页面一道远端守卫**。把它们留成「不渲染」，代价是手机上**能看不能改**：回滚要回电脑前按、模型要回电脑前切、后台任务跑了什么要回电脑前看。它不改安全模型（同一个开关、同一份白名单、同一条设备级通道），但确实把远端通道的**写**面从「发消息 / 审批 / 权限档 / 重命名 / 压缩」扩到「按快照还原文件、清空会话、写用户级设置、停后台任务」——所以每条写操作都带明确回执，代价最大的两条（回滚、清空）在页面上各加一次确认。

**独立测试**：在页面上点一条消息的回滚按钮 → 出现确认框（文案写明会撤销**电脑侧工作区**的文件改动）→ 确认后对话截断到那条消息，且电脑侧被后续回合改过的文件回到回滚点的内容；敲 `/rewind` 打开检查点列表并选中一条，走同一个确认；敲 `/clear` 出现确认框，确认后对话清空（电脑侧那条会话换绑新 sessionId）；在底栏模型选择器里选中另一个模型 → 确认后电脑侧 `~/.wave/settings.json` 的 `model` 变了；敲 `/tasks` 与 `/workflows` 能打开并看到任务与运行、能停止其中一个；排队消息出现编辑 / 立即发送 / 删除三个动作且都生效；在输入框打 `@` 出现文件建议且能选中；「+ → 历史提示词」能列出历史且能搜索。同一批操作在插件端与桌面端**行为不变**（桌面端仍不拦截 `/clear`，回滚仍不多一次确认）。

**验收场景**：

1. **假设** 页面要执行本批解禁的任一动作，**则** 它背后是**已在 `AgentBridge` 上实现**的方法，白名单只放行这些：`rewindToMessage` / `listRewindCheckpoints`（回滚）、`clearMessages`（清空）、`getConfiguredModels` / `setModel`（模型）、`updateQueuedMessage` / `deleteQueuedMessageById`（队列）、`getBackgroundTaskOutput` / `stopBackgroundTask` / `getWorkflowRuns` / `stopWorkflowRun`（后台任务与工作流）、`backgroundCurrentTask`（把本轮转到后台，见本故事场景 11）、`searchFiles`（`@` 建议）、`getPromptHistory` / `searchPromptHistory`（历史提示词）。**不新增任何一个协议方法**：本批全部是「已实现但远端不放」，所以放行即生效。**依据**：白名单只该回答「页面能不能承载」，不该回答「宿主有没有实现」；第二批之前这两个问题被混成了一条。

2. **假设** 用户在页面上点某条消息的**回滚按钮**，或敲 `/rewind` 选中一个检查点，**则** 执行前**必须有一次确认**（消息按钮与 `/rewind` 走的是**同一个**确认框，页面不为这两条入口各造一套），且确认文案必须写明**这会同时撤销电脑侧工作区的文件改动**——不是「只删对话」。确认后页面发 `rewindToMessage`，宿主执行完回 `rewindResult {requestId, messageId, ok, inputContent}`，页面只在 `ok === true` 时**切自己的列表**（回执不携带消息列表）并**把 `inputContent` 填回输入框、聚焦**——「回滚后改一改再发」这条动线其余三端本来就有，页面缺的是通道而不是能力（`commands/rewind-command.md` 场景 9：桌面 / IDE 的草稿由宿主保存，页面自己持有输入框，所以回填只能随这次回执带回）。**语义与桌面 / IDE 完全一致**：`agent.truncateHistory` → `messageManager.truncateHistory` → `reversionManager.revertTo`，即除了截断对话，还会按每条消息携带的 `file_history` 快照**逆序**把本机工作区文件还原（`create` 的删掉、有快照的写回、快照内容为空的删除）——所以「回滚到这条消息之前」在电脑上是一次**文件操作**，这是手机误触代价最高的一个动作，也是它必须确认的理由。

3. **假设** 用户在页面上敲 `/clear`，**则** 执行前同样有一次确认（清空会换绑新 sessionId、跑 SessionEnd/SessionStart hooks，电脑侧那条会话随之换新），确认后发 `clearMessages`。**桌面端行为不变**：桌面端 `/clear` 仍整端不拦截（当普通消息发给模型），本场景只作用于远控页与插件端的既有拦截。

4. **假设** 用户在页面底栏的模型选择器里选中另一个模型，**则** 与 IDE / 桌面走同一套 UI 与 RPC（`getConfiguredModels` 拉列表、`setModel` 切换；页面不再有 `/model` 命令）。**确认文案必须说清作用范围**：`setModel` 落盘的是**用户级全局配置**（`~/.wave/settings.json` 的 `model` 键，写失败时退化为仅当前会话生效），不是这条会话的临时覆盖——在手机上换一次模型，**这台机器上所有会话、以及此后新开的会话**都会跟着换。因此远端切换前给一次确认；桌面 / IDE 不加。

5. **假设** 队列里有待发消息，**则** 三个动作在页面上都可用了：**编辑**（纯本地，把内容装回输入框，提交时才发 `updateQueuedMessage {id, text, images}`，宿主回 `{ok}`，`ok === false` 时页面提示「这条已不存在」）、**删除**（`deleteQueuedMessageById {id}`）、**立即发送**（**没有专门的宿主方法**：就是 `sendMessage {force: true}` + `deleteQueuedMessageById` 两条既有方法，与 IDE / 桌面实现同形——先以 `force` 打断并立即发出这条，再把队列里那条删掉，否则它会再发一遍；`force` 只打断当前回合、**不清空队列**，其余排队消息保留并在该回合结束后按原顺序发出）。

6. **假设** 用户在页面上敲 `/tasks` 或 `/workflows`，**则** 两个弹窗照常打开并**有真实内容**：任务列表来自会话状态（宿主通知 `backgroundTasksChange` → 页面 `updateBackgroundTasks`，冷启动 / 切会话 / 重连 / 重挂 / 回前台各重读一次 `getSessionInfo`——该回包为此扩一个 `backgroundTasks` 字段，与 `tasks` / `queuedMessages` 同族），工作流运行来自 `getWorkflowRuns`（同一批对齐路径各读一次，与桌面端 `refreshWorkflowRuns` 同源）；`getBackgroundTaskOutput` 按任务读取输出（运行中由弹窗按既有节奏轮询），`stopBackgroundTask` / `stopWorkflowRun` 停止并回执。**停止不要求二次确认**：它杀的是任务进程、不删文件也不动会话，回执（`backgroundTaskStopped` / `workflowRunStopped`）+ 列表随之更新就够。

7. **假设** 用户在输入框里打 `@`，**则** 远端也启用文件建议：`requestFileSuggestions` → `searchFiles {query, maxResults}`，**工作目录由宿主按信封 `sessionId` 取该会话的 workdir**（页面不猜目录，也不用自己那份 `workdir`），回包映射成与桌面端同一个建议形状（`path` / `relativePath` / `name` / `extension` / `isDirectory`）。**依据**：这条此前被关掉的唯一理由是「会发出一个没人应答的请求，下拉永远停在『正在搜索文件…』」——现在有人应答了，那条理由随之消失。

8. **假设** 用户打开「+ → 历史提示词」，**则** 弹窗**真的能列出历史**并能搜索：`requestHistory` → `getPromptHistory`、`searchHistory` → `searchPromptHistory`，回包 `historyResponse {requestId, history}` / `historyError`，页面的过期回执按 `requestId` 丢弃。**这是一条现成 bug 的修复**：入口与请求早就写好了，shim 却没有这两个 case ⇒ 点了以后永远停在加载中，且连告警都没有（`default:` 分支只 `warnOnce`、不回执）。同族的「未实现即无回执」在本批一并收口：shim 对未映射命令的兜底改为**回一条错误给页面**，而不是让界面停在那一步。

9. **假设** 用户碰到的是本批**仍然不做**的项，**则** 它们**照旧不渲染**（清单见「Web 页作为第五种宿主」场景 24）：设置页与各导航、插件市场、登录/退出、上传文件、点路径打开本地文件、磁盘历史会话（`/resume`）、`/plan`。**`getWorkspaceDiff` / `getWorktreeChanges` / `getPlanFile` / `getFullMessageThread` 也仍在白名单外**：页面上**没有** diff 面板、计划面板、工作树面板可承载它们——`DiffPane` / `PlanPane` 挂在桌面端的 pane 体系里（`isDesktop` 分支），页面既没有 pane 也没有侧栏，计划正文本来就渲染在 `ExitPlanMode` 的审批卡内（`core/plan-mode.md` 场景 13）。放行它们只会扩大暴露面而没有调用方。**`removeWorktree` 同理不放行**：页面上没有删除会话 / 删除工作树的入口（那是桌面端侧栏的动作，`desktopDeleteSession` 是桌面专有命令），所以「能建不能删」不是白名单漏了一条，而是白名单**跟页面能力一致**。

10. **假设** 手机发的这批方法在**当前宿主上没有实现**（前台 CLI 只接了会话内那一批），**则** 宿主回一个**结构化的「该宿主不支持」**错误（固定错误码，区别于 `-32601` 的「通道不放行」与 `-32603` 的内部错误），页面据此收尾这条请求并给**一句可见文案**（「这台电脑上的会话不支持这个操作：<能力>」）。**依据**：白名单是通道级的（daemon / GUI 子进程 / 前台 CLI 共用一份），三个宿主的实现面不同；把「通道不放行」和「这个宿主没实现」压成同一个错误，页面就分不清该升级、该换个电脑、还是该等实现——而静默不回执会让界面停在那一步。

11. **假设** 本轮有正在运行的 Bash 或 Agent 工具块，**则** 页面的助手消息上出现**后台按钮**（与桌面端同一颗，见 [task-background-execution.md](../multi-agent/task-background-execution.md)「前台工具后台化」场景 3–6），点击发 `backgroundCurrentTask` 并**按同一条链路**把电脑侧本轮转入后台；**不要求二次确认**（它杀不掉任务、不删文件、不动会话，只是把执行位置换到后台），也不新增协议方法（`AgentBridge` 早已实现该方法，放行即生效）。**页面上的入口从此不再只有 Ctrl/Cmd+B**：手机键盘没有那颗键，这正是这条此前被排除在白名单外的唯一理由，按钮落地后该理由消失。**手机上这颗按钮与回滚按钮同一套显隐规则**：点一下消息、按钮才出现，再点按钮才生效（触控设备没有真实悬停，`:hover` 由这次 tap 建立并粘住）。**2026-11-01 他否掉了此前「触控档常显」的做法**：常显时页面上永远挂着两颗按钮，静置下读不出它属于哪条消息、还压在正文上，要求回到默认的「点一下再显示」——代价是手机上多一次点击，他认可（见该规格场景 6）。

***

### 用户故事：Web 页也能用 /btw 旁路提问（优先级：P2）

作为在手机上接管会话的用户，我希望在远控页敲 `/btw <问题>` 能像在电脑前一样拿到一个**不进对话历史、不触发工具、不打断主回合**的旁路回答，而不是把 `/btw 今天几号` 这句字面文本当普通消息发出去。

**为什么是这个优先级**：页面的审批闭环与发消息都不依赖它，所以不进 P1；但现状不是「缺一条能力」而是**错了**——`/btw` 在页面上被当成普通消息发给宿主（`interceptHostLocalCommand = !isRemotePage` 那一道守卫），于是模型把「/btw 今天几号」当**用户正文**回答：这条**进了主对话历史**、还**可能触发工具执行**（一次「题外话」在电脑侧改文件，正是手机上最不该发生的事），而用户以为自己问的是一句可以随时关掉、不留痕的话。三处各一行（白名单、shim 转发、页面守卫）即可修好，且能力在 `AgentBridge` 上早已实现（`askBtw` 与 `btwContent`），成本与收益都不支持把它往后排。

**独立测试**：在页面上敲 `/btw 今天几号` → 输入区上方出现 BTW 面板（问题标题 + 闪烁光标 +「正在回答」），消息列表**不新增**任何一条、待发队列不新增、正在流式输出的主回合不受影响；回答完成后面板以 Markdown 渲染答案；关掉面板后这条问答在消息列表里**找不到**；宿主回错误时面板显示错误而不是停在加载中；敲裸 `/btw` 显示中文 usage 且不发请求；同一批操作在插件端与桌面端行为**不变**。

**验收场景**：

1. **假设** 用户在页面上敲 `/btw <问题>`，**则** 页面**不再把它当普通消息发出**（一条 `sendMessage` 都不发），而是发 `askBtw {question}`（信封带页面当前会话的 `sessionId`），宿主答案作为回执回来 → 面板渲染 Markdown 答案；请求失败 → 面板显示错误串（与插件端同一个面板同一套文案：`(API error: …)`）。**判据**：这条命令在远控页上**必须**被拦截——`/btw` 从「页面必然无效、不渲染」那一列里移出（「Web 页作为第五种宿主」场景 24），而其余宿主本地命令（`/config` `/plugin` `/mcp` `/agents` `/skills` `/hooks` `/plan` `/reload-plugins` `/remote-control`）**继续不拦**，照旧按普通消息发出。**依据**：那条守卫当初是按「背后是宿主的界面能力」划的，`/btw` 的面板长在页面自己的输入区上方、不在宿主界面里，它是这条判据的例外而不是同类。

2. **假设** 页面发 `askBtw`，**则** 远端白名单（`REMOTE_ALLOWED_METHODS`）**放行**它，放行后共 **33** 个方法。**不新增任何协议方法**：`askBtw` 与其流式通知都是既有协议（`AgentBridge` 早就实现，IDE / 桌面端在用），本轮只是把「页面能不能承载」这一条判成**能**——它作用于当前会话、不需要编辑器、不需要文件系统，与第二批解禁同一条分界。

3. **假设** 答案在生成过程中，**则** 页面看到**流式尾部**而不是纯转圈：宿主的 `btwContent {question, content, type: "content" | "thinking"}` 通知由 shim 映射成页面既有的 `btwStream`，面板在「正在回答」后显示该文本的**最后 30 个字符**（折叠换行、超长以 `…` 前缀截断）；`thinking` 与 `content` 共用这条尾部通道，content 一旦开始就不再显示 thinking 尾部（`ui/btw-command.md` 的既有面板语义，页面不另写一份）。**归属按 `question` 校验**：切过会话或换过问题的迟到增量一律丢弃（面板的 `btwActiveRef` 已是这个判据，shim 只负责把 `question` 原样带上）。

4. **假设** 这条流式增量要走中继，**则** 它**并入同一个 150ms 合并窗口**（累积字段 `content`，key = `question` + `type`）：它是纯增量、不带累积值，与主对话那三路（正文 / 推理 / 工具参数）同族——不合并会让一次旁路提问在手机上产生与主对话同量级的多余帧，而合并不会丢任何一帧。

5. **假设** `askBtw` 的回执迟迟不来，**则** 它**不设时限**（与 `sendMessage` / `compact` 同属 shim 的 `TURN_METHODS`，那一类不挂超时，见 Web 页故事场景 15）：它的回执只在整轮答完之后才发，而整轮没有上界——20s 会把它误报成超时，任何一个更大的数字也只是把误报推后；等待期间用户看到的是流式尾部（场景 3）。

6. **假设** 主对话正在流式输出时用户提出 `/btw`，**则** 旁路请求**不打断、不排队、不进入**主对话：消息列表不新增 `userMessageAdded`、待发队列不新增条目、主回合继续流式；按 Esc / 点关闭只关面板，**不中止**主回合；面板关闭后迟到的流式增量与最终答案**全部丢弃**（沿用面板既有语义，页面侧无需新增判断）。

7. **假设** 用户敲的是**不带问题的** `/btw`，**则** 不发起请求，面板显示中文 usage `用法：/btw <你的问题>`（与 IDE / 桌面同一句），不显示加载态。

8. **假设** 手机发的 `askBtw` 落在**当前宿主没实现**的那一类上，**则** 按第二批那条既有机制收口：宿主回**结构化的「该宿主不支持」**错误（固定错误码，区别于 `-32601` 的「通道不放行」），页面收尾这条请求并在面板里给一句可见文案，**不停在加载中**。**实现面**：`daemon` 与 GUI 宿主拉起的 `--stdio` 子进程都由 `AgentBridge` 服务（`askBtw` 与 `btwContent` 已实现，放行即生效）；**前台 CLI 会话**（`RemoteControlCliHost`，只接了会话内那批）本轮**不实现**——它连「新建会话」「回滚」都还没接，`/btw` 与它们同列，等前台 CLI 的宿主能力面统一补一次（「前台 CLI 会话也能被手机接管」故事场景 9）。

9. **假设** 用户打开页面的**斜杠命令弹窗**，**则** `/btw` **在里面**，选中后只把 `/btw ` 前缀插进输入框、等用户补问题（`runOnSelect` 为假，与 IDE / 桌面同一条语义）。这份清单不是页面自己拼的：宿主本地命令由服务端按「入口 = 远控页」过滤后随 `getSlashCommands` 回包下发（见 `ui/slash-commands.md` 的「弹窗里的宿主命令只有一份表」故事）——**本故事上一版那条「弹窗不列 `/btw`」的取舍随之作废**（当时的理由是不为一条命令新增一份本地命令表，现在这类命令整体改成由服务端下发，页面一行名单都不存）。面板的视觉本轮仍不动：它复用共享的 `BtwPanel`（`BtwPanel.css` 里没有触控档媒体查询，关闭键仍是按指针定的一个小图标），真机上若嫌关闭键难按，按场景 27 那条「第三族」同一类处置另立一轮（手机档的判据是 `pointer: coarse` / `hover: none`，不按页面身份）。

***

### 用户故事：Web 端也能发起新会话（优先级：P2）

作为离开电脑的用户，我希望在 Web 页上直接开一条新会话——选定电脑上的工作目录、（在仓库里时）分支，以及是否放进 worktree——以便「想起新任务」时不必等回到电脑前先在电脑侧把会话建好。

**为什么是这个优先级**：审批闭环与既有故事一条都不依赖它；而它把远端通道的能力面从「接管已存在的会话」扩到「在那台机器的任意目录发起会话并驱动 agent」，是一次真实的扩权。收益真实（发起即不必回电脑），代价也真实（暴露面扩大），所以单独成篇、可以独立不落地。

**为什么服务端几乎现成**：Web 页背后就是它所连的那个 wave 进程的 `AgentBridge`——桌面端在远程主机上新建对话用的那几个 RPC（带 `workdir` 的 `initialize`、`listGitBranches`、`createWorktree`）都在同一个 bridge 上，远端白名单是唯一闸门；主机不可选是天然的（`?host=` 定死设备级通道，页面没有主机维度——这是它与桌面端远程主机唯一的不同，而它不需要任何设计）。真正的新东西只有三样：白名单放行、一个列目录 RPC、页面 UI。

**独立测试**：点顶栏「新建对话」图标打开「新建会话」流程，「浏览…」给出与桌面端远端同形的目录浏览器（面包屑 + 子目录 + 手输），输入 `~` 或相对路径能解析成该机器上的绝对路径并列出其子目录；选定目录创建后，页面切到新会话，电脑端（CLI / 桌面端）能看到这条会话出现在服务进程的会话列表里；在 git 仓库目录下列出分支、可用 worktree 创建；构造带 `apiKey` 的请求被忽略而非生效。

**验收场景**：

1. **假设** 用户想新建会话，**则** 入口就是顶栏的「新建对话」图标——与插件端**同一个按钮位、同一个图标**（远控页复用 IDE 那份 ChatApp，这个按钮此前因语义未定而随「历史对话」一起隐藏）；在 Web 页上它的语义**重绑**为「打开新建会话流程」（下一条）。插件端这个图标的底层是 `clearChat` → `clearMessages`——在 agent 内部**换绑一个新 sessionId**（旧会话归档、标题随旧会话丢弃），效果是「开一条**同目录**的新会话」；Web 页上底层换成 `initialize`，原因见第 9 条。「历史对话」仍不提供，切换走「会话」面板。
2. **假设** 用户打开「新建会话」，**则** 目录选择与桌面端**同一套形状**：**最近目录**（两段合并，见 [recent-workdirs.md](../core/recent-workdirs.md)。**前一段是活会话派生的目录**：去重，**只列主仓库根目录**——某条会话的工作目录若是 linked worktree，就列它挂靠的仓库根，worktree 目录本身不出现，与桌面端最近目录同口径。判据用宿主已经给的那份事实：`listDaemonSessions` 每行自带 `worktree?: {path, branch, repoRoot, hookBased}`（见「手机端的会话列表与删除」场景 9），页面按 `repoRoot ?? workdir` 归并即可，**派生这一段不新增协议方法、不逐行跑 git**；推导不出来（不是 worktree 或不确定）就按原目录列，不猜。会话自己的 `workdir` **不改写**：那仍是这条会话真实的 cwd，`@` 文件提及拼绝对路径靠它。**后一段是这台机器的持久化最近目录**：`getRecentWorkdirs` 读那台机器上的 `~/.wave/recent-workdirs.json`，MRU 顺序，与桌面端（按主机）共用同一份——会话被删光时它是唯一还记得旧目录的来源；两段之间按路径去重，活会话那段在前。**读不到就只列前一段**（旧版电脑没有这条方法），不得因此报错或把弹窗置空）与\*\*「浏览…」**；「浏览…」打开的目录浏览器与桌面端**远端主机**那个同形——面包屑、单层子目录列表（点击进入）、底部路径输入 + 「选择此目录」（手输住在这里，不是独立入口），数据源从桌面端的宿主命令换成 `listDirectories`（下一条），浏览器组件与桌面端同源可复用。桌面端**本机**的「浏览…」是 OS 目录对话框，浏览器里没有这个能力——远端那套浏览器面板就是 Web 页的对应物，二者不要求同形。无论哪条路，最终都落成**显式的绝对 `workdir`\*\*——不存在「不选目录直接创建」（隐式落到服务进程的工作目录，是桌面端 cwd 漂移的同款老坑）。
3. **假设** 页面要列出某个目录下的子目录，**则** 调用 bridge 的 `listDirectories`（随本故事进白名单）：**这个方法没有主机概念——你连着哪个 wave 进程，就列哪个进程所在机器的目录**（与 `listGitBranches` 完全同形：同一方法名，本机 stdio 与 daemon socket 各自吃自己的 bridge）。`~` 按该机器上服务进程用户的家目录展开，返回 realpath 化的绝对路径与排序后的子目录名；目录不存在或不可读时返回明确错误而不是空列表。桌面端远程主机的目录浏览随之切到同一个 RPC（那条 ssh shell 路径退役），两端吃同一份实现。
4. **假设** 用户提交创建，**则** 页面经远端通道调用 `initialize`（带 `workdir`）拿到新 `sessionId`，随后做的是与「会话」切换**同一套本地动作**：改自己的 `sessionId`、清空列表、重拉尾页与待审批——不引入第二条切换机制。新会话**真实落进当前服务进程的会话注册表**：服务方是 daemon 时电脑端 CLI / 桌面端都能看到它（「Web 端发起、机器落地」）；服务方是某个 GUI 子进程时它出现在那一端的侧栏里——页面指向哪个进程，会话就落在哪个进程，与设备级通道一致。
5. **假设** `initialize` 从远端通道进来，**则** 它的入参在远控入口**收敛**：只放行 `workdir` 与 `permissionMode`（不传即 `default`），其余参数面（`apiKey` / `baseURL` / `model` / `tools` / `plugins` / `mcpServers`…）一律忽略——白名单放行的是「在选定目录建一条会话」这个能力，不是 `initialize` 的整张参数表。
6. **假设** 选定的是一个 git 仓库，**则** 页面给出分支选择（`listGitBranches`）与「在 worktree 中打开」，默认值与桌面端新建会话页一致：基准分支默认取该目录的**当前分支**（回执 `{branches, current}` 的 `current`，缺失时退到分支列表第一项），worktree 开关**默认勾选**；勾选后按桌面端同形的两步走——`createWorktree` 拿到 worktree 路径，再 `initialize(workdir=该路径)`。控件的显示判据是**回执的形状**：宿主回的是一对 `{branches, current}` 而不是裸的分支名数组，非仓库目录下宿主直接回错 ⇒ 这两个控件不出现（能力不可达），而不是渲染出来再报错。
7. **假设** 新会话的生成触发一次需要审批的工具调用，**则** 审批卡走既有链路到达页面——新会话初始 `default` 档，执行侧的兜底与既有会话无差别。
8. **假设** 有人持有企业端登录态与本机通道键，**则** 本故事意味着他能在这台机器的**任意目录**发起会话并驱动 agent 跑工具——远端通道「只碰已存在会话」的既有语义被**显式**扩大，这份扩大必须随本故事一起被接受（执行侧兜底不变：`default` 档 + 审批链）。
9. **假设** 有人问「底层为什么不直接放行 `clearMessages`——插件端的『新建对话』本来就是它」，**则** 答案钉在这里：`clear` 在 agent 内部确实是**换绑新 sessionId**（旧会话归档），效果上就是开一条新会话，但它开的是**继承旧会话工作目录**的新会话（agent 的 `workdir` 在 `initialize` 时定死，`clear` 不碰它）——本故事的核心（选目录 / 分支 / worktree）它一样都给不了；而且它的回执**不携带新 sessionId**（bridge 返回 `null`，插件端清完也只推空列表），页面要发现新 id 只能靠信封漂移；同进程里电脑端正在看的那个视图随 agent 换绑而漂移，活会话列表里旧 id 原地变成新 id、切换语义跟着乱。`initialize` 四条全无——这就是按钮位可以复用、底层不能复用的完整论证。
10. **假设** 电脑侧这条会话此刻**正在生成**、**正在压缩上下文**，或**存在正在运行的后台任务**（即插件端把「新建对话」置灰的那三种忙闲状态），**当** 用户在页面上点顶栏的「新建对话」时，**则** 这个入口**照常可用**——它打开的「新建会话」流程与创建本身都不受这三种状态影响（不置灰，也不在提交时被忽略）。**依据**：页面上这个按钮不是 IDE 那个 `clearChat`——它是在机器上**另起一条会话**（`initialize`），页面随后只把自己的视图挪过去（场景 4，与「会话」切换同一套本地动作）。那条守卫（`session-management.md` 场景 2 / 6 / 7 与边界情况）防的是「**原地**清空或替换当前会话」——把正在跑的这一轮生成、或正在跑的后台任务中断掉、或让它与 UI 脱离；而页面上这个动作**没有对象可破坏**：电脑侧那条会话继续按自己的节奏跑，新会话与它**并行**（与桌面端多会话并行模型同形，`session-management.md` 边界情况已把那个模型排除在本守卫之外，页面属同一理由）。反过来，按忙闲置灰的代价是实打实的：手机用户在电脑侧一个长回合跑着的时候**完全无法**发起新会话，而「想起新任务时不必等回到电脑前」正是本故事存在的理由。同一口径也适用于顶栏「会话」切换（「Web 页作为第五种宿主」场景 10）。

***

### 用户故事：手机端的会话列表与删除（优先级：P1）

作为用手机盯着好几条对话的用户，我希望「会话」是一张**整页的列表**——每条带项目与最近活跃时间、带「谁在跑 / 谁在等我」，还能就地重命名、删除，以便在手机上把这几条对话管理完整，而不是只能在一份挤在顶栏下面的窄下拉里点一行切过去。

**为什么是这个优先级**：列表是手机上**唯一**的会话管理面——这一页没有侧栏、没有右键、没有终端，桌面端侧栏里那些行级动作在这里无处安放。当前那份下拉是**借来的**：它本来是插件端「历史对话」的弹窗（`SessionListPopup`，标题换成了「会话」），只有搜索与点击切换，行级动作一个都没有；删一条会话、或者给一条会话改个名，手机上彻底做不到（改名只能借用顶栏标题的就地编辑，且只对**当前那条**有效）。所以这一篇补的不是「更好看」，而是把「能切」补成「能管」。

**独立测试**：打开远控页点顶栏「会话」→ 出现整页列表（顶部「返回」+ 标题「会话」），最上一个搜索框，每行两行字与右侧「⋯」；输入关键词能筛到对应行；点一行切到该会话并**自动返回对话页**；点「⋯」从底部升起动作面板（重命名 / 删除会话）；选「重命名」后该行标题变成输入框，回车生效、Esc 回滚、清空后回车什么都不发生；选「删除会话」→ 全屏确认卡片，第一句固定是「对话记录仍保留在这台电脑上，可以从桌面端恢复。」；普通会话直接可删；建在 worktree 里的会话先显示「正在检查该 worktree 的改动…」且确认键禁用，计数回来后显示「…将一并删除：N 个未提交文件、M 个未合并提交，删除后不可恢复」，确认键文案变成「删除会话与工作树」；让检查超时（或宿主报错）则降级成泛警告、确认键恢复可用；会话正在回答时卡片上多一行红字「会终止这条会话正在进行的回答」，且不勾选「我知道了」就点不动；删完回到列表、顶部出现一条「已删除」，页面**不**自动跳进别的会话；把**最后一条**也删掉后回到对话页，输入区整体置灰、占位文案是「请先新建对话并选择工作目录」、空态也不再出「快捷方式」模板（场景 14）；新建一条会话、只发一条消息，再点顶栏「会话」→ 那一行的标题已经是刚发的那句话，不再是「新对话」（场景 15）；同一张列表在插件端与桌面端**一个字都没变**（IDE 顶栏「历史对话」仍是那个下拉，桌面侧栏仍是它自己的行菜单）。

触控设备上这行的标题字号、行内「⋯」与页头「返回」的热区见 `remote-mobile.md` 的「触控设备上的尺寸与字号」故事（场景 3 / 4）。

**验收场景**：

1. **假设** 用户在远控页点顶栏的「会话」按钮，**则** 打开的是**整页**的「会话」列表（顶部一行「返回」+ 标题「会话」），不再是原先那份从顶栏右上方弹出的下拉。**这一条只对远控页成立**：插件端的「历史对话」与桌面端的会话入口/侧栏一个字都不改（同一个 `SessionListPopup` 在插件端照旧以 `variant="dropdown"` 渲染），因为「下拉」在指针设备上是好形态、在手机上才不够用（行级动作、触控热区、整屏滚动都是它给不了的）。

2. **假设** 列表已打开，**则** 最上一个**搜索框**（沿用原下拉的搜索逻辑：按显示标题做大小写不敏感的子串匹配），下面每行**两行字 + 右侧一个「⋯」**：第一行是标题（`customTitle` ?? `firstMessage` ?? id 前缀，与其余四端同一个 `formatSessionLabel`），第二行是「项目目录名 · 最近活跃时间」，第二行前面一个**状态点**——正在生成转圈、有待确认（琥珀点）、有后台任务在跑（蓝点）。**一条只出一个点**，优先级 **待确认 > 运行中 > 有后台任务在跑**（被审批卡住的对话不算「在跑」；后台任务在跑但前台回合已结束的对话仍要标出来，那正是「它还在动」的唯一信号）。排序沿用下拉的口径（`lastActiveAt` 倒序）。

3. **假设** 用户点列表里的某一行，**则** 切到那条会话并**自动返回对话页**——与原先下拉点一行完全同一套本地动作（只改页面自己的 `sessionId` 与视图、不调用 `restoreSession`、不套用 IDE 那条忙闲守卫），见「Web 页作为第五种宿主」场景 10。行内的「⋯」**不触发切换**（它有自己的可点区，点它只开动作面板）。

4. **假设** 用户点某一行的「⋯」，**则** 从底部升起一个**动作面板**（action sheet），两项：`重命名` 与 `删除会话`（红字）。**不做左右滑删除、不做长按菜单**：移动网页里横向滑动与纵向滚动、系统返回手势互相打架，长按与选词冲突；「⋯」稳定、可测、不依赖手势冲突的裁决。

5. **假设** 用户选「重命名」，**则** 该行标题**就地**变成输入框（与桌面侧栏、顶栏标题同一套口令：Enter 或失焦保存、Esc 回滚、trim 后为空则什么都不做），走既有的 `renameSession`（乐观更新 → 按 `requestId` 关联回复 → 失败回滚并给出可见提示）。**不新增任何协议**：shim 早就有这个 case，白名单也早就放行了它。

6. **假设** 用户选「删除会话」，**则** 进入删除流程的第三/第四步：一个**全屏确认卡片**，内容按下面的顺序给全——① 固定一句「**对话记录仍保留在这台电脑上，可以从桌面端恢复。**」（这句话是删除语义的核心：删的是**这台机器上正在跑的这条会话**，不是磁盘上的对话记录）；② **worktree 警告**（复用桌面端 `utils/worktreeDeleteWarning.ts` 的**同一份**文案与流程，见场景 7）；③ 会话**正在回答**（`isLoading` 或 `hasRunningBackgroundWork`）时一行红字「会终止这条会话正在进行的回答」，且**必须勾选「我知道了」确认键才可点**；④ 按钮 `取消` / `删除会话`（这条会话的工作目录是 linked worktree 时改成 `删除会话与工作树`，红色）。

7. **假设** 被删会话的工作目录是一个 **linked git worktree**，**则** 确认卡片上的 worktree 警告走桌面端那条**逐字相同**的流程：先显示「正在检查该 worktree 的改动…」并**禁用确认键**；计数回来后显示「该会话的 worktree 目录与临时分支将一并删除：N 个未提交文件、M 个未合并提交，删除后不可恢复。」（干净工作树按那份文案的既有规则不显示警告，但确认键照常可点）；计数**查不到**（超时 / 宿主失败 / 工作树已经不在）时**降级成泛警告**再放行确认——**不许静默删**（未知不等于干净）。working tree 干净或不是 worktree 时，卡片只有场景 6 那句固定文案。

8. **假设** 用户确认删除，**则** 页面发两条请求的一条：`destroy`（带 `removeWorktree: <这条是否是 worktree>`，按信封 `sessionId` 指向被删的那条）。**不新增协议方法、也不放行裸 `removeWorktree`**——后者收 `path` / `branch` / `repoRoot`，放给手机等于把「删这台机器上任意工作树目录」开出去；改成让 daemon **自己**从该会话的 `createdWorkdir` 定位工作树（与 `wave daemon destroy --remove-worktree` 同一条推导），且**必须排在 `agent.destroy()` 之后**（agent 的 cwd 就在工作树里，先删目录会让保存 transcript 报 ENOENT、丢尾消息——桌面端同一条顺序注释照抄）。**定位不到工作树时**：会话照删、回 `worktreeRemoved: false`，不整体失败。`destroy` 的返回值从 `null` 扩成 `{worktreeRemoved: boolean, worktreeRemoval: "none" | "pending"}`；**不传参数时行为逐字不变**（CLI `wave daemon destroy` 就是那条路）。改动计数走一条**会话作用域**的只读方法（`getSessionWorktreeChanges`，路径由 daemon 从该会话的 `createdWorkdir` 推导、**不接受调用方给路径**）——与「不放行裸 `removeWorktree`」同一条理由：页面不该能指名这台机器上的任意目录。

   **工作树移除不回执（2026-11-04 修正）**：`destroy` 只做「摘注册表 + `agent.destroy()` 收尾」这两件**必须**在回执前完成的事，工作树移除排在其后**在后台跑**，回执立刻返回 `worktreeRemoval: "pending"`。**顺序约束不变**——`agent.destroy()` 的 `saveSession` 要 realpath 那个目录，先删目录会丢尾消息（见上面那条注释），所以后台任务挂在 `agent.destroy()` 的 promise 之后，不是与它并发。**理由**：远控页每个请求只有 20 秒预算（`shim.js` 的 `REQUEST_TIMEOUT_MS`，只有 `sendMessage` / `compact` / `askBtw` 免预算），而 Windows 上删一个带 `node_modules` 的工作树是**分钟级**——`git worktree remove` 删不掉 pnpm 的 junction（报成功、目录还在），只能回落到 `fs.rmSync` 递归删——等它删完再回执等于每次删除都假超时，用户看到的是「destroy 超时」而会话其实已经删了。桌面端本来就是这个口径：`handleDeleteSession` 把 `target.destroy()` 与工作树移除都 fire-and-forget，界面立刻刷新。**不是工作树 / 定位不到工作树**时 `worktreeRemoval: "none"`、`worktreeRemoved: false`。

9. **假设** 用户问「列表里那行的 `worktree` 从哪来」，**则** 它由 daemon 从该会话的 `createdWorkdir` **推导**（`listDaemonSessions` 每行追加可选字段 `worktree?: {path, branch, repoRoot, hookBased}`），**不新增「worktree ↔ 会话」的持久状态**——daemon 重启就没了，而桌面端那套索引是它自己的（`entry.worktree`），两边各自持有各自的事实。**计数绝不逐行跑 git**（那会让每次列表刷新都变成 N 次 git 调用）：列表只回 `worktree` 本身，计数走场景 8 那条按需读。**推导不出来或失败一律降级为「没有这个字段」**（= 不是工作树或不确定），绝不让整个列表报错。

10. **假设** 某条会话是由**别的宿主**托管的（桌面端 / IDE 的 `--stdio` 子进程），**则** 它照样能在这张列表里被删——**不按托管方做任何区分或限制**（能列出来的就是能删的，它们都活在同一个服务进程里）。**已知残留（明确接受，2026-11-07 收窄）**：手机删掉一条由桌面端 / IDE 托管的会话后，**电脑侧那个面板不会自动关闭**——本轮**不做**「会话被远端删除」的跨进程通知（那要一条新的宿主内广播，收益只覆盖一个残留，代价是又一条状态同步链路）。但「点进去就报错、关不掉」这个死口子已经堵上：电脑侧**下一次从别处点那条侧栏条目**时，为刷新分屏而读消息会答 `Session not found: <id>`，桌面端按 `desktop-remote.md`「会话还在进程池里、但已被别的客户端销毁」那条口径**自动把条目从侧栏移除、toast 提示、分屏落回新会话**——与「会话已被删除」在本地/远端两种情况下的表现一致（不会停在原地让人以为点了没反应）。仍不覆盖的两种姿态：该会话**正显示在某个分屏里**时点它只是聚焦那个分屏（面板不会自己关、也不触发清理），以及配置重建窗口（改模型/登录/登出）内的读失败不判定为删除。

11. **假设** 删除成功，**则** 页面**回到列表**并在顶部给一条「**已删除**」（短暂显示），**不自动跳进别的会话**。被删的若是**当前正在看的那条**，页面按既有 `chooseSession` 规则重新挑一条活会话绑定（否则每次读都会落到 `Session not found`），但**视图仍停在列表上**——「挑一条绑上」与「替用户跳进去」是两件事，后者会把用户刚做完的动作变成一次意外导航。**文案分三档**：`worktreeRemoval: "pending"` 时是「已删除（工作树正在后台删除）」；老机器只回 `worktreeRemoved: false` 时仍是既有的「工作树没跟着删掉，可在电脑上手动清理」；其余是「已删除」。两者都不是失败，但「正在删」与「删不掉」必须分清，否则用户会去手动清一个正在被删的目录。

12. **假设** 有人把这条故事读成「手机能翻磁盘历史会话了」，**则** 本篇只补「**删活会话**」——读磁盘历史会话是**另一条入口**（`/resume`，见「手机端也能接着磁盘上的历史会话聊」故事），两者不共用数据源：这张整页列表仍只报进程内活会话（`listDaemonSessions`），顶栏「历史对话」仍**不渲染**（「Web 页作为第五种宿主」场景 21）。白名单口径随之分开：`listAllSessions` 放行（`/resume` 的只读取数）、`restoreSession` **仍不放行**（原地载入会换掉电脑侧那条会话）。删掉一条活会话**不删它的磁盘 transcript**（因此场景 6 那句「对话记录仍保留在这台电脑上」是字面意思），被删的会话此后可以从 `/resume` 里重新打开——那是磁盘上独立于「活会话」的一份记录。

13. **假设** 有人问「桌面端那条删除规格要不要跟着改」，**则** **不改**：`desktop-sessions.md`「删除 worktree 会话前提示将丢失的改动」的语义与文案归桌面端，手机端**复用同一份文案**（`utils/worktreeDeleteWarning.ts` 是两端共用的唯一来源，本故事不改它的一个字），差别只在**承载物**——桌面端是侧栏行菜单 + 居中确认框，手机端是整页列表的行「⋯」+ 底部动作面板 + 全屏确认卡片。

14. **假设** 最后一条会话也被删掉（机器上一条活的都不剩），**当** 用户回到对话页时，**则** 页面不摆出一条「看起来能用」的新对话：消息列表为空时不渲染「快捷方式」模板，输入区按既有 `disabled` 语义整体置灰（不可编辑、发送 / 附件 / 快捷指令 / 权限模式一并禁用），禁用原因写在输入框占位文案里——「**请先新建对话并选择工作目录**」。**依据**：此刻页面手里既没有 `sessionId` 也没有任何 workdir（删完机器的注册表是空的），而「新建一条」必须先选目录（见「Web 端也能发起新会话」场景 2：不存在「不选目录直接创建」）；放着一份可编辑的输入区，用户敲下去只会收到机器侧的 `sessionId is required for this request` 红块，看上去像删除把页面弄坏了。禁用期结束于下一次快照带来会话（新建成功，或电脑侧又开了一条）——判据是 `currentSession`，不是「消息列表为空」（`/clear` 与 `/rewind` 回到第一条时列表同样为空，但那两条背后都有真会话，照旧可发）。

15. **假设** 用户点顶栏的「会话」打开列表，**则** 打开这一页本身**重读一次**机器的会话注册表并把列表重画（页面发一条 `refreshSessionList`，shim 重读 `listDaemonSessions` 后推 `updateSessions` 与状态标记），而不是直接显示手里那份副本。**依据**：列表数据平时只在快照里下发（首屏、切会话、新建 / 删除、机器切换、中继要求重拉），而「打开列表」过去只是本页的一次视图切换、不产生任何请求——于是「刚新建、刚发了第一条消息」的对话在列表里仍叫「新对话」：那一行的标题取自 `firstMessage`（`customTitle` ?? `firstMessage`，场景 2），而新建成功那一刻它还是空的，之后发消息、回合结束都不再拉列表，要等下一次快照才更新。打开列表正是用户要看「现在什么样子」的时刻，所以对齐一拍的时机就放在这里；读的是注册表同一个方法（fs-only、不逐行跑 git，见场景 9 的取舍），**失败静默**——保留旧列表、只记一条日志，不拿红条盖住一张还能用的列表。**只在打开时对齐一次**：不轮询、也不在每回合结束时刷新，标记与标题的下一次对齐仍是既有的快照与 `updateSessionStatus` 通道（见「待确认只在它自己的对话里弹」故事的已知取舍）。**这一条只对远控页成立**：`refreshSessionList` 只登记在远控页那条路由上，插件端与桌面端的列表由它们自己的宿主链路更新。

***

### 用户故事：手机端也能接着磁盘上的历史会话聊（`/resume`，优先级：P2）

作为在手机上要接着昨天的活干的人，我希望远控页像桌面端一样能翻出这台机器磁盘上的历史会话（包括 CLI 和别的客户端创建的、当前进程里没在跑的），点一条就接着聊，而不是只能在「还活着的那几条」里挑。

**为什么是这个优先级**：远控页的会话列表只有一个数据源——`listDaemonSessions`，也就是承接进程内存里的活会话（边界情况「手机能看、能切的那批」）。机器一重启、或换了个进程承接，昨天的对话在手机上就**彻底不可达**：不是没显示，是没有任何入口能打开它。桌面端解决这件事的入口是 `/resume`（整机磁盘枚举 + 点一条换绑 pane），页面一直没有——早期口径把「读磁盘会话」与「替换电脑侧会话」一起关掉了。这次把两者分开：**枚举是只读的**，**打开的方式是新建一条会话**（`initialize` + `restoreSessionId`），页面仍然不替换、不改动任何已经活着的会话。

**独立测试**：远控页的斜杠弹窗里出现 `/resume`，敲它 → 出现整页列表（搜索框 + 每行「标题 / **工作目录完整路径** · 最近活跃时间」），里面包含 CLI 建的、当前进程没在跑的会话 → 点一条 → 回到对话页并显示那条会话的历史消息、忙闲与权限档按首屏那套对齐 → 再点顶栏「会话」，这条恢复出来的会话已在活会话列表里，能继续发消息；同一条会话在电脑端桌面端侧栏里也在（同一个磁盘 transcript）。选一条工作目录已被删除的会话 → 停在列表上并得到一句「该会话的工作目录已不存在」。手机接管的是**前台 CLI** 那条会话时，弹窗里**没有** `/resume`。

**验收场景**：

1. **假设** 用户在远控页敲 `/resume`，**则** 页面发一条 `listAllSessions`（host 级读，不带 `sessionId`，与「新建会话」的 `listDirectories` 同一档），宿主枚举**这台机器所有项目目录**的主会话（`~/.wave/projects/` 下逐项目目录，按 `lastActiveAt` 倒序、按会话 id 去重），页面按整页列表呈现——形态与「手机端的会话列表与删除」一致（顶部「返回」+ 标题「恢复会话」+ 搜索框），每行两行字：标题（与其余四端同一个 `formatSessionLabel`）与「**工作目录完整路径** · 最近活跃时间」。**为什么给完整路径而不是项目目录名**：这份列表跨工作目录，目录名会在两个同名项目上撞成两行一模一样的字；桌面端那张同源列表也是给完整路径（`showProject`）。**为什么复用整页列表而不是借来的下拉**：手机上那个下拉形态早被「手机端的会话列表与删除」判过不够用（触控热区、整屏滚动）。

2. **假设** 这台机器上的会话超过 `200` 条，**则** 页面只列**最近 200 条**，**不分页**。**依据**：远控每一帧有 512 KiB 上界，而 `sendResult` 对 `getMessages` 以外的回包超预算时是**明确报错、不截断**（截断一份会话列表等于给手机一份「哪些会话存在」的假话）；按最近活跃时间取上限是唯一既不新增分页协议、又不会摸到上界的选择。更老的会话在手机上暂不可达（桌面端还能翻），这是明确接受的取舍。上限由**页面请求**带上（`limit`），宿主照单截取——页面不需要知道帧预算，宿主不必猜渲染能力。

3. **假设** 用户选中一条会话，**则** 页面用 `initialize {workdir, restoreSessionId}` 在这台机器上**新建一条会话**并从磁盘那条恢复（`workdir` 取该行的完整路径，`restoreSessionId` 取该行的会话 id），回执带回新会话的 `sessionId`；页面绑上它、关闭列表、按首屏那套拉尾页与状态，然后**重读会话列表**——恢复出来的这条此时是一条活会话，与桌面端恢复后出现在侧栏同义。**判据**：这条路**不是** `restoreSession`。`restoreSession` 是「原地载入」——把承接进程里某条正在跑的会话换成另一条（在桌面端就等于换掉用户正看着的那个 pane，见边界情况），与「手机只当控制面」相冲，所以它**仍不进白名单**。**为什么不在页面里改 `currentSession` 了事**：读消息与发消息都要求信封 `sessionId` 在承接进程的注册表里（`getMessages` 走 `requireSessionForRead`），磁盘会话不先载入进程，点进去只会回 `Session not found`。

4. **假设** 有人问「页面为什么能建会话、还能带 `restoreSessionId`」，**则** 这是同一道口子的新用法，不是新开一道：`initialize` 早在「Web 端也能发起新会话」时放行，入参收敛为 `workdir` / `permissionMode`；本次把 `restoreSessionId` 加进这份收敛清单（仍要求是非空字符串，仍由宿主决定这条会话落到哪个工作目录）。放行的能力是「按某个已有会话的历史开一条新会话」，而页面本来就已能用 `initialize` 在**任意**目录建会话，暴露面没有变化；被恢复的那些会话本身也已经通过 `listAllSessions` 对页面可见。

5. **假设** 目标会话的工作目录**已被删除**（worktree 撤了、临时目录清了），**则** 页面得到一句可见的「该会话的工作目录已不存在」并**停在列表上**，不进入空对话页、不留下半条绑定的状态。**依据**：桌面端同一条路径是先探测目录再切换（`desktop-sessions.md`「跨客户端会话来源」场景 6），页面拿不到探测结果时退化成「初始化失败」——两种形状对用户是同一句话。

6. **假设** 手机接管的是**前台 CLI** 那条会话（`RemoteControlCliHost`），**则** 弹窗里**没有** `/resume`，页面也不会去请求 `listAllSessions`。**依据**：这个宿主只有一条会话槽位，恢复磁盘会话等于把用户终端里正看着的那条换掉，而它连 `initialize` 都没实现（「Web 端也能发起新会话」的未落地记录里是同族缺口）；「弹窗里列出来 ⇒ 选中可用」这条既有承诺要求它**不列**。收窄发生在**宿主侧**（服务端那张表按入口给出集合，前台宿主在回包前删掉这一条），页面不存名单（见 `commands/slash-commands.md` 的「弹窗里的宿主命令只有一份表」故事）。

7. **假设** 机器侧是**旧版本**（老 CLI 的 `listAllSessions` 不在白名单里、或宿主没实现），**则** 页面收到既有的「该宿主不支持」结构化错误，按「页面能执行的那批宿主命令」故事场景 10 翻成一句可见文案收尾——不出现空列表，也不长时间转圈。

8. **假设** 恢复成功，**则** 页面的通知过滤照旧按信封 `sessionId` 生效：新会话与旧会话在同一个进程里并存，别的会话的增量不会混进这条新视图（「Web 页作为第五种宿主」场景 11 不变）。

***

### 用户故事：前台 CLI 会话也能被手机接管（优先级：P1）

作为正坐在终端前对话的用户，我希望离开工位时把**手上这条**会话交给手机，而不是另开一条再重述一遍上下文，以便走出工位还能接着推进。

**为什么是这个优先级**：前面几条故事覆盖的都是「常驻着、可以被指着的会话」（daemon、GUI 子进程）。前台 TUI 会话是唯一一种**只活在用户眼前那个进程里**的会话——它的寿命跟着用户敲的那一下退出走。能不能接管，取决于这条会话所在的进程自己愿不愿意拨号，没有第二个进程能替它做（「谁持有会话谁服务」在这条故事里是极端情形）。它不改安全模型（同一个开关、同一份白名单、同一条设备级通道），所以既可以独立落地，也可以独立不落地；缺了它，既有闭环一条都不少。

**独立测试**：在交互式 TUI 里执行开启动作，拿到页面地址与二维码；手机连上后看到的**就是终端里这条会话**（消息逐条一致，不是空会话）；两端各发一句对方都能看到；构造一次等待确认的工具调用，两端同时出现审批卡，任意一端作答后另一端那张卡消失、会话继续；退出 TUI 后页面显示「电脑已断开」；让会话流式生成一段长正文，手机端正文**逐段增长**（不是停在某一瞬的片段），且这期间推给手机的增量帧数远少于模型增量数、结构变化信号只在结构变化时出现；再让一次工具调用失败，手机端那条回复下出现**同一条错误块**（不必手动刷新）。

**验收场景**：

1. **假设** 开关已打开且用户已登录，**当** 用户在交互式 TUI 里执行开启远程控制的动作时，**则** 拨号的是**这个前台进程自己**——不经 daemon 代理、也不为了完成这个动作去拉起 daemon——并在 TUI 的**远程控制覆盖层**里回显与 `wave remote-control on` 同形的页面地址与可扫二维码（承载面见场景 13）；失败时的提示同样指向 `enableRemoteControl`（并说明前台进程也要重启才换设置），且同样只回显在那一层里。

2. **假设** 手机连上了前台 CLI 会话，**则** 它看到的是这条会话**本身**（与终端同一份消息序列、同一个模型与权限模式），不是新会话、也不是空会话。

3. **假设** 用户在手机上发出一条消息，**则** 它进入这条会话的历史并驱动同一条生成循环，终端的消息列表随之更新；反过来，在终端发出的消息同样出现在手机上。

4. **假设** 会话正在生成、用户在手机上触发中断，**则** 中断的是这条会话当前这一轮，与在终端按中断键等价。

5. **假设** 这条会话有等待确认的工具调用，**则** **两端都能作答**（终端沿用既有审批 UI，手机用审批卡），**先作答的一端生效**、另一端同一张卡随即消失；两端看到的待确认集合一致。这不是新增权限：同一个请求本来就要用户点一次，区别只在点在哪一端。

6. **假设** 用户正常退出 TUI、或前台进程被结束，**则** 本机**主动断开这条中继连接**（不替一个已经不存在的进程保留订阅），页面呈现为「电脑已断开」并停止展示内容（与「服务端不落内容」这条既有口径一致）；该会话按 CLI 既有语义落盘，将来恢复出来的是一条**新会话**，不会自动接回手机原来那条连接。

7. **假设** 用户想在 TUI 里关掉，**则** 关闭断开的正是**这个进程**这条连接（关的入口必须和开的入口在同一个进程里，理由同第 1 条）；此时本机别的进程若在服务（daemon / GUI 子进程），它不受影响。

8. **假设** 同一台机器上已经有另一个进程在用同一个通道键服务（daemon 在跑、或某个 GUI 子进程已连），**当** 前台 TUI 开启时，**则** 由前台进程接管、那个进程的连接被中继关掉并如实呈现（见「一个通道一个服务方」故事）——**不允许**两个进程都以为自己在线（通道键是设备级的，见边界情况）。

9. **假设** 手机打开顶栏的「会话」列表，**则** 前台 CLI 这个宿主**有且只有一条**可列：它自己手上这条会话（`listDaemonSessions` 报的就是它，一条，带 `sessionId` / `sessionCwd` / 标题材料 / `lastActiveAt` / 忙闲，全部取自进程内这条会话），**不读磁盘 jsonl、也不列这台机器上的历史会话**；手机点进去只能是这条，「切换」在这个宿主上没有第二个目标。**依据**：页面列会话的唯一数据源是 `listDaemonSessions`（见「边界情况」——手机只能看进程内活着的会话），而前台 CLI 的「进程内注册表」就是它这一条；宿主漏答这个方法时页面落到「读不到电脑侧的会话列表」横幅上（就是这条把「前台 CLI 也必须答它」钉成契约，而不是留给实现去发现）。**这条宿主上也没有 `/resume`**：它只有这一个会话槽位（恢复磁盘会话等于把用户正看着的这条换掉），且没实现 `initialize`，所以服务端那张宿主命令表在回包前把它删掉，「弹窗里列出来 ⇒ 选中可用」的承诺不在这里破例（见「手机端也能接着磁盘上的历史会话聊」场景 6）。

10. **假设** 这条会话正在流式生成，**则** 推给手机的通知与 `AgentBridge` 为 daemon / `--stdio` 会话发出的**是同一张映射表**（同一个 agent 回调 → 同一条通知、同方法名同载荷），于是「白名单与增量拉取」故事场景 4 那个 150ms 合并窗口在两条路上照常生效：正文 / 推理 / 工具参数 / **压缩摘要尾部**（`compactionContentUpdate`，纯增量片段，口径见 `ui/stdio-transport.md`「流式通知纯增量负载」）的增量帧各按 key 累积拼接，离散事件（审批请求、工具块 `start`/`end`、压缩开始/结束、忙闲与其它状态）立即写出并先冲刷缓冲；手机端正文**由这些增量帧驱动增长**，`messagesChange` 只在真的结构变化（新消息、消息被丢弃、清空 / rewind、会话切换）时发一条。**这张表必须是代码里的同一处，而不是两份约定**：方法名与载荷字段集中在 `packages/code/src/remote/remoteNotifications.ts` 的 `remoteWire`，两个宿主都从它取帧（含上下文占用百分比的取整规则 `percentOf`）——宿主各写各的「谁发哪条」，但**形状只有一处**，所以将来改一个字段不会只在一条路上生效。**只由 bridge 发的那些族不进这张表**（会话注册表、`tasksChange` / `queuedMessagesChange` / `commandRunningChange` / 压缩与计划等），因为它们没有第二份实现要对齐，前台宿主也不为此补一条（判据见场景 11）。**依据（2026-10-16 实测）**：改之前这条通道只有「监视本机 UI 状态、变一次推一条粗信号」一条路，而 CLI 的数据层节流已于 2026-10-15 移除 ⇒ 每个模型增量都改一次消息状态，手机侧真实速率是「每个增量一条信号 + 一次 `getMessages` 回拉」，不受任何窗口约束；更糟的是信号不带内容、`since` 又是**排他**锚点，而流式中那条消息正是手机列表的最后一条 ⇒ 页面正文停在拉取那一瞬的片段（实测停在 `He`），同一个增量以增量帧发出却能长到 `Hello`。即「这条路上没有节流」与「手机看不到流式增长」是同一个根因，本场景一次收掉。

11. **假设** 这一轮在**尾部那条消息内部原地**多出一个块（最典型：工具失败 / 生成失败的错误块——前台宿主把它追加到正在流式的最后一条 assistant 消息上，只有当最后一条是 user 消息时才另起一条），**则** 宿主必须用**带载荷的通知**（`noticeAdded`，与 `AgentBridge` 同方法名、同载荷）把它送出去，页面按既有的 `updateNoticeBlock` → `APPEND_NOTICE_BLOCK` 落到那条消息里，**两端看到同一条错误块**。**不允许**只发「列表变了」信号了事：`since` 是**排他**锚点、而锚点正是这条消息本身 ⇒ 回拉返回空 `messages`，错误在手机上永远不出现（与场景 10 的 `He` 是同一个坑，只是方向相反：那边是帧太少，这边是没有帧）。**明确允许的降级**：以「读」为准的状态族——待办清单 / 待发队列 / 命令运行中 / 压缩中——**不要求**在这个宿主上实时推送，页面在冷启动、切会话、重连、重挂、回前台时各读一次 `getSessionInfo` 即对齐（与「Web 页作为第五种宿主」故事里那条既有口径同形：读出来的事实等下一次对齐，页面不为它新增轮询）。判据是**内容能不能被回拉补回**：一手内容（模型增量、工具参数、错误块）必须由通知携带；只作指示用的状态可以只靠读。

12. **假设** 手机请求了一个**这个宿主没有实现**的方法（白名单是通道级的，daemon / GUI 子进程 / 前台 CLI 共用同一份，而三个宿主的实现面不同——前台 CLI 只接了会话内那一批），**则** 宿主回一个\*\*结构化的「该宿主不支持」\*\*错误：固定错误码 + 带方法名的消息，语义上明确是「这个宿主没接」，而不是 `-32601 Method not found`（那是「通道不放行」的意思，页面会据此以为该升级电脑侧）、也不是 `-32602`（参数错）、更不是静默不回。**依据**：把「通道不放行」「这个宿主没实现」「内部报错」压成同一个错误，页面就分不清该升级、该换一台电脑、还是该等实现；而静默不回执会让界面停在那个动作上（没有回执、也没有告警）。该错误由页面翻成一句可见文案并收尾这条请求（见「页面能执行的那批宿主命令」故事场景 10）。

13. **假设** 用户在 TUI 里执行 `/remote-control`，**则** 结果的承载面是**覆盖层**——与 `/status` `/skills` `/hooks` `/model` `/login` 同一族：渲染在输入框位置、输入框隐藏、`Esc` 关闭（2026-10-20 变更，此前是往消息列表尾部追加一条 `role: "assistant"` 的消息）。**这个斜杠命令不带参数：没有 `on` / `off` / `status` 子命令，也不接受机器名**（命名只属于脚本入口 `wave remote-control on <name>`，且只在登记那一次生效——登记之后再传名字也不会静默改名）。判据是**状态自己决定动作，用户不必先算一遍**：`off` / `error` 时唯一合法动作是开启、`connecting` / `online` 时唯一合法动作是断开，所以层里只呈现**当前那一个**动作——层里那**一个**键的标签随状态换（`d connect` / `d disconnect`），而不是把两个动作写成命令参数让用户挑（与 GUI 设置页按 `state === "off" || state === "error"` 翻转按钮同一判据）。打开这一层时：当前是 `off` / `error` 就顺手拨号（敲这个命令的意图就是让它服务，也与「打开即连接」的直觉一致），当前是 `connecting` / `online` 就只显示、不重拨——重拨会把已经连上的链路推倒重来。**这条「打开即拨号」只在打开那一刻成立**：用户在层里断开之后必须保持断开（不得因为一次重渲染又拨回去），再连只能由用户**再次**要求——否则断开键等于没有。**层内的每一类结果都只回显在这一层里，一律不进消息列表**：四态标签（未开启 / 连接中 / 已连接 / 出错）、`snapshot.message` 那句说明、页面地址与二维码、开启与断开两个动作的执行结果，以及所有失败原因（未登录、未开启 `enableRemoteControl`、登记 / 中继失败）。**因此一次 `/remote-control` 执行前后，消息列表必须逐条一致**：不多出一条 assistant 消息、不进模型上下文、也不受 `refreshMessages()` 那次整页替换的影响。**依据**：（a）这块内容要停在屏幕上够久以便掏手机扫码，而状态还会继续变（`connecting` → `online`、被别的进程接管、订阅方掉线）——消息是快照，留不住也更新不了，按 compact / clear / rewind 时会从 SDK 重新拉消息再把地址凭空抹掉；（b）与 GUI 设置页「远程控制」选项卡的口径一致——那一页同样是状态、说明、地址、二维码、失败原因全在页内（`SettingsRemoteControlView` 的「无法读取状态」就是页内一行），从不写进对话，也不给用户 `on`/`off` 两个动词去挑；（c）CLI 既有分界本就是「需要停留或有后续状态 → 覆盖层；一次性一两行回执 → 消息」，二维码面板属于前者；（d）参数是多出来的一层语法，既逼用户先算状态，又自带一条「用法错误」分支——去掉参数，那条错误路径和它的提示文案一起消失。

### 边界情况

**页面「去哪、用什么说」都取自同源，不靠页面配置**：中继地址不做成构建期常量——页面本身就部署在中继同一个域下的 `/remote/`，因此地址由 `location` 同源推导（`http(s)` → `ws(s)`，路径用中继公布的通道形态 `/v1/hosts/ws/{hostId}/subscribe`），`?ws=` 只留作本地调试；通道键就取地址里的 `host=`（地址由本机自己拼，形状与推送落地页模板 `/remote/?host={hostId}&session={sessionId}` 一致）。凭证同理：**握手用的就是企业端那一份登录令牌**（同源存储的同一个键），页面既不要求单独登录、也不注册第二套账号。代价是页面对企业端**会话存储与续期接口的完整形状**形成了耦合，键名、过期时机与接口形状都归对方前端定——这条耦合要显式记一笔，别指望将来有人能从我们的代码里推出来：**四个存储键**（`token` / `refreshToken` / `expiresAt` / `user`，后三个只服务于续期——`user` 是「登录地改过邮箱后别在页面上显示旧信息」这一条）加**一个端点**（`POST /api/auth/refresh`，请求体 `{refreshToken}`，响应 `{token, refreshToken, expiresInSeconds, user}`，与登录响应同形；少一个键就少一条退路）。**为什么值得**：页面的寿命是「一直挂着、或随时打开」，而访问令牌只有 1 小时、刷新令牌是 30 天滑动——不续期就等于用户每过一小时被赶去登录一次，续期把那个每小时一次的打断换成一个用户看不见的请求。**代价是失败模式而不是能力损失**：企业端改了键名、换了端点路径、或改了响应形状，页面**不会硬失败**，而是静默退化成下面那条「跳登录页」的路——有意的选择，宁可退化成「多点一次登录」，也不要留一个只在对方改键名那天才炸的硬依赖。登录态不可用时的处置见下一条——续期在先，跳转在后。

**登录态缺失先就地续期，续期不成才是一次重定向**：页面没有自己的登录步骤（它只是企业端登录态的消费者），所以「登录态不可用」有三种来源——本地读不到令牌、本地令牌已过期、或中继握手回 `invalid_token`（令牌过期或被吊销）。三者都**先试续期**（刷新令牌还在时；耦合清单见上一条），续不上才把**当前地址**（原样带着 `host=`）当回跳目标、跳到企业端登录页（见「Web 页作为第五种宿主」故事场景 8 / 9）。三条硬约束：① **回跳目标只接受同源站内路径**（以 `/` 开头的相对路径；`//evil.com` 与绝对跨站 URL 一律拒绝）——开放重定向是这条路上唯一真实的攻击面；② **每次会话只自动跳一次**（`sessionStorage` 记标记），跳回后仍没有有效登录态就停在明确提示上，不与登录页来回弹跳——续期同理只自动试一次，否则「续期失败 → 跳登录 → 回来 → 再续期失败」只是把同一场弹跳换了个形式；③ **只在企业端域名上跳**——本地调试（`file:`、`localhost`）没有登录页可跳，那里照旧只提示。收端要给的配合是登录页与 SSO 回调支持这个站内回跳参数，且**不复用 CLI 的 `callback_url`**：那个参数的语义是「把授权码回调到本机 localhost 的 HTTP 服务」（服务端也只放行 localhost，见 `POST /api/auth/code` 的白名单），与「登录完回到哪个站内页面」是两件事，混用只会让白名单变成两种语义的叠加。

**图片路径的边界画在「内容类型」上，不画在目录上**：按需取图这条通道**不**把可读范围限死在会话工作目录内——`/tmp` 下的截图必须能看，那是真机验证最常见的落点，也是这个功能存在的理由；代价是它原则上能读到这台机器上任意路径的**图片**。收窄靠三道判据而不是目录前缀：① 入口只对图片扩展名开放，且路径来自对话内容（页面不提供手输路径的入口）；② 宿主按 magic 判真实类型，符号链接指向文本文件会被挡下；③ 只读、不写不删，回包体积被压缩档位限住。**为什么不按目录限制**：限制到工作目录会直接打掉主场景，而 `/tmp` 本身也不是安全边界；真正决定风险的是「拿到页面地址的人能不能操作」——地址不是凭证，中继握手校验这台机器属于当前账号且账号是该企业活跃成员（见「没有配对码」那条）。若要进一步收紧，正确做法是「只允许本会话消息里出现过的路径」，但那要求宿主回扫消息，代价与收益不成比例，本轮不做。

**文本预览不设扩展名白名单——在这里，白名单既没用也不诚实**：2026-11-01 拍板，入口覆盖四条消息通道与工具块头，判据只有「读出来是不是文本」（「点消息里的文件路径看内容」故事场景 4）。**先说这条通道到底在暴露面上加了什么：几乎为零。** 「页面上能读到这台机器的所有文本文件」不是它带来的——那是这款产品本来就有的能力面：页面的白名单里早就有 `sendMessage` 与 `permissionResponse`，持页面者发一句话让模型 `Read` 那个路径、再点一下批准，拿到的就是同一条内容，路径随便指名；`initialize` + `listDirectories` 还能在任意目录建会话。桌面端连远程主机同理——文件面板本来就能浏览整台机器、编辑器能打开任意文件。**这条通道唯一真正不同的地方是「静默」**：它不进对话记录、不调模型、不花 token、不触发审批，而 agent 那条路每读一次都会在转录里留一条消息、花一个来回。这条差异的值不值钱，取决于「谁能拿到这个页面地址」，而那道门另有其门：中继握手校验这台机器属于当前账号、且账号是该企业的活跃成员（见「没有配对码」那条），页面地址不是凭证本身。**因此没有「收紧」这一档可做**：任何在这里加的路径限制（包括排除 `~/.ssh`、`~/.aws`、`~/.gnupg`、`~/.config/gh` 这类凭据目录）都是**假防护**——同一个持页面者发一句话就能读到同一份文件，被挡住的只是更好用的那个入口。真要收紧，动的是「页面上能不能发消息」那一层，不是「点路径能不能看文本」。白名单在这里也两头都不对：会漏（`.env`、`credentials`、无扩展名配置全是文本），又会误伤主场景（`Dockerfile`、`Makefile`、无扩展名的构建日志）。**与图片那条共同的底线不变**：不按目录限制（AI 把日志与截图都写在 `/tmp`，限目录直接打掉主场景）、只读、失败只报不降级。图片那条的「解码出来必须是位图容器」是**判型**闸门（防的是拿扩展名骗过一次读），不是权限闸门——两条通道在权限上都与页面既有能力同档。

**手机能看、能切的，只限「服务进程内活着的那批会话」**：这不是 UI 取舍而是可达性事实——① 手机列会话只能靠 `listDaemonSessions`（进程内存注册表，跨工作目录都在里面）；② 读消息与发消息都要求信封 `sessionId` 在本进程注册表里（`getMessages` 走 `requireSessionForRead`），磁盘 jsonl 里没被载入本进程的历史会话会回 `Session not found`；③ 唯一能把历史会话「原地」载入进程的 `restoreSession` **不进白名单** —— 它是「原地载入」语义，用它等于把服务进程里该条会话换掉（在桌面端就是换掉用户正在看的那个 pane），与「手机只当控制面」相冲。**④ 但磁盘会话并非不可达**：`/resume` 走的是另一条**新建**路径——`listAllSessions` 只读枚举 + `initialize {workdir, restoreSessionId}` 在这台机器上新开一条会话并从那份历史恢复（回执带回新 id），所以它在注册表里出现的是**一条新会话**，没有任何已有会话被替换（见「手机端也能接着磁盘上的历史会话聊」故事）。所以手机侧的会话列表与切换都只看进程内活会话，历史会话要么留给电脑端、要么由 `/resume` 显式开一条新的。切换只改手机自己那份 `sessionId` 与视图，**不发起任何替换已有会话的写操作**（发起新会话与 `/resume` 是另外两条**显式设计**的写路径，见「Web 端也能发起新会话」故事与本条 ④，不在此列）。

**顶栏那两个按钮的分工**：「历史对话」仍不提供（切换收在「会话」面板）；「新建对话」图标恢复渲染但**重绑语义**——插件端它发 `clearChat` → `clearMessages`（agent 内部换绑新 sessionId = 开一条**同目录**的新会话；在 Web 页从未被实现过、点了只会留一条 console 警告），Web 页上它打开「发起新会话」流程（见「Web 端也能发起新会话」故事）——`clear` 那条路给不了选目录，也不回传新 id，为什么不能直接放行见该故事场景 9。桌面端同样把会话的新建与历史收进侧栏、顶栏不渲染那两个按钮（`hideSessionButtons`）——桌面端口径不变，页面是唯一恢复图标的一端：它是唯一没有侧栏、又需要发起入口的宿主。

## 未落地与待决

* \***\*企业端登录页的站内回跳**：需要收端（codechat `frontend-enterprise`）在登录页与 SSO 回调上支持一个**站内回跳参数**——密码登录与 SSO 两条路都要，且 SSO 要跨一次外部 IdP 往返，所以回跳目标得先落 `sessionStorage` 扛过那一次跳转（现有 `callback_url` 已经这么做，可照搬形状）。本仓库侧只负责在登录态不可用时带上这个参数；参数名与校验逻辑归收端，本规格只约定「只接受同源站内路径」这一条。
* \***\*页面产物的打包与分发**：本仓库侧已就绪——`pnpm -F wave-webview pack:remote` 构建页面并把 `remote/dist` 里的产物（`index.html` + `chat`/`shim`/`sw` 三个哈希命名的 JS 与 `chat` 哈希命名的 CSS，加上按需拉取的 `mermaid` 哈希 JS，以及主屏元信息三件——哈希命名的 manifest.json 与两张 PNG 图标——共九个文件）打成 `remote/dist/remote-page.zip`，**根含 `index.html`**，即制品发布接口（`POST /api/publish/remote-page`，multipart 字段 `zip`，静态令牌 `Authorization: Bearer $PUBLISH_TOKEN` 鉴权；SaaS 挂在运营端域名、私有化挂在客户企业端域名，路径与语义两端一致）吃的形状。入口 HTML 只留骨架（约 1 KB），样式与脚本全部走哈希命名：这样入口重校验时不必捎带没变过的 CSS，而未变的资源可长期缓存（入口 `index.html` 走 `no-cache`，哈希资源可走 `immutable`）。**另一半（上传 + 托管）也已接通**：zip 由制品发布接口收下、由企业端在 `<服务端地址>/remote/` 托管并配好缓存策略——上传本身是运维侧的一次令牌鉴权动作（本仓库用 `.wave/skills/ops-upload` 的 `upload.mjs --type remote`）、落的是线上对象，不在本仓库。托管就位之后，页面**不再需要任何地址配置**：它部署在中继同源，地址自行推导（见边界情况），此前「本机/中继地址经页面配置或 URL 参数指定」的说法随之作废。
* \***\*待确认只在它自己的对话里弹、状态在会话列表标出来（2026-10-15 落地，真机未验）**：用户报到的那条（「有多个对话，非当前对话的 ask user question 弹窗有时候会出现在当前对话里」）已按本规格改掉——ⓐ shim 的 `deliver` 去掉了「审批例外」：跨会话的 `permissionRequest` 不再弹卡（它只更新「待确认」集合）；ⓑ 弹卡改成**会话级**：`listPendingPermissions` 仍报全部会话，但快照与补读都按每条请求的 `sessionId` 筛出属于当前会话的那些才弹（不带 `sessionId` 的老机器请求当作当前会话处理——fail-open，藏起来会让审批彻底不可达），于是「弹窗只渲染队列第一条」不再意味着当前对话的卡可能被别条挤掉；ⓒ 标记是**进程级**的：宿主侧记 `waiting` / `running` 两个集合（分别来自 `listPendingPermissions` 与会话注册表的 `isLoading`，零新增读路径），经新消息 `updateSessionStatus` 广播、并随 `setInitialState` 的 `sessionStatus` 一次上屏（会话列表本身只由快照重建，不重建的那些路径靠广播）；ⓓ 列表侧渲染规则＝运行中**含当前那条**、待确认**只标非当前**、一条只出一个标记（待确认 > 运行中）、**不做**「已完成未读」绿点；ⓔ 顶栏「会话」按钮的图标从 `HistoryIcon`（时钟 + 回溯箭头，读作「历史」）换成 codicon `codicon-comment-discussion`（双气泡＝多条对话；手机档随其余图标一起抬大一档），桌面端侧栏那颗状态点抽成共用资产 `HeaderIcons.StatusDotIcon`。用例见 `packages/webview/tests/remote/shimSessions.test.ts`（跨会话帧与标记集合）与 `packages/webview/tests/webview/remotePageSessionStatus.test.tsx`（渲染规则）。**仍未做的**：真机（真中继 + 真浏览器）确认；本机侧 jsdom 用例与一次无头截图（390×844 手机档：三条会话分别呈现 运行中 / 待确认 / 运行中，顶栏双气泡图标）已确认，但「标记在下一次对齐后是否真的消失」这类时序只有真链路能覆盖。**一处已知取舍**：标记是「读」出来的事实（`listPendingPermissions` / 会话注册表），电脑侧把请求作答掉后页面不会立刻收到任何通知——标记要等下一次对齐（切会话 / 重连 / 回前台 / 重挂）才消失，与场景 4 的口径一致，页面不为此新增通知或轮询。
* \***\*页面上的计划内容与无效入口（2026-10-14 落地，真机未验）**：三件事一并改的——ⓐ `ExitPlanMode` 的**计划全文改由审批卡承载**（页面既没有桌面端右栏面板、也没有插件端宿主另开的计划标签页 ⇒ 这是与其余各端不同的**刻意例外**，见 `core/plan-mode.md` 同名故事的场景 11）；ⓑ 页面上**必然无效**的入口按「不渲染」处理（清单见 Web 页故事场景 24），其中**文件路径退化为纯文本**（Write 块的内容预览保留），企业控制台/帮助文档改成真链接；ⓒ 远端白名单放行 `renameSession` / `compact` / `getSlashCommands` 三条**并补上宿主实现**（此前 shim 侧已写好调用、白名单却一律回 `-32601 Method not available over remote control`，页面会弹「…指令没有回执」）。**仍未做的**：真机（真中继 + 真浏览器）确认计划卡在手机上的可读性，以及隐藏清单在实际界面上有无遗漏。
* \***\*页面上的权限档（2026-10-14 落地，真机未验）**：用户报「电脑端这条会话明明是 `bypassPermissions`，页面上显示的却是 `default`」。原因是模式只以**过渡**（`permissionModeChange`）到达页面，而页面自己的初值就是 `default` ⇒ 在本页到位之前定的模式、断线期间改的模式、以及切到另一条会话后上一条的模式，页面都照不到（与场景 17 的忙闲、场景 20 的待审批同一类缺口）。现在 shim 在冷启动 / 切会话（`pickSession`）、断线重连（`auth_ok`）、页面重挂（`webviewReady`）、回前台（`visibilitychange` / `pageshow`）各重读一次 `getSessionInfo`（回包带该会话的 `permissionMode`），只按**当前**会话的结果落笔（在途回复里那条会话已经不是页面在看的那条时丢弃）。**仍未做的**：真机（真中继 + 真浏览器）上确认一次——本机侧单测与 jsdom 测试都对着桩跑。
* \***\*页面会话状态族（2026-10-14 落地，真机未验）**：#2437 收口的第二批——待办清单 / 压缩中 / 命令运行中 / 待发队列从「只接过渡」改为按状态重读（载体：两个宿主的 `getSessionInfo` 扩 `tasks` / `isCompacting` / `isCommandRunning` / `queuedMessages` 四字段，SDK 补公开的 `getTasks()`；上下文占用消费 `getMessages` 回包既有的 `contextUsagePercent`，不新增读路径）。盘点初判「有意为之」的两项（tasks、压缩）经查证页面都在渲染、desktop 的首屏快照都带 ⇒ 改判为缺口（勘误记录在 #2437）。**仍未做的**：真机（真中继 + 真浏览器）确认四项与上下文环在刷新 / 重连后的对齐。
* \***\*页面能执行的那批宿主命令（第二批解禁，2026-10-17 落地，真机未验）**：新增「页面能执行的那批宿主命令」故事（10 条场景），把回滚 / `/clear` / 底栏模型选择器 / `/tasks` / `/workflows` / 排队消息三动作 / `@` 文件搜索 / 历史提示词从「页面不渲染」改成「真的能用」。三层各补一处：**白名单**放行 14 个**已由 `AgentBridge` 实现**的方法（`rewindToMessage` / `listRewindCheckpoints` / `clearMessages` / `getConfiguredModels` / `setModel` / `updateQueuedMessage` / `deleteQueuedMessageById` / `getBackgroundTaskOutput` / `stopBackgroundTask` / `getWorkflowRuns` / `stopWorkflowRun` / `searchFiles` / `getPromptHistory` / `searchPromptHistory`），**零新增协议方法**；**shim** 补对应 case 与回包（`rewindCheckpoints` / `rewindResult` / `configuredModels` / `backgroundTaskOutput` / `backgroundTaskStopped` / `workflowRunStopped` / `fileSuggestionsResponse` / `historyResponse`），并映射 `backgroundTasksChange` 通知；**页面**去掉对应的远端守卫，回滚与 `/clear` 各加一次确认，模型切换（2026-10-21 起为底栏选择器，不再是 `/model` 命令）在远端也加一次确认（文案写明回滚会撤销**电脑侧工作区**的文件改动、换模型写的是**用户级** `~/.wave/settings.json`）。三处口径值得单独记：① **回滚语义与桌面完全一致**——`truncateHistory` → `revertTo` 会按 `file_history` 快照逆序把本机工作区文件还原，所以它是手机误触代价最高的动作，确认不可省；② **`setModel` 不是会话级**——它写 `~/.wave/settings.json` 的 `model` 键（写失败才退化为仅本会话），手机上换一次这台机器所有会话都跟着换；③ **`getWorkspaceDiff` / `getWorktreeChanges` / `getPlanFile` / `getFullMessageThread` 仍在白名单外**，因为页面没有承载它们的面板（`DiffPane` / `PlanPane` 挂在桌面端 pane 体系里、计划正文在审批卡内），`removeWorktree` 同理（页面没有删除会话 / 工作树的入口，那是桌面侧栏的动作）——白名单跟页面能力一致，不是漏项；④ **`backgroundCurrentTask`（把当前这轮转为后台）本批未放行，2026-10-30 补放行**：当时的理由是「页面上唯一的入口是 Ctrl/Cmd+B，手机键盘没有这个键，放行一个调不到的方法只等于放大暴露面」；助手消息的后台按钮落地后（本故事场景 11）这条理由消失，白名单因此加了一行——**共 36 条**（此前各批累计 35 条，以 `REMOTE_ALLOWED_METHODS` 集合为准）。页面上的模型入口自 2026-09-15 起改为输入卡片底栏的模型选择器，`getConfiguredModels` / `setModel` 两条仍按白名单转发。**仍未做的**：真机（真中继 + 真浏览器）确认整批（尤其回滚在手机上的确认-执行-截断链路，以及 `/tasks` 在冷启动后的列表是否真有内容），以及**宿主侧的能力置灰**——页面在收到「该宿主不支持」时只给一句文案并收尾请求，**没有**提前把入口置灰（那需要一个能力探测方法，属于新增协议，本批刻意不做）。
* \***\*Web 页的 `/btw`（2026-10-20 落地，真机未验）**：新增「Web 页也能用 `/btw` 旁路提问」故事（9 条场景），把 `/btw` 从「页面必然无效、不渲染」改成「页面照常拦截、真发 `askBtw`」。三层各补一处：**白名单**放行 `askBtw`（32 → **33**，零新增协议方法）；**shim** 补 `askBtw` 转发（回执落成 `btwResponse`、失败落成 `btwError`，宿主未实现时按 `REMOTE_HOST_UNSUPPORTED` 说「这台电脑上的会话还不支持附带提问」）、把宿主的 `btwContent` 映射成页面的 `btwStream`（`question` 原样带上，面板靠它判归属）、`askBtw` 进 `TURN_METHODS`（300s —— 回执只在整轮答完才发，20s 会误报超时；**该档已于 2026-11-01 整体撤销**，`TURN_METHODS` 此后不设时限，见本节「回合级方法不设请求时限」那条）；**中继合并窗口**把 `btwContent` 并入同一条 150ms（key = `question` + `type`，thinking 与 content 不合并 —— 面板在 content 开始后会丢弃 thinking，合起来等于把它写回去）；**页面**去掉 `/btw` 的远端守卫（`interceptHostLocalCommand`）。对外文档的口径见 `docs/web.md`「顺口问一句（/btw）」。用例见 `packages/webview/tests/remote/shimSessions.test.ts`（RPC + 流式映射 + 宿主不支持 + 超时预算）、`packages/code/tests/remote/notificationCoalescer.test.ts`（合并与两条不合并的边界）与 `packages/webview/tests/webview/remotePageDegradedEntries.test.tsx`（页面拦截 / 裸 `/btw` 只给 usage）。**仍未做的**：真机（真中继 + 真浏览器）确认面板与流式尾部的实际手感；**`/btw` 出现在页面弹窗里**由「弹窗里的宿主命令只有一份表」那条给出（已落地，见该故事与本文件的下一条）；**前台 CLI 会话不实现**（`RemoteControlCliHost` 只接了会话内那批，与「新建会话」「回滚」同列），页面在那种会话上得到的是一句可见文案而不是一直加载。
* \***\*弹窗里的宿主命令只有一份表（2026-10-20 落地，真机未验）**：新增 `ui/slash-commands.md` 的「弹窗里的宿主命令只有一份表——由服务端下发并按入口过滤」故事（7 条场景），把宿主本地命令表从「三份各自维护」收敛成「服务端一份 + 按每次请求的入口标识过滤」。**改动前的现状**：桌面 17 条、VS Code 18 条、JB 17 条（差异两条：桌面端无 `/clear`，JB 无 `/config`），远控页一条都没有——页面的 `getSlashCommands` 打到 `AgentBridge`，那里只回 `agent.getSlashCommands()`，宿主本地那批在三个 GUI 宿主各自的 RPC handler 里拼，桥里不存在。**落地形状**：表在 `packages/code/src/stdio/hostSlashCommands.ts`（与 `AgentBridge` 同侧），每条标出它属于哪些入口；`getSlashCommands` 收请求级的 `surface`（`desktop` / `vscode` / `jetbrains` / `remote-page`，`parseHostSurface` 收窄，缺失或未知值一律只回 SDK 那半——旧客户端与版本错配都按这条走），回包只带 `id` / `name` / `description` / `skillSource?` / `runOnSelect?`。三个 GUI 宿主删掉各自的表改传 `surface`（JB 那条 Kotlin `localCommand` 副本随之退役，它读同一份 JSON），shim 传 `remote-page`，`RemoteControlCliHost`（前台 CLI 会话）也拼同一批——daemon 与前台两条链路回给页面的清单一致。**页面因此拿到的 7 条**：`/status` `/clear` `/compact` `/tasks` `/workflows` `/rewind` `/btw`（`/model` 自 2026-10-21 起换成输入卡片底栏的模型选择器，四个入口都不再有这条命令）；设置页 / 插件市场 / 计划预览 / 远程控制自身那几条不出现（场景 24 的能力面）。**两道防漂移用例已补**（`packages/code/tests/stdio/hostSlashCommands.test.ts`）：①每个入口的集合与其真实执行点对齐——表里出现的每个名字在共享 webview 的拦截 switch 里都有 `/名字` 分支，远控页那一档再与 `REMOTE_ALLOWED_METHODS` 逐条对齐（`/btw`↔`askBtw`、`/rewind`↔`rewindToMessage`…），避免「弹窗里有、点了回 `-32601`」；②四个入口的集合与差异（16 / 17 / 16 / 7）逐条钉住。**仍未做的**：真机（四个入口各开一次弹窗）确认清单与选中行为。
* \***\*点消息里的图片路径看原图（2026-10-30 落地，真机未验，#2696）**：新增「点消息里的图片路径看原图」故事（8 条场景），把「点路径打开本地文件」这条禁令的**图片部分**解禁——文本路径照旧是纯文本（场景 23 不变），只给图片开一条按需取图通道；场景 24 的「不渲染」清单里那一项相应标注「图片路径除外」，白名单故事场景 10 也补一句说明按需取图不在这条剥离的范围内。三层各补一处：**白名单** +1（`getImagePreview`；条数以 `REMOTE_ALLOWED_METHODS` 为准）；**宿主**（`AgentBridge` 与前台 CLI 的 `RemoteControlCliHost` **两个都要**，见故事场景 7）按 magic 判真实类型、用 `imageRewrite.ts` 既有阶梯压到 512 KiB 单帧预算内、压不下去回缩略图并标注「已缩小」；**页面**在 `filePathLinks.ts` 之上加一个**只认图片扩展名**的窄开关（远控页的 `linkFilePaths` 仍为 `false`，不改成全量放开），弹层复用页面已有的图片预览形态。**判据**：这是唯一一个「界面缺口 ≠ 能力缺口」的入口——看图不需要编辑器，宿主读得到、页面显示得了，缺的只是一条按需取图的通道。图片白名单取 `readTool` 的 `SUPPORTED_IMAGE_EXTENSIONS`（png / jpg / jpeg / gif / webp），不含桌面文件面板那套多出来的 `svg` / `bmp` / `ico`。**依据（为什么先 PNG 再 JPEG）**：真机截图是 UI 画面（大片纯色 + 文字），Guacamole 官方文档与 GUACAMOLE-668 都记着「非照片画面用有损 JPEG 会因文字振铃反而更差」，所以压不动时才退 JPEG。**仍未做的**：真机（真中继 + 真浏览器）确认——重点看压缩后 PNG palette 的实际回包字节（是否常能一次进 512 KiB）、弱网下一次取图的耗时，和宿主两套实现（bridge / 前台 CLI）是否都真的能回图。
* \***\*点消息里的文件路径看内容（2026-11-01 落地，真机未验）**：新增同名故事（10 条场景），并把场景 20 / 21 里「文件路径不渲染成入口」改成「点开就就地看内容」；入口**不设扩展名白名单**（判据是内容不是名字，理由与代价见边界情况那条）。三层各补一处：**白名单** +1（`getTextPreview`；条数以 `REMOTE_ALLOWED_METHODS` 为准）；**宿主两个实现**（`AgentBridge` 与前台 CLI 的 `RemoteControlCliHost`，同图片故事场景 7 那条「白名单放行 ≠ 宿主实现」）——`packages/code/src/remote/textPreview.ts` 先 `stat` 判普通文件、超过 256 KiB 只读前 256 KiB（`TextDecoder` 带 `stream: true`，截断处不吐半个字）、含 `NUL` 或替换字符占比超过一成即当二进制拒绝（`-32016`），零星乱码降级显示并在标题栏标注；**页面**把远控页的路径链接档放到全量——此前那处「远控页只认图片」的硬编码（`pathLinkMode` 变量与 Bash 输出里 `linkifyPlainText(result, "images")` 的第二参）一并删掉，四条通道（正文纯文本 / 行内代码 / 围栏代码块 / Bash 输出）此后都按 `"all"` 走，图片扩展名再在点击时分流去取图；工具块头（Read / Edit 头部的路径、Write 块的「打开预览」按钮）在这一端改成「可点、但动作是按需读取」而不是 `openFile`（`WriteToolPreview` 的 `canOpenFile` 从「是不是本机宿主」改成「有没有去处」）。两处与当初设计不同的地方：① 相对路径拼回用的是\*\*`workdir`\*\* 而不是会话工作根——工具块头的相对路径本来就是 `toRelativePath(x, workdir)` 生成的，同一个值拼回才自洽（Bash `cd` 之后 `workdir` 会变，块头显示的也是变过之后的那条）；② 弹层复用 `IdeContextPreview` 覆盖层，按扩展名分两档显示（**只决定怎么显示、不影响能不能读**）：Markdown 文档走文件面板的 `renderFileMarkdown`，代码 / 日志 / 配置走等宽 `<pre>` + `hljs` 高亮（语言取自文件面板同一份 `EXT_LANGUAGE`，配色共用 `FilePane.css` 那一节），而不是把源文件也塞进 markdown 渲染——那会把 `/** */` 注释吃成项目符号、把 4 空格缩进吃成缩进代码块。截断走标题栏一句短说明，编码问题单独一行提示在正文上方（390px 的标题栏放不下两句）。**两条按需读通道共用一套页面侧底座**：`utils/previewRequest.ts` 管待办表与 `requestId` 关联和超时，`imagePreviewRequest` 收敛成它的薄包装（对外 API 不变），`textPreviewRequest` 挂在同一张表上——同一页同时取图与取文本不会串号。**审计脚本跟着改**：命令的发送点从 `vscode.postMessage` 换成 `requestPreview(...)`，`scripts/audit-webview-commands.mjs` 的两个正则都要认这个形状（否则重构之后连原有的 `getImagePreview` 都识别不到发送方），`getTextPreview` 登记进 `REMOTE_PAGE_GATED`。**顺手修**：`e2e/remote-composer-compact.e2e.ts` 是 #2697（无会话置灰输入区）之后漏改的既有红——那个文件的 `setInitialState` 没带会话，底栏控件全是 `disabled`，e2e 不在 PR CI 里跑所以一直没暴露。**仍未做的**：真机（真中继 + 真浏览器）确认——重点看 256 KiB 截断在手机上是否真的读得动、GBK / 混排日志的降级显示、两个宿主是否都真的能回内容。
* \***\*弹层里的相对链接（2026-11-02 修，真机未验）**：他报「点开一份 md，弹窗里点相对路径就报错」，错误是静态站点的 `NoSuchKey`——正文是 `dangerouslySetInnerHTML` 渲进页面文档的，没有任何点击接管，浏览器按页面地址（`<站点>/remote/`）解析那枚 `<a href>` 就去要一个不存在的对象。这与 `#2719` 修桌面端文件面板是同一类缺陷，当时的「同类面盘查」漏算了这个共用弹层（它的 markdown 档只有远控页在置）。修法照搬面板那套判据、复用同一个解析器：`IdeContextPreview` 新增可选 `filePath`（markdown 相对链接的解析基准）与 `onOpenFile`（点到本地文件时回调），`Message` 把 `filePath` 一起记进 `idePreview`、`onOpenFile` 接回 `showRemoteFilePreview` —— 于是在同一个弹层里换一份（图片扩展名的链接仍旧分流去取图），页内锚点与外链保持默认行为。用例：`tests/webview/remotePageDegradedEntries.test.tsx`（相对链接被 `preventDefault` 且按被预览文件目录解析出第二次 `getTextPreview`；页内锚点不发起读取），以及 `e2e/remote-path-preview.e2e.ts` 里真 Chromium 跑的同一条（点一次页面地址不变、弹层里换成目标文件的内容；去掉接管后这条会红）。**仍未做的**：真机（真中继 + 真浏览器）上点一次确认。
* \***\*Web 端的会话列表与切换**：**已按本规格落地**（机器侧：白名单加 `listDaemonSessions`，并给它补 `customTitle` / `firstMessage` / `lastActiveAt`；shim：改用进程内活会话列表并按 `lastActiveAt` 倒序、切换只在本地改 `sessionId` 与视图、按信封 `sessionId` 过滤通知、同时认 `messagesChange` 与 `messagesChanged` 两种写法、列表拉不到时给出横幅；webview：手机页隐藏「新建对话 / 历史对话」并给出顶栏「会话」入口）。用例见 `packages/webview/tests/remote/shim.test.ts` 与 `packages/webview/tests/webview/remotePageSessionSwitch.test.tsx`。**未做的**：真机（真中继 + 真浏览器）联调，以及让电脑侧生成的页面地址带上 `?session=` —— 该参数目前只在地址里手工带上时生效（pin 一条活会话），电脑侧拼地址时还没有「默认选中我正看着的那条」这个语义。
* \***\*Web 端发起新会话（2026-10-15 落地，真机未验）**：已按本规格落地——bridge 加 `listDirectories`（与 `listGitBranches` 同形的主机无关列目录），远端白名单放行 `initialize`（入参收敛为 `workdir` / `permissionMode`）与 `listGitBranches` / `createWorktree` / `listDirectories`；shim 补 `newSessionListDirs` / `newSessionListBranches` / `newSessionCreate` 三条页面命令转发（按 requestId 回执）；页面顶栏「新建对话」图标重绑为打开新建会话流程（最近目录 + 「浏览…」含手输 + 分支与 worktree 开关，目录浏览器与桌面端同源）；这个入口与顶栏「会话」切换在页面上**都不套用 IDE 那条忙闲守卫**（正在生成 / 压缩中 / 有正在运行的后台任务时不置灰、也不退化成静默忽略，见该故事场景 10）；桌面端远程主机的目录浏览与存在性探测同步切到同一 RPC（`remoteCli.ts` 的 `listRemoteDirs` / `remotePathExists` ssh shell 路径退役）。用例见 `packages/webview/tests/webview/remotePageNewSession.test.tsx`、`packages/webview/tests/remote/shimSessions.test.ts` 与 `packages/desktop/tests/desktopHost.test.ts`。**仍未做的**：真机（真中继 + 真浏览器）联调。**同族缺口（2026-10-16 记一笔，本轮不补）**：这四条（`initialize` / `listDirectories` / `listGitBranches` / `createWorktree`）只落到了 `AgentBridge` 上——前台 CLI 的宿主 `RemoteControlCliHost` 一条都没实现（它的 switch 只有读消息/状态、审批、发消息与中断、权限档、重命名、压缩、指令清单），于是页面顶栏「新建对话」在 CLI 会话上必落到「`Method not available for a CLI session: listDirectories`」。这与前台 CLI 故事场景 9 是同一类缺口：**白名单放行 ≠ 宿主实现**，两个宿主各自实现各自的那部分。本轮只把「会话列表」那条补齐（页面打开即报错，是可见的坏件）；新会话这四条先不动，因为前台 CLI 的「会话」只有它手上这一条，「在这台机器上再开一条」对它意味着什么——新会话归谁持有、页面切过去之后终端那一侧看什么、这条前台会话要不要退居其后——还没定，语义定了再实现。
* \***\*手机端的会话列表与删除（2026-10-17 落地，真机未验）**：新增「手机端的会话列表与删除」故事（13 条场景），把顶栏「会话」从**借来的下拉**（插件端 `SessionListPopup`，标题换成「会话」）改成远控页专用的**整页列表**：搜索框 + 两行行文（标题 / 「项目目录名 · 最近活跃时间」+ 一个状态点，待确认 > 运行中 > 有后台任务在跑）+ 行内「⋯」→ 底部动作面板（重命名 / 删除会话）→ 全屏确认卡片（固定一句「对话记录仍保留在这台电脑上，可以从桌面端恢复。」+ worktree 警告 + 运行中勾选 + `取消`/`删除会话`(`删除会话与工作树`)）。三层各补一处：**页面**新增整页列表视图（只在远控页挂载，IDE / 桌面的入口与那份下拉一个字不改）与「已删除」提示（删完回列表、不自动跳）；**shim** 补 `deleteSession` / `getSessionWorktreeChanges` 两条转发（按 `requestId` 回执 `sessionDeleted` / `sessionWorktreeChanges`），并把会话注册表里新出现的两个事实（`hasRunningBackgroundWork`、`worktree`）并进既有的 `sessionStatus` 广播（不加读路径、不加通知）；**宿主**给 `destroy` 加可选 `removeWorktree`（daemon 内部从 `createdWorkdir` 定位工作树、**排在 `agent.destroy()` 之后**删，定位不到则会话照删 + `worktreeRemoved: false`），返回值扩成 `{worktreeRemoved: boolean}`，`listDaemonSessions` 每行追加可选 `worktree` 字段（fs 推导，不新增持久状态、不逐行跑 git），并新增一条**会话作用域**的只读方法 `getSessionWorktreeChanges` 供确认卡片计数——**裸 `removeWorktree` 与 `getWorktreeChanges` 仍不放行**（远端能指名的只能是它列出来的会话，不是任意路径）。本单对白名单是**纯追加**：30 → **32**（只加 `destroy` 与 `getSessionWorktreeChanges`）；也因为「进程内活会话」这件事第一次有了管理面，「顶栏『会话』整页列表」与场景 24 里那个**磁盘历史会话入口**（`/resume` / 「历史对话」）彻底分开——后者仍不渲染、`listAllSessions` / `restoreSession` 仍不放行（见本故事场景 12）。**已知残留（明确接受、本轮不处理）**：手机删掉一条由**桌面端 / IDE 托管**的会话后，电脑侧那个面板不会自动关闭，会留一个点进去就报错的口子，需用户手动关——不做「会话被远端删除」的跨进程通知。**仍未做的**：真机（真中继 + 真浏览器）确认整条链路（列表滚动、行「⋯」与底部面板的手感、worktree 计数在真仓库上的文案、删除后电脑侧的实际表现）。
* \***\*打开「会话」列表时对齐一拍（2026-11-01 落地，真机未验）**：用户报「在远控页新建一条对话、发了第一条消息，再点顶栏『会话』，那一行标题还是『新对话』」。查因不是标题取值算错，而是**列表数据的下发时机**——列表只在快照里重建（首屏、切会话、新建 / 删除、机器切换、中继要求重拉），而「打开列表」过去只是本页的一次视图切换（`setSessionPageOpen(true)`）、不产生任何请求；新建会话成功那一刻的那一拍快照里还没有第一条用户消息，行标题的材料 `firstMessage` 是空的，之后发消息、回合结束（`loadingChange` → 只推忙闲与状态标记）都不再拉列表，于是那一行停在新会话时的默认标题上（对话页顶栏的标题不会停，它走 `getSessionTitle` 从消息里派生）。**改法**：新增场景 15，页面打开列表时发一条 `refreshSessionList`（远控页专属，登记 `REMOTE_PAGE_GATED`），shim 重读一次 `listDaemonSessions` 后推 `updateSessions` + `updateSessionStatus`——**不加协议方法、不加读方法**，用的就是既有那条 fs-only 的注册表读（不逐行跑 git，见本故事场景 9）。失败静默：保留旧列表、只记一条 warn，不拿红条盖住一张还能用的列表。**刻意不做的**：轮询、每回合结束刷新、在 `loadingChange` 里顺带推列表——对齐的时机就放在「用户要看现在什么样子」的那一刻。用例见 `packages/webview/tests/remote/shimSessions.test.ts`（重读后标题材料与标记一起到、失败静默）与 `packages/webview/tests/webview/remotePageSessionSwitch.test.tsx`（远控页打开列表发这条命令，插件端那个下拉不发）。**仍未做的**：真机（真中继 + 真浏览器）确认——手机上新发一条消息后立即点开列表，那一行是否已经是最新标题。
* \***\*最近工作目录（2026-10-21 落地，真机未验）**：新增 [core/recent-workdirs.md](../core/recent-workdirs.md)——「最近打开过的目录」改由**机器侧**持久化（`~/.wave/recent-workdirs.json`，MRU 10 条，原子写），桌面端按主机读取、远控页新建会话时合并进来，两端共用同一份。它修的是本故事场景 2 暴露的问题：那一版的最近目录**只从活会话派生**，删光会话就归零（浏览器 localStorage 不行——那属于这台手机的浏览器，不属于那台电脑）。对本故事的影响只有两处：场景 2 的最近目录改成「活会话派生（前）+ 机器 MRU（后）」两段合并，读不到机器那段就退回只列派生目录；白名单 32 → **34**（只读的 `getRecentWorkdirs` + 新建会话成功后调用一次的 `rememberRecentWorkdir`——写入点放调用方手里，因为 worktree 新建的第二步 `initialize` 收到的已经是工作树路径）。**两层都落了**：shim 侧两条转发（前者走无会话通道，旧版电脑没有这个方法时回空表）、桌面端把 `wave-desktop.json` 的分组降为旧版机器的回退。顺带修掉「把对话全部删光后顶部出现 `Session ID is required`」——shim 的 `pullSnapshot` 在机器一条会话都没有时仍带着空 `sessionId` 发 `getMessages`。**仍未做的**：真机（真中继 + 真浏览器 + 真桌面端）联调。
* \***\*手机端 `/resume`：接着磁盘上的历史会话聊（2026-11-01 定方案，未实现）**：新增「手机端也能接着磁盘上的历史会话聊」故事（8 条场景），把「磁盘历史会话」从场景 21 的「不渲染」清单里移出——**只移 `/resume`**，顶栏「历史对话」仍不渲染。三层各补一处：**白名单** +1（`listAllSessions`，host 级只读；`restoreSession` **仍不放行**——它是原地载入，会换掉电脑侧那条会话）；**`initialize` 的参数收敛清单**加 `restoreSessionId`（`pickInitializeParams` 此前把它显式剥掉：那段注释的语境是「切活会话走 `getMessages`」，而 `/resume` 是「打开一条不在注册表里的会话」，两者不冲突，见新故事场景 4）；**shim** 加 `listAllSessions` 转发（页面带上 `limit: 200`）并把恢复动作接到既有的 `newSessionCreate`（initialize）通道上、参数加 `restoreSessionId`；**页面**把 `/resume` 移出远端守卫、加整页列表（形态同「会话」整页列表，每行给**完整 workdir 路径**）、选中后按回执的新 `sessionId` 绑定并重读会话列表；**宿主命令表** `resume` 的 `surfaces` 加 `remote-page`，同时 `RemoteControlCliHost` 在回包前删掉它（这张表第一次出现「入口一致、宿主再收窄」）。**为什么只列 200 条**：帧上界 512 KiB，而 `sendResult` 对 `getMessages` 以外的回包超预算时直接报错、不截断。**未做的**：实现本身与真机（真中继 + 真浏览器）验证。
* \***\*回合级方法不设请求时限（2026-11-01 落地，真机未验）**：用户报「sendMessage 还没有回执（电脑侧仍在处理，回合可能还没结束）出现的概率好高」。这句是 shim 给 `TURN_METHODS`（`sendMessage` / `compact` / `askBtw`）单独留的 5 分钟档超时文案，而它的两个前提都站不住：① 回执要等整轮跑完，整轮没有上界（工具循环几小时也可能），所以任何固定数字都会被真长回合撞上；② 回执是一次性帧，链路一断就永久丢（机器侧 `RemoteControlService.send` 是 fire-and-forget、页面 `pending` 也不在重连后重问），所以这个超时分不清「还在跑」与「回执丢了」——而丢回执恰恰是手机上最常见的那种（锁屏 / 切后台挂起、Wi-Fi↔蜂窝切换）。红字只在页面认为会话空闲时呈现（`applyStreaming` 口径），于是「回合早就跑完、回执丢了」这条最走不到 console 抑制的路径反而全落在红字上，用户读到「没发出去」就重发。**改法**：`TURN_METHODS` 这一类**完全不挂超时**（不是换成更大的数字），保留 `pending` 条目让真错误帧仍能落到 `.catch`（白名单拒绝 / 会话不存在 / 宿主不支持都是秒级回执）；`sendMessage` 那条 `if (streaming)` 抑制分支随之删除——不再有假错误要抑制。**代价与取舍**：回执丢了就留一个永不 settle 的 `pending` 条目（`resetMachineView` 会清，无 UI 影响）；「没送达」不再有专门提示，因为可判的失败信号都在别处且更快——错误回执、连接状态条、发件箱在 `auth_ok` 的补发，而回合真卡住时输入区本来就有停止入口。**用例**：`packages/webview/tests/remote/shimSessions.test.ts`（长回合 / 回执丢失都不产生错误块，`askBtw` 同样不限时）、读接口与普通控制写的 20 秒档不变。**未做的**：真机（真中继 + 真浏览器）确认长回合与锁屏回来后的表现。
* \***\*手机上往上翻更早的消息（2026-11-06 落地，本机真中继已验证）**：新增同名故事（11 条场景），把远控页的「顶部即终点」改成「滚到顶再要一页」，与其余三端同一条协议（`loadEarlierMessages` → `earlierMessages`，判据是回包的 `hasMore`）。三层各补一处：**协议不用动**——`getMessages` 本来就在 `REMOTE_ALLOWED_METHODS` 里、`before` 本来就是它的参数，白名单与帧预算（`sendResult` 对超预算的 `getMessages` 回包裁到最新的能装下的那一段）都照旧；**shim** 补该 case 与回包，并把两件事做实：① 影子列表把上翻取回的页**接进 `messages`**（这一侧每次结构变化都会整表重发，不接就等于下一页重发又把翻出来的几页抹掉），② `hasEarlier` 成为顶部开关的唯一判据，随 `setInitialState` 与每次 `updateMessages` 一起下发（值只来自宿主回包，不按条数猜）；**宿主**两个服务端实现都要把 `before` 透传给 `getMessages`——多会话的 `AgentBridge` 原本就有，前台 CLI 的 `RemoteControlCliHost` **此前只映射了 `since` 与 `limit`**，于是那一类会话上「上翻」会退化成「再读一次尾部」（按 id 去重后列表不变、`hasMore` 仍为 true ⇒ 每次滚到顶都白要一次、永远翻不动），本轮补齐并加了用例钉住（故事场景 11）。用例见 `packages/webview/tests/remote/shimSessions.test.ts`（首屏 `hasMore` 随快照下发 / 滚到顶按已加载第一条当锚点 / 回包说没有更早后不再开 / 取不到就回空页收手且不新增页内提示 / `anchorLost` 不当成到底 / 切会话丢弃回看深度且不接收在飞的那一页）与 `packages/code/tests/remote/remoteControlCliHost.test.ts`（`before` 透传）。**本机真中继已验（2026-11-06）**：起一个按 `remote-channel.md` 两道闸实现的中继 + 真 Chromium，宿主侧是真 `RemoteControlService`/`RelayClient`/`frameBudget`（只有背后的会话是桩转录，一条真会话要模型），5.7 KB/条 × 250 条与 40 KB/条 × 80 条两例都翻到了 `msg-001`：0 丢帧、0 漏条、0 重复、接缝连续、最大帧 342,851 B / 492,935 B，页长随预算在 60 与 12 之间变化（翻页手感确实不均，用户只会感到「又得上拉一次」，与「代价照实说」一致）。
* \***\*页大小降到 60 + 裁短时强制「还有更早」（2026-11-06 落地，本机真中继已验证）**：上一轮落地后立刻发现两件事，一并修掉。① **洞**：`sendResult` 裁超预算的 `getMessages` 回包时把宿主的 `hasMore` 原样透传——上翻正好取到这条会话最早那一页（宿主说 `false`）、而这一页又刚好超预算，裁掉的正是最早那几条，页面却照着 `false` 把上翻开关关掉，那几条从此再也翻不回来。改法是裁短时把 `hasMore` 强制成 `true`（裁掉的是最早的，所以「更早的还在」必然成立），未裁的页一个字不改（故事场景 5 的后半句）。② **页长忽多忽少**：页大小 100 × 实测 5.7 KB/条 ≈ 0.57 MB 常态越过 512 KiB 预算，于是常态被裁掉十来条。调大预算不是选项——中继那两道闸的最小值是每订阅者 1 MiB，把阈值往上调只是把丢弃点往后推（见 `remote-channel.md` 边界情况「有效单帧上限取三者最小值」），参考物也没有可抄的参数（Claude Code 的 `MAX_CHUNK_BYTES = 512 KiB` 是分片上传、不是裁剪；claude.ai 前端根本没有单帧预算）。所以把页大小降到 **60**（≈ 0.34 MB，落在预算内），常态变成**整页整页**地到。用例：`packages/code/tests/remote/remoteControlService.test.ts`（裁短后 `hasMore` 必为 `true`；整页装得下时原样透传）与 `packages/webview/tests/remote/shimSessions.test.ts`、`shim.test.ts`（首屏与上翻一律 `limit: 60`）。**本机真中继已验（2026-11-06）**，并做了正反两跑：造一条 26 条 × 40 KB 的会话（首屏那一帧 1043 KiB 必然被裁到 12 条，正是「最后一页被裁」的形状），修复版跑出 26/14/2 三页、`msg-001`/`msg-002` 仍然到达页面（PASS）；把那行强制改回原样再跑同一例，`msg-001`…`msg-014` 共 **14 条在真浏览器里彻底丢失**（页面照 `false` 关掉开关，DOM 顶行停在 `msg-015`），恢复后同一例又 PASS——所以这一处是实打实兜住的，不是只在单测里成立。**仍未做的**：真企业端中继、真手机、弱网三样（本机那次是照契约实现的同构 relay + Chromium 视口/触控模拟 + 回环链路）。
