dsh-mobile-plugin 0.4.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +47 -24
  3. package/host.js +22 -36
  4. package/lib/client.js +29 -82
  5. package/lib/desktop-view.cjs +5 -10
  6. package/lib/git/read.js +6 -8
  7. package/lib/host-compat.cjs +13 -25
  8. package/lib/host-guard.cjs +9 -39
  9. package/lib/host-patches.cjs +5 -16
  10. package/lib/http/routing.js +7 -27
  11. package/lib/impl.js +6 -12
  12. package/lib/index.js +1 -1
  13. package/lib/mirror/pusher.cjs +33 -85
  14. package/lib/mirror-snapshot.cjs +6 -24
  15. package/lib/mobile/agent-presets.js +6 -26
  16. package/lib/mobile/allowed-zone.js +100 -0
  17. package/lib/mobile/approvals.js +18 -44
  18. package/lib/mobile/archive-clean.js +13 -21
  19. package/lib/mobile/attachments.js +16 -37
  20. package/lib/mobile/channels.js +15 -41
  21. package/lib/mobile/credential-upgrade.js +7 -16
  22. package/lib/mobile/credentials.js +5 -21
  23. package/lib/mobile/directory-browse.js +8 -27
  24. package/lib/mobile/directory-listing.js +10 -23
  25. package/lib/mobile/drive-roots.js +59 -0
  26. package/lib/mobile/envelope.js +4 -15
  27. package/lib/mobile/events.js +35 -65
  28. package/lib/mobile/extensions.js +5 -8
  29. package/lib/mobile/file-read.js +168 -0
  30. package/lib/mobile/git-repos.js +6 -15
  31. package/lib/mobile/git.js +11 -19
  32. package/lib/mobile/history-derive.js +85 -134
  33. package/lib/mobile/host-bridge/endpoints.js +4 -6
  34. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  35. package/lib/mobile/host-bridge/shims.js +12 -22
  36. package/lib/mobile/host-bridge.js +8 -20
  37. package/lib/mobile/host-settings.js +9 -19
  38. package/lib/mobile/inbox-clean.js +7 -24
  39. package/lib/mobile/local-images.js +48 -23
  40. package/lib/mobile/model-vendors.js +105 -0
  41. package/lib/mobile/monitor.js +8 -17
  42. package/lib/mobile/pairing.js +7 -19
  43. package/lib/mobile/plugin-market.js +475 -0
  44. package/lib/mobile/plugin-self.js +6 -23
  45. package/lib/mobile/plugin-update.js +8 -27
  46. package/lib/mobile/plugins.js +6 -24
  47. package/lib/mobile/questions.js +15 -40
  48. package/lib/mobile/references.js +11 -31
  49. package/lib/mobile/relay-control.js +136 -33
  50. package/lib/mobile/rpc.js +5 -13
  51. package/lib/mobile/schedule.js +8 -33
  52. package/lib/mobile/self-check.cjs +9 -36
  53. package/lib/mobile/session-cache.js +13 -19
  54. package/lib/mobile/session-cwd.js +28 -0
  55. package/lib/mobile/session-file.js +9 -33
  56. package/lib/mobile/session-flags.js +7 -13
  57. package/lib/mobile/session-mirror.js +8 -15
  58. package/lib/mobile/session-models.js +3 -3
  59. package/lib/mobile/session-schedule-mark.js +8 -14
  60. package/lib/mobile/session-send.js +13 -21
  61. package/lib/mobile/session-snapshot.js +4 -6
  62. package/lib/mobile/sessions.js +7 -23
  63. package/lib/mobile/stats-collect.cjs +154 -37
  64. package/lib/mobile/stats-ledger.cjs +258 -75
  65. package/lib/mobile/stats-scheduler.cjs +41 -0
  66. package/lib/mobile/stream.js +39 -130
  67. package/lib/mobile/subagents.js +5 -18
  68. package/lib/mobile/uploads.js +75 -21
  69. package/lib/mobile/vendor-logos.cjs +30 -0
  70. package/lib/mobile/wire-cut.js +212 -0
  71. package/lib/mobile/workspaces.js +20 -8
  72. package/lib/panel/index.html +7 -7
  73. package/lib/read-receipt.cjs +10 -21
  74. package/lib/relay/apply-ledger.cjs +52 -0
  75. package/lib/relay/core.js +14 -3
  76. package/lib/relay/lan-network.cjs +14 -28
  77. package/lib/relay/lan-proxy.js +14 -21
  78. package/lib/relay/migrate.js +90 -0
  79. package/lib/relay/pairing-policy.js +6 -9
  80. package/lib/relay/preview.js +8 -25
  81. package/lib/relay/protocol.js +9 -12
  82. package/lib/relay/status.js +5 -8
  83. package/lib/relay/tunnel.js +9 -14
  84. package/lib/relay/verify.js +52 -0
  85. package/lib/routes/control-plane.js +39 -47
  86. package/lib/routes/mobile-api.js +47 -34
  87. package/lib/session-cleanup.cjs +26 -27
  88. package/lib/shared/credentials-entry.js +4 -11
  89. package/lib/shared/device-headers.js +12 -4
  90. package/lib/shared/dshtoken.js +4 -4
  91. package/lib/shared/e2ee-request.js +1 -1
  92. package/lib/shared/e2ee.js +8 -14
  93. package/lib/shared/fresh-request.js +6 -22
  94. package/lib/shared/http-util.js +3 -4
  95. package/lib/shared/list-revision.js +5 -14
  96. package/lib/shared/session-signal.js +7 -16
  97. package/lib/shared/sse-write.js +14 -20
  98. package/lib/shared/workspace-guard.js +7 -15
  99. package/lib/shell/device-ota.js +4 -6
  100. package/lib/shell/manager.js +12 -27
  101. package/lib/shell/ota-package.js +7 -12
  102. package/lib/shell/runtime.js +8 -16
  103. package/lib/shell/self-check-after-swap.js +9 -23
  104. package/lib/shell/updater.js +15 -28
  105. package/lib/sidecar-facade-worker.cjs +75 -41
  106. package/lib/sidecar-facade.cjs +18 -8
  107. package/lib/sidecar-lifecycle.cjs +21 -10
  108. package/lib/update-install.cjs +24 -62
  109. package/lib/update-registry.cjs +6 -18
  110. package/package.json +1 -1
  111. package/lib/auto-archive.cjs +0 -98
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  <h3 align="center">AI-HUB 移动端插件</h3>
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/banner.png" alt="AI-HUB:用手机接着电脑上的 DSH 往下用" width="100%">
4
+ <img src="assets/banner.jpg" alt="AI-HUB:手机和平板上的 DSH 会话界面" width="760">
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
+ <a href="https://app.wnluo.com/public/apps/14"><img alt="安卓 分发下载" src="https://img.shields.io/badge/%E5%AE%89%E5%8D%93-%E5%88%86%E5%8F%91%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
9
+ <a href="https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk"><img alt="安卓 GitHub 下载" src="https://img.shields.io/badge/%E5%AE%89%E5%8D%93-GitHub%E4%B8%8B%E8%BD%BD-181717?style=for-the-badge&logo=github"></a>
8
10
  <a href="https://apps.apple.com/us/app/ai-hub/id6821681438"><img alt="App Store" src="https://img.shields.io/badge/App_Store-%E4%B8%8B%E8%BD%BD-black?style=for-the-badge&logo=appstore&logoColor=white"></a>
