---
url: /wave-agent/specs/remote/remote-channel.md
description: 本机到中继的那条出站通道：白名单与单帧预算、连接生命周期（重连 / 背压 / 断连恢复）、断线自愈，以及状态怎么在页面上播报
---

# 功能规格说明：中继通道

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

本文只讲**手机连本机的那条通道**：哪些方法能过、一帧能装多少、断了怎么回来、状态怎么播报。入口与开关见 `remote/remote-machine.md`，机器身份见 `remote-machine.md`。

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

### 用户故事：远端通道只开一个白名单、且不把全量内容推给手机（优先级：P1）

作为用户与管理员，我希望手机只能问聊天与审批相关的那几个方法，并且长会话不会因为「手机在旁边」而把 MB 级全量帧推给中继，以便同时控制暴露面与流量成本。

**为什么是 P1**：本地传输一直是什么都能调——那是用户自己的机器跟自己说话；手机是一张持有令牌的网页，暴露面必须显式收窄。这条不改也能跑通审批闭环，但它决定这个功能能不能长期开着。

**独立测试**：对已连接的中继路径发一个白名单外的方法，得到明确的「Method not available over remote control」拒绝；对一个≥1000 条消息的会话，远端路径看不到 MB 级全量帧，只有「列表变了」的信号 + 按 `since` 的增量拉取。

**验收场景**：

1. **假设** 手机发来白名单内的方法（`listSessions` / `listDaemonSessions` / `getSessionInfo` / `getMessages` / `listPendingPermissions` / `permissionResponse` / `sendMessage` / `abortMessage` / `setPermissionMode`，以及后续两条故事放行的会话维护、建新会话与第二批方法——**完整名单以 `remoteControlService.ts` 的 `REMOTE_ALLOWED_METHODS` 为准**），**则** 请求照常经 bridge 执行并把结果回执给手机。其中 `listDaemonSessions` 是手机列会话的主数据源：它报的是**该进程内存里的活会话**（`sessionId` / `sessionCwd` / `isLoading` / 消息数），而 `listSessions` 读的是磁盘上的 jsonl、会把早已结束或本进程从未载入的会话也列出来 —— 那些条目在手机上打不开（见边界情况），所以它只留作兼容、不做手机列表的数据源。回执是**带 `method` 的 JSON-RPC 信封**——成功 `{id, method:"result", params:{result}}`、失败 `{id, method:"error", params:{code, message}}`，**不是**裸的 `{id, result}`：页面侧必须按这个形状取结果与错误（把结果读成顶层 `result` 字段会让每一份回执都变成 `undefined`，于是会话列表恒为空，而失败一声不吭）。
2. **假设** 手机发来白名单外的方法，**则** 回明确错误（`-32601`「Method not available over remote control: \<method>」），不执行、不静默丢弃。
3. **假设** 某会话发生结构变化（新消息、消息被丢弃、清空 / rewind、会话 re-attach），**则** 发往中继的那一路只发一条**不含内容**的「列表变了」信号，内容由手机按 `since` 自己拉；本机侧的桌面 / IDE 客户端收到的也是**同一个不含内容的信号**——它们不消费它的载荷，restore 后用自己的 `getMessages` 兜底（2026-10-18 收成一种形态：本机侧原发全量数组，但零消费者，且长会话可达 MB 级的构造与序列化是中继路径上的纯成本）。
4. **假设** 流式输出以每秒几十到上百帧的速率产生增量，**则** 只有**发往中继**的那一路按 150ms 窗口合并累积型增量，合并是**延迟而非抽样**（不丢任何一帧，合并后的帧携带的字节与逐帧发出的一样多）；本地进程内的客户端完全不走这条路径。这个窗口属于**远端通道**而不是某个宿主：窗口挂在「通知扇出 → 中继」这条缝上，因此**每一个**持有会话并拨号的服务方（常驻 daemon / GUI 宿主的 `--stdio` 子进程 / 前台 CLI 进程）都按同一条 150ms 走，手机在哪个宿主上看到的帧率上界都一样（约 1000/150 ≈ 6.7 帧/秒，且窗口是一个进程一个定时器，正文/推理/工具参数三路一起冲刷，不按路数翻倍）。
5. **假设** 发生离散事件（权限请求、工具块结束、状态信号），**则** 它立即写出，并先冲刷已合并的增量，手机不会看到乱序的流。
6. **假设** 某会话正卡在等待审批，**则** 本机上报 `requires_action`；`running` 表示仍有事情在飞（含后台任务，手机没有终端可以瞄一眼，所以「忙」的判据取宽），`idle` 表示空闲。该信号每会话**一个词**、不含任何消息内容，按 250ms 去抖推送，且只在真的变化时才发。上报走**两条**：业务通知 `sessionStatus`（不带顶层 `type`，中继逐字转发给手机，手机 UI 照旧消费）**再加**一条控制帧 `{"type":"host_state","sessionId":"…","state":"…"}`——中继只消费后者来判断「有人正在等审批」并触发 Web Push，因为从业务信封里取状态等于要它解析 body 语义，与「不解析」的前提冲突。
7. **假设** 手机用 `getMessages` 只带 `since`（增量）、只带 `limit`（首屏尾段），或 `before` + `limit`（上翻一页），**则** 按锚点 / 条数上限返回：`since` 指向的消息已被 rewind / clear 截断时回「锚点已消失」标志（`reset`），让手机重拉尾段而不是猜；`before` 指向的消息不在转录里时回 `anchorLost`，让手机换一个更早的已知 id 重试；回包同时带 `hasMore`（这一页之前还有没有更早），首屏尾段那一份也带——它是页面上翻开关的唯一判据。**不传任何新参数时，既有调用方的返回与行为逐字节不变。**
8. **假设** 手机请求的那一段在序列化后超过**单帧字节预算**（默认 512 KiB），**则** 宿主按预算截短（从最早的条目开始丢，保证留下的是**最新**的一段）后发出，而**不是**把超预算的帧发出去。`limit` 因此只是**条数起点**：条数够小是常态，但**保证一律来自字节预算** —— 因为「一段多少字节」随会话内容（工具输出、diff）而变化，只有字节数是能兑现的承诺。
9. **假设** 手机上的这条会话是由**前台 CLI 进程**（而不是 daemon / GUI 子进程）服务的，**则** 它收到的推送面与另一个宿主**同形**：同方法名、同载荷、同触发点——流式正文 / 推理 / 工具参数走 `assistantContentUpdated` / `assistantReasoningUpdated` / `toolBlockUpdated` 的 `streaming` 增量帧（因此同样落进场景 4 那个 150ms 窗口），结构变化才走不含内容的 `messagesChange` 信号。**不允许**用「监视本机 UI 状态、变一次就推一条信号」来充当这条通道（见「前台 CLI 会话也能被手机接管」场景 10 的实测依据）：那样推送速率不受任何窗口约束，而且因为信号不带内容、手机只能按 `since` 回拉，而同一条流式消息正是手机列表里的最后一条（`since` 是**排他**锚点），页面只能拿到拉取那一刻的片段、之后的增长永远补不回来。
10. **假设** 消息里带用户粘贴的图片（字节就在消息块里，见 `ui/image-pasting.md`「已发送图片的标签点开就能看」），**则** 发往中继的那一路把图片字节**剥掉**：一张截图的 base64 常 1–3 MB，远超场景 8 的 512 KiB 单帧预算，而页面本来也读不到宿主那台机器上的文件、拿到字节同样无处可用。剥离**保持列表长度与顺序不变**——图片块留在原位（正文里的 `[imageN]` 标签按下标映射），只是不带字节，页面照场景 24 把标签渲染成不可点。本机侧客户端（桌面 / IDE）收到的是**带字节的原样**，不受这条约束。**这条剥离只针对随消息下发的字节**：用户点消息里的图片路径时走的是另一条**按需取图**的请求（见「点消息里的图片路径看原图」故事），那条的载荷只有一张压到预算内的图、单帧装得下，不在此列。

