---
url: /wave-agent/specs/remote/remote-control.md
description: >-
  本机怎么开启、关闭与查询远程控制：`wave remote-control` 与 `/remote-control`
  两个入口、开关与拨号的两层语义，以及设置页里的那一项
---

# 功能规格说明：远程控制开关

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

## 概述

远程控制把「本机正在跑的会话」开放给手机：手机只当**控制面**——看会话列表与消息流、审批等待确认的工具调用、派发消息与中断，也能在这台机器上**发起新会话**（选定目录、分支、是否用 worktree，见「Web 端也能发起新会话」故事）；agent 与工具执行始终留在用户自己的电脑上，手机上不跑任何代码。手机不做原生 App，客户端是一张网页（可「添加到主屏幕」）。

连接方向的根因是**手机连不到电脑**（电脑没有公网地址、也不做端口转发）⇒ 由**持有会话的那个 wave 进程主动向中继服务拨出一条出站 WebSocket**，订阅自己这台机器的通道；手机经中继说话。因此通道是**设备级**（一条长连承载该进程上的全部会话，`sessionId` 只作信封字段），不是每会话一条。通道键（hostId）**由本机按机器级稳定标识派生、随登记请求提交**：本机首次启用时向中继登记（`POST /api/relay/hosts`，请求体带上这个 id），把响应里的 `host.id` 与长期令牌一起持久化到 `~/.wave/remote-control.json`，此后每次启动只读本地、不再登记（唯一的例外是连接被**凭据类** 4003 拒绝时的自愈重登记，见「连接生命周期」故事的场景 4–6）。中继按「本账号 + 该 id」**幂等**处理这条登记：命中已有行就复用它并重新签发令牌，于是本地状态丢失后重新登记也只是把同一行认回来、不再多出一行（见「机器身份稳定」故事）。它不取主机名——机器标识与主机名是两回事；中继的通道归属仍以登记行为唯一权威，库里没有行的 id 会让两端握手直接 4003。

**谁在服务、谁在开关**：服务能力只由**持有会话的进程**提供——常驻的 `wave --daemon`、某个 GUI 宿主（desktop / VS Code / JetBrains）自己拉起的 `wave --stdio` 子进程，或**用户正坐在前面的那个交互式 CLI（TUI）进程本身**（前台会话只活在这个进程里，没有任何别的进程能替它服务）。所以同一个用户可能同时有多条出站连接，手机看到的是**被指着的那一个进程**的会话集合；这一点是刻意的，不跨进程汇聚。该能力默认具备，用设置键 `enableRemoteControl: false` 可关闭（见「开关语义」故事）。开启是**两层**的：开关说「本机**可以**服务」，`wave remote-control on` / GUI 设置页「远程控制」项里的开启动作才是**真的拨号**——所以默认具备不等于默认联网。开关只在该进程启动时读一次，改设置后要重启进程（`wave daemon restart`）才生效。

**跨仓库分工**：中继服务（会话中继 + 设备登记 + Web Push）不在本仓库，实现在内部 GitLab 的 codechat 仓库（跟踪单 `vusion/codechat#40`）。本规格只约定本机侧与它交互的那一小块契约，**不复制中继自己的 schema**：

* **登记**（一次性 REST）：`POST /api/relay/hosts`（企业成员令牌，请求体带本机派生的 `id`）→ `host.id` / `hostToken`（`rch_` 前缀，长期、可吊销）。中继按「本账号 + `id`」幂等复用同一行；请求不带 `id` 时维持今天的行为（中继生成随机 id、每次调用新增一行），响应里的 `host.id` 就是最终身份（见「机器身份稳定」故事）。**没有配对码**：页面地址由本机按自己的通道键拼出来（见「没有配对码」那条边界情况）。
* **订阅**：`wss://<中继>/v1/hosts/ws/{hostId}/subscribe`。
* **控制帧**：本机上行 `{type:"auth"|"ping"|"host_state"}`，中继下行 `{type:"auth_ok"|"auth_error"|"peer_disconnected"|"resync_required"|"pong"}`。判定是结构性的——「合法 JSON 对象 + 有顶层 `type` + **不带 `jsonrpc`**」；控制帧永不被转发。
* **业务信封**：其余一切（含带 `jsonrpc` 的 JSON-RPC 信封）逐字透传，中继不解析、不落内容。