9
- <a href="https://app.wnluo.com/public/apps/14"><img alt="Android APK" src="https://img.shields.io/badge/Android_APK-%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
10
- <a href="https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk"><img alt="GitHub APK" src="https://img.shields.io/badge/GitHub_APK-%E6%9C%80%E6%96%B0%E7%89%88-181717?style=for-the-badge&logo=github"></a>
11
- <a href="https://testflight.apple.com/join/4fn5PCMD"><img alt="TestFlight" src="https://img.shields.io/badge/TestFlight-%E6%B5%8B%E8%AF%95%E7%89%88-0D96F6?style=for-the-badge&logo=apple&logoColor=white"></a>
11
+ <a href="https://testflight.apple.com/join/4fn5PCMD"><img alt="苹果测试版 下载" src="https://img.shields.io/badge/%E8%8B%B9%E6%9E%9C%E6%B5%8B%E8%AF%95%E7%89%88-%E4%B8%8B%E8%BD%BD-0D96F6?style=for-the-badge&logo=apple&logoColor=white"></a>
12
12
  </p>
13
13
 
14
14
  <p align="center">
@@ -22,25 +22,46 @@
22
22
 
23
23
  **AI-HUB 的手机端插件。** 装在电脑这边,手机 App 就通过一条中转隧道或局域网配对连进你的 DSH —— 看会话、接着聊、管理文件和工作区,进度实时同步。它也可以脱离桌面外壳单独跑在服务器上,当作无头宿主用。
24
24
 
25
- ## 🌟 功能
25
+ <p align="center">
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-dark.svg">
28
+ <img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-light.svg" alt="手机 App 经中转或局域网连到你电脑上的 DSH" width="720">
29
+ </picture>
30
+ </p>
31
+
32
+ 手机连的是**你自己那台电脑上的 DSH**,不是某个云端服务。电脑不用公网 IP,也不用在路由器上开端口:插件从内网主动往外建一条隧道,手机沿这条路回来。
33
+
34
+ ## 功能
35
+
36
+ ### 会话与工作区
26
37
 
27
- - 🚀 **两种连法**:经 relay v2 隧道从公网连回来,或者在同一个 Wi-Fi 下走局域网代理直连。
28
- - 📱 **受限的移动 API**:`/api/mobile/v1/*` 只开手机需要的口子 —— 不暴露 shell、系统设置与任意文件写入。
29
- - 🗂️ **会话与工作区**:新建会话、归入指定工作区,手机端排的顺序也会同步回电脑。
30
- - 📡 **实时事件**:设备事件 SSE 全程转发,手机上看到的就是电脑那边的进度。
31
- - 🧹 **自动归档**:闲置会话自动收进归档,只从列表里隐藏,日志不删。
32
- - 🔐 **配对与凭据**:本机配置接口、可选的配对策略,设备凭据可以单独吊销。
38
+ - 新建、接着聊、改名、搜索会话,也能从任意一条分出分支单独往下走。
39
+ - 工作区建、改名、删除、置顶、归档都在手机上完成,手机端排的顺序会同步回电脑。
40
+ - 回复逐块流式到达,跑到一半就能看见内容,工具调用也是实时的。
41
+ - 空闲会话自动进归档,列表里不再占位,日志不删。
42
+ - 手机上读过,电脑那边的未读绿点跟着消失;电脑侧边栏的展开、折叠状态同步到手机,两边结构一样。
33
43
 
34
- ## 📥 下载 AI-HUB 手机 App
44
+ ### 不用回电脑也能操作
35
45
 