***

### 用户故事：连接生命周期——重连、背压与断连恢复（优先级：P1）

作为用户，我希望网络抖动、电脑休眠、中继重启之后连接能自己回来，也接受「电脑联系不上时手机不展示历史」，以便不必盯着状态、也不必担心电脑这边被拖垮。

**为什么是 P1**：首版能连上就能跑通闭环，但这些规则决定它能不能长时间挂着——重试策略写错会变成断线后永不回来或无限重试，背压写错会让一台电脑被一个读到一半的手机吃掉内存。

**独立测试**：断开中继后连接能按固定间隔自动重试并恢复；把中继回成凭据类 4003（`invalid_host_token`）后本机能作废本地身份、重新登记同一行并自己连回来，且同一个进程里只自愈一次；把中继回成非凭据类的永久原因（`organization_mismatch`）时不再自愈、永久停止并把原因呈现为错误状态；让手机停止读取，本机在未发送数据超过阈值时断开连接而不是无上限排队。

**验收场景**：

1. **假设** 连接因普通原因断开（中继重启、网络抖动），**则** 本机以 2 秒固定间隔重试、最多 5 次（固定而非指数退避：中继是知名主机而非抖动对端，退避只会拖慢一台刚休眠醒来的笔记本）；期间状态如实呈现为断开/重连中，不静默假死。
2. **假设** 中继以 4001 告知「本机暂不可用」（瞬态），**则** 本机用**独立的、更小的**预算重试 3 次——真实的不可用重试五次也买不到什么。
3. **假设** 中继以 4003 拒绝凭证，**则** 本机先按随关闭帧回来的 `auth_error.error` **分类**，而不是一律当永久失败：**凭据类**（`invalid_host_token` / `unknown_host`——中继那边已经不认本地这份身份了）走下面第 4–6 条的自愈；**其余永久类**（`organization_mismatch` / `unauthorized`，以及中继侧将来可能新增的其它原因）仍然立即永久停止重连、把原因交给上层呈现——这几类重试或重新登记都改变不了（账号或组织要对上），自动重登记只会把真正的原因盖掉。中继**不做版本门槛**：电脑侧版本高低不影响能否握手，旧版机器连上来之后是按「缺哪个能力就哪条功能不可用」呈现，而不是整条连接被拒。
4. **假设** 失败是**凭据类**——本地那份 `hostId` + `hostToken` 在中继上已不成立（行被吊销、令牌过期、或本地状态半写导致两者不配对），**则** 本机自愈一次：作废本地那份身份 → 重新登记（请求照旧带本机派生的 id，见「机器身份稳定」故事）→ 把响应里的 `host.id` / `hostToken` **原子写回** `~/.wave/remote-control.json` → 立刻重连。**为什么该自愈**：这是「本机记的身份过期了」，不是「中继拒绝了这台机器」——中继侧的吊销语义本就是「立刻失效、断开连接、从列表消失」而不是永久拉黑，本机带同一个 id 重登记会把同一行认回来。**代价**：本地状态文件从「丢了才用得上」的缓存变成了这条路径要动的对象，所以写它必须原子（半写会制造下一次自愈）。
5. **假设** 一次凭据类失败触发过自愈，**则** **同一个进程生命期内不再自愈第二次**：再失败就按永久失败停下，把「已重新登记过但仍被拒」与中继给的原因一起交给上层呈现。**依据**：重新登记会**重签令牌**，而同一台机器上另一个入口手里还是旧的那份——不限次数的自愈会变成两个进程互相重签、互相踢下线的无限对踢（与换代接管那次同一类）。限一次后收敛为「最后自愈的那个在服务，另一个停下」，与「一个通道一个服务方」故事的口径一致。
6. **假设** 自愈过程本身失败（daemon 的登录态已过期 ⇒ 登记被拒未登录、网络不可达、或重新登记后仍被中继拒绝），**则** 本机不再重试，状态置为错误并**分两层如实说明**：本地身份已失效 + 自愈失败的原因（未登录时明写「请先 `/login`」），而不是一句笼统的「连接失败」。
7. **假设** 手机停止读取，导致未发送数据超过 4MB，**则** 本机断开这条连接并告知手机不必重发已缓冲的内容，**不做无上限排队**；手机重连后按 `since` 追赶（与冷启动同一条路径）。
8. **假设** 链路当时是断的，**则** 新产生的通知直接丢弃而不缓冲：手机重连时会用 `since` 补齐，缓冲只会换来一串它马上要丢掉的重放帧。
9. **假设** 连接已建立，**则** 本机每 30 秒发一个**应用层**心跳控制帧 `{"type":"ping"}`（浏览器不能发 WebSocket 控制帧，中继面向的又是手机，所以保活必须长在应用层），中继回 `{"type":"pong"}`，且**两端都不把它转发**——改之前的心跳长得像 JSON-RPC，会被广播给该电脑下全部订阅端。链路层的活性另有一层：中继每 30 秒发 WS 协议层 ping，浏览器与 `ws` 库自动回 pong。
10. **假设** 中继发来回程控制帧（`auth_ok` / `auth_error` / `peer_disconnected` / `resync_required` / `pong`），**则** 本机的中继控制层必须**逐种消化**：`auth_ok` 标记握手完成、`peer_disconnected` 呈现为「电脑已断开」（随后是 4001）、`resync_required` 触发重拉一次快照、`pong` 只是心跳回执；**一个都不许进 agent 桥**——它既不能让手机看到一条凭空多出来的方法调用，也不能被本机当成对端发来的请求去执行。判定是结构性的：「无 `method` 且有顶层 `type`」⇒ 控制层，其余 ⇒ JSON-RPC 层。
11. **假设** `--stdio` 子进程的宿主消失了（stdin EOF）或服务器停止，**则** 本机主动断开中继连接，不让中继替一个已经不存在的进程保留订阅。
12. **假设** 本机要发往中继的某帧在序列化后超过**单帧字节预算**（默认 512 KiB），**则** 处置按类别分两种，但都不许把这一帧发出去：读接口返回**按预算截短的页**（见白名单故事的场景 8），通知类降级为**不含内容的「有更新」信号**（`messagesChange` 早已是这条形状）。理由是中继侧两道闸都不站在我们这边：`maxPayload` 默认 4 MiB **越界即 `1009` 关闭连接**，而**订阅者缓冲默认 1 MiB，判定是把 `bufferedAmount + 本帧字节` 与 1 MiB 比较、超了就丢弃该帧并下发 `resync_required`** —— 这条判定的形态意味着**任何超过 1 MiB 的单帧必然被丢，跟手机读得多快无关**。因此有效单帧上限 = 三者的最小值（本机 4 MiB 累积缓冲 / 中继 4 MiB `maxPayload` / **中继 1 MiB 订阅缓冲**），而**预算必须按最小的那个来定**；本机那个 4 MiB 只作最后兜底，不该有机会被用到。
13. **假设** 一条超过一页的长会话在手机上打开，**则** 首屏那一页本身也必须满足上面那条预算（默认页大小按此选取，而不是按「条数看起来够用」选取）——否则首屏帧在中继被丢弃，`since` 补差又因缺少基准而失败，于是每次重连都重演同一个循环。