**覆盖范围**：这是**跨宿主**规格。CLI 侧有两个入口：`wave remote-control [on|off|status] [name]` 子命令组（作用于 daemon 上常驻的会话，可脚本化、可无人值守），以及交互式 TUI 内的 `/remote-control` 斜杠命令（**无参数**，打开远程控制覆盖层；作用于**你手上这条前台会话**，见「用户故事：前台 CLI 会话也能被手机接管」）；desktop / VS Code / JetBrains 侧的入口是聊天输入框里的 `/remote-control`（打开设置页的「远程控制」项，三端共用同一份 webview UI，各自路由到自己的子进程）；Web 页是本仓库产出的**第五种宿主**（同一份 webview 产物 + 浏览器 shim）。

本目录其余五篇：中继通道见 `remote-channel.md`，这台机器在远控里的身份见 `remote-machine.md`，Web 端这一页的宿主接入、页面动作与会话见 `remote-web.md`，触控设备的尺寸与字号见 `remote-mobile.md`，装到主屏与锁屏推送见 `remote-pwa.md`。

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

### 用户故事：在本机开启远程控制并连接手机（优先级：P0）

作为在电脑前工作的用户，我希望一条命令就把本机连上中继并拿到一个可扫的页面地址，以便用手机接管这台机器上的会话。

**为什么是这个优先级**：这是手机唯一的入场券——没有地址，手机不知道去哪台机器；没有登录态，它也不知道自己凭什么说话（页面复用企业端登录态，没有第二套账号）。开关打开但没人拨号时，本机什么也不做（开关只是「可以服务」，不是「已在服务」）。

**独立测试**：在已登录且开关打开的本机运行 `wave remote-control on`，能打印页面地址与可扫的二维码；再次运行得到同一个地址（幂等）；把开关关掉后运行同一命令，得到退出码 1 与指向设置键的提示。

**验收场景**：

1. **假设** 开关已打开且本机已登录、daemon 在运行，**当** 用户运行 `wave remote-control on` 时，**则** 本机向中继发起出站连接，并打印**页面地址与可扫的二维码**，退出码 0。页面地址是**本机按自己的通道键拼出来的**（`<服务端地址>/remote/?host=<hostId>`），不依赖中继在响应里带回什么——通道键是这台机器自己的身份，地址也就该由它自己拼。
2. **假设** daemon 尚未运行，**当** 用户运行 `wave remote-control on` 时，**则** 命令按需拉起 daemon 再执行（语义与 `wave daemon` 子命令组一致），而不是报「连不上」。
3. **假设** 本机已经处于已连接状态，**当** 用户再次运行 `wave remote-control on` 时，**则** 命令是幂等的——不建立第二条连接、也不重新登记（登记是「本地没有身份时才发生」的一次性动作，本地状态在就一直复用），而是重新读回本机自己的通道键并打印同一个页面地址。
4. **假设** 连接因未登录、中继地址无法解析、登记失败（超时 / 无企业上下文 / 非已激活成员）或被中继拒绝凭证（4003）而失败，**当** 用户运行 `on` 时，**则** 命令打印失败原因、以及指向 `enableRemoteControl` 的修复提示（含「已运行的 daemon 要重启才换设置」），退出码 1；其中「不是企业成员」与「不是已激活成员」要给出不同措辞的下一步动作。登记超时**就是失败**——拿不到通道键就没有可连的东西，不再有「连接已建立、只是码没拿到」这种半成功。
5. **假设** 用户运行 `wave remote-control on my-laptop`，**当** 手机侧列出这台账户下的机器时，**则** `my-laptop` 作为本机的标签随**登记**请求（`POST /api/relay/hosts`）上报并存进中继的机器行里，用于区分同账户下的多台电脑。

