Appearance
功能规格说明:远程控制开关
创建日期: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 与指向设置键的提示。
验收场景:
- 假设 开关已打开且本机已登录、daemon 在运行,当 用户运行
wave remote-control on时,则 本机向中继发起出站连接,并打印页面地址与可扫的二维码,退出码 0。页面地址是本机按自己的通道键拼出来的(<服务端地址>/remote/?host=<hostId>),不依赖中继在响应里带回什么——通道键是这台机器自己的身份,地址也就该由它自己拼。 - 假设 daemon 尚未运行,当 用户运行
wave remote-control on时,则 命令按需拉起 daemon 再执行(语义与wave daemon子命令组一致),而不是报「连不上」。 - 假设 本机已经处于已连接状态,当 用户再次运行
wave remote-control on时,则 命令是幂等的——不建立第二条连接、也不重新登记(登记是「本地没有身份时才发生」的一次性动作,本地状态在就一直复用),而是重新读回本机自己的通道键并打印同一个页面地址。 - 假设 连接因未登录、中继地址无法解析、登记失败(超时 / 无企业上下文 / 非已激活成员)或被中继拒绝凭证(4003)而失败,当 用户运行
on时,则 命令打印失败原因、以及指向enableRemoteControl的修复提示(含「已运行的 daemon 要重启才换设置」),退出码 1;其中「不是企业成员」与「不是已激活成员」要给出不同措辞的下一步动作。登记超时就是失败——拿不到通道键就没有可连的东西,不再有「连接已建立、只是码没拿到」这种半成功。 - 假设 用户运行
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 与修复提示。
验收场景:
- 假设 本机已连接,当 用户运行
wave remote-control off时,则 本机断开中继连接、打印「远程控制已断开」,退出码 0。 - 假设 daemon 根本没有运行,当 用户运行
off时,则 命令打印「远程控制未开启(wave daemon 未运行)」并以退出码 0 结束,且不借此拉起 daemon(与wave daemon stop同一口径)。 - 假设 本机进程里压根没有中继(开关是关的),当 用户运行
off时,则 命令按「用户要的终态已经达成」处理:打印已关闭并附上原因、把开关提示写到 stderr,退出码 0。 - 假设 本机已连接,当 用户运行
wave remote-control status时,则 命令打印当前状态(off/connecting/online/error),有页面地址时一并打印。 - 假设 daemon 未运行,当 用户运行
status时,则 命令打印「状态:未开启(wave daemon 未运行)」,退出码 0。 - 假设 状态读不出来(例如 daemon 是更旧的构建、不认识这个 RPC),当 用户运行
status时,则 命令打印失败原因与开关提示、退出码 1——「读不到」不等于「已关闭」。 - 假设 用户不带任何参数运行
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(不被发给模型)→ 设置页打开并选中「远程控制」→ 能显示未开启状态、能点击开启并回显状态、能显示页面地址与可扫的二维码;手动从导航点进同一项得到同一页;开关关闭时显示明确的读取失败原因而不是一个假的默认状态;切到别的导航项后不再每秒查询。
验收场景:
- 假设 用户在任一 GUI 宿主的输入框里输入
/remote-control并回车,则 这条文本不被发给模型、不进入会话历史,而是打开设置页并选中「远程控制」项(desktop 为整页替换会话区,插件端为宿主另开的设置标签页);该命令也出现在斜杠命令列表的「系统指令」分组里,可直接点选。该项位于左导航「通用」组的第三位(全局设置 / 个性化 / 远程控制),手动点击进的是同一页、同一份视图。 - 假设 该选项卡被选中,则 它立即向本端进程查询一次状态,并在此后每秒重查一次(宿主到 CLI 只有请求/应答这一个通道,没有状态推送),状态按「未开启 / 连接中… / 已连接 / 出错」呈现。只在这一项被选中时轮询:切到别的导航项即卸载、即停止(与弹窗时代「关掉就停」等价)。
- 假设 当前状态是未开启或出错,则 主按钮是「开启远程控制」;其余状态主按钮是「断开」;点击后对应的连接/断开结果在同一视图内回显。
- 假设 已连接,则 视图显示页面地址(可点「在浏览器中打开」交给系统浏览器),并画出一个可扫的二维码——内容就是那个页面地址本身(地址里带着本机的通道键)。地址由持有会话的那个进程拼出来(它手上有通道键,也知道服务端地址)随状态一起交给视图,视图只负责显示;二维码与终端命令打印的是同一个地址。因此 GUI 这边总是画得出二维码——没有「等中继带回地址」这一说,也就没有「没带回所以不画」的回退。二维码必须保持库自己的白底(深色主题下换成主题色就是反相码,扫不出来——实测
jsQR的常规通道在反相版本上失败)。Web 页自己不显示二维码——它是被扫码打开的那一方,不是扫码的一方(PC 浏览器直接打开同一个地址也一样,不需要再扫一次);页面上这一项也不出现(场景 24)。 - 假设 视图开着、每秒一次的轮询让多个请求同时在飞,则 只有最新一次请求的回执会被渲染——迟到的旧回执(例如晚一秒到达的「未开启」)不得把用户刚建立的连接改回去。
- 假设 宿主侧读取失败(开关关闭 / 会话尚未就绪 / 对端不认识该 RPC),则 视图显示「无法读取状态」与原因原文,并给出开关提示;不得 伪造一个默认快照充数。
- 假设 用户切换到别的导航项或按返回离开设置页,则 停掉这条每秒一次的状态查询、不触发「中断生成」——切走与弹窗时代的「关掉弹窗」同义。(搬进设置页后不再有「按 Escape 只关弹窗」这条:Escape 不属于这一项,它是设置页整体的键位。)
- 假设 桌面端同时开着多个 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 并重启进程后,能力变为不可用。
验收场景:
- 假设
enableRemoteControl在用户级设置、项目级设置与远端托管设置里都没有配置,则 本机具备服务能力(开关按默认「开」判定),但不自动联网——不建立任何中继连接,要等用户执行wave remote-control on或点设置页的「开启远程控制」才拨号。 - 假设 用户在
~/.wave/settings.json里写入"enableRemoteControl": false并重启进程,则 本机不具备服务能力:不建立任何中继连接,remoteControlStatus/remoteControlConnect/remoteControlDisconnect三个 RPC 一律失败,错误信息指出要移除或改回该设置键。 - 假设 企业侧经远端托管设置(
GET /api/wave/settings)显式下发了该键,则 它优先于本地设置与代码默认值。 - 假设 进程启动时开关是关的、之后用户改了设置文件,则 已在运行的进程保持旧答案(开关只在进程启动时读一次),失败提示必须引导用户重启 daemon 而不是让用户反复改文件。
- 假设 设置文件损坏或读不到,则 判定按「关」处理——既不按开、也不让读设置这件事把进程带崩(判定发生在准备开网络监听的位置,坏设置必须读成「关」;这是独立于默认值的显式兜底:默认值改成「开」之后,读失败仍然读成「关」)。
- 假设 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。