***

### 用户故事：断线自愈——等待上线、永久即停、手动重连（优先级：P1）

作为把页面一直挂着的用户，我希望电脑侧「关掉远程控制再打开」、休眠醒来、或中继重启之后，页面能**自己接回来**，而不是重试十秒就永久罢工、只能杀掉页面重进。

**为什么是这个优先级**：主屏 web app 从后台恢复**不会重新加载**，页面寿命以天计——重试预算一旦耗尽就是永久假死，而这正是远控场景最常规的波动（电脑重启、网络抖动、机器侧开关一次）。

**独立测试**：断开电脑侧远程控制，页面进入「等待上线」并按退避持续重试；重新开启后页面自动接回（快照与状态重读齐全）；把中继回成 4003，页面立即停止并给出明确文案；等待/断开状态下切到后台再回前台，页面立刻重试一次而不是等完当前这一档退避。

**验收场景**：

1. **假设** 订阅被中继以 4001 关闭（电脑侧不在线，瞬态），**则** 页面**不落入固定 5 次的重试预算**，而是无限退避重试（指数、有上限），期间状态如实呈现「电脑侧不在线，等待上线」；电脑重新开启远程控制后，下一次重试自动接回，接回路径与既有重连一致（增量补差 + 忙闲 / 待审批 / 权限档重读）。**这一句还要点出最可能的成因与出口**：多机器账号下页面这一条通道只对应其中一台，而「等待上线」只描述这条通道本身——如果用户连的是**另一台**（尤其是同名的那台），这条通道永远不会上线，等下去没有尽头。所以文案在状态之外补一句换机器的指引：「若有多台电脑，可在「更多」的「切换电脑」里换一台」。**依据**：2026-11-05 实测，同一账号在 WSL 与 Windows 各登记一台机器，WSL2 默认把 hostname 继承成 Windows 机器名，两台在机器列表里同名、只有 `platform` 不同；用户点错机器后页面只反复说「等待上线」，而入口（「更多」菜单第一项，见「机器列表与切换」场景 5）不在这句话里，用户不会想到去换一台，最后判断成「远控坏了」。**代价**：这句变长后在手机档会折成两行（场景 9 允许，关闭键仍不折行），状态条多占的那一行从消息区扣（见「状态只在一条流内状态条上播报」场景 8）。**依据**：现页面 4001 与其余瞬态断开共用 `MAX_ATTEMPTS = 5`、2 秒一次的预算（约 10 秒耗尽），耗尽后停在「已停止重试」不再尝试——机器侧一次「关掉再打开」就够触发；iOS 主屏形态不会自己重载，用户看到的是一张再也连不上的页面。
2. **假设** 连接因其余瞬态原因断开（网络抖动、中继重启、链路层关闭码），**则** 走同一条退避路径——页面寿命内的瞬态波动都值得等，不再出现「已停止重试」的终态。
3. **假设** 失败是**永久**的（4003、`auth_error` 的永久类错误），**则** 页面立即停止并给出明确文案，不进入退避——重试改变不了这些原因，只会掩盖它们。
4. **假设** 页面处于等待上线或断开状态，**则** 这条状态**不带**「重新连接」按钮：退回前台（场景 5）本身就是「立刻重试一次」的入口，退避也在自己走，所以页面上不需要第二个手动入口（2026-10-29 移除，见「状态只在一条流内状态条上播报」场景 4）。
5. **假设** 页面从后台回到前台（`visibilitychange` / `pageshow`）时正在退避等待，**则** 立即试连一次并把退避基数归零——回前台的既有一组重读（消息增量、忙闲、待审批、权限档）只在链路可用时发起，等待态这条路是它的补全：用户掏出手机的瞬间就是最好的重试时机。
6. **假设** 任何一次连接成功，**则** 退避基数归零，回到最快响应。
7. **假设** 一条**瞬态**连接（非 4001、非永久失败）断开后，在 3 秒宽限期内重连成功，**则** 状态条**全程不出现**——页面本来就会自己接回来，切后台 / 网络抖动这类常态波动不该在用户眼前闪一条红色警告；**假设** 宽限期过了仍未接回，**则** 这时才以**中性色**呈现「连接断开（`code`），正在重连…（第 N 次）」，并按场景 1 / 2 的退避继续。宽限只对**这一轮断开**计一次：后续重试再失败不把出现时机往后推（否则连续失败会让这条提示永远不出现）。**4001 不进宽限**（电脑侧不在线需要人去机器上开，属于要立刻看见的状态）。**依据**：iOS 主屏 web app 从后台切回时 socket 的 `close` 事件是**滞后**派发的（`onResume` 看到 `readyState` 还是 1，只补读不重连，随后 `close` 才到），用户看到的就是「顶部红一下、随即自愈」（2026-10-30 报障）；红色应留给需要用户介入的状态。