***

### 用户故事：关闭与查询远程控制（优先级：P0）

作为用户，我希望随时能查当前状态、也能一句话收回手机的访问权，以便确认手机还能不能看，并且不必相信「我记得我开过」。

**为什么是这个优先级**：开得起来就必须收得回去——「关」是安全底线，而「查」是用户判断自己是否暴露在外的唯一手段。「关」的实现口径是不撒谎也不制造副作用：没有东西可关时不拉起 daemon、也不假装关过。

**独立测试**：对已连接的机器运行 `wave remote-control status` 能看到连接状态与地址，运行 `off` 后状态变为未开启；在 daemon 未运行时运行 `off`/`status` 不拉起 daemon 且退出码 0；开关关闭时的读状态失败要给出退出码 1 与修复提示。

**验收场景**：

1. **假设** 本机已连接，**当** 用户运行 `wave remote-control off` 时，**则** 本机断开中继连接、打印「远程控制已断开」，退出码 0。
2. **假设** daemon 根本没有运行，**当** 用户运行 `off` 时，**则** 命令打印「远程控制未开启（wave daemon 未运行）」并以退出码 0 结束，且**不**借此拉起 daemon（与 `wave daemon stop` 同一口径）。
3. **假设** 本机进程里压根没有中继（开关是关的），**当** 用户运行 `off` 时，**则** 命令按「用户要的终态已经达成」处理：打印已关闭并附上原因、把开关提示写到 stderr，退出码 0。
4. **假设** 本机已连接，**当** 用户运行 `wave remote-control status` 时，**则** 命令打印当前状态（`off` / `connecting` / `online` / `error`），有页面地址时一并打印。
5. **假设** daemon 未运行，**当** 用户运行 `status` 时，**则** 命令打印「状态：未开启（wave daemon 未运行）」，退出码 0。
6. **假设** 状态读不出来（例如 daemon 是更旧的构建、不认识这个 RPC），**当** 用户运行 `status` 时，**则** 命令打印失败原因与开关提示、退出码 1——「读不到」不等于「已关闭」。
7. **假设** 用户不带任何参数运行 `wave remote-control`，**当** 命令解析参数时，**则** 它等价于 `status`；给出未知 action 时打印用法并以退出码 1 结束，不落到交互式 TUI。

***

### 用户故事：`/remote-control` 打开设置页的远程控制选项卡（优先级：P0）

作为 desktop / VS Code / JetBrains 的用户，我希望在聊天输入框里输入 `/remote-control` 就能开关并看到页面地址，以便不必切到终端敲另一条命令；这一项和 MCP 服务一样住在设置页里，不另开一个弹窗。

**为什么是这个优先级**：GUI 用户本来就在聊天界面里工作，把开启入口放到他们眼前的那个界面上，是用户把开关关掉之后还能重新打开的前提——否则关掉的开关等于没有入口。**为什么是设置页里的一项而不是弹窗**（2026-10-17 拍板）：它和「MCP 服务」「项目设置」同属「这台机器上的一项能力」，一个自己管自己的浮层要多带一套关闭语义（点外面关、Escape 关、与其它弹窗互斥），而设置页已经有返回按钮、有导航、有聚焦管理。搬进去之后**只有承载位置变了**：查询/轮询节奏、回执过滤、二维码与地址、失败文案全部逐条不变（见下面场景 2–7），入口仍是同一条命令。

**独立测试**：在任一 GUI 宿主输入 `/remote-control`（不被发给模型）→ 设置页打开并选中「远程控制」→ 能显示未开启状态、能点击开启并回显状态、能显示页面地址与可扫的二维码；手动从导航点进同一项得到同一页；开关关闭时显示明确的读取失败原因而不是一个假的默认状态；切到别的导航项后不再每秒查询。

**验收场景**：