36
- - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(正式版,已在架)
37
- - [Android APK](https://app.wnluo.com/public/apps/14)
38
- - [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
39
- - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)(比商店版先拿到新构建,正式版照常从 App Store 更新)
46
+ - **审批与提问**:电脑上会弹的卡手机同样有,权限请求、选择题、多选、自定义回答都能直接点。
47
+ - **排队插话**:正在跑的时候补一句,按队列排在当前这一步之后。
48
+ - **Git**:看状态和 diff、暂存、提交、推送、切分支,提交信息可以先让模型写。
49
+ - **文件**:浏览目录、读文件,也能把手机上的文件传回电脑,落在固定的收件目录。
50
+ - **插件**:手机上的插件市场里搜索、安装、更新、卸载;插件自己也能升级到新版。
51
+ - **模型**:随时换模型,看有哪些渠道和可用模型,本机自建的渠道也能直接在手机上配。
52
+ - **日程与子代理**:查看定时任务、删掉不要的;看正在跑的子代理,打断它或者补一句话。
40
53
 
41
- 装好 App 只有一半,另一半是这个插件。
54
+ ### 连接
42
55
 
43
- ## 🚀 安装与更新
56
+ - 两种连法:经 relay v2 隧道从公网连回来,或者同一个 Wi-Fi 下走局域网直连。
57
+ - 配对码与配对策略可选,设备凭据能单独吊销,也能一次把全部撤掉。
58
+ - iOS 离线推送可选,不配也不影响其它功能。
59
+
60
+ 移动 API 只开手机需要的口子(`/api/mobile/v1/*`)—— 不暴露 shell、系统设置与任意文件写入。
61
+
62
+ ## 安装与更新
63
+
64
+ 装好手机 App 只是第一步,插件装在这边才算接通。
44
65
 
45
66
  ### 桌面端
46
67
 
@@ -64,9 +85,20 @@ curl http://127.0.0.1:3080/api/relay/status
64
85
  curl http://127.0.0.1:3080/api/relay/pairing
65
86
  ```
66
87
 
67
- 更多的部署方式见下面的「无头部署」。
88
+ ### 无头部署
89
+
90
+ 无头服务器上,启动器和插件本体是分开管的:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体装进 profile:
91
+
92
+ ```sh
93
+ cd <DSH_HOME>/profiles/<profile>
94
+ pnpm add dsh-mobile-plugin@<版本>
95
+ ```
96
+
97
+ 宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
68
98
 
69
- ## 🔌 配置与 API
99
+ > 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
100
+
101
+ ## 配置与 API
70
102
 
71
103
  无头运行时用这几个本机接口管远程连接:
72
104
 
@@ -77,42 +109,37 @@ curl http://127.0.0.1:3080/api/relay/pairing
77
109
 
78
110
  移动 API 要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 用 SSE 转发宿主事件。建会话时可以传 `workspaceId` 或 `cwd`,前者优先,并会把会话归进那个工作区;手机端的排序同样会同步到宿主工作区。
79
111
 
80
- 配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。
112
+ 配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。配对请求可以带设备信息,App 会发 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`,局域网代理与中转会补上它们观察到的手机 IP。
81
113
 
82
- `session_cleanup` 工具能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上删 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件,它会打开一个已经不在的日志,那是未捕获异常,会带走整个宿主进程。
114
+ `session_cleanup` 能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上清掉 —— 宿主没有「删除会话」的接口,运行中从它脚底下删文件会带走整个宿主进程。
83
115
 
84
- ## 🖥️ 无头部署
116
+ ## 为什么做这个
85
117
 
86
- 无头服务器上,启动器和插件本体是分开管的:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体装进 profile:
118
+ DSH 跑在电脑上,但我不是一直坐在电脑前。出门、在别的房间、躺床上,想知道刚才那个任务跑到哪一步了,或者它正卡在一个要我点的审批上,只能走回去。
87
119
 
88
- ```sh
89
- cd <DSH_HOME>/profiles/<profile>
90
- pnpm add dsh-mobile-plugin@<版本>
91
- ```
120
+ 这个插件的出发点就这么窄:不是把整个 DSH 塞进手机屏幕,而是让人离开电脑之后,还能看见、还能接着聊、还能把该点的那一下点掉。
92
121
 
93
- 宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
122
+ 所以有两条线是硬的。一条是网络:不要求公网 IP、不要在路由器上开端口、不要为了用一次去配 DDNS,插件从内网主动往外出,手机顺着回来。另一条是能力:手机上只开需要的那几个口子,能看会话、能审批、能传文件、能改点配置,但不把 shell 和任意文件写入递到手机上 —— 手机丢了或者被人捡到,损失是可估的。
94
123
 
95
- > 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
124
+ ## 和别的方案比
96
125
 
97
- ## 🔄 本机调试 OTA
126
+ **用手机浏览器开 DSH 的网页**:不用装东西,但每次都得记住地址,界面按桌面尺寸排,在手机上要靠缩放拖拽;没有推送,离开页面就什么都不知道;审批那种要点准的东西最难用。
98
127
 
99
- 新版插件的管理面板里有张「本机调试 OTA」卡片,可以手动检查并安装中转发布的紧急完整包。入口只认宿主本机的访问,手机和公网都触发不了;装完靠切换实现代生效,切换失败会退回上一代。正式发布源仍然是 npm,等 npm 上的版本号追到 OTA 版本时就自动接管。第一次发带这个功能的 npm 包需要重启一次 DSH,让稳定外壳把 OTA 能力加载进来。
128
+ **远程桌面**:本质还是操作那块屏幕,手机只是个显示器。网络断了就是黑屏,小屏上做任何事都要先放大。
100
129
 
101
- 要发一个紧急完整包,用仓库里的打包脚本,别手工 `tar`:
130
+ **各家 AI 助手的手机 App**:那些跑在厂商的服务器上,会话、文件和额度都在别人那儿。这里连的是你自己电脑上那个 DSH,模型是你配的,文件在你硬盘上。
102
131
 
103
- ```sh
104
- # 只打包(产物在当前目录)
105
- node scripts/pack-ota.mjs
132
+ ## 交流与反馈
106
133
 
107
- # 打包并发布,发布后会下载回来核对哈希
108
- node scripts/pack-ota.mjs --publish https://你的中转 --token <中转的 OTA 发布 token>
109
- ```
134
+ <p align="center">
135
+ <img src="assets/wechat-group.png" alt="企业微信群二维码" width="180">
136
+ </p>
110
137
 
111
- 脚本干四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对 —— 半个包、被中间层改过的包都装不上去。
138
+ 扫上面的码进企业微信群。不方便的话发邮件到 <admin@wnluo.com> 或 <wnluo2@gmail.com>。用着顺手就给仓库点个 star,这是最实在的支持。
112
139
 
113
- `--platform` 默认 `process.platform`(`darwin` / `linux` / `win32`);这个插件是纯 JS,也可以 `--platform any` 发一份三个系统通用,客户端在自己平台没有包时会回落去问 `any`。`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
140
+ 隐私政策见 [PRIVACY.md](PRIVACY.md),常见问题见 [SUPPORT.md](SUPPORT.md)。
114
141
 
115
- ## 👨‍💻 开发
142
+ ## 开发
116
143
 
117
144
  ```sh
118
145
  pnpm install --frozen-lockfile
@@ -121,36 +148,6 @@ npm test
121
148
 
122
149
  改过 `React/` 面板源码,先在该目录跑 `pnpm run build`,并把生成的 `lib/panel/index.html` 和源码一起提交。
123
150
 
124
- ## 📦 仓库与发布
125
-
126
- 这里是插件的公开发布仓库,仓库根就是 npm 包根。开发和 App、中转服务器一起放在私有 monorepo 里,所以这个仓库的提交历史从拆分那天开始;发布时要把同一次改动同步过来。
127
-
128
- - npm 包的 `repository` 字段负责关联插件列表条目,改完要再发一次版,注册表里的信息才会更新。
129
- - 收录靠往 awesome-dsh-plugin 提 `data/plugins/<owner>__<repo>.yml`。描述里的数字和 API 名称都要能在代码里核实;列表要求 `description.en`、`category: remote`,包要声明 `dsh.bundle`,仓库要有 `dsh-plugin` topic。
130
- - 「仓库创建满一天」这条检查过一阵会自己重跑。截图可以用 `screenshots.json` 提供,也可以让市场从这个 README 里找。
131
- - GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,而且要发成正式版 —— 草稿和预发布都不会成为「最新」,README 里那条直连跟着就断了。插件自己走 npm 发布,不在这个仓库发 release。
132
- - 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
133
-
134
- ### 发版流程
135
-
136
- **顺序是死的:抬号重建 → 提交 → 预检全绿 → 同步 → 打 tag → 等流水线。** 预检没过不许往下走,尤其不许「先把 tag 推上去看看」。
137
-
138
- 1. 跑 `node scripts/bump-version.mjs` 更新版本号(演示页的 `DEMO_VERSION` 会一起改,别手改)。
139
- 2. 改过 `React/` 就跑 `cd React && pnpm run build`。
140
- 3. 提交这次发版该带的文件,别 `git add -A`。
141
- 4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物跟不跟得上源码(拿当前源码真重建一次,与盘上那份逐字节比)、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
142
- 5. 把提交同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
143
- 6. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
144
- 7. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
145
- 8. 要在本机用上新版就跑 `node scripts/reload-dev.mjs`(打包 + 正规安装 + 就地重载),它报 ✔ 才算数。
146
-
147
- 两条别弄反:
148
-
149
- - **流水线是第二道闸,不是唯一一道。** 它在单独检出里跑,跨仓对账(手机端接口常量 ↔ 插件路由表)会自己跳过;面板产物它只查「在不在包里」,查不出新旧。这两件事只有第 4 步能查。
150
- - **热重载报 ✔ 不算测过。** 它把当前工作区装进 profile 跑一遍,不跑测试、不打包,也碰不到任何跨版本组合(老 App 配新插件之类)。它证明的是装载没问题,不是这一版能发。
151
-
152
- `npm publish` 仍能在开发机上手动跑,但现在要人过二次验证:账号上只有安全密钥时,npm 会打印一条 `www.npmjs.com/auth/cli/…` 链接,要用密钥在浏览器里点一下,且发起那台机器的出口 IP 要能打开 npm 官网。这条路留着应急,日常发版走 tag。
153
-
154
- ## 📎 远程控制元数据
151
+ ## License
155
152
 
156
- 配对请求可以带设备信息,App 现在会发 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`;局域网代理与中转会补上它们观察到的手机 IP。
153
+ MIT,见 [LICENSE](LICENSE)。
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * 每设备凭据库:签发、校验、吊销手机端的持久化凭据。
5
5
  *
6
- * **凭据本身就是 Bearer**(讨论记录 §3.2 的 T1 口径)。配对签发一枚
6
+ * **凭据本身就是 Bearer**。配对签发一枚
7
7
  * `<instanceId>.<exp>.<random>`,同一枚同时充当 `Authorization: Bearer`、`dsh_lan` cookie
8
8
  * 与 `X-Dsh-Device-Credential`。三段形状只为兼容老 App「实例号缺失时从第一段补」那条逻辑;
9
9
  * 服务端**只按整串的 sha256 查库**,不看前缀、不解析三段、也不信里面的 `exp`
@@ -13,7 +13,7 @@
13
13
  * 验签必须持有密钥本身,所以它按 base64url 原文落盘 —— 「整库只有哈希」这句话在它身上不成立。
14
14
  * `hostToken` 只为旧的 HMAC 记录保留:新记录一律留空,代理直接转发请求里出示的那一枚。
15
15
  *
16
- * 写入只有一个方向(§3.7):宿主主进程是唯一写方;旁路子进程拿的是只读副本,靠文件
16
+ * 写入只有一个方向:宿主主进程是唯一写方;旁路子进程拿的是只读副本,靠文件
17
17
  * mtime/size 变了就重载来跟住变化,签发与吊销走 IPC 交给主进程(`lib/impl.js`)。
18
18
  */
19
19
  const crypto = require('node:crypto');
@@ -24,7 +24,7 @@ const os = require('node:os');
24
24
  const FILE = process.env.DEVICE_CREDENTIALS_FILE || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'relay-device-credentials.json');
25
25
  /** 默认有效期一年。`issue(..., ttlMs)` 可覆盖;`ttlMs <= 0` 表示不过期。 */
26
26
  const DEFAULT_TTL_MS = 365 * 24 * 3600 * 1000;
27
- /** 已吊销或过期超过这么久的记录,下次写入时整条删掉(§3.9):别让每条请求扫一整年的账。 */
27
+ /** 已吊销或过期超过这么久的记录,下次写入时整条删掉:别让每条请求扫一整年的账。 */
28
28
  const PRUNE_GRACE_MS = 30 * 24 * 3600 * 1000;
29
29
  /** 剩余有效期超过这个比例就不续期:滑动续期只在过掉一半之后发生。 */
30
30
  const RENEW_BELOW_RATIO = 0.5;
@@ -132,6 +132,10 @@ function deviceMeta(device) {
132
132
  const meta = {};
133
133
  const name = truncate(device?.name, NAME_MAX);
134
134
  if (name) meta.name = name;
135
+ // 机型(「iPhone 15 Pro」/「Xiaomi 9」):名字是用户起的、两台可能一模一样,机型是手机
136
+ // 自己报的,用来补上「这台到底是什么」。缺了就不写 —— 判重仍只看 `deviceId`。
137
+ const model = truncate(device?.model, NAME_MAX);
138
+ if (model) meta.model = model;
135
139
  const platform = truncate(device?.platform, FIELD_MAX);
136
140
  if (platform) meta.platform = platform;
137
141
  const osVersion = truncate(device?.osVersion ?? device?.version, FIELD_MAX);
@@ -142,7 +146,7 @@ function deviceMeta(device) {
142
146
  }
143
147
 
144
148
  /**
145
- * 已吊销、或过期超过宽限期的整条删掉(§3.9)。
149
+ * 已吊销、或过期超过宽限期的整条删掉。
146
150
  *
147
151
  * 旧库只追加、吊销只打标记,今天不疼是因为「查不到就回退验 HMAC」把问题掩盖成了可用;
148
152
  * 查库优先之后,每条请求都要扫整张表,一年下来几千条就是每条请求扫几千次。
@@ -257,7 +261,7 @@ function findByKeyId(instanceId, keyId) {
257
261
  }
258
262
 
259
263
  /**
260
- * 记一次「这台设备刚连上过」(§3.4)。
264
+ * 记一次「这台设备刚连上过」。
261
265
  *
262
266
  * 内存里每次都更新(面板 3 秒轮询要的是当下的值),落盘按全局 1 分钟节流:每条请求都写一次
263
267
  * 盘,是拿磁盘换一个只用来显示「几分钟前」的字段。`issue` / `revoke` 立刻写,不受它影响。
@@ -269,7 +273,7 @@ function touch(record, info = {}) {
269
273
  record.lastSeenAt = now;
270
274
  const channel = typeof info.channel === 'string' ? info.channel : '';
271
275
  if (channel) record.lastChannel = channel.slice(0, FIELD_MAX);
272
- // 身份那几格(2026-10-26):名字 / 平台 / 系统版本 / App 版本由**每一次请求**带上来,
276
+ // 身份那几格:名字 / 平台 / 系统版本 / App 版本由**每一次请求**带上来,
273
277
  // 顺手刷新。它们以前只在配对那一刻写一次,于是「手机升到新版了、面板还报配对时的旧版本」,
274
278
  // 而旧会话升级写进来的记录干脆什么身份都没有(面板只能画「未知设备」)。
275
279
  // **只写这次真带了值的格子**:老版本 App 不带头,绝不能把已经有的名字冲成空。
@@ -290,7 +294,7 @@ function touch(record, info = {}) {
290
294
  }
291
295
 
292
296
  /**
293
- * 滑动续期(§3.9):剩余不到一半就把 `expiresAt` 后推一个新 TTL。
297
+ * 滑动续期:剩余不到一半就把 `expiresAt` 后推一个新 TTL。
294
298
  *
295
299
  * 不需要新协议 —— 服务端本来就以记录为准、不验 token 里的 `exp`,所以续期就是改这条记录。
296
300
  * **不能改 token 本身**:库按整串哈希查,改一个字节手机上那枚就对不上了。
@@ -362,11 +366,22 @@ function listActive() {
362
366
  }
363
367
 
364
368
  /**
365
- * 面板要的那份设备清单(§3.4)。
369
+ * 面板要的那份设备清单。
366
370
  *
367
- * 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。名字与平台取最新一条
368
- * **有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),绑定时间取最早,最近连接取
369
- * 最大,走哪条路取最近那条的。
371
+ * 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。机型号、名字、平台、
372
+ * 系统版本、App 版本都取最新一条**有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),
373
+ * 绑定时间取最早,最近连接取最大,走哪条路取最近那条的。
374
+ *
375
+ * **`deviceId` 是这一页唯一的键**:它是手机第一次启动时自己生成的,配对那一刻交给宿主,
376
+ * 之后每条请求都带着它。名字、机型、平台、版本一概不参与判重 —— 改个名字不该变成两台。
377
+ *
378
+ * `bindings` 是这台名下**还有几条活凭据**:同一台手机重配一次就多一条,旧的那条仍然能用。
379
+ * 大于 1 时面板要把「另有几条旧绑定」摆出来,否则人只在解除绑定那一下看到「2 条凭据」,
380
+ * 之前一直以为只有一份。
381
+ *
382
+ * `suffix` 只在**同一批设备里标题会撞**时给出序号(`1`、`2`…),其余是空串——分组用的就是
383
+ * 面板拿来当标题的那一格:机型优先,机型空着才用名字。按绑定时间编号:早绑的是 1。
384
+ * 序号是显示用的,不进库、也不回给手机。
370
385
  *
371
386
  * `legacy` 标的是「这个 deviceId 下还有不带设备密钥的活记录」:那种凭据的吊销杀不掉旧
372
387
  * Bearer,界面上的撤销文案必须区别对待,不能写「立即失效」。
@@ -386,6 +401,7 @@ function listDevices(instanceId) {
386
401
  row = {
387
402
  deviceId: key,
388
403
  name: '',
404
+ model: '',
389
405
  platform: '',
390
406
  osVersion: '',
391
407
  appVersion: '',
@@ -393,14 +409,18 @@ function listDevices(instanceId) {
393
409
  lastSeenAt: 0,
394
410
  lastChannel: '',
395
411
  lastSeenFrom: 0,
412
+ bindings: 0,
413
+ suffix: '',
396
414
  legacy: false,
397
415
  encrypted: false,
398
416
  expiresAt: 0,
399
417
  };
400
418
  rows.set(key, row);
401
419
  }
420
+ row.bindings += 1;
402
421
  if (createdAt && (!row.boundAt || createdAt < row.boundAt)) row.boundAt = createdAt;
403
- // 名字与平台取最新一条有值的:老记录没有这些字段,空不能覆盖有值。
422
+ // 机型号、名字、平台等取最新一条有值的:老记录没有这些字段,空不能覆盖有值。
423
+ if (record.model && createdAt >= (row.modelAt || 0)) { row.model = record.model; row.modelAt = createdAt; }
404
424
  if (record.name && createdAt >= (row.nameAt || 0)) { row.name = record.name; row.nameAt = createdAt; }
405
425
  if (record.platform && createdAt >= (row.platformAt || 0)) { row.platform = record.platform; row.platformAt = createdAt; }
406
426
  if (record.osVersion && createdAt >= (row.osVersionAt || 0)) { row.osVersion = record.osVersion; row.osVersionAt = createdAt; }
@@ -416,21 +436,24 @@ function listDevices(instanceId) {
416
436
  // 带设备密钥的记录才走逐请求签名;只带 6 位码配对的那个版本永远是明文。
417
437
  if (record.deviceKey) row.encrypted = true;
418
438
  }
419
- const out = [];
420
- const labelCount = new Map();
439
+ // 撞名才编号:分组用的正是面板拿来当标题的那一格 —— **机型优先,没有机型才用名字**。
440
+ // 两台都叫「iPhone 15 Pro」时「· 1 / · 2」是人认得出的;挂着机器号后四位则不是。
441
+ const groups = new Map();
421
442
  for (const row of rows.values()) {
422
- const label = `${row.name}\u0000${row.platform}`;
423
- labelCount.set(label, (labelCount.get(label) || 0) + 1);
443
+ const label = row.model || row.name || '';
444
+ const bucket = groups.get(label);
445
+ if (bucket) bucket.push(row);
446
+ else groups.set(label, [row]);
447
+ }
448
+ for (const bucket of groups.values()) {
449
+ if (bucket.length < 2) continue;
450
+ const ordered = [...bucket].sort((a, b) => (a.boundAt - b.boundAt) || String(a.deviceId).localeCompare(String(b.deviceId)));
451
+ ordered.forEach((row, index) => { row.suffix = String(index + 1); });
424
452
  }
453
+ const out = [];
425
454
  for (const row of rows.values()) {
426
- const { nameAt, platformAt, osVersionAt, appVersionAt, lastSeenFrom, ...rest } = row;
427
- // 同名同平台的两台手机在列表里长得一样,加 deviceId 后 4 位才分得清;名字本来就不重复的
428
- // 不加,别让每行都挂一串十六进制。
429
- const duplicated = Boolean(row.name) && (labelCount.get(`${row.name}\u0000${row.platform}`) || 0) > 1;
430
- out.push({
431
- ...rest,
432
- name: duplicated ? `${row.name} · ${String(row.deviceId).slice(-4)}` : row.name,
433
- });
455
+ const { modelAt, nameAt, platformAt, osVersionAt, appVersionAt, lastSeenFrom, ...rest } = row;
456
+ out.push(rest);
434
457
  }
435
458
  // 最近连着的排前面;从没连过的按绑定时间倒序垫后。
436
459
  out.sort((a, b) => (b.lastSeenAt - a.lastSeenAt) || (b.boundAt - a.boundAt));
package/host.js CHANGED
@@ -2,16 +2,13 @@
2
2
  /**
3
3
  * Headless Cordis Host for the DSH mobile app.
4
4
  *
5
- * The repository used to ship an Electron desktop shell that owned the Host
6
- * process; the DSH mobile app never used that surface. This entry replaces it:
7
- * it boots the same upstream Cordis Host, composes the shipped DSH Web
8
- * bundle, and adds one plugin — the mobile API at `/api/mobile/v1`.
5
+ * Boots the upstream Cordis Host, composes the shipped DSH Web bundle, and adds
6
+ * one plugin — the mobile API at `/api/mobile/v1`.
9
7
  *
10
- * The Host itself is upstream. `@deepseek-ai/dsh-app-boot#boot` mounts a
11
- * `cordis.yml` entry list, so the root list stays empty and every plugin is
12
- * contributed by a patch layer: the profile's bundle layers (dsh-base then
13
- * dsh-web-app), the profile's own `cordis.patch.yml`, and finally this
14
- * package's `cordis.patch.yml`.
8
+ * `@deepseek-ai/dsh-app-boot#boot` mounts a `cordis.yml` entry list, so the root
9
+ * list stays empty and every plugin comes from a patch layer: the profile's
10
+ * bundle layers (dsh-base then dsh-web-app), the profile's own
11
+ * `cordis.patch.yml`, and finally this package's `cordis.patch.yml`.
15
12
  */