***

### 用户故事：状态只在一条流内状态条上播报——不盖顶栏、可手动关掉（优先级：P1）

作为把页面长期挂着的用户，我希望「连不上 / 读不到」这类提示不要压在我头顶上，也不要在我看够了之后还赖着——因为顶栏那几颗按钮（会话、新建、更多）正是出问题时最要点的东西。

**为什么是这个优先级**：连接层与读失败的状态是**持续**的、不是一次性的——「等待上线」按退避无限期重试，「读不到电脑侧的会话列表」在电脑侧答不上话时也不会自己消失。而改动前它是一条全宽浮层（`position:fixed; top:0; z-index:9999`），压在整个顶栏之上（手机档顶栏更高）：横幅在屏的每一秒，顶栏三颗按钮既看不见也点不到。更矛盾的是「机器列表与切换」场景 5 要求的那个「断开时也能换机器」的常驻入口（它落在「更多」菜单里，而「更多」正是顶栏上的一颗），正好被这条横幅自己盖住。它不是新问题（2026-10-15 报过一次，当时以「等交互设计师」搁置），变的是暴露面：读失败类文案也会长期挂在上面，而这类失败不会自愈。

**独立测试**：让页面停在「等待上线」，用命中测试确认顶栏三颗按钮仍是命中最上层且能点；点状态条上的 × 之后，同一条状态在后续重试中不再出现，而连接恢复、或换成一类新状态时照常出现；在 390×844 的**触控视口**下确认状态条出现时顶栏、状态条、输入区同屏、页面无纵向滚动，并量出关闭键（×）的 `getBoundingClientRect()` 不小于 40×40、命中点落在它自己身上。