1. **假设** 用户在任一 GUI 宿主的输入框里输入 `/remote-control` 并回车，**则** 这条文本不被发给模型、不进入会话历史，而是**打开设置页并选中「远程控制」项**（desktop 为整页替换会话区，插件端为宿主另开的设置标签页）；该命令也出现在斜杠命令列表的「系统指令」分组里，可直接点选。该项位于左导航「通用」组的第三位（全局设置 / 个性化 / **远程控制**），手动点击进的是同一页、同一份视图。
2. **假设** 该选项卡被选中，**则** 它立即向本端进程查询一次状态，并在此后每秒重查一次（宿主到 CLI 只有请求/应答这一个通道，没有状态推送），状态按「未开启 / 连接中… / 已连接 / 出错」呈现。**只在这一项被选中时轮询**：切到别的导航项即卸载、即停止（与弹窗时代「关掉就停」等价）。
3. **假设** 当前状态是未开启或出错，**则** 主按钮是「开启远程控制」；其余状态主按钮是「断开」；点击后对应的连接/断开结果在同一视图内回显。
4. **假设** 已连接，**则** 视图显示页面地址（可点「在浏览器中打开」交给系统浏览器），并**画出一个可扫的二维码**——内容就是那个页面地址本身（地址里带着本机的通道键）。地址由**持有会话的那个进程**拼出来（它手上有通道键，也知道服务端地址）随状态一起交给视图，视图只负责显示；二维码与终端命令打印的是同一个地址。因此 GUI 这边**总是画得出**二维码——没有「等中继带回地址」这一说，也就没有「没带回所以不画」的回退。**二维码必须保持库自己的白底**（深色主题下换成主题色就是反相码，扫不出来——实测 `jsQR` 的常规通道在反相版本上失败）。**Web 页自己不显示二维码**——它是被扫码打开的那一方，不是扫码的一方（PC 浏览器直接打开同一个地址也一样，不需要再扫一次）；页面上这一项也**不出现**（场景 24）。
5. **假设** 视图开着、每秒一次的轮询让多个请求同时在飞，**则** 只有**最新一次请求**的回执会被渲染——迟到的旧回执（例如晚一秒到达的「未开启」）不得把用户刚建立的连接改回去。
6. **假设** 宿主侧读取失败（开关关闭 / 会话尚未就绪 / 对端不认识该 RPC），**则** 视图显示「无法读取状态」与原因原文，并给出开关提示；**不得** 伪造一个默认快照充数。
7. **假设** 用户切换到别的导航项或按返回离开设置页，**则** 停掉这条每秒一次的状态查询、不触发「中断生成」——切走与弹窗时代的「关掉弹窗」同义。（搬进设置页后不再有「按 Escape 只关弹窗」这条：Escape 不属于这一项，它是设置页整体的键位。）
8. **假设** 桌面端同时开着多个 pane，**则** 这一项作用于**当前 pane 自己的** `wave --stdio` 子进程（每端各自持有一条出站连接），不影响其它 pane 或其它进程。设置页本身按 pane 布局挂在根实例上（pane 内的斜杠命令经 `onOpenSettingsFromPane` 委派，与 `/config` 同一条路）。**插件端那条路不一样但结论相同**：设置 tab 是**独立于任何会话**的（不能靠「本 tab 的会话」去问），而远程控制本来就是**进程级**的（`remoteControl*` 在 CLI 里由进程的控制器应答、不看 sessionId），设置 tab 又与聊天会话共用**同一个** `wave --stdio` 子进程——所以这里问到的就是本窗口那个进程，它正是「你在这台电脑的这个窗口里能看到的会话」的持有者；与聊天路由同一份实现，差别只在回包发给设置面板本身。**判据**：只要进程没开 `enableRemoteControl`，两条路给出的是同一句原因原文（场景 6）。

***

### 用户故事：开关语义——默认具备、显式拨号、进程启动时定型（优先级：P0）

作为用户与管理员，我希望「本机能不能被手机接管」默认就是具备的、但真正联网必须由我显式拨号，以便这项能力开箱可用，而「把会话暴露到本机之外」永远是用户的主动动作。

