本次发布给远程桥补上一条整页通道(§35 的 ui.v1):agent 可以把一整页交给会话,用户在桌面端看到的是一张真正的页面卡,页面里的第一次操作作为该次调用的结果回到模型;不宣告 ui.v1 的客户端看到的握手与帧与从前完全一致。推送这一版修掉「同一台手机收到两条同样的通知」——发送前按 APNs 令牌收敛目标,一条令牌只投递一次;回合结束的公告也不再在帧到达时就发,而是等到该会话真正静默(宿主自己的运行标志、后代会话与宿主作业都停下)之后再发,最多等十分钟,超时就照发并在日志里写明当时还有什么在跑,因为「永远等不到的通知」正是这条通道要防的失败。桌面端的浏览器视图改用外壳发布的 Host 地址,dsh-app: 交付的页面不再拿自己的 scheme 去猜一个连不上的套接字。
协议仍是 v1,次版本 20 升到 21,纯追加:
PROTO保持 1;MINOR由 20 升到 21,只因多了一条ui.v1能力。hello 的能力表把协议自己的名字排在前面且保持原顺序,扩展插件注册的能力按注册表顺序追加并去重,所以没有挂载扩展的桥宣告的仍是它一直宣告的那份列表。
旧客户端零影响:不宣告ui.v1的客户端拿到的握手、帧与话题都没有变化——整页卡只在声明了该能力的连接上出现。这一版没有删除任何帧、能力位或话题,推送存储的结构版本仍是 2,三条既有推送限额保持原值。
设备表的收敛是幂等的:一台手机一行,重新配对只替换该行上的凭据;读取时把还残留的重复行收敛成「每个指纹一行、每条令牌一条登记」,并经名册自己的串行队列写回。收敛后的表再读一次是空操作(compacted 0 duplicate row(s), retired 0 stale registration(s)),可以重复执行。
本版没有重命名任何持久化或线协议标识符:设置 section id、配置 key、localStorage 键、控制面 API path 与聚合行 id 全部保持原值;新增的都是纯追加。没有手工迁移步骤,正常升级即完成。
卫星范围这一版不动:聚合包对四个卫星仓的依赖范围仍是^0.4.9。卫星仓在各自仓库发布0.4.10并把聚合范围抬到^0.4.10,是紧随其后的下一阶段,不在本次提交内。
转发的整页卡桌面端只渲染、不作答:桌面端用srcdoc加sandbox=allow-scripts的受控 iframe 画出整页文档,页面发来的postMessage只换来一句可见提示,不转发、不落库、不发请求;超过 256 KiB 的页面不渲染并给出可见提示。
回合结束现在要等会话静默:到达的turn/end先登记而不是直接发送,每五秒向宿主问一次「静默了吗」,最长等 600000 毫秒(十分钟)后照发,并在日志里记下当时仍在运行的东西。锁屏不会再在子会话还在跑的时候说「已经结束」。
判定「新回合」更严格:只有能证明开启了一个新回合的增量才算数;子会话的落定通知以 agent 身份的 user 消息到达父会话,不再被当成新回合。
推送去重与既有契约:发送前的目标按设备令牌收敛,重复登记的行在读取时收敛掉,schema 版本不变,没有引入新的帧、能力或话题。
已知限制:干净 profile 首次安装停在ERR_PNPM_IGNORED_BUILDS,而构建脚本的批准必须在首次 add 之前就位:在本机 pnpm 11.9.0 加官方 CLI 0.2.1-rc.3 下,干净 profile 首次用dsh plugin --profile <name> add装@linxin666/dsh-web-all@0.4.9以 exit 1 结束,唯一的报错是[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0。失败之后再补审批修不回来:官方 CLI 只在安装命令 exit 0 之后才回写 profile(dsh-plugin-manager的lib/types/operations.js约 499 行),而回写时的 reconcile 只遍历本次运行新加的依赖(同一文件里的if (beforeDeps.has(name)) continue,约 106 行),失败的那次已经把聚合包写进了 manifest,所以补完审批再重跑同一条 add 虽然以 exit 0 结束,dsh.profile.bundles里仍然没有这一行——装上了却没有挂载,--dump-config看不到该 bundle,重跑多少次都停在这个静默的半挂载状态。实测能挂载的顺序是「先审批、后安装」:先用官方--from-default-profile web重置 profile(或换一个没被这次失败污染过的新 profile),在首次 add 之前把这三项写进<profile>/pnpm-workspace.yaml的allowBuilds,然后再 add。--config.strictDepBuilds=false实测只买到挂载,买不到完整安装:它在干净首次 add 上以 exit 0 结束并写入 bundle 行,却在 profile 里留下占位条目(形如cloudflared: set this to true or false),三项原生/二进制构建并不真正执行,既没有bin/cloudflared,也没有build/Release/cpufeatures.node。官方 CLI 只在 git 源插件失败时提示allowBuilds这一步,注册表包不提示;本仓pnpm-workspace.yaml:13的allowBuilds只作用于开发工作区,不作用于用户 profile。已带审批的存量 profile 实测不受影响:一个已经有构建脚本审批的 0.4.9 profile 升级到 registry 上的 0.4.10,以 exit 0 结束,不再要求新的审批,dsh.profile.bundles那一行不变也不重复,--dump-config逐字节相同,重复执行仍然幂等;这道门由审批状态决定,不由依赖新旧决定。完全没有allowBuilds审批记录的存量 profile 实测:命令报错,升级已经落地且仍然挂着:一个没有任何allowBuilds审批记录的 0.4.9 存量 profile,在 pnpm 11.9.0 加官方 CLI 0.2.1-rc.3 下把聚合包升到 registry 上的 0.4.10,命令以 exit 1 结束并报[ERR_PNPM_IGNORED_BUILDS](同样是 cloudflared@0.7.3、cpu-features@0.0.10、ssh2@1.17.0),但升级本身已经落地且保持挂载:profile 的package.json依赖已是 0.4.10,node_modules里 25 个家族包全部 0.4.10,dsh.profile.bundles里聚合包仍恰好一行、不重复,dsh --profile <name> --dump-config以 exit 0 结束且能看到该 bundle 段,25 个家族包声明的 60 个依赖 0 个不可解析;失败的是退出码与后续操作的干净度,不是升级结果或挂载状态,重跑同一条命令仍以 exit 1 结束、不自愈。这一情形的补救实测不需要重装、不需要删 profile:在<profile>/pnpm-workspace.yaml里把 cloudflared、cpu-features、ssh2 三个键写成true(或走交互式pnpm approve-builds),再跑同一条升级命令即以 exit 0 结束,三项原生/二进制构建真正执行(cloudflared 的 postinstall 生成bin/cloudflared,cpu-features 经 node-gyp 生成build/Release/cpufeatures.node,ssh2 生成sshcrypto.node),版本与挂载状态不变、--dump-config逐字节相同。这道门只读审批记录,与构建产物是否已存在无关:构建产物已经在、审批记录缺席的变体同样以 exit 1 结束,状态同样已是 0.4.10 且已挂载。两种失败形态不同,不要混为一谈:干净首发失败后补审批再重跑,是「exit 0 但 bundle 行从未写入」的静默半挂载;这里这种存量升级则是「行本来就在、状态已升级、只是命令报错」。这条限制不是本版引入的:已发布的 0.4.9 在同样环境下同样复现。
新功能
- [remote-bridge]
ui.v1:agent 可以把一整页交进会话,声明了该能力的客户端拿到ui.present,页面里的第一次操作作为这次调用的结果回到模型 - [remote-bridge] 桌面端渲染 §35 的整页卡:
ui.present注册为 keyedtool.call.toolview,调用进行中从参数读html,结算后从meta.ui读html与state,载荷缺席时回退参数 - [remote-bridge] 整页卡是受控 iframe(
srcdoc加sandbox=allow-scripts),高度先按height顶首帧、之后以注入脚本量到的实测为准,主题令牌从 GUI 根元素读出后注入并跟随明暗切换 - [remote-bridge] 桌面端只渲染不作答:页面发来的
postMessage只换一句可见提示,不转发、不落库、不发请求 - [remote-bridge] 超过 256 KiB 的页面不渲染,改为显示可见提示
- [i18n] 整页卡的俄文文案补齐同键
修复
- [browser-view] 桌面端改用外壳发布的 Host 地址:
dsh-app:交付的页面无法在该 scheme 上升级 WebSocket,不再用自己的 location 猜地址;Web 页面仍保留自己的 origin - [remote-bridge] 一次事件按 APNs 令牌投递一次,而不是按行投递一次:同一台手机的同一个令牌挂在两行上不会再收到两条同样的通知,发送前先按设备令牌收敛目标
- [remote-bridge] 重新配对复用指纹已经拥有的那一行,只把凭据搬过去,不再为每次接受新造一行
- [remote-bridge] 读取设备表时收敛重复行与过期登记,并经名册的串行队列写回,收敛可重复执行
- [remote-bridge] 回合结束改在会话静默之后公告:答案只来自宿主(
session/list的运行标志、父链与宿主作业),不猜、不读客户端状态 - [remote-bridge] 静默等待有上限:每五秒问一次,最长 600000 毫秒,到点照发并在日志里写明当时仍在运行的东西
- [remote-bridge] 只有能证明开启了新回合的增量才算新回合:子会话的落定通知、用量计数与目录变更不再被误当成新回合
- [remote-bridge] hello 公告扩展注册的能力:握手时按注册表计算,协议自己的名字在前且保序,扩展名追加去重
- [remote-bridge] 静默判定的读取失败按失败处理,不回答成「已静默」
其他改动
- [notes] 记录 §35 可交互回复的决策
- [notes] 补齐「一枚令牌一次投递」记录的双语三件套
- [libs] 重建聚合包 bundle 并刷新记录指纹
- [release-skill] 发布后检查去掉已下线的桌面打包车道
- [docs] 远程桥双语 README 写明「一台手机一行」与「一条令牌一次投递」两条规则
English
This release adds a whole-page channel to the remote bridge (§35's ui.v1): the agent can hand an entire page into the session, the desktop renders it as a real page card, and the first action inside that page comes back to the model as the result of the call. A client that does not declare ui.v1 sees the handshake and the frames it always saw. Push delivery in this version fixes "one phone received the same notification twice": targets are converged by APNs token before fan-out, so one token is delivered once. A turn end is no longer announced when the frame arrives but once the session it belongs to falls quiet — the host's own running flag, any descendant session still running and any job the host attributes to either — waiting at most ten minutes and, at the limit, announcing anyway and logging what was still going, because a notification that never arrives is the failure this channel exists to prevent. The desktop browser view now uses the Host address the shell publishes, so a page delivered over dsh-app: no longer derives a socket from its own scheme that could never connect.
The protocol stays v1, the minor moves from 20 to 21, and the change is additive:
PROTOremains 1;MINORmoves from 20 to 21 only because there is one more capability,ui.v1. The hello capability list keeps the protocol's own names first and in their existing order, and appends the capabilities extensions registered in registry order, deduplicated, so a bridge with no extension mounted advertises exactly the list it always did.
No impact on older clients: a client that does not declareui.v1gets the handshake, the frames and the topics it got before — the whole-page card appears only on a connection that declared the capability. No frame, capability or topic is removed, the push storage schema stays at version 2, and the three existing push limits keep their values.
Converging the device table is idempotent: one phone is one row, and pairing again only replaces the credential on the row the phone already has. A load converges whatever duplicates remain into one row per fingerprint and one registration per token, written back through the roster's own serialized queue. Reading the converged table again is a no-op (compacted 0 duplicate row(s), retired 0 stale registration(s)), so it is safe to repeat.
This version renames no persisted or wire-protocol identifier: settings section ids, configuration keys, localStorage keys, control-plane API paths and aggregate row ids all keep their values; everything new is purely additive. There are no manual migration steps; a normal upgrade is all it takes.
The satellite ranges do not move in this version: the aggregate's dependency ranges for the four satellites stay at^0.4.9. Publishing0.4.10from each satellite's own repository and raising the aggregate range to^0.4.10is the next stage that follows this one, and is not part of this commit.
The desktop renders a forwarded page but does not answer it: the desktop draws the whole-page document in a controlled iframe built withsrcdocandsandbox=allow-scripts. ApostMessagefrom the page only swaps in a visible hint — nothing is forwarded, stored or requested — and a page over 256 KiB is not rendered at all; a visible notice takes its place.
A turn end now waits for the session to fall quiet: an arrivingturn/endis registered rather than sent, the host is asked every five seconds whether the session is quiet, and at 600000 milliseconds (ten minutes) the announcement goes out anyway with a log line naming what was still going. A lock screen no longer says "finished" while a child session is still working.
What counts as a new turn is stricter: only increments that prove a turn started are read as one. A child's settle notice arrives on the parent as a user message with an agent actor and is no longer mistaken for a new turn.
Push deduplication and the existing contract: targets are converged by device token before fan-out and duplicate rows are converged when the table is read, with the schema version unchanged and no new frame, capability or topic introduced.
Known limitation: a clean profile stops atERR_PNPM_IGNORED_BUILDS, and build-script approval has to be in place before the first add: on this machine, under pnpm 11.9.0 with the official CLI 0.2.1-rc.3, a clean profile that runsdsh plugin --profile <name> addfor@linxin666/dsh-web-all@0.4.9for the first time exits 1, and the only diagnostic is[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0. Approving the scripts afterwards does not repair it: the official CLI reconciles a profile only after an install that exited 0 (dsh-plugin-manager, aroundlib/types/operations.js:499), and the reconcile pass walks only the dependencies that run added (if (beforeDeps.has(name)) continuein the same file, around line 106), so the failed attempt has already put the aggregate into the manifest and patching the approvals before re-running the same add exits 0 whiledsh.profile.bundlesstill gains no row: installed but not mounted, absent from--dump-config, and stuck in that silent half-mount however often it is re-run. The order measured to mount the bundle is approval first, add second: reset the profile with the official--from-default-profile web(or start from a new profile this failure never touched), write the three names into<profile>/pnpm-workspace.yamlunderallowBuildsbefore the first add, and only then add. Measured,--config.strictDepBuilds=falsebuys the mount and not a complete install: it exits 0 on a clean first add and writes the bundle row, but it leaves a placeholder entry behind in the profile (shapedcloudflared: set this to true or false), and the three native or binary builds do not actually run: there is nobin/cloudflaredand nobuild/Release/cpufeatures.node. The official CLI prompts for theallowBuildsstep only when a git-source plugin fails, not for a registry package, and this repository's ownallowBuilds(pnpm-workspace.yaml:13) governs the development workspace and does not reach a user profile. An existing profile that already carries the approvals was measured to be unaffected: a 0.4.9 profile that has the build-script approvals upgrades to the registry version 0.4.10 with exit 0, asks for no new approval, keeps thedsh.profile.bundlesrow unchanged and unduplicated, leaves--dump-configbyte-identical and stays idempotent on repeat; the gate is decided by the approval state, not by whether the dependencies are new. Measured on an older profile that carries noallowBuildsapproval record at all: the command fails while the upgrade has already landed and stays mounted: a 0.4.9 profile whosepnpm-workspace.yamlrecords noallowBuildsentry, upgraded to the registry version 0.4.10 under pnpm 11.9.0 with the official CLI 0.2.1-rc.3, exits 1 with[ERR_PNPM_IGNORED_BUILDS](the same cloudflared@0.7.3, cpu-features@0.0.10 and ssh2@1.17.0), and the upgrade itself has landed and stays mounted: the profile'spackage.jsonrecords the 0.4.10 dependency, all 25 family packages innode_modulesare 0.4.10,dsh.profile.bundlesstill holds exactly one row for the aggregate,dsh --profile <name> --dump-configexits 0 with the bundle section present, and the 60 dependencies those 25 packages declare resolve with zero failures. The exit code and the cleanliness of what follows are what fail, not the upgrade result or the mount state, and re-running the same command exits 1 again instead of healing. The recovery measured for that case needs no reinstall and no profile deletion: with cloudflared, cpu-features and ssh2 written astruein<profile>/pnpm-workspace.yaml(or approved through the interactivepnpm approve-builds), the same upgrade command exits 0 and the three native or binary builds really run (cloudflared's postinstall writesbin/cloudflared, cpu-features producesbuild/Release/cpufeatures.nodethrough node-gyp, and ssh2 producessshcrypto.node), with the versions and the mount state unchanged and--dump-configbyte-identical. The gate reads the approval record, not whether the build output already exists: a variant whose build output is present but whose approval record is absent also exits 1, with the same already-upgraded 0.4.10 state and the same mounted bundle. The two failure shapes are distinct and do not substitute for each other: a clean first attempt that fails and is retried after the approvals are patched in ends at exit 0 with the bundle row never written, which is the silent half-mount, while the upgrade here keeps the row it had, has already upgraded, and only reports the error on the command. This version did not introduce the limitation: the published 0.4.9 reproduces it in the same environment.
New Features
- [remote-bridge]
ui.v1: the agent can hand a whole page into the session, a client that declared the capability getsui.present, and the first action inside the page comes back to the model as the result of that call - [remote-bridge] The desktop renders §35's whole-page card:
ui.presentis registered as a keyedtool.call.toolview;htmlcomes from the arguments while the call is in flight and frommeta.ui(withstate) once it settles, falling back to the arguments when the payload is absent - [remote-bridge] The card is a controlled iframe (
srcdocwithsandbox=allow-scripts);heightsizes the first frame and the measurement its injected script reports wins afterwards, while theme tokens are read from the GUI root, injected into thesrcdocand follow the light/dark switch - [remote-bridge] The desktop renders without answering: a
postMessagefrom the page only swaps in a visible hint, and nothing is forwarded, stored or requested - [remote-bridge] A page over 256 KiB is not rendered and a visible notice takes its place
- [i18n] The Russian copy for the whole-page card is filled in with the same keys
Bug Fixes
- [browser-view] The desktop uses the Host address the shell publishes: a page delivered over
dsh-app:cannot carry a WebSocket upgrade on that scheme, so it no longer guesses an address from its own location, while a web page keeps its own origin - [remote-bridge] An event is delivered once per APNs token, not once per row: one phone holding the same token on two rows no longer receives the same notification twice, because targets are converged by device token before fan-out
- [remote-bridge] Pairing again reuses the row its fingerprint already owns and moves only the credential, instead of minting a row per accept
- [remote-bridge] A load converges duplicate rows and stale registrations and writes the result back through the roster's serialized queue, so the convergence is safe to repeat
- [remote-bridge] A turn end is announced once its session falls quiet: the answer comes from the host alone (the
session/listrunning flag, the parent chain and the host's job registry), never from a guess or from client state - [remote-bridge] The quiet wait is bounded: the host is asked every five seconds and the announcement goes out at 600000 milliseconds at the latest, with a log line naming what was still going
- [remote-bridge] Only increments that prove a turn started are read as a new turn: a child's settle notice, usage counts and catalog changes are no longer mistaken for one
- [remote-bridge] hello advertises the capabilities extensions registered: computed per greeting, protocol names first and in order, extension names appended and deduplicated
- [remote-bridge] A quiet check that cannot read the host is treated as a failure rather than answered as "quiet"
Other Changes
- [notes] Record the decision behind §35's interactive reply
- [notes] Add the bilingual note set for one-delivery-per-token
- [libs] Rebuild the aggregate bundles and refresh the recorded fingerprints
- [release-skill] Drop the retired desktop packaging lane from the post-release checks
- [docs] The remote bridge's bilingual READMEs state the one-row-per-phone and one-delivery-per-token rules