**验收场景**：

1. **假设** 任何一条状态正在显示（等待上线、断开重连、永久拒绝、读不到电脑侧的会话列表、拉取会话失败、电脑侧还没有正在跑的会话、这条会话已经不在运行中、某条指令没有回执），**则** 它以**页面流内的一行**呈现：不脱离布局流、不设 `position:fixed` 与浮层 `z-index`，整条顶栏照旧可见可点。判据是**几何**不是样式：状态条显示时对顶栏每颗按钮做命中测试（`document.elementFromPoint` 落在按钮或其子节点上），并断言状态条与顶栏的矩形不相交。**依据**：状态是持续事实，业界对持续状态一律用流内条（Slack / Teams / Telegram 的断线条都占一行、把内容挤下去），浮层留给一次性 toast——两者混用就会出现「浮层长期占着不放」这个最坏形态。
2. **假设** 页面跑在刘海 / 灵动岛机型上，**则** 状态条与顶栏一起被 body 的 `padding-top: env(safe-area-inset-top)` 让开系统 UI，状态条自己不处理安全区，也不写死顶栏高度（手机档顶栏更高、且会随字号档变）。**依据**：`top:0` 的浮层绕过 body 的 padding，横幅会画到状态栏底下。
3. **假设** 状态是**需要用户介入**的拒绝 / 失败（永久拒绝、未授权、电脑侧不在线、读不到电脑侧的会话列表），**则** 用错误色；是进度（冷启动读机器列表、宽限期已过仍在**自动重连**），**则** 用中性色——**「正在重连」本身不是错误**：页面会自己接回来，用红色报的是假警（2026-10-30 报障「从其他 App 切回来顶部红一下、随即自愈」，见「断线自愈」场景 7）。两档语气照旧，且每次绘制都重设，长命的状态条能被后一条刷回中性色。
4. **假设** 状态正在显示，**则** 它是**一句话加一个关闭键**，条内不带任何动作按钮。等待 / 断开时的两个动作各有自己的常驻入口：「重新连接」由页面自身的退避、以及本页回到前台时的立即重试承担（见「断线自愈」场景 4），「换机器」是「更多」菜单里的常驻入口（菜单项「切换电脑」，见「机器列表与切换」场景 5）。**依据**：手机上这一行本来就窄，这两个文字按钮（手机档按手指给足高度、带边框）连同关闭键吃掉近一半宽度，被它们服务的那句错误反而只剩一条窄栏；按钮的视觉重量也压过了它要说明的错误本身（2026-10-29 反馈「文字按钮太大」）。
5. **假设** 用户点状态条右侧的关闭（×），**则** 这条从流里消失、不再占用高度。关闭**只影响呈现**，不改连接状态本身：退避重试照常继续、重连成功后照常恢复，页面也不因此多发或少发任何请求。
6. **假设** 用户关掉的是「等待上线」（文案带着重试计数，每退避一次就变一次），**则** 之后**同一条状态**不再自动出现——计数变化不算新状态；重新出现的条件是状态**换了**（等待上线 → 永久拒绝、断开 → 已接回、读列表失败 → 拉取成功之后又失败）或页面重载。静音的粒度是**状态**而不是文案字面，否则「每 2 秒换一次文案」会把关闭按回去。
7. **假设** 是页内的一次过渡（打开机器列表时的「正在读取」、提交中的加载态），**则** **不**写进这条状态面，由发起它的组件自己承载（既有口径，见「机器列表与切换」场景 8）——状态面只讲连接层与全局读失败，且全场只有这一处。
8. **假设** 状态条正在显示，**则** 「顶栏 + 状态条 + 输入区同屏」这条整屏约束照旧成立（Web 页故事场景 14）：状态条占的那一行从消息区里扣，不挤输入区，也不让页面出现纵向滚动。
9. **假设** 文案里带着错误原文（`err.message` 这类，可能很长），**则** 折行完整呈现、不截断成读不懂的半句；宁可多占一行，也不给一句「读不到电脑侧的会话列表：」后面跟省略号。
10. **假设** 页面在**触控设备**上打开（`pointer: coarse` / `hover: none`，判据与第 27 条同源），**则** 这条线右侧的关闭（×）按手指给足：**热区与字形按手机档给足**，与文案的间距仍是手机档那一档；线自身的内边距相应收一点以抵消关闭键的高度，整条仍是一行。**文案字号不随之抬高**（仍是这一页手机档的「辅助信息」档：提示、空态、工具结果都是这一档，只有阅读主体再大一档）——再抬一档换不来可读性，只让一条三行文案凭空变高。**PC 浏览器保持设计稿密度**（关闭键与内边距都留设计稿那一档）。**依据**：关闭键原来是按鼠标指针定的尺寸（热区与字形都小），2026-10-27 手机上报「图标太小」。**另需照实记一笔**：那枚关闭键的字号当年写成 `font: <字号>/1 inherit`，这是**无效 CSS**（CSS-wide 关键字不能作为 `font` 简写的组成部分），整条声明被丢弃、按钮落到浏览器默认的小字号 Arial——所以实际比设定的还小；真浏览器量出来才发现，这也是「字形与热区必须一起给足」的直接来由。
11. **假设** 用户把设备从触控换成鼠标（或反过来，例如平板接上键鼠），**则** 下一次绘制就换到那一档：档位在每次绘制状态条时重新判定，不做一次性缓存（与样式层媒体查询的实时性对齐）。