**为什么是这个优先级**：暴露面真正发生在**拨号**那一步——默认具备只表示这个进程可以服务，不建立任何出站连接、不产生任何外部可见的状态。所以默认值与「不默认联网」可以并存：只要拨号仍是显式的，「打开它」就仍然是有意为之；企业侧要收紧时，经远端托管设置下发 `enableRemoteControl: false` 即可统一关掉整个企业（优先级高于本地与默认值）。

**独立测试**：在不配置任何开关的机器上运行 `wave remote-control status`（或 GUI 设置页的「远程控制」项），得到「可用但未开启」的明确结果，且本机不做任何出站连接；写 `~/.wave/settings.json` 的 `"enableRemoteControl": false` 并重启进程后，能力变为不可用。

**验收场景**：

1. **假设** `enableRemoteControl` 在用户级设置、项目级设置与远端托管设置里都没有配置，**则** 本机**具备**服务能力（开关按默认「开」判定），但**不自动联网**——不建立任何中继连接，要等用户执行 `wave remote-control on` 或点设置页的「开启远程控制」才拨号。
2. **假设** 用户在 `~/.wave/settings.json` 里写入 `"enableRemoteControl": false` 并重启进程，**则** 本机**不具备**服务能力：不建立任何中继连接，`remoteControlStatus` / `remoteControlConnect` / `remoteControlDisconnect` 三个 RPC 一律失败，错误信息指出要移除或改回该设置键。
3. **假设** 企业侧经远端托管设置（`GET /api/wave/settings`）显式下发了该键，**则** 它优先于本地设置与代码默认值。
4. **假设** 进程启动时开关是关的、之后用户改了设置文件，**则** 已在运行的进程保持旧答案（开关只在进程启动时读一次），失败提示必须引导用户重启 daemon 而不是让用户反复改文件。
5. **假设** 设置文件损坏或读不到，**则** 判定按「关」处理——既不按开、也不让读设置这件事把进程带崩（判定发生在准备开网络监听的位置，坏设置必须读成「关」；这是**独立于默认值**的显式兜底：默认值改成「开」之后，读失败仍然读成「关」）。
6. **假设** GUI 宿主（desktop / VS Code / JetBrains）拉起自己的 `--stdio` 子进程，**则** 该子进程以**它自己的工作目录**判定开关：用户级 `~/.wave/settings.json` 总是被读到，项目级 `.wave/settings.json` 只在子进程恰好从该项目目录启动时才参与合并。

***

### 边界情况

**中继订阅地址不上界面**：`wss://<中继>/v1/hosts/<hostId>/subscribe` 是本机到中继的**出站订阅端点**——用户既打不开它也无处输入它，而它携带的 `hostId` 已经原样出现在页面地址的 `?host=` 里，唯一的信息增量是中继域名（即「这台机器连的是哪个环境」），那条信息属于排查场景，排查走状态输出与日志更直接。因此 CLI（`on` / `status`）、前台 TUI 与 GUI 设置页的「远程控制」项都不渲染它；快照里的 `url` 字段保留（连接与日志仍需要），去掉的只是呈现。

**「开关打开」≠「已连接」**：开关只授权**能力**，连接是用户的动作。两者分开的结果是：一台开着开关但没人拨号的机器不会建立任何出站连接，而一个失败连接会停在 `error` 状态（带原因），不是 `off`。

**`off` / `error` 两种失败必须分开呈现**：宿主侧根本读不到状态（开关关闭、会话未就绪、对端不认识该 RPC）与「中继连上了但自报出错」是两件事，界面文案与 CLI 退出码都不同——把前者伪装成 `state: "error"` 会让用户去修一个不存在的网络问题。

## 未落地与待决

* \***\*中继服务本身**：会话中继、设备登记表、Web Push 的触发都在另一个仓库（内部 GitLab codechat 的 `#40`），本仓库不含其实现；本规格只约束本机侧与它交互的最小契约（登记 REST、订阅 URL 形态、控制帧与回程控制帧、信封逐字透传），跨仓细节以对方仓库为准，这里不复制其 schema。