16
13
 
17
14
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
@@ -67,24 +64,19 @@ try {
67
64
  /**
68
65
  * 本插件是**装在这个 profile 里**的,还是「源码树直接跑」的?
69
66
  *
70
- * 这两种现场的挂载方式不一样,而差别全在「挂哪一份入口」上(2026-10-11 起两条都留着):
67
+ * 两种现场挂的不是同一份入口:
68
+ * - **装在 profile 里**(桌面端)→ 挂 `<profile>/node_modules/dsh-mobile-plugin/lib/index.js`,
69
+ * 即**稳定外壳**:查更新、装新版、就地换代都在它手里,面板上才有「检查 / 立即更新」。
70
+ * - **源码树直接跑**(无头部署、开发时 `node host.js`)→ 没有任何人把它放进 `node_modules`,
71
+ * 于是挂随包发布的 `lib/impl.js`。代价是**没有外壳**,那台只能同步源码 + 重启宿主。
71
72
  *
72
- * - **装在 profile 里**(桌面端;服务器那台改成 `pnpm 装进 profile` 之后也是)→ 挂
73
- * `<profile>/node_modules/dsh-mobile-plugin/lib/index.js`,也就是**稳定外壳**:
74
- * 查更新、装新版、就地换代那一套全在它手里,所以面板上才有「检查 / 立即更新」。
75
- * - **源码树直接跑**(老的无头部署、开发时直接 `node host.js`)→ 插件**就是** Loader 自己
76
- * 正跑的那个包,没有任何人把它放进 `node_modules`,于是这一支挂随包发布的
77
- * `lib/impl.js`(真正的实现)。代价是**没有外壳** → 那台的面板里更新不了,只能同步
78
- * 源码 + 重启宿主。
73
+ * **两处都走绝对路径而不是包名**:`cordis.patch.yml` 里那行按包名挂载是给桌面端用的,
74
+ * 无头宿主这边解析不到 —— loader 从**它自己所在的位置**解析包名,而它在宿主自己的依赖树里,
75
+ * 看不见 profile 里装的那份。loader 对解析失败不是留一条警告,是**整棵插件树加载失败**,
76
+ * 所以那行在这里一律摘掉,由这里按绝对路径插一行。装没装 profile 决定的是**挂哪一份**,
77
+ * 不是挂不挂。
79
78
  *
80
- * **两处都走「绝对路径」而不是包名**:`cordis.patch.yml` 里那行按包名挂载是给桌面端用的,
81
- * 无头宿主这边解析不到 —— loader 是从**它自己所在的位置**解析包名的(实测报错原文:
82
- * `Cannot find package 'dsh-mobile-plugin' imported from <…>/@deepseek-ai/cordis-plugin-loader/lib/index.js`),
83
- * 而它在宿主自己的依赖树里,profile 里装的那份它根本看不见。而 loader 对解析失败不是留一条
84
- * 警告,是**整棵插件树加载失败**(服务器那台当年就是这么完全起不来的)。所以那行在无头宿主
85
- * 里一律摘掉,由这里按绝对路径插一行 —— 装没装 profile 决定的是**挂哪一份**,不是挂不挂。
86
- *
87
- * 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实,改不了口径。
79
+ * 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实。
88
80
  */
89
81
  const installedDir = join(profileDir, 'node_modules', BIN_NAME)
90
82
  const selfInstalled = existsSync(join(installedDir, 'package.json'))
@@ -230,17 +222,11 @@ try {
230
222
  await assertEntriesActivated(ctx, BIN_NAME)
231
223
  // 装载期结束,撤掉 installFailLoud 的兜底。
232
224
  //
233
- // 它自己的语义是对的:一个插件**装载**失败不该让宿主半死不活地上线,所以
234
- // 任何 unhandledRejection 都直接 exit(1)。但它注册在 process 上之后**一辈子
235
- // 不摘**(releaseFailLoud 在成功路径上从来没被调用过),而宿主是长驻进程——
236
- // 于是运行期任意一次游离 reject 都会走到它那里把进程带走。上一版提交
237
- // (ca60417)在真机上被这条打脸:账本记下了「未处理的 Promise 拒绝(已忽略)」,
238
- // 紧接着一行 `fatal load failure` 就把 PID 换掉了。
239
- //
240
- // 这里在 assertEntriesActivated 通过之后才撤:那一刻树已经确认全部激活
241
- //(激活失败的 entry 会在它内部 throw,进上面的 catch 走 boot failed),
242
- // 此后再出现的 reject 就不再是「装载失败」而是运行期故障,
243
- // 交给进程级错误边界记账 + 熔断。
225
+ // 它注册在 process 上之后**一辈子不摘**,而宿主是长驻进程 —— 运行期任意一次游离
226
+ // reject 都会走到它那里把进程带走,而它本该只管「装载失败」。所以这里在
227
+ // assertEntriesActivated 通过之后才撤:那一刻树已确认全部激活(激活失败的 entry 会在
228
+ // 它内部 throw,进上面的 catch 走 boot failed),此后的 reject 是运行期故障,交给
229
+ // 进程级错误边界记账 + 熔断。
244
230
  releaseFailLoud()
245
231
  process.stderr.write(`${BIN_NAME}: mobile host ready (profile ${PROFILE_NAME})\n`)
246
232
  } catch (error) {
package/lib/client.js CHANGED
@@ -1,40 +1,12 @@
1
1
  /**
2
2
  * dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
3
3
  *
4
- * 它做四件事:
4
+ * 四件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
5
+ * 提供「设置 → 移动端」那颗由本机打开的网页管理开关。方向是单向的(桌面 → 宿主 → 手机),
6
+ * 手机上的折叠只存在 App 本地,不写回。
5
7
  *
6
- * 1. **桌面端工作区展开态 → 宿主**。桌面侧边栏「哪些工作区分组是展开的」只记在
7
- * **浏览器 localStorage**(`dsh.workspace.view.vN`,由 @deepseek-ai/dsh-client-ui-workspace
8
- * 的 createWorkspaceViewStore 持久化到 dsh-client-store 的 localStorage),
9
- * 宿主进程、中转和手机都读不到。移动端要与桌面端对齐展开态,就只能由页面里的
10
- * 这一半把状态读出来交给宿主。
11
- *
12
- * 数据方向是单向的:桌面 → 宿主 → 手机。手机上手动折叠/展开只存在 App 本地
13
- * (AsyncStorage),不会写回这份 localStorage。
14
- *
15
- * 2. **设置 →「移动端」**(设置菜单里的顶层一项,不再挂在内置插件页签下)。保留
16
- * **网页版入口**,并只加一颗由可信本机 DSH 设置页操作的公网访问开关;网页管理默认关闭,
17
- * 每次开启固定 2 小时,到期不因访问续时。管理界面本体仍在宿主吐出的 HTML 里。
18
- *
19
- * 为什么这里只加开关:这半边不可热替换,管理界面继续留在网页版;本机开关只控制
20
- * 网页管理路由,不改手机 App API 或隧道状态。插件更新仍交给 DSH 插件市场。
21
- *
22
- * 3. **桌面端已读 → 宿主**(`startDesktopReadSync`)。桌面侧栏的「已完成待读」绿点
23
- * 只活在渲染进程内存里(`UiSession.completionUnread`),宿主和手机都读不到。
24
- * 这一半盯着 `uiSession.sessionStatus`,把「刚从未读集合掉出去」的会话报给
25
- * `/api/relay/desktop-read`,宿主据此清掉手机端那份完成提醒。
26
- *
27
- * 4. **手机端已读 → 桌面端**(`startReadReceiptSync`)。反方向:宿主把手机端读过
28
- * 哪些会话记成 `sessionId → readAt` 台账,这一半拉下来,对水位前进过的会话做一次
29
- * 瞬时 `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
30
- * `reconcileStatus()` 把绿点摘掉。见 lib/read-receipt.cjs 的文件头。
31
- *
32
- * 写成惰性工厂(`window.__ModuleLoader__.load`)是 DSH 客户端插件的约定:
33
- * 运行 bundle 只注册工厂,副作用(这里的定时器、页签注册)在物化时才发生。
34
- * 工厂收到的 `require` 是 DSH 的模块表 require,`react` 是平台 seed word。
35
- *
36
- * 本文件**不经任何构建**(package.json 的 files 直接收录 lib 下的 .js),所以不能
37
- * 写 JSX:统一用 `React.createElement` 的别名 `h`。
8
+ * 本文件不经任何构建(package.json 直接收录 lib 下的 .js),所以不能写 JSX,统一用
9
+ * `React.createElement` 的别名 `h`;客户端插件按约定写成惰性工厂,定时器与页签在物化时才起。
38
10
  */
39
11
 
40
12
  window.__ModuleLoader__.load({
@@ -195,18 +167,11 @@ window.__ModuleLoader__.load({
195
167
  }
196
168
 
197
169
  /**
198
- * 控制面调用。`path` 既可以是 `/xxx` / `xxx` 这样的**短路径**(推荐,与
199
- * `postJson('/desktop-read')` 的写法一致),也可以直接甩一个完整的
200
- * `/api/relay/xxx` 或 `api/relay/xxx`——几种都接受,最终一律折成文档相对地址。
170
+ * 控制面调用。`path` 既接受 `/xxx` 这样的短路径,也接受已经带 `/api/relay` 的全路径,
171
+ * 几种都折成文档相对地址。
201
172
  *
202
- * **为什么必须几种都接受(2026-10-10 真机故障)**:这里以前是无条件
203
- * `${RELAY}${path}`,而绿点那两条同步传进来的偏偏是**已经带 `/api/relay` 的全路径**
204
- * (`READ_RECEIPT_ENDPOINT` / `DESKTOP_READ_ENDPOINT`),于是真正发出去的是
205
- * `/api/relay/api/relay/read-receipt` —— 一个不存在的地址。它返回 401/404,被
206
- * 两条同步各自的 `catch` 静静吞掉,所以「手机上读过桌面不灭灯」「桌面上读过手机
207
- * 不灭灯」从来就没通过,而且**一条日志都不留**(宿主侧活体台账里那两个计数恒为 0)。
208
- * 改断言而不是改实现是不行的:守卫测试里的假 fetch 按 `endsWith(endpoint)` 匹配,
209
- * 而 `/api/relay/api/relay/read-receipt` 也满足这个后缀 —— 测试因此一直是绿的。
173
+ * 必须两种都认:绿点那两条同步传进来的本来就是全路径,无条件拼前缀会发到
174
+ * `/api/relay/api/relay/...` 这个不存在的地址,而调用方的 catch 会把它静静吞掉。
210
175
  */
211
176
  function controlUrl(path) {
212
177
  const raw = String(path);
@@ -241,15 +206,14 @@ window.__ModuleLoader__.load({
241
206
  });
242
207
 
243
208
  /* ── 三:侧边栏未读绿点的双向同步 ───────────────────────────────────────
244
- * 两端的绿点是**两份互不相干**的状态:
209
+ * 两端的绿点是两份互不相干的状态:
245
210
  * - 手机端 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
246
211
  * 由 `POST /api/mobile/v1/session/read` 清掉;
247
212
  * - 桌面端 = 渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。
248
- * 所以「手机上读过了,桌面还亮着」和「桌面上读过了,手机还亮着」都得靠这一半补。
213
+ * 两边各缺一块,所以两个方向都要这一半补。
249
214
  *
250
- * 这两条同步都要桌面端的服务(uiSession / sessions),拿不到就整条不启动:
251
- * 注册了也只是空转的定时器。`ctx.get` 在 Cordis 上恒有,服务可能比我们晚就位,
252
- * 所以每个 tick 现取一次,而不是启动时取一次。
215
+ * 两条同步都依赖桌面端的服务(uiSession / sessions),拿不到就整条不启动:注册了
216
+ * 也只是空转的定时器。服务可能比我们晚就位,所以每个 tick 现取一次。
253
217
  */
254
218
  /** 轮询间隔:与展开态同步同频。绿点晚 2s 灭掉无感,但没必要更勤。 */
255
219
  const READ_SYNC_INTERVAL_MS = 2000;
@@ -286,15 +250,11 @@ window.__ModuleLoader__.load({
286
250
  /**
287
251
  * 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
288
252
  *
289
- * 三种情形算「桌面读过了」,每种只报一次(变回未读或重新跑起来时摘掉标记、可以再报):
290
- * - 本次打开页面之后**看见它从别的状态变成未读、又掉出去**(普通的点开);
291
- * - 此刻**正开在主视图里**(`retainedBy.mainView > 0`:用户盯着它读完,它压根不进未读集合);
292
- * - 本次打开页面之后**它变过、而且现在不是未读**(补掉漏采的那一瞬间)。
253
+ * 三种情形算「桌面读过了」,每种只报一次:看见它从未读里掉出去、此刻正开在主视图里
254
+ * (`retainedBy.mainView > 0`)、或本次页面里它变过且现在不是未读。
293
255
  *
294
- * **首帧一条都不报**:桌面页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`
295
- * (`completionUnread` 只在浏览器内存里,重开页面就没了),照单全收会把手机上真的
296
- * 未读全灭掉。所以「页面打开之后变过」这条要求 `updatedAt > 页面打开时刻`,
297
- * 没拿到会话清单时宁可不报。
256
+ * 首帧一条都不报:页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`,
257
+ * 照单全收会把手机上真未读的全灭掉。所以要求 `updatedAt > 页面打开时刻`。
298
258
  */
299
259
  function startDesktopReadSync(ctx) {
300
260
  withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
@@ -381,15 +341,9 @@ window.__ModuleLoader__.load({
381
341
  * 拿到桌面客户端的两个服务:`uiSession`(未读绿点的真源 + 每会话运行态)与
382
342
  * `sessions`(会话清单 + `retain`)。
383
343
  *
384
- * **必须用 `ctx.inject([...], child => …)`,不能用 `ctx.get(name)`。** 2026-10-10
385
- * 真机故障:两条绿点同步通道一条都没在跑 —— 宿主侧的活体台账(`/api/relay/status`
386
- * 的 `desktopHalf`)显示桌面半边**一次 read-receipt 都没拉过**,而 read-receipt 那条
387
- * effect 是**无条件 2s 轮询**、`ctx.get` 之后才 fetch,所以只可能是 tick 在服务这一
388
- * 道 guard 上就返回了。同一个桌面里能正常工作的客户端插件(内置的 conversation /
389
- * approval、第三方 `dsh-opencode-go`)一律走 `inject`:DSH 自己的插件把服务写进
390
- * `inject` 数组,opencode-go 用 `ctx.inject([...], child => …)`。`ctx.get` 是「绕过
391
- * 注入直接取」,在客户端运行时里取不到这两个服务。拿不到就一个定时器都不起(与旧
392
- * 行为一致),由 lib/client.js 的自检上报把「到底看见了什么」说给宿主听。
344
+ * 必须用 `ctx.inject([...], child => …)`,不能用 `ctx.get(name)`:`ctx.get` 是绕过注入
345
+ * 直接取,在客户端运行时里取不到这两个服务,两条绿点同步会一条都不跑。拿不到就一个
346
+ * 定时器都不起,由自检上报把「到底看见了什么」说给宿主听。
393
347
  */
394
348
  /**
395
349
  * 客户端半边的自检状态:只给自检上报用(谁注入了、谁没注入)。
@@ -437,16 +391,12 @@ window.__ModuleLoader__.load({
437
391
  * 方向二:手机上读过了 → 在桌面页面里就地清掉绿点。
438
392
  *
439
393
  * 拉宿主那份 `sessionId → readAt` 台账,对水位前进过的会话做一次瞬时
440
- * `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()`。桌面自己的
441
- * `reconcileStatus()` 订阅了列表 store,而 `retain` 里的 `publishRetention` 会同步
442
- * 抬 `retainedBy.mainView`,于是 `completionUnread.delete(id)` 在 release 之前就已经
443
- * 发生;release 把引用数降回 0 也不会把绿点加回来(只有 `observeRunning(false)`
444
- * 且非 mainView 才会加)。整个过程同一个 tick 内完成,React 还没重渲染,看不到抖动。
394
+ * `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()`:`retain` 会同步
395
+ * 抬 `retainedBy.mainView`,桌面自己的 `reconcileStatus()` 因此摘掉绿点,而 release
396
+ * 把引用数降回 0 也不会把它加回来。同一个 tick 内完成,React 还没重渲染,看不到抖动。
445
397
  *
446
- * 为什么还要跟 `updatedAt` 比一次:台账只记「读过」不记「读完之后又跑完了一轮」。
447
- * 若不管这一条,桌面端**重新打开页面**时(内存里的 applied 游标是空的)会把一条
448
- * 早已被新一轮完成盖过的旧回执重新应用,把本该亮的绿点灭掉。会话的最后活动时间
449
- * 晚于已读水位,就说明这一轮是读完之后才跑完的,必须跳过。
398
+ * 还要跟 `updatedAt` 比一次:台账不记「读完之后又跑完了一轮」,重新打开页面时会把
399
+ * 旧回执重新应用,灭掉本该亮的绿点。最后活动时间晚于已读水位就跳过。
450
400
  */
451
401
  function startReadReceiptSync(ctx) {
452
402
  withClientServices(ctx, ['sessions'], 'sessions', 'mobile-plugin: 已读回执应用', (scope, isDisposed) => {
@@ -682,14 +632,11 @@ window.__ModuleLoader__.load({
682
632
  /**
683
633
  * 客户端半边的自检上报:每 10s 把「这一半到底看见了什么」报给宿主。
684
634
  *
685
- * 为什么要它:宿主能数出「那半边没来敲门」(`/api/relay/status` 的 `desktopHalf`),
686
- * 但数不出「它为什么没来」。2026-10-10 那次故障里,正是这一点让「服务没拿到」和
687
- * 「服务拿到了但清理失败」两种可能耗掉了一整轮排查 —— 这条通路把答案直接摆出来:
688
- * 两条同步各自的 `ctx.inject` 到底有没有回调(`uiSession` / `sessions` 两格)、
689
- * `ctx.get` 能不能看见那两个服务、最后一次错在哪。
635
+ * 宿主能数出「那半边没来敲门」,数不出「为什么没来」。这条通路直接摆出:两条同步各自的
636
+ * `ctx.inject` 有没有回调、`ctx.get` 能不能看见那两个服务、最后一次错在哪。
690
637
  *
691
- * 刻意**不**依赖任何客户端服务:探测全包在 try/catch 里,它自己要是也挂在服务上,
692
- * 就永远报不出「服务拿不到」这件事了。
638
+ * 刻意不依赖任何客户端服务:探测全包在 try/catch 里,它自己挂在服务上就永远报不出
639
+ * 「服务拿不到」这件事。
693
640
  */
694
641
  function startClientHalfReport(ctx) {
695
642
  const probe = (name) => {