**代价照实说**：状态条只要有状态就占一行（它自己的内边距 + 内容，文案折行时更高；手机档关闭键按手指给足、内边距相应收小，实测仍是一行），手机上等于少一行消息；不想看的时候要用户自己点一次 ×。

***

### 边界情况

**没有配对码：通道键不是凭证，页面靠登录态说话**。不发码的两条理由：① 短期票据只有能被**重复签发**才有意义，而本机的登记是一次性的（本地有身份就不再登记，也没有第二处会重发码）——把码塞进登记响应，等于让它的寿命跟着那次登记一起结束，过期即无解；② 码换来的东西本来就不需要保护——中继在订阅握手时校验「这行 host 属于当前账号、且账号是该企业的活跃成员」，**通道键本身没有权限**，印在二维码上不泄露任何东西。对齐参照是同类的 Claude Code：它也没有配对码，二维码只是会话 URL 的快捷入口。因此页面地址就是 `<服务端地址>/remote/?host=<hostId>`，与中继给推送用的落地页模板**同一个形状**——扫码进来和点推送进来是同一条路。

**一条连接承载全部会话**：通道键是设备（本机）而不是会话——会话级通道在这里根本不成立：手机要先从本机学到会话列表，会话级通道会让「列表页」无处可问。因此 `sessionId` 只是信封字段，中继逐字转发不需要理解内容。

**服务端不落内容，断连不展示历史**：中继是无状态转发，不缓存会话内容。因此本机离线时手机只应显示离线状态并停止展示内容，而不是继续渲染本地旧列表「假装还在」。

**`messagesChange` 只有一种形态（不含内容的信号）**：所有产出方——bridge 的 re-attach 推送、前台 CLI 的结构变化通知、转发到远端的扇出——都只发不带 `messages` 字段的空载荷，内容一律由消费方按 `since` 自己拉。历史上本机侧曾发全量数组、由远端扇出丢弃，2026-10-18 收成一种形态（零消费者 + MB 级帧的构造与序列化是纯成本）；中继侧仍保留剥离动作，兼容还在发数组的旧机器。

**`since` 是位置不是版本号**：消息 id 稳定，所以调用方可以只报「我最后看到的那条」；唯一致命的失败是锚点被截断（rewind / clear），此时必须回「重拉尾页」的信号，因为存储里没有版本号能发现这件事。同一个 id 可能在显示列表里出现多次（压缩后的消息会被重新加回），锚点按**最后一次**出现解析。

**远端路径的异常不外溢**：远端扇出被就地兜住（try/catch），一个手机、一条坏帧不能让本地桌面 / IDE 的客户端掉线；本地路径（unix socket / 子进程管道）的逐帧行为完全不变。

**版本在握手帧的顶层，不在 `params` 里**：`{"type":"auth","credential":{…},"version":"1.2.3"}`——中继只读顶层 `frame.version`；写进 `params.version` 是**静默失效**（两侧都不报错，只是这个字段没人看见）。这个字段现在**只上报、不判定**：中继不做版本门槛（`minHostVersion` 与 `host_version_unsupported` 已整条下线，理由见下条），它留着只为排查时能看出这台机器跑的是哪份构建。该机标签（`name`，≤100 字符）则是在**登记**时上报的，只用于让手机区分同账号下的多台电脑。

