Skip to content

功能规格说明:中继通道 ​

创建日期: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「手机上往上翻更早的消息」),一页的条数与首屏同一个取值(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「手机上往上翻更早的消息」;本机三端口径见 message-rendering-system.md「首屏一页,上翻按需取更早」。