Appearance
功能规格说明: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 的「触控设备上的尺寸与字号」故事。
验收场景:
假设 用带通道键的地址打开 Web 页,则 页面先把「去哪、用什么说」两件补全:中继地址由页面同源推导(页面就部署在中继同一个域下的
/remote/,?ws=只留给本地调试覆盖),通道键直接取地址里的host=;然后连上订阅地址、以{"type":"auth","credential":{"type":"sso","token":"<登录 JWT>"},"version":"…"}完成握手(拿到role:"subscriber")。这一份 JWT 就是企业端登录态本身(页面与登录页同源,读的是同一份已存下的令牌),页面不引入第二套账号、也没有单独的登录步骤。地址里那个通道键不是凭证:中继在订阅握手时校验「这行 host 属于当前账号、且账号是该企业的活跃成员」,别人的通道键换不来任何东西——所以它可以坦然印在二维码上。假设 页面已连上,则 首屏先列本进程内此刻持有的活会话(主数据源是
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)。假设 中继发来「列表变了」的信号、或页面重新连上,则 页面只按本地最后一条消息 id 走
since补差;补不上时(锚点已消失)回落为重拉最后一页。假设 本机触发了一次需要审批的工具调用,则 页面弹出审批卡(工具名、参数等);
ExitPlanMode的完整计划内容就渲染在这张卡里(markdown、独立滚动区、不截断)—— 桌面端把它放在右栏计划面板、插件端由宿主另开一个计划标签页,页面这两处都没有,所以页面这一处刻意用卡片承载(这是与其余各端不同的例外,见core/plan-mode.md的同名故事与其场景 11);用户批准或拒绝后以permissionResponse回执给本机(关闭卡片时的拒绝语义见场景 24);关掉页面再打开,该待审批请求仍在(含计划正文:正文随权限请求的 context 一起来到页面,冷启动快照与断线补读两条路都带)。假设 页面渲染的是与桌面 / IDE 相同的那份聊天 UI,则 它是一次看一条的单会话视图(没有 pane、没有工作目录管理),会话之间用顶栏的「会话」入口切换(见第 10 条);顶栏不提供「历史对话」入口(切换收在「会话」面板),顶栏的「新建对话」图标则重绑为「发起新会话」的入口(插件端同位同图标,见「Web 端也能发起新会话」故事场景 1;插件端那个
clearChat——开一条继承旧目录的新会话、不回传新 id——语义不在页面上提供)。并且不支持的命令(打开外链、上传文件、单独登录/登出等)必须给出明确原因或显式提示,绝不能静默无响应把用户晾在一个灰着的界面上。假设 用户点「远程控制」项里的「在浏览器中打开」,则 Web 页把它列为不支持(页面自己就运行在浏览器里),不产生未处理的动作。
假设 用户在 Web 页上看或改权限模式,则 口径是钉死的:Web 端默认就提供四档(含「跳过权限确认」),不设「会话创建时是否已授权」那道门;默认模式保守 —— 会话初始仍是
default,连接这个动作本身不改模式;「不自动继承」指的是连接动作不改模式,不是「Web 页看不到当前模式」(同一个会话只有一份模式状态)。模式是状态、不是过渡:页面必须显示这条会话当前的模式,所以冷启动 / 页面重挂(刷新)/ 从列表切到这条会话 / 断线重连 / 回前台这几条路都要把它重读一次(getSessionInfo是载体,与适配器推的permissionModeChange同源;同一次读还带待办清单 / 压缩中 / 命令运行 / 待发队列,见场景 23)。宿主推的permissionModeChange是转换通知——只在页面挂着的那段时间里发生的那次切换才会到——所以只听它的页面会停在自己 reducer 的初始值default:那是这条会话并没有的一个权限,用户看到的是一个比实际更保守、却不是实际生效的档位(场景 14 的忙闲、场景 17 的待审批、场景 23 的其余会话状态是同一类缺口)。假设 页面手上的访问令牌不可用——本地读不到
token(这台设备没登录过)、本地令牌已过期(同源存储的expiresAt已到)、或中继在握手时回了invalid_token——但企业端的刷新令牌(同源存储的refreshToken)仍在,则 页面先就地静默续期:对同源的POST /api/auth/refresh发一次请求,成功后把新凭证写回存储、用新令牌继续原来的连接流程(握手与后续请求都用新的那一个),全程不发生任何导航。续期在页内单飞(同一时刻只发一个请求,其余调用复用同一份结果)且每次会话只自动尝试一次;本地没有expiresAt时按「令牌仍可用」处理、直接连接,过期与否交给握手说话。这是「每小时被赶去登录一次」与「一个用户看不见的请求」之间的取舍,见边界情况那条耦合清单。假设 静默续期没拿到可用凭证——没有刷新令牌、刷新令牌也已失效、响应不合形状(缺
token),或请求本身发不出去(网络异常、存储不可用)——则 页面才走原来那条路:自动导航到企业端登录页,并把当前页面地址(原样带着host=等参数)作为回跳目标一并带上;用户在企业端完成登录(密码与 SSO 两条路都算)后自动回到这个地址,页面重新读一次登录态、继续原来的连接流程。回跳目标只接受同源站内路径,跨站地址一律拒绝——这是这条路上唯一真实的攻击面(开放重定向);页面只负责带参数,不解析登录结果,也不接管登录表单。这条路的其余硬约束不变:每次会话只自动跳一次、只在企业端域名上跳(本地调试页照旧只提示)。假设 用户点开顶栏的「会话」入口,则 页面列出本进程内此刻持有的活会话(跨工作目录:每条带自己的
sessionCwd,它就是这条会话的身份),当前正在看的那条有选中态;选中另一条后,页面只切自己的视图 —— 改本地sessionId、清空消息列表、重拉该会话的尾页、它的待审批请求与会话状态(场景 7 / 23)—— 并且不调用restoreSession:电脑端那条会话本身、以及电脑端用户正看着的界面都不受任何影响。切换只改手机视图,这是「控制面」的含义。这条切换也不套用 IDE 那条忙闲守卫(当前会话正在生成 / 压缩中 / 有正在运行的后台任务):守卫防的是「原地恢复替换当前会话」,而页面上的「恢复」只改自己这份sessionId与视图,电脑侧那条会话照旧跑着——所以页面上选中另一条会话始终有效,不得在忙时被静默忽略(依据与同一口径的「新建对话」入口见「Web 端也能发起新会话」故事场景 10)。假设 页面收到一条通知,其信封
sessionId与页面当前会话不同(设备级通道下这是常态:同一进程里别的会话也在产出),则 直接丢弃、不落进当前视图;只有信封sessionId与当前会话一致(或该通知不带sessionId,如状态信号与机器信息)才处理。否则多个会话的消息与流式增量会混进同一个列表。审批与提问没有例外:跨会话的permissionRequest同样丢弃——2026-10-15 收严的一处(此前把它当「进程级事实、用户最不能漏」放行,代价是页面会在当前对话里弹别条对话的卡,见「待确认只在它自己的对话里弹,对话状态在会话列表标出来」故事场景 1;别处等在确认这件事改由会话列表的标记承接)。假设 用户把消息列表滚到顶部并继续上拉,则 页面按同一套协议再要一页更早的内容(
loadEarlierMessages→earlierMessages,与其余三端相同),接在已加载内容之前;判据是宿主回包的hasMore,翻到「没有更早」为止,具体场景见「手机上往上翻更早的消息」(2026-11-06 起;此前这一侧是「顶部即终点」,那条取舍的由来见该故事的「为什么是 P1」)。假设 页面首连或切到一条超过
60条消息的会话,则 首屏只取最近60条(条数与 512 KiB 单帧预算谁先到停谁),并把「还有更早」随快照带给页面(hasEarlier)——它是上翻开关的来源,不按条数猜(一页被裁短不代表到底)。假设 电脑侧这条会话此刻正在生成(之后任何时刻,只要不重启就成立),则 页面打开后输入区必须呈现停止入口,而不是「发送」——不论页面是什么时候到位的:冷启动 / 刷新、从列表切到这条会话、断线重连、中继丢帧后的重拉,都算。依据:手机上没有 ESC 键,页面上的这枚按钮是中断唯一可达的入口(「前台 CLI 会话也能被手机接管」场景 4 已把「手机上能中断」写成硬要求,缺的只是它可达)。宿主推的
loadingChange是转换通知——只在页面挂着的那段时间里发生的那次开始/结束才会到——所以「只听通知」的页面必然漏掉它到位之前就已开始的那一轮:那一整轮都没有停止入口,用户只能等它跑完。真值在机器的会话注册表里(listDaemonSessions的isLoading,与loadingChange同源):页面应在每次重新读取注册表后按它对齐自己呈现的忙闲,而不是把快照里的忙闲写死成「空闲」;重连那条路只补消息增量、不带状态,所以它也要单独重读一次注册表。假设 用户在手机上发出一条消息(
sendMessage),则 页面不给这条请求设任何时限,也不把「回执有没有回来」当成发送是否成功的判据:它的回执是整个回合结束时才回的(桥接层要await agent.sendMessage()到生成循环走完,而循环会停在一次等人作答的审批上——在手机上作答的往往正是刚发出这条消息的人),而回合长度没有上界——工具循环想跑多久就跑多久,几小时也行。因此:(a) 回合级方法(sendMessage/compact/askBtw,即 shim 的TURN_METHODS)不挂超时,而不是挂一个更大的数字:回执是一次性帧,链路一断就永久丢(两端都不重投),所以任何时限都分不清「还在跑」与「回执丢了」,唯一可能产出的结果是假的「没发出去」——用户据此重发,机器就多收一条一样的消息;(b) 用户在页面上能看到的失败不依赖这个回执,而且都到得很快:白名单拒绝 / 会话已不存在 / 宿主不支持是秒级错误回执,链路断是连接状态条(场景 13)的事,断连时发出的那条由发件箱在auth_ok时补发;(c) 回合真的卡住时,出路是输入区那枚停止入口(场景 14 的忙闲口径保证它一定在位),不是在对话里插一条红字。依据:桌面 / 插件端是同进程调用、根本没有这层超时,所以这条按 Web 页收口,不改共享聊天 UI;而「读接口与普通控制写仍按 20 秒」那档不受影响——它们本来就是机器立刻回答的东西(场景 19)。假设 用户在本页触发控制指令(暂停本轮生成 / 切换权限档 / 压缩上下文),则 指令的失败必须有回显:这三件事的结果都会从消息流自己长出来(忙闲、模式、压缩后的列表),所以成功不需要额外提示,但失败此前被空
catch吞掉——屏幕上什么都没有,而「点了暂停但没反应」在这台设备上无处追问(没有 ESC 键,也没有终端可看)。失败时页面给出可见状态(例如「暂停指令没有回执:…」),不静默。假设 链路断过一段(手机锁屏 / 切走、信号抖动,或「电脑侧不在线」的 4001 重试期间)且 断开期间本机有工具调用进入待审批,则 页面重连成功时必须重新读取待审批请求(
listPendingPermissions)并把页面上还没有的审批卡补上——只补消息增量不够。依据:链路断开时本机主动丢弃通知(RemoteControlService在无订阅端时不缓冲,理由正是「手机重连会用getMessages({ since })补齐」),而那句话只对消息成立:待审批是一次进程级事实(listPendingPermissions报告全部会话的请求,不限于页面正在看的那条——页面只把属于当前会话的那些补成卡,其余喂会话列表的「待确认」标记,见「待确认只在它自己的对话里弹,对话状态在会话列表标出来」故事),它不在消息流里,所以断线期间到达的审批在页面上永远不出现——列表里只剩那个工具块(工具名与图标),选项一个都渲染不出来(选项只由审批卡渲染),页面看起来像卡住了。冷启动是另一条路:首屏快照本就带待审批(场景 2),所以「杀掉 App 重开就有选项」——这正是用户 2026-10-14 报的现象,也是它区别于「页面卡住」的判据。补读与场景 14 同源(重连只补增量、状态要单独重读),并必须按请求 id 去重:同一条审批可能先由通知送达、又在这次重读里出现,而页面的 reducer 是追加——不去重就会为一次审批出两张卡。用户在页面上作答过的那条若在机器侧仍待审批(作答那一帧在断线时丢了,permissionResponse是无回执的通知),下一次重读应当再次出现:它确实还没有被处理。假设 页面从后台回到前台(
visibilitychange变成 visible,或从 bfcache 恢复时的pageshow),则 页面必须重新对齐一次:补消息增量、重读忙闲(场景 14)、重读待审批(场景 17)、重读权限模式(场景 7)与待办清单 / 压缩中 / 命令运行 / 待发队列(场景 23)——即把「重连成功」那几件事再做一遍。依据:页面在后台被冻结期间不会收到任何通知(iOS 直接挂起整个页面,帧也不排队),所以睡过去的那段时间与断线等价:醒来时消息停在旧位置、忙闲可能早已翻转、睡着的过程中到达的审批一张都没有;而这一页此前没有任何回前台动作(visibilitychange与pageshow都不监听),从后台回来是一张静止的旧画面。这四处重读只在链路可用(已完成握手且 socket 是 OPEN)时发起:链路不可用时这些请求只会压在发件箱里、最后以一条误报的超时收场,而重连成功那条路(场景 17)本来就会补做同样的事。(场景 23 的状态同走这几处重读,回前台对它们同样生效。)假设 本机在计划模式下请求退出(
ExitPlanMode待审批),则 页面这张审批卡里渲染计划全文(markdown、可滚动的独立区域、不截断),并且两条到达路径都带正文——实时通知与冷启动/断线补读取回的卡片没有区别(正文随权限请求的 context 一起来,页面不额外去取);用户只看到卡片就能决定批准还是拒绝。依据:计划内容是批准流程的核心信息,而页面没有桌面端右栏面板、也没有 插件端那个由宿主另开的计划标签页——不放进卡片,手机上就只能盲批准。假设 用户在页面上遇到文件路径(消息正文里的路径、工具块头部的路径、Write 工具块里的「打开预览」),则 点开它就有内容可看:文本路径按需读那份文件并渲染(见「点消息里的文件路径看内容」故事),图片路径按需取那张图(见「点消息里的图片路径看原图」故事)。两条都不落进
openFile,所以「页面上没有编辑器」这个理由不再成立——它挡住的是「开编辑器」,不是「读得到那份文件」:宿主读得到、页面显示得了,缺的只是一条按需读取的通道。Write 块的内容预览仍然保留:那是消息里现成的内容,不需要任何请求。仍不可点的是已发送粘贴图的[imageN]标签(字节只留在宿主那台机器上,下发帧已剥掉,见「白名单」故事场景 10)与其余工具的原始输出(逐行纯文本,不走链接化)。依据:同一份 webview 在插件端把这些路径点成openFile(宿主开编辑器),页面点了只会落进未实现分支、连回执都没有——所以这一端换的是动作(就地看内容),不是把openFile接出来。假设 用户浏览页面上的各入口,则 凡在页面上必然无效的入口都不渲染(不出现「看着能点、点了没反应」)。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,不做守卫就会把一批宿主专属入口带出来;留在清单里的这些,背后要么是宿主的界面能力(开编辑器、开设置页、装插件、登录流程),要么只有宿主侧才有意义(磁盘上的历史会话),而不是「方法还没接」——那条分界正是第二批解禁的判据。假设 用户在页面上重命名会话、触发压缩上下文、或打开斜杠命令菜单,则 这三件事在页面上真的能用(远端的白名单放行
renameSession/compact/getSlashCommands,宿主侧各自实现),而不是留一条「未实现」的提示。弹窗里的清单也跟着补齐:宿主本地命令(/btw/clear/resume/rewind/tasks/workflows/status/compact)由同一次回包带回,页面上没有的能力则不在其中——过滤在服务端按「入口 = 远控页」做,页面不存名单(见ui/slash-commands.md的「弹窗里的宿主命令只有一份表」故事)。/resume是这张表第一次出现「入口一致、宿主再收窄」:同一张表给「远控页」这一个入口的集合,由前台 CLI 宿主在回包前删掉这一条——它只有一条会话槽位,恢复磁盘会话等于把用户终端里正看着的那条换掉(见「前台 CLI 会话也能被手机接管」场景 9)。页面照旧不存名单,收窄发生在宿主侧。依据:这三条与页面已有能力同源(都作用于当前会话、不需要编辑器或文件系统),是「先按隐藏处理」还是「补成能用」的分界;后台任务与工作流不在此列(它们要的是宿主的进程与输出流,本轮仍按场景 21 隐藏)。假设 页面在五条重新对齐路径中的任何一条上到位——冷启动 / 从列表切到这条会话 / 断线重连 / 页面重挂(刷新)/ 回前台,则 页面上由会话状态驱动的呈现都按当前值对齐,而不是停在 reducer 初值或只等下一次过渡:待办清单(
TaskCreate/TaskUpdate的任务列表,TaskList挂在输入区上方——它与场景 21 隐藏的/tasks后台任务弹窗不是一回事,后者是backgroundTasks)、压缩中(「正在压缩对话」指示;压缩不只会由页面自己发起——自动压缩在每次 API 请求前由机器侧触发,页面根本不在场)、命令运行中、待发队列(场景 21 已把它定为只读清单——只读不等于可以显示成空)与上下文占用(输入区的百分比环)。依据:与场景 7 / 14 / 17 同一族——首屏快照是唯一一次无过渡的初始化,没写进它的字段就只能靠后续过渡补;「页面到位之前就是那个值」「断线期间变了」「重挂后回到初值」三种情况全都照不到。载体是getSessionInfo扩的四个字段(tasks/isCompacting/isCommandRunning/queuedMessages):该方法已过远端白名单、两个宿主都已实现,扩字段是增量改动,不动白名单;上下文占用不新增读路径——getMessages回包本就带contextUsagePercent,每次(重)拉消息顺手对齐。版本错配的容忍:页面比机器新是常态(机器在跑哪份构建,页面管不到)——回包里缺哪个字段就不写哪个字段(保持现值),绝不把undefined落成空清单或「空闲」。假设 用户在页面上关掉一张确认卡(提问弹窗右上角的关闭键、或任意确认卡的 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)一次都不重试。
验收场景:
- 假设 冷启动那一次列表读失败(超时、中继丢帧、电脑侧暂时不回话),则 页面按固定节拍自动重试(2s / 4s / 8s,共三次),期间状态条如实说明原因;任意一次成功就照常上屏并把状态条清掉。
- 假设 三次都用完仍失败,则 停止自动重试,状态条上只留原因和关闭键——没有「重试」按钮(2026-10-29 移除,见「状态只在一条流内状态条上播报」场景 4)。恢复靠场景 4 的三条路径:回前台、页面重挂、断线重连。
- 假设 失败是永久的(
-32601:这台机器的远端白名单里没有这个方法;或宿主明说「这台机器的会话不支持」),则 一次都不自动重试,只把原因留在状态条上。依据:重试改变不了「这台机器不会答」,只会把真实原因埋在不停的重试下面。这与「断线自愈」的无限退避不矛盾——那里等的是「机器还没上线」,是有意义的等待。 - 假设 首屏从没成功过(列表读一直没成功),则 回前台、页面重挂、断线重连这三条既有对齐路径都重跑整份快照,而不是走增量补差。判据是「首屏是否成功过」这个状态,不是「刚刚失败过」这个事件。
- 假设 首屏已经成功过,则 这三条路径恢复成今天的增量行为(补新消息、重读忙闲 / 待审批 / 权限档),不做整份重拉。依据:整份重拉要占手机带宽与中继缓冲,而已经上屏的页面只需要补齐差额。
代价照实说:一次失败最多多打三次读,每次在电脑侧都是一次 listDaemonSessions(多会话进程里每条会话都要做一次 fs 推导);节拍取短值是因为失败多半是瞬时,但要接受「电脑侧一直不回话时,页面会多问三次」。
用户故事:点消息里的图片路径看原图(优先级:P1)
作为用手机看 AI 真机验证结果的用户,我希望点消息里出现的截图路径就能看到那张图,以便不必回到电脑前确认效果。
为什么是 P1:这是页面上唯一一个「界面缺口」与「能力缺口」不等价的入口。场景 23 / 24 把「点路径打开本地文件」整条关掉,判据是「页面上没有编辑器可以打开它」——但看图不需要编辑器:宿主读得到那个文件,页面也显示得了图片,缺的只是一条按需取图的通道。而真机验证是 AI 会话里最常见的截图场景之一(截图落在宿主机器的一个绝对路径上,正文里只出现路径),手机上只能看到路径、看不到效果,这个场景就只能等回到电脑前。
独立测试:在远控页上点一条消息正文里以图片扩展名结尾的绝对路径(如 /tmp/shot.png),弹层里出现该图片;同一段文本里的 .ts / .log 路径仍是纯文本、不可点;点一张远超单帧预算的大图仍能看到(宿主压到预算内,必要时标注已缩小);把一张文本文件改名为 .png 后点它,得到「不是图片」的可见说明而不是乱码;宿主是不认识这条方法的旧构建时,得到可见的「这台电脑还不支持」而不是一直转圈。
验收场景:
假设 消息正文、行内代码或工具块头的路径既是绝对路径、扩展名又落在图片白名单内(
png/jpg/jpeg/gif/webp),当 用户点它时,则 页面向宿主请求这张图并用全屏覆盖层显示。这个入口只对图片扩展名开放:远控页的linkFilePaths(正文 / 行内代码 / 围栏代码块 / bash 输出四条通道的路径链接化)仍然是关闭的,场景 23 对它继续成立——打开的是另一个更窄的开关,而不是把「能打开本地文件」整条解禁。白名单取的是readTool的SUPPORTED_IMAGE_EXTENSIONS(agent 的 Read 工具能看到的就这五种),不是桌面文件面板那八个(后者多svg/bmp/ico):svg是 XML、既不是位图也没有「压到字节预算内」的同一套语义,先不放进这个对外网的入口;bmp/ico在真机截图里不出现。假设 页面要显示这张图,则 宿主先把它压到单帧字节预算(512 KiB,见白名单故事场景 8 与「连接生命周期」场景 12)以内再回包,压缩顺序是**「PNG palette(无损,UI 截图最优)→ 降采样 → JPEG 降质量」,走的是
imageRewrite.ts已有的那套阶梯(SCALES×JPEG_QUALITIES),不为这条通道另写一套;压到最小档仍超预算时回更小的缩略图并标注「已缩小」**,而不是把超预算的帧发出去。格式次序不是随手定的:这类图是 UI 截图(大片纯色 + 文字),Guacamole 官方文档与 GUACAMOLE-668 都记着同一条经验——非照片画面用有损 JPEG 会因文字振铃反而更差、主观响应也更慢,所以PNG palette在前、JPEG 只在 PNG 压不下去时兜底。假设 宿主收到这条请求,则 「这是不是图片」由文件内容的真实类型判断,不是只看扩展名:扩展名只是页面侧「给不给点击入口」的判据,宿主侧还要按 magic 判一次真的图片 mime,判不过就回「不是图片」的可见说明、不返回任何内容。依据:路径来自对话内容而不是用户手输,但一条
/tmp/x.png仍可能是指向别处的符号链接——只信扩展名等于把「读宿主任意文件」开了一个形状很窄但真实的口子。假设 用户在页面上点了一张图,则 弹层沿用页面的图片预览形态(全屏覆盖层、点遮罩或右上按钮关闭、Esc 关闭),与输入框里未发送图片的预览同形;关闭后回到消息流原来的位置,不丢滚动位置。
假设 页面手里那份构建比电脑侧新、宿主不认识这条按需取图方法,则 页面给出可见的「这台电脑还不支持」文案,图片路径退回纯文本不可点的形态,不静默无响应、也不留一个一直转圈的弹层。这是本规格已有的「缺哪个能力就哪条功能不可用」口径(见「连接生命周期」场景 3)。
假设 用户点的是已发送粘贴图的
[imageN]标签(那批字节留在宿主机器上、下发帧已剥掉,见白名单故事场景 10),则 这条通道不覆盖它,标签仍按场景 24 渲染成不可点。两者数据来源不同:图片路径是「宿主上有个文件,按路径取」,粘贴图是「消息块里曾经有字节,现已剥离」——把它们并成一条通道会给后者凭空造一个反查路径,本轮不做。假设 这条会话由前台 CLI 进程服务(而不是 daemon 或 GUI 子进程),则 按需取图同样可用。依据:本规格已经踩过一次「白名单放行 ≠ 宿主实现」的坑(见「Web 页也能发起新会话」那条记录:四条新会话方法只落到
AgentBridge、RemoteControlCliHost一条没实现,页面在那类会话上必报错)——所以这条能力在规格里就把两个宿主一起写上。假设 取图失败(文件已不存在、读盘报错、不是图片、超出大小上限),则 页面上给出可见说明并收尾,不静默——与场景 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 后点那个链接,弹层就地换成链接指向的文件(不刷新页面、不导航);点页内锚点与外链则什么都不发。
验收场景:
假设 用户走到页面上的任一条路径通道(消息正文纯文本、行内代码、围栏代码块、Bash 输出、工具块头 Read / Write / Edit、Write 块的「打开预览」按钮),则 这些路径全部变成可点入口,点了就地看内容,不设扩展名白名单:判据不是「名字像不像文本」,而是「宿主读出来是不是文本」(场景 4)。这比图片那条宽得多,理由写在边界情况——文本没有等价于「解码出来必须是位图」的那道天然闸门,所以它不靠名字收窄,靠内容判型 + 大小上限 + 只读这三条。带
:12/:12-30行号后缀的路径照旧按链接化时的解析剥掉后缀再发。假设 用户点的是相对路径(工具块头显示的就是相对路径)或以
~/开头的路径,则 页面按会话工作根(workspaceRoot,不是会随 Bash 里cd变化的那份workdir)把它拼成绝对路径再发给宿主。依据:路径来自会话内容,AI 中途cd过也不该让「src/foo.ts」指向别处;工具块头这份显示文本本来就是宿主相对化过的,页面只有工作根这一个可信锚点。假设 页面要显示这份内容,则 走一条新方法(
getTextPreview),不并进取图那条。两条的授权判据不可互换(图靠「解码出来必须是 png/jpeg/gif/webp」,文本靠「能按 UTF-8 解出且不含 NUL」),合成一个方法会让「方法名」与「判据」错位。页面侧可以共用同一张按requestId关联的待办表与同一个超时档(形态相同),但宿主侧必须是两个实现。假设 宿主收到请求,则 「这是不是文本」由内容判,不看扩展名:读出的字节里含
NUL(0x00)一律当二进制拒绝;按 UTF-8 解码后替换字符(U+FFFD)占比超过一成也当二进制拒绝。判不过就回「这不是文本文件」的可见说明、不返回任何内容。依据:入口既然不设扩展名白名单,判型就是这条通道唯一的实质闸门——没有它,点一个可执行文件会得到满屏乱码,点一个 500 MB 的二进制会先把宿主内存吃满。假设 文件很大,则 宿主先
stat、超过 256 KiB 只读前 256 KiB(不readFile全量),回包里带truncated与文件真实大小,页面头部写「只显示前 256 KiB,完整文件 5.2 MB」。截断按字节做,落在多字节字符中间就退回最近的字符边界(宁可少几个字节,不要吐出半个字)。256 KiB 不是帧预算的极限(单帧 512 KiB,文本走 JSON 字符串不像 base64 膨胀 4/3,理论上能放更多):一屏手机文本只有几 KB,再大只是让弹层渲染变慢、让弱网下的一次点击更久——取「够看」与「别拖慢」的交点。假设 文件大部分能解、零星解不通(GBK 日志里混了几行、二进制与文本混排的构建输出),则 照常显示,解不通的部分用替换字符降级,并在头部提示「内容里可能有编码问题」——不整个报错。这与场景 4 是两件事:那里是「整份不像文本」(拒),这里是「是文本但编码不干净」(显示并提示)。
假设 用户在手机上点开一份内容,则 弹层沿用图片预览那套全屏覆盖层(点遮罩关闭、右上 ✕、
Esc关闭、关闭后回到消息流原来的滚动位置),正文排版沿用文件面板那套(renderFileMarkdown:markdown + 代码高亮 + 表格滚动容器,已有消毒)——不是裸<pre>:.md计划文件与代码文件在手机上按原始文本铺开等于不可读。头部一行给「文件名 + 完整路径 + 截断/编码标注」。不新写渲染器:远控页跑的就是同一份 webview 产物,这套渲染器本来就在包里。假设 读取失败(文件已不存在、不是文本文件、读盘报错、超出大小上限),则 弹层里给出宿主那句原因并收尾,不静默(与图片故事场景 8 同一条判据:手机上没有终端可以追问)。链路没回音时按取图那一档计时(25 秒,比 shim 普通请求档长 5 秒,让宿主的明确拒绝先到),不用回合级那档——取文件不产生 LLM 回合。
假设 页面手里的构建比电脑侧新、宿主不认识
getTextPreview,则 给出可见的「这台电脑还不支持」,路径退回纯文本不可点(与图片故事场景 5 同一条口径:「缺哪个能力就哪条功能不可用」)。假设 用户点的是图片路径,则 它仍然走取图那条(弹图),文本通道只接其余路径。扩展名这里只是页面侧的呈现分流,不是授权:宿主侧两条通道各自判各自的内容(场景 4 与图片故事场景 3)。
假设 用户点开的是一份 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。
验收场景:
- 假设 用户打开一条消息数超过首屏的会话,则 首屏仍是最近
60条(远控页自己的取值不变),快照带上「还有更早」;这一页与其余各端一样按条数填满、可以跨过压缩边界。 - 假设 用户把列表滚到顶部并继续上拉,则 页面发一次上翻请求(锚点=当前已加载的第一条,与其余三端同一条协议:
loadEarlierMessages→earlierMessages),取回的一页接在已加载内容之前而不是替换,视口仍停在用户刚才看的那一行。 - 假设 一页已经取回、用户又滚到顶,则 再取一页;同一时刻只有一个请求在飞(重复滚到顶不叠加),在顶部连续翻页时晚到的旧页按
requestId丢弃。 - 假设 回包说「没有更早了」,则 顶部就是这条会话的第一条,页面不再发请求、也不渲染加载占位或空白占位行。
- 假设 回包说「这一页之前还有更多」,则 上翻的开关保持打开——判据是回包的
hasMore(与首屏那次读同一个信号),不按条数猜:一页被裁短不代表到底。且裁短本身也由机器侧改这个字段:一页超出单帧预算时裁掉的是最早那几条,所以「更早的还在」必然成立,机器侧因此把回包的hasMore强制成true,哪怕宿主那一页原本说的是false(上翻正好取到这条会话最早那一页时就是这种情形);不改这一处,页面会照着false把开关关掉,被裁掉的几条再也翻不回来。 - 假设 取更早失败(链路断开、宿主拒绝、或那一页连一条都装不进单帧预算),则 已渲染内容不动、开关关掉、不新增页内提示条(与其余三端「失败回空页、停止再要」的兜底同形),下一次滚到顶再试。
- 假设 锚点在转录里已经不在了(rewind / 清空 / 换会话),则 按回包的
anchorLost换成已加载列表里的下一个 id 重试,几次之后收手——不当成「没有更早」,否则这段历史再也翻不出来。 - 假设 用户翻出了更早的几页后,电脑侧发生一次结构变化(新消息、工具块结束、重连补差、重挂),则 已翻出来的那几页不消失:宿主这一侧的影子列表把上翻取回的页一并接住,后续整表重发带的就是「已翻出来的那一整份」,滚动位置不动。
- 假设 用户切到另一条会话、或页面重载,则 回看深度丢弃,从首屏一页重新开始(与其余三端同口径:回看深度不跨会话累积)。
- 假设 电脑侧的构建还不认
before(版本错配),则 首屏与其余功能照常,上翻不出现或白要一次(回来的那段按 id 去重后列表不变)——降级方向是「翻不动」,绝不出现空白页或串会话。 - 假设 这条会话由前台 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 的请求作答掉,页面(无论停在哪条)下次对齐后该标记消失。
验收场景:
- 假设 页面正在看会话 A,另一条会话 B 进入待确认(
AskUserQuestion或工具审批),则 B 的卡不进当前视图——不弹、也不进待确认队列;A 自己的待确认照旧弹。B 的等待在「会话」列表里以「待确认」标记呈现,当前正在看的那条不标(它本来就弹着卡,重复提醒没有增量;与桌面端侧栏「打开后消失」同款取舍)。 - 假设 用户从列表切到会话 B,或冷启动就落在 B,则 这次重读把 B 自己的待确认带进来并弹卡——切会话是「这条对话的待确认第一次进入视图」的时刻,与冷启动同一条路,且不新增读路径:
listPendingPermissions本就报告全部会话、每条带sessionId,页面按该字段筛出属于当前会话的那些弹卡,其余留着喂列表标记。 - 假设 同一条对话有多个待确认,则 卡按到达顺序弹;队列里不再混进别的对话的请求,于是「弹窗只渲染第一条」这个既有约束不再意味着「当前对话的卡可能被别条挤在看不见的位置」。
- 假设 某条对话的待确认在电脑侧(终端 / 桌面端)被作答、或随会话结束消失,则 该标记在下一次重读后消失——标记是
listPendingPermissions的当前事实、不是历史累积,与场景 20 的补卡共用同一次读。 - 假设 某条对话正在生成(进程内这条会话有活 turn / 正在压缩),则 它在列表里带「运行中」标记,含当前正在看的那条——与桌面端「选中的对话也应显示 loading」同款(那里是拍板过的例外),因为「我正在看的这条还在不在跑」同样是列表要回答的问题(页面输入区另有一份忙闲呈现,见场景 17;两者同源、不冲突)。判据是会话注册表
listDaemonSessions的isLoading:与场景 17 的重读同一次,零新增读路径。 - 假设 一条对话同时满足「待确认」与「运行中」,则 只呈现一个标记,优先级 待确认 > 运行中(被审批卡住的对话不算「在跑」,它更需要被看见)——与桌面端侧栏的优先级同款。「已完成未读」(桌面端绿点)不做:那是宿主的本地已读跟踪(打开即清),页面每次切视图都重读消息,没有「未读」这个语义,照搬只会给出一个永远亮着或永远不亮的点。
- 假设 用户看到某条对话带标记,则 标记只指路:它不替用户切视图、也不自动跳到那条对话(切视图只改手机自己,进那条对话才弹卡,见场景 10 / 场景 2)。标记本身不发系统通知——页面内的标记不弹横幅、也不申请通知权限;「别处有东西等你」在锁屏 / 后台时的那条通道是 Web Push(见「锁屏也能知道」故事,那里同时规定页面可见时不弹横幅),与桌面端 SSH 后台会话同款口径(见
desktop-app.md的「SSH 远程后台会话」故事)。 - 假设 页面在任何一条重对齐路径上到位(冷启动 / 切会话 / 断线重连 / 页面重挂 / 回前台),则 两类标记都按当前值对齐,与场景 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,回滚仍不多一次确认)。
验收场景:
假设 页面要执行本批解禁的任一动作,则 它背后是已在
AgentBridge上实现的方法,白名单只放行这些:rewindToMessage/listRewindCheckpoints(回滚)、clearMessages(清空)、getConfiguredModels/setModel(模型)、updateQueuedMessage/deleteQueuedMessageById(队列)、getBackgroundTaskOutput/stopBackgroundTask/getWorkflowRuns/stopWorkflowRun(后台任务与工作流)、backgroundCurrentTask(把本轮转到后台,见本故事场景 11)、searchFiles(@建议)、getPromptHistory/searchPromptHistory(历史提示词)。不新增任何一个协议方法:本批全部是「已实现但远端不放」,所以放行即生效。依据:白名单只该回答「页面能不能承载」,不该回答「宿主有没有实现」;第二批之前这两个问题被混成了一条。假设 用户在页面上点某条消息的回滚按钮,或敲
/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的删掉、有快照的写回、快照内容为空的删除)——所以「回滚到这条消息之前」在电脑上是一次文件操作,这是手机误触代价最高的一个动作,也是它必须确认的理由。假设 用户在页面上敲
/clear,则 执行前同样有一次确认(清空会换绑新 sessionId、跑 SessionEnd/SessionStart hooks,电脑侧那条会话随之换新),确认后发clearMessages。桌面端行为不变:桌面端/clear仍整端不拦截(当普通消息发给模型),本场景只作用于远控页与插件端的既有拦截。假设 用户在页面底栏的模型选择器里选中另一个模型,则 与 IDE / 桌面走同一套 UI 与 RPC(
getConfiguredModels拉列表、setModel切换;页面不再有/model命令)。确认文案必须说清作用范围:setModel落盘的是用户级全局配置(~/.wave/settings.json的model键,写失败时退化为仅当前会话生效),不是这条会话的临时覆盖——在手机上换一次模型,这台机器上所有会话、以及此后新开的会话都会跟着换。因此远端切换前给一次确认;桌面 / IDE 不加。假设 队列里有待发消息,则 三个动作在页面上都可用了:编辑(纯本地,把内容装回输入框,提交时才发
updateQueuedMessage {id, text, images},宿主回{ok},ok === false时页面提示「这条已不存在」)、删除(deleteQueuedMessageById {id})、立即发送(没有专门的宿主方法:就是sendMessage {force: true}+deleteQueuedMessageById两条既有方法,与 IDE / 桌面实现同形——先以force打断并立即发出这条,再把队列里那条删掉,否则它会再发一遍;force只打断当前回合、不清空队列,其余排队消息保留并在该回合结束后按原顺序发出)。假设 用户在页面上敲
/tasks或/workflows,则 两个弹窗照常打开并有真实内容:任务列表来自会话状态(宿主通知backgroundTasksChange→ 页面updateBackgroundTasks,冷启动 / 切会话 / 重连 / 重挂 / 回前台各重读一次getSessionInfo——该回包为此扩一个backgroundTasks字段,与tasks/queuedMessages同族),工作流运行来自getWorkflowRuns(同一批对齐路径各读一次,与桌面端refreshWorkflowRuns同源);getBackgroundTaskOutput按任务读取输出(运行中由弹窗按既有节奏轮询),stopBackgroundTask/stopWorkflowRun停止并回执。停止不要求二次确认:它杀的是任务进程、不删文件也不动会话,回执(backgroundTaskStopped/workflowRunStopped)+ 列表随之更新就够。假设 用户在输入框里打
@,则 远端也启用文件建议:requestFileSuggestions→searchFiles {query, maxResults},工作目录由宿主按信封sessionId取该会话的 workdir(页面不猜目录,也不用自己那份workdir),回包映射成与桌面端同一个建议形状(path/relativePath/name/extension/isDirectory)。依据:这条此前被关掉的唯一理由是「会发出一个没人应答的请求,下拉永远停在『正在搜索文件…』」——现在有人应答了,那条理由随之消失。假设 用户打开「+ → 历史提示词」,则 弹窗真的能列出历史并能搜索:
requestHistory→getPromptHistory、searchHistory→searchPromptHistory,回包historyResponse {requestId, history}/historyError,页面的过期回执按requestId丢弃。这是一条现成 bug 的修复:入口与请求早就写好了,shim 却没有这两个 case ⇒ 点了以后永远停在加载中,且连告警都没有(default:分支只warnOnce、不回执)。同族的「未实现即无回执」在本批一并收口:shim 对未映射命令的兜底改为回一条错误给页面,而不是让界面停在那一步。假设 用户碰到的是本批仍然不做的项,则 它们照旧不渲染(清单见「Web 页作为第五种宿主」场景 24):设置页与各导航、插件市场、登录/退出、上传文件、点路径打开本地文件、磁盘历史会话(
/resume)、/plan。getWorkspaceDiff/getWorktreeChanges/getPlanFile/getFullMessageThread也仍在白名单外:页面上没有 diff 面板、计划面板、工作树面板可承载它们——DiffPane/PlanPane挂在桌面端的 pane 体系里(isDesktop分支),页面既没有 pane 也没有侧栏,计划正文本来就渲染在ExitPlanMode的审批卡内(core/plan-mode.md场景 13)。放行它们只会扩大暴露面而没有调用方。removeWorktree同理不放行:页面上没有删除会话 / 删除工作树的入口(那是桌面端侧栏的动作,desktopDeleteSession是桌面专有命令),所以「能建不能删」不是白名单漏了一条,而是白名单跟页面能力一致。假设 手机发的这批方法在当前宿主上没有实现(前台 CLI 只接了会话内那一批),则 宿主回一个结构化的「该宿主不支持」错误(固定错误码,区别于
-32601的「通道不放行」与-32603的内部错误),页面据此收尾这条请求并给一句可见文案(「这台电脑上的会话不支持这个操作:<能力>」)。依据:白名单是通道级的(daemon / GUI 子进程 / 前台 CLI 共用一份),三个宿主的实现面不同;把「通道不放行」和「这个宿主没实现」压成同一个错误,页面就分不清该升级、该换个电脑、还是该等实现——而静默不回执会让界面停在那一步。假设 本轮有正在运行的 Bash 或 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 且不发请求;同一批操作在插件端与桌面端行为不变。
验收场景:
假设 用户在页面上敲
/btw <问题>,则 页面不再把它当普通消息发出(一条sendMessage都不发),而是发askBtw {question}(信封带页面当前会话的sessionId),宿主答案作为回执回来 → 面板渲染 Markdown 答案;请求失败 → 面板显示错误串(与插件端同一个面板同一套文案:(API error: …))。判据:这条命令在远控页上必须被拦截——/btw从「页面必然无效、不渲染」那一列里移出(「Web 页作为第五种宿主」场景 24),而其余宿主本地命令(/config/plugin/mcp/agents/skills/hooks/plan/reload-plugins/remote-control)继续不拦,照旧按普通消息发出。依据:那条守卫当初是按「背后是宿主的界面能力」划的,/btw的面板长在页面自己的输入区上方、不在宿主界面里,它是这条判据的例外而不是同类。假设 页面发
askBtw,则 远端白名单(REMOTE_ALLOWED_METHODS)放行它,放行后共 33 个方法。不新增任何协议方法:askBtw与其流式通知都是既有协议(AgentBridge早就实现,IDE / 桌面端在用),本轮只是把「页面能不能承载」这一条判成能——它作用于当前会话、不需要编辑器、不需要文件系统,与第二批解禁同一条分界。假设 答案在生成过程中,则 页面看到流式尾部而不是纯转圈:宿主的
btwContent {question, content, type: "content" | "thinking"}通知由 shim 映射成页面既有的btwStream,面板在「正在回答」后显示该文本的最后 30 个字符(折叠换行、超长以…前缀截断);thinking与content共用这条尾部通道,content 一旦开始就不再显示 thinking 尾部(ui/btw-command.md的既有面板语义,页面不另写一份)。归属按question校验:切过会话或换过问题的迟到增量一律丢弃(面板的btwActiveRef已是这个判据,shim 只负责把question原样带上)。假设 这条流式增量要走中继,则 它并入同一个 150ms 合并窗口(累积字段
content,key =question+type):它是纯增量、不带累积值,与主对话那三路(正文 / 推理 / 工具参数)同族——不合并会让一次旁路提问在手机上产生与主对话同量级的多余帧,而合并不会丢任何一帧。假设
askBtw的回执迟迟不来,则 它不设时限(与sendMessage/compact同属 shim 的TURN_METHODS,那一类不挂超时,见 Web 页故事场景 15):它的回执只在整轮答完之后才发,而整轮没有上界——20s 会把它误报成超时,任何一个更大的数字也只是把误报推后;等待期间用户看到的是流式尾部(场景 3)。假设 主对话正在流式输出时用户提出
/btw,则 旁路请求不打断、不排队、不进入主对话:消息列表不新增userMessageAdded、待发队列不新增条目、主回合继续流式;按 Esc / 点关闭只关面板,不中止主回合;面板关闭后迟到的流式增量与最终答案全部丢弃(沿用面板既有语义,页面侧无需新增判断)。假设 用户敲的是不带问题的
/btw,则 不发起请求,面板显示中文 usage用法:/btw <你的问题>(与 IDE / 桌面同一句),不显示加载态。假设 手机发的
askBtw落在当前宿主没实现的那一类上,则 按第二批那条既有机制收口:宿主回结构化的「该宿主不支持」错误(固定错误码,区别于-32601的「通道不放行」),页面收尾这条请求并在面板里给一句可见文案,不停在加载中。实现面:daemon与 GUI 宿主拉起的--stdio子进程都由AgentBridge服务(askBtw与btwContent已实现,放行即生效);前台 CLI 会话(RemoteControlCliHost,只接了会话内那批)本轮不实现——它连「新建会话」「回滚」都还没接,/btw与它们同列,等前台 CLI 的宿主能力面统一补一次(「前台 CLI 会话也能被手机接管」故事场景 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 的请求被忽略而非生效。
验收场景:
- 假设 用户想新建会话,则 入口就是顶栏的「新建对话」图标——与插件端同一个按钮位、同一个图标(远控页复用 IDE 那份 ChatApp,这个按钮此前因语义未定而随「历史对话」一起隐藏);在 Web 页上它的语义重绑为「打开新建会话流程」(下一条)。插件端这个图标的底层是
clearChat→clearMessages——在 agent 内部换绑一个新 sessionId(旧会话归档、标题随旧会话丢弃),效果是「开一条同目录的新会话」;Web 页上底层换成initialize,原因见第 9 条。「历史对话」仍不提供,切换走「会话」面板。 - 假设 用户打开「新建会话」,则 目录选择与桌面端同一套形状:最近目录(两段合并,见 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 漂移的同款老坑)。 - 假设 页面要列出某个目录下的子目录,则 调用 bridge 的
listDirectories(随本故事进白名单):这个方法没有主机概念——你连着哪个 wave 进程,就列哪个进程所在机器的目录(与listGitBranches完全同形:同一方法名,本机 stdio 与 daemon socket 各自吃自己的 bridge)。~按该机器上服务进程用户的家目录展开,返回 realpath 化的绝对路径与排序后的子目录名;目录不存在或不可读时返回明确错误而不是空列表。桌面端远程主机的目录浏览随之切到同一个 RPC(那条 ssh shell 路径退役),两端吃同一份实现。 - 假设 用户提交创建,则 页面经远端通道调用
initialize(带workdir)拿到新sessionId,随后做的是与「会话」切换同一套本地动作:改自己的sessionId、清空列表、重拉尾页与待审批——不引入第二条切换机制。新会话真实落进当前服务进程的会话注册表:服务方是 daemon 时电脑端 CLI / 桌面端都能看到它(「Web 端发起、机器落地」);服务方是某个 GUI 子进程时它出现在那一端的侧栏里——页面指向哪个进程,会话就落在哪个进程,与设备级通道一致。 - 假设
initialize从远端通道进来,则 它的入参在远控入口收敛:只放行workdir与permissionMode(不传即default),其余参数面(apiKey/baseURL/model/tools/plugins/mcpServers…)一律忽略——白名单放行的是「在选定目录建一条会话」这个能力,不是initialize的整张参数表。 - 假设 选定的是一个 git 仓库,则 页面给出分支选择(
listGitBranches)与「在 worktree 中打开」,默认值与桌面端新建会话页一致:基准分支默认取该目录的当前分支(回执{branches, current}的current,缺失时退到分支列表第一项),worktree 开关默认勾选;勾选后按桌面端同形的两步走——createWorktree拿到 worktree 路径,再initialize(workdir=该路径)。控件的显示判据是回执的形状:宿主回的是一对{branches, current}而不是裸的分支名数组,非仓库目录下宿主直接回错 ⇒ 这两个控件不出现(能力不可达),而不是渲染出来再报错。 - 假设 新会话的生成触发一次需要审批的工具调用,则 审批卡走既有链路到达页面——新会话初始
default档,执行侧的兜底与既有会话无差别。 - 假设 有人持有企业端登录态与本机通道键,则 本故事意味着他能在这台机器的任意目录发起会话并驱动 agent 跑工具——远端通道「只碰已存在会话」的既有语义被显式扩大,这份扩大必须随本故事一起被接受(执行侧兜底不变:
default档 + 审批链)。 - 假设 有人问「底层为什么不直接放行
clearMessages——插件端的『新建对话』本来就是它」,则 答案钉在这里:clear在 agent 内部确实是换绑新 sessionId(旧会话归档),效果上就是开一条新会话,但它开的是继承旧会话工作目录的新会话(agent 的workdir在initialize时定死,clear不碰它)——本故事的核心(选目录 / 分支 / worktree)它一样都给不了;而且它的回执不携带新 sessionId(bridge 返回null,插件端清完也只推空列表),页面要发现新 id 只能靠信封漂移;同进程里电脑端正在看的那个视图随 agent 换绑而漂移,活会话列表里旧 id 原地变成新 id、切换语义跟着乱。initialize四条全无——这就是按钮位可以复用、底层不能复用的完整论证。 - 假设 电脑侧这条会话此刻正在生成、正在压缩上下文,或存在正在运行的后台任务(即插件端把「新建对话」置灰的那三种忙闲状态),当 用户在页面上点顶栏的「新建对话」时,则 这个入口照常可用——它打开的「新建会话」流程与创建本身都不受这三种状态影响(不置灰,也不在提交时被忽略)。依据:页面上这个按钮不是 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)。
验收场景:
假设 用户在远控页点顶栏的「会话」按钮,则 打开的是整页的「会话」列表(顶部一行「返回」+ 标题「会话」),不再是原先那份从顶栏右上方弹出的下拉。这一条只对远控页成立:插件端的「历史对话」与桌面端的会话入口/侧栏一个字都不改(同一个
SessionListPopup在插件端照旧以variant="dropdown"渲染),因为「下拉」在指针设备上是好形态、在手机上才不够用(行级动作、触控热区、整屏滚动都是它给不了的)。假设 列表已打开,则 最上一个搜索框(沿用原下拉的搜索逻辑:按显示标题做大小写不敏感的子串匹配),下面每行两行字 + 右侧一个「⋯」:第一行是标题(
customTitle??firstMessage?? id 前缀,与其余四端同一个formatSessionLabel),第二行是「项目目录名 · 最近活跃时间」,第二行前面一个状态点——正在生成转圈、有待确认(琥珀点)、有后台任务在跑(蓝点)。一条只出一个点,优先级 待确认 > 运行中 > 有后台任务在跑(被审批卡住的对话不算「在跑」;后台任务在跑但前台回合已结束的对话仍要标出来,那正是「它还在动」的唯一信号)。排序沿用下拉的口径(lastActiveAt倒序)。假设 用户点列表里的某一行,则 切到那条会话并自动返回对话页——与原先下拉点一行完全同一套本地动作(只改页面自己的
sessionId与视图、不调用restoreSession、不套用 IDE 那条忙闲守卫),见「Web 页作为第五种宿主」场景 10。行内的「⋯」不触发切换(它有自己的可点区,点它只开动作面板)。假设 用户点某一行的「⋯」,则 从底部升起一个动作面板(action sheet),两项:
重命名与删除会话(红字)。不做左右滑删除、不做长按菜单:移动网页里横向滑动与纵向滚动、系统返回手势互相打架,长按与选词冲突;「⋯」稳定、可测、不依赖手势冲突的裁决。假设 用户选「重命名」,则 该行标题就地变成输入框(与桌面侧栏、顶栏标题同一套口令:Enter 或失焦保存、Esc 回滚、trim 后为空则什么都不做),走既有的
renameSession(乐观更新 → 按requestId关联回复 → 失败回滚并给出可见提示)。不新增任何协议:shim 早就有这个 case,白名单也早就放行了它。假设 用户选「删除会话」,则 进入删除流程的第三/第四步:一个全屏确认卡片,内容按下面的顺序给全——① 固定一句「对话记录仍保留在这台电脑上,可以从桌面端恢复。」(这句话是删除语义的核心:删的是这台机器上正在跑的这条会话,不是磁盘上的对话记录);② worktree 警告(复用桌面端
utils/worktreeDeleteWarning.ts的同一份文案与流程,见场景 7);③ 会话正在回答(isLoading或hasRunningBackgroundWork)时一行红字「会终止这条会话正在进行的回答」,且必须勾选「我知道了」确认键才可点;④ 按钮取消/删除会话(这条会话的工作目录是 linked worktree 时改成删除会话与工作树,红色)。假设 被删会话的工作目录是一个 linked git worktree,则 确认卡片上的 worktree 警告走桌面端那条逐字相同的流程:先显示「正在检查该 worktree 的改动…」并禁用确认键;计数回来后显示「该会话的 worktree 目录与临时分支将一并删除:N 个未提交文件、M 个未合并提交,删除后不可恢复。」(干净工作树按那份文案的既有规则不显示警告,但确认键照常可点);计数查不到(超时 / 宿主失败 / 工作树已经不在)时降级成泛警告再放行确认——不许静默删(未知不等于干净)。working tree 干净或不是 worktree 时,卡片只有场景 6 那句固定文案。
假设 用户确认删除,则 页面发两条请求的一条:
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"};不传参数时行为逐字不变(CLIwave 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。假设 用户问「列表里那行的
worktree从哪来」,则 它由 daemon 从该会话的createdWorkdir推导(listDaemonSessions每行追加可选字段worktree?: {path, branch, repoRoot, hookBased}),不新增「worktree ↔ 会话」的持久状态——daemon 重启就没了,而桌面端那套索引是它自己的(entry.worktree),两边各自持有各自的事实。计数绝不逐行跑 git(那会让每次列表刷新都变成 N 次 git 调用):列表只回worktree本身,计数走场景 8 那条按需读。推导不出来或失败一律降级为「没有这个字段」(= 不是工作树或不确定),绝不让整个列表报错。假设 某条会话是由别的宿主托管的(桌面端 / IDE 的
--stdio子进程),则 它照样能在这张列表里被删——不按托管方做任何区分或限制(能列出来的就是能删的,它们都活在同一个服务进程里)。已知残留(明确接受,2026-11-07 收窄):手机删掉一条由桌面端 / IDE 托管的会话后,电脑侧那个面板不会自动关闭——本轮不做「会话被远端删除」的跨进程通知(那要一条新的宿主内广播,收益只覆盖一个残留,代价是又一条状态同步链路)。但「点进去就报错、关不掉」这个死口子已经堵上:电脑侧下一次从别处点那条侧栏条目时,为刷新分屏而读消息会答Session not found: <id>,桌面端按desktop-remote.md「会话还在进程池里、但已被别的客户端销毁」那条口径自动把条目从侧栏移除、toast 提示、分屏落回新会话——与「会话已被删除」在本地/远端两种情况下的表现一致(不会停在原地让人以为点了没反应)。仍不覆盖的两种姿态:该会话正显示在某个分屏里时点它只是聚焦那个分屏(面板不会自己关、也不触发清理),以及配置重建窗口(改模型/登录/登出)内的读失败不判定为删除。假设 删除成功,则 页面回到列表并在顶部给一条「已删除」(短暂显示),不自动跳进别的会话。被删的若是当前正在看的那条,页面按既有
chooseSession规则重新挑一条活会话绑定(否则每次读都会落到Session not found),但视图仍停在列表上——「挑一条绑上」与「替用户跳进去」是两件事,后者会把用户刚做完的动作变成一次意外导航。文案分三档:worktreeRemoval: "pending"时是「已删除(工作树正在后台删除)」;老机器只回worktreeRemoved: false时仍是既有的「工作树没跟着删掉,可在电脑上手动清理」;其余是「已删除」。两者都不是失败,但「正在删」与「删不掉」必须分清,否则用户会去手动清一个正在被删的目录。假设 有人把这条故事读成「手机能翻磁盘历史会话了」,则 本篇只补「删活会话」——读磁盘历史会话是另一条入口(
/resume,见「手机端也能接着磁盘上的历史会话聊」故事),两者不共用数据源:这张整页列表仍只报进程内活会话(listDaemonSessions),顶栏「历史对话」仍不渲染(「Web 页作为第五种宿主」场景 21)。白名单口径随之分开:listAllSessions放行(/resume的只读取数)、restoreSession仍不放行(原地载入会换掉电脑侧那条会话)。删掉一条活会话不删它的磁盘 transcript(因此场景 6 那句「对话记录仍保留在这台电脑上」是字面意思),被删的会话此后可以从/resume里重新打开——那是磁盘上独立于「活会话」的一份记录。假设 有人问「桌面端那条删除规格要不要跟着改」,则 不改:
desktop-sessions.md「删除 worktree 会话前提示将丢失的改动」的语义与文案归桌面端,手机端复用同一份文案(utils/worktreeDeleteWarning.ts是两端共用的唯一来源,本故事不改它的一个字),差别只在承载物——桌面端是侧栏行菜单 + 居中确认框,手机端是整页列表的行「⋯」+ 底部动作面板 + 全屏确认卡片。假设 最后一条会话也被删掉(机器上一条活的都不剩),当 用户回到对话页时,则 页面不摆出一条「看起来能用」的新对话:消息列表为空时不渲染「快捷方式」模板,输入区按既有
disabled语义整体置灰(不可编辑、发送 / 附件 / 快捷指令 / 权限模式一并禁用),禁用原因写在输入框占位文案里——「请先新建对话并选择工作目录」。依据:此刻页面手里既没有sessionId也没有任何 workdir(删完机器的注册表是空的),而「新建一条」必须先选目录(见「Web 端也能发起新会话」场景 2:不存在「不选目录直接创建」);放着一份可编辑的输入区,用户敲下去只会收到机器侧的sessionId is required for this request红块,看上去像删除把页面弄坏了。禁用期结束于下一次快照带来会话(新建成功,或电脑侧又开了一条)——判据是currentSession,不是「消息列表为空」(/clear与/rewind回到第一条时列表同样为空,但那两条背后都有真会话,照旧可发)。假设 用户点顶栏的「会话」打开列表,则 打开这一页本身重读一次机器的会话注册表并把列表重画(页面发一条
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。
验收场景:
假设 用户在远控页敲
/resume,则 页面发一条listAllSessions(host 级读,不带sessionId,与「新建会话」的listDirectories同一档),宿主枚举这台机器所有项目目录的主会话(~/.wave/projects/下逐项目目录,按lastActiveAt倒序、按会话 id 去重),页面按整页列表呈现——形态与「手机端的会话列表与删除」一致(顶部「返回」+ 标题「恢复会话」+ 搜索框),每行两行字:标题(与其余四端同一个formatSessionLabel)与「工作目录完整路径 · 最近活跃时间」。为什么给完整路径而不是项目目录名:这份列表跨工作目录,目录名会在两个同名项目上撞成两行一模一样的字;桌面端那张同源列表也是给完整路径(showProject)。为什么复用整页列表而不是借来的下拉:手机上那个下拉形态早被「手机端的会话列表与删除」判过不够用(触控热区、整屏滚动)。假设 这台机器上的会话超过
200条,则 页面只列最近 200 条,不分页。依据:远控每一帧有 512 KiB 上界,而sendResult对getMessages以外的回包超预算时是明确报错、不截断(截断一份会话列表等于给手机一份「哪些会话存在」的假话);按最近活跃时间取上限是唯一既不新增分页协议、又不会摸到上界的选择。更老的会话在手机上暂不可达(桌面端还能翻),这是明确接受的取舍。上限由页面请求带上(limit),宿主照单截取——页面不需要知道帧预算,宿主不必猜渲染能力。假设 用户选中一条会话,则 页面用
initialize {workdir, restoreSessionId}在这台机器上新建一条会话并从磁盘那条恢复(workdir取该行的完整路径,restoreSessionId取该行的会话 id),回执带回新会话的sessionId;页面绑上它、关闭列表、按首屏那套拉尾页与状态,然后重读会话列表——恢复出来的这条此时是一条活会话,与桌面端恢复后出现在侧栏同义。判据:这条路不是restoreSession。restoreSession是「原地载入」——把承接进程里某条正在跑的会话换成另一条(在桌面端就等于换掉用户正看着的那个 pane,见边界情况),与「手机只当控制面」相冲,所以它仍不进白名单。为什么不在页面里改currentSession了事:读消息与发消息都要求信封sessionId在承接进程的注册表里(getMessages走requireSessionForRead),磁盘会话不先载入进程,点进去只会回Session not found。假设 有人问「页面为什么能建会话、还能带
restoreSessionId」,则 这是同一道口子的新用法,不是新开一道:initialize早在「Web 端也能发起新会话」时放行,入参收敛为workdir/permissionMode;本次把restoreSessionId加进这份收敛清单(仍要求是非空字符串,仍由宿主决定这条会话落到哪个工作目录)。放行的能力是「按某个已有会话的历史开一条新会话」,而页面本来就已能用initialize在任意目录建会话,暴露面没有变化;被恢复的那些会话本身也已经通过listAllSessions对页面可见。假设 目标会话的工作目录已被删除(worktree 撤了、临时目录清了),则 页面得到一句可见的「该会话的工作目录已不存在」并停在列表上,不进入空对话页、不留下半条绑定的状态。依据:桌面端同一条路径是先探测目录再切换(
desktop-sessions.md「跨客户端会话来源」场景 6),页面拿不到探测结果时退化成「初始化失败」——两种形状对用户是同一句话。假设 手机接管的是前台 CLI 那条会话(
RemoteControlCliHost),则 弹窗里没有/resume,页面也不会去请求listAllSessions。依据:这个宿主只有一条会话槽位,恢复磁盘会话等于把用户终端里正看着的那条换掉,而它连initialize都没实现(「Web 端也能发起新会话」的未落地记录里是同族缺口);「弹窗里列出来 ⇒ 选中可用」这条既有承诺要求它不列。收窄发生在宿主侧(服务端那张表按入口给出集合,前台宿主在回包前删掉这一条),页面不存名单(见commands/slash-commands.md的「弹窗里的宿主命令只有一份表」故事)。假设 机器侧是旧版本(老 CLI 的
listAllSessions不在白名单里、或宿主没实现),则 页面收到既有的「该宿主不支持」结构化错误,按「页面能执行的那批宿主命令」故事场景 10 翻成一句可见文案收尾——不出现空列表,也不长时间转圈。假设 恢复成功,则 页面的通知过滤照旧按信封
sessionId生效:新会话与旧会话在同一个进程里并存,别的会话的增量不会混进这条新视图(「Web 页作为第五种宿主」场景 11 不变)。
用户故事:前台 CLI 会话也能被手机接管(优先级:P1)
作为正坐在终端前对话的用户,我希望离开工位时把手上这条会话交给手机,而不是另开一条再重述一遍上下文,以便走出工位还能接着推进。
为什么是这个优先级:前面几条故事覆盖的都是「常驻着、可以被指着的会话」(daemon、GUI 子进程)。前台 TUI 会话是唯一一种只活在用户眼前那个进程里的会话——它的寿命跟着用户敲的那一下退出走。能不能接管,取决于这条会话所在的进程自己愿不愿意拨号,没有第二个进程能替它做(「谁持有会话谁服务」在这条故事里是极端情形)。它不改安全模型(同一个开关、同一份白名单、同一条设备级通道),所以既可以独立落地,也可以独立不落地;缺了它,既有闭环一条都不少。
独立测试:在交互式 TUI 里执行开启动作,拿到页面地址与二维码;手机连上后看到的就是终端里这条会话(消息逐条一致,不是空会话);两端各发一句对方都能看到;构造一次等待确认的工具调用,两端同时出现审批卡,任意一端作答后另一端那张卡消失、会话继续;退出 TUI 后页面显示「电脑已断开」;让会话流式生成一段长正文,手机端正文逐段增长(不是停在某一瞬的片段),且这期间推给手机的增量帧数远少于模型增量数、结构变化信号只在结构变化时出现;再让一次工具调用失败,手机端那条回复下出现同一条错误块(不必手动刷新)。
验收场景:
假设 开关已打开且用户已登录,当 用户在交互式 TUI 里执行开启远程控制的动作时,则 拨号的是这个前台进程自己——不经 daemon 代理、也不为了完成这个动作去拉起 daemon——并在 TUI 的远程控制覆盖层里回显与
wave remote-control on同形的页面地址与可扫二维码(承载面见场景 13);失败时的提示同样指向enableRemoteControl(并说明前台进程也要重启才换设置),且同样只回显在那一层里。假设 手机连上了前台 CLI 会话,则 它看到的是这条会话本身(与终端同一份消息序列、同一个模型与权限模式),不是新会话、也不是空会话。
假设 用户在手机上发出一条消息,则 它进入这条会话的历史并驱动同一条生成循环,终端的消息列表随之更新;反过来,在终端发出的消息同样出现在手机上。
假设 会话正在生成、用户在手机上触发中断,则 中断的是这条会话当前这一轮,与在终端按中断键等价。
假设 这条会话有等待确认的工具调用,则 两端都能作答(终端沿用既有审批 UI,手机用审批卡),先作答的一端生效、另一端同一张卡随即消失;两端看到的待确认集合一致。这不是新增权限:同一个请求本来就要用户点一次,区别只在点在哪一端。
假设 用户正常退出 TUI、或前台进程被结束,则 本机主动断开这条中继连接(不替一个已经不存在的进程保留订阅),页面呈现为「电脑已断开」并停止展示内容(与「服务端不落内容」这条既有口径一致);该会话按 CLI 既有语义落盘,将来恢复出来的是一条新会话,不会自动接回手机原来那条连接。
假设 用户想在 TUI 里关掉,则 关闭断开的正是这个进程这条连接(关的入口必须和开的入口在同一个进程里,理由同第 1 条);此时本机别的进程若在服务(daemon / GUI 子进程),它不受影响。
假设 同一台机器上已经有另一个进程在用同一个通道键服务(daemon 在跑、或某个 GUI 子进程已连),当 前台 TUI 开启时,则 由前台进程接管、那个进程的连接被中继关掉并如实呈现(见「一个通道一个服务方」故事)——不允许两个进程都以为自己在线(通道键是设备级的,见边界情况)。
假设 手机打开顶栏的「会话」列表,则 前台 CLI 这个宿主有且只有一条可列:它自己手上这条会话(
listDaemonSessions报的就是它,一条,带sessionId/sessionCwd/ 标题材料 /lastActiveAt/ 忙闲,全部取自进程内这条会话),不读磁盘 jsonl、也不列这台机器上的历史会话;手机点进去只能是这条,「切换」在这个宿主上没有第二个目标。依据:页面列会话的唯一数据源是listDaemonSessions(见「边界情况」——手机只能看进程内活着的会话),而前台 CLI 的「进程内注册表」就是它这一条;宿主漏答这个方法时页面落到「读不到电脑侧的会话列表」横幅上(就是这条把「前台 CLI 也必须答它」钉成契约,而不是留给实现去发现)。这条宿主上也没有/resume:它只有这一个会话槽位(恢复磁盘会话等于把用户正看着的这条换掉),且没实现initialize,所以服务端那张宿主命令表在回包前把它删掉,「弹窗里列出来 ⇒ 选中可用」的承诺不在这里破例(见「手机端也能接着磁盘上的历史会话聊」场景 6)。假设 这条会话正在流式生成,则 推给手机的通知与
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。即「这条路上没有节流」与「手机看不到流式增长」是同一个根因,本场景一次收掉。假设 这一轮在尾部那条消息内部原地多出一个块(最典型:工具失败 / 生成失败的错误块——前台宿主把它追加到正在流式的最后一条 assistant 消息上,只有当最后一条是 user 消息时才另起一条),则 宿主必须用带载荷的通知(
noticeAdded,与AgentBridge同方法名、同载荷)把它送出去,页面按既有的updateNoticeBlock→APPEND_NOTICE_BLOCK落到那条消息里,两端看到同一条错误块。不允许只发「列表变了」信号了事:since是排他锚点、而锚点正是这条消息本身 ⇒ 回拉返回空messages,错误在手机上永远不出现(与场景 10 的He是同一个坑,只是方向相反:那边是帧太少,这边是没有帧)。明确允许的降级:以「读」为准的状态族——待办清单 / 待发队列 / 命令运行中 / 压缩中——不要求在这个宿主上实时推送,页面在冷启动、切会话、重连、重挂、回前台时各读一次getSessionInfo即对齐(与「Web 页作为第五种宿主」故事里那条既有口径同形:读出来的事实等下一次对齐,页面不为它新增轮询)。判据是内容能不能被回拉补回:一手内容(模型增量、工具参数、错误块)必须由通知携带;只作指示用的状态可以只靠读。假设 手机请求了一个这个宿主没有实现的方法(白名单是通道级的,daemon / GUI 子进程 / 前台 CLI 共用同一份,而三个宿主的实现面不同——前台 CLI 只接了会话内那一批),则 宿主回一个**结构化的「该宿主不支持」**错误:固定错误码 + 带方法名的消息,语义上明确是「这个宿主没接」,而不是
-32601 Method not found(那是「通道不放行」的意思,页面会据此以为该升级电脑侧)、也不是-32602(参数错)、更不是静默不回。依据:把「通道不放行」「这个宿主没实现」「内部报错」压成同一个错误,页面就分不清该升级、该换一台电脑、还是该等实现;而静默不回执会让界面停在那个动作上(没有回执、也没有告警)。该错误由页面翻成一句可见文案并收尾这条请求(见「页面能执行的那批宿主命令」故事场景 10)。假设 用户在 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(时钟 + 回溯箭头,读作「历史」)换成 codiconcodicon-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 那条 KotlinlocalCommand副本随之退役,它读同一份 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)生成的,同一个值拼回才自洽(Bashcd之后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/remotePathExistsssh 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——「最近打开过的目录」改由机器侧持久化(
~/.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 视口/触控模拟 + 回环链路)。