**版本门槛为什么不做**：判断「电脑侧够不够新」的依据本该是**页面/协议这一侧需要什么能力**，而旧实现把它做成服务端的部署环境变量（`RELAY_MIN_HOST_VERSION`），这个数字既不跟页面同一份产物、也不随页面的改动评审，页面加了新调用而门槛没抬时，用户拿到的是超时和「指令没有回执」而不是一句升级提示。所以先整体下线，需要时再按「页面自报所需能力 + 握手时按能力判断」重做，而不是恢复一个全局版本号。

**两种凭证角色靠 `credential.type` 显式区分**：电脑侧 `{"type":"host","token":"rch_…"}`（登记时取到、持久化在本地，长期可吊销，撑得住几小时长连与重连）；手机侧 `{"type":"sso","token":"<登录 JWT>"}`。**两边是同一个人，身份区分不出角色**，所以角色只能显式声明——漏了它最隐蔽的后果是电脑侧走成订阅者分支：**能连上、两侧都不报错**（拿到的是 `role:"subscriber"`），但没人占 host 位，于是**它自己**先被中继按 4001 `host_offline` 关掉（订阅者必须挂在一台在线的主机上），随后真正的手机连上时也是 `auth_ok` → `peer_disconnected` → 4001。看到 4001 先查「电脑侧是不是走了 sso 分支」。电脑侧的长期令牌免掉了「常驻 daemon 的寿命长于 1 小时 JWT」这个矛盾，而端点解析仍在**连接时**做（用户也可能在进程跑了几小时后才想起开远程控制）；手机侧沿用已有登录态，没有第二套账号体系。登录态不可用时的错误不是笼统的「连接失败」，而是先就地续期、续不上再跳到企业端登录页并跳回来（见「登录态缺失先就地续期，续期不成才是一次重定向」那条）。

**握手完成前一个业务帧都不能发**：中继把「未认证连接发来的任何帧」一律当 `auth` 帧解析，解析不过即 4003 永久关闭（不重连）。而页面挂载、会话就绪这类事件随时可能早于握手完成，因此出站队列必须闸在 `auth_ok` 之后——抢跑一帧就再也连不上。回程那五个控制帧（`auth_ok` / `auth_error` / `peer_disconnected` / `resync_required` / `pong`）同理一个都不许进 agent 桥，这条纪律与中继控制层那条用户故事完全一致。

**有效单帧上限取三者最小值**：本机未发送**累积**缓冲 4 MiB、中继 `maxPayload` 4 MiB、**中继每订阅者缓冲 1 MiB**。我们的单帧预算（512 KiB）按**最小的那个**定，并留出余量给同时在途的其它帧。⚠️ 这两条中继侧的闸语义不同、别混：`maxPayload` 越界是**关连接**（1009），订阅缓冲越界只是**丢这一帧 + 要求重同步**——后者不会报错，只会让手机静默少一页，因此**不能指望「中继会拦住」来替代本机自己守预算**。把中继的阈值调大也不解决问题（弱网手机照样撞），只会把丢弃点往后推。

**上翻一页走的是同一条字节预算，因此不需要任何新协议**：远控页自 2026-11-06 起支持「上滑翻更早」（故事见 [remote-web.md](remote-web.md)「手机上往上翻更早的消息」），一页的条数与首屏同一个取值（`60`），真正的保证仍是单帧预算——超预算的 `getMessages` 回包从**最早一条**开始丢，留下的是与已加载内容**相邻**的最新那段，所以裁短不会在历史里捅出一个洞（下一页的锚点取回包第一条）。**裁短时 `hasMore` 由本机改成 `true`**：丢掉的是最早那几条，「更早的还在」必然成立，而宿主给的那一页可能正说 `false`（上翻取到这条会话最早一页时就是如此）——照抄这句 `false`，页面就把上翻开关关掉，被裁掉的几条再也翻不回来。这也是这一侧能翻页却零新增协议的原因：`before` 是 `getMessages` 已有的参数，白名单本来就放行 `getMessages`，页面与宿主之间只多了一个 `hasMore` 的消费方。**一页连一条都装不下**（单条消息自己就超预算）时回 `-32603`，页面据此收手上翻并保留已渲染内容——不重试到死，也不把「装不下」当成「没有更早」。

## 未落地与待决

* \***\*Web 端到端**：**电脑侧与真中继的联调已实测通过**（登记拿 hostId/hostToken → 订阅 → `auth_ok` → 会话列表 → 审批往返 → 状态信号 → 重连）；**页面侧也已改到本规格描述的形状**（地址同源推导、企业端登录态作凭证、`auth` 帧带顶层 `version`、回程五个控制帧逐种消化、应用层心跳、出站队列闸在 `auth_ok` 之后），并在对着桩 WebSocket 的用例里逐条断言过（地址推导、第一帧必须是 `auth`、握手前的出站闸门、登录态缺失的跳转与不跳转、登录态过期时的静默续期——续期成功不导航、失败才跳、每次会话只试一次、令牌仍有效时不发多余请求、跨会话帧过滤、进程内会话列表与本地切换）。**仍未做的是「真中继 + 真浏览器」那一次**：两端各自都对着契约对齐了，但页面还没对着真中继跑过完整链路（会话列表 → 审批往返），这一步留到测试环境的人工验证里一并覆盖（页面侧的静默续期同样只对着桩 `fetch` 断言过，还没打过企业端真的 `POST /api/auth/refresh`）。**版本地板已整条下线**（2026-10-19，见关键决策「版本门槛为什么不做」）：老机器不再被握手拒绝，而是落到各自的能力缺口上——例如页面要 `listDaemonSessions` 而老机器的远端白名单只放行 `listSessions`，呈现为「读不到电脑侧的会话列表」的横幅（不是静默空白）。此外，**收端仍在返回并保留** `pairingCode` / `pairingUrl` / `pairingExpiresAt` 与 `POST /api/relay/pairings/{code}/claim` —— 本机改成自造页面地址后不再读它们（本规格已无配对码），这套字段与接口可以下线，要在收端另行处理。**手机视口适配（Web 页故事场景 14）在本机也无法验证**：无头浏览器里既没有会伸缩的工具栏、也没有安全区，「页面比可视区高」这一条在本机复现不出来——只有在 iPhone Safari（标签页）与「添加到主屏幕」两种形态下打开才能确认它的实际效果。**2026-10-15 已补上这一次真机确认**：两种形态都验过，并据此把**底部让位去掉**（顶部与横屏左右保留，见场景 14 与那条边界情况）——顶栏与输入区同屏成立、底部不再多出空带。
* \***\*状态面是流内的一条状态条、可手动关掉（2026-10-26 规格确认并落地，真机未验）**：新增「状态只在一条流内状态条上播报」故事（9 条场景），把 `#remote-status` 从 `position:fixed; top:0; z-index:9999` 的全宽浮层改成**页面流内的一行**——顶栏在状态显示期间完整可见可点，安全区随 body 的 `padding-top` 走（浮层的 `top:0` 会绕过它、把横幅画进状态栏）。每条状态右侧补一颗关闭（×），关闭**只静音这一条状态**：重试计数变化不重算，状态换了或页面重载才再出现，退避与重连本身不受影响。**几何断言只能在真 Chromium 里咬到**（顶栏按钮的 `elementFromPoint` 命中、状态条与顶栏矩形不相交），jsdom 与 shim 的 FakeElement harness 都咬不到；整屏约束（顶栏 + 状态条 + 输入区同屏）同理。**仍未做的**：真机（真中继 + 真浏览器）确认，含刘海机型的安全区与 390×844 下的同屏。
* \***\*「上滑翻更早」（2026-11-06 起远控页也做，本机真中继已验证，真企业端中继与真手机未验）**：本机三端 2026-10-29 落地，远控页此前单独留在「首屏封顶 `100` 条、没有更早可翻」的窄形态上，理由是这一侧独有的中继预算（每订阅者 1 MiB 缓冲，超了静默丢帧）。现在按**同一条协议**补上：shim 认 `loadEarlierMessages` → `getMessages({before, limit})` → 回 `earlierMessages`（`messages` / `hasMore` / `anchorLost`），快照的 `updateMessages` 带上 `hasEarlier`；页大小沿用首屏的 `60`（自 2026-11-06 第二轮起；此前取 `100`，按 5.7 KB/条折算约 0.57 MB，常态越过 512 KiB 预算而被裁，页长忽多忽少），真正的保证仍是宿主侧 512 KiB 单帧预算的裁剪（见边界情况「上翻一页走的是同一条字节预算」）——**裁短时本机把回包的 `hasMore` 强制成 `true`**，否则上翻正好取到最早那一页（宿主说 `false`）时被裁掉的几条再也翻不回来。shim 同时把翻出来的页接进自己的影子列表，所以结构变化后的整表重发不会把已翻出的历史冲掉。**本机真中继已验证（2026-11-06）**：起了一个按本规格两道闸实现的中继（`maxPayload` 4 MiB 越界关连接、每订阅者 1 MiB 越界丢帧并发 `resync_required`，带计数器），用真 Chromium 跑完整 shim 页面链路，宿主侧是真 `RemoteControlService` + `RelayClient` + `frameBudget`（只有它背后的会话是桩转录——一条真会话要模型）。三种负载组合全过：5.7 KB/条 × 250 条（首屏 60 条，之后 60/60/60/10，0 丢帧、0 漏条、0 重复、接缝连续、最大帧 342,851 B）；40 KB/条 × 80 条与 × 26 条（首屏被裁到 12 条，页长 12/12/12/… 不等）。**仍未做的**：真企业端中继（本机那次是照契约实现的同构 relay）、真手机（本机是 Chromium 的 390×844 视口 + 触控模拟，没有真 Safari、没有真安全区）、弱网（本机是回环链路，没有延迟抖动与中断重连）。用户故事与验收场景见 [remote-web.md](remote-web.md)「手机上往上翻更早的消息」；本机三端口径见 [message-rendering-system.md](../ui/message-rendering-system.md)「首屏一页，上翻按需取更早」。
