dsh-mobile-plugin 0.3.1 → 0.4.1

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 (96) hide show
  1. package/config/agent-presets/standard/agent.cordis.yml +7 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +8 -8
  3. package/host.js +108 -39
  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 +120 -0
  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 -8
  12. package/lib/mirror/pusher.cjs +33 -85
  13. package/lib/mirror-snapshot.cjs +6 -24
  14. package/lib/mobile/agent-presets.js +6 -26
  15. package/lib/mobile/approvals.js +18 -44
  16. package/lib/mobile/archive-clean.js +10 -19
  17. package/lib/mobile/attachments.js +11 -36
  18. package/lib/mobile/channels.js +15 -41
  19. package/lib/mobile/credential-upgrade.js +7 -16
  20. package/lib/mobile/credentials.js +5 -21
  21. package/lib/mobile/directory-browse.js +8 -27
  22. package/lib/mobile/directory-listing.js +10 -23
  23. package/lib/mobile/drive-roots.js +59 -0
  24. package/lib/mobile/envelope.js +4 -15
  25. package/lib/mobile/events.js +35 -65
  26. package/lib/mobile/extensions.js +5 -8
  27. package/lib/mobile/git-repos.js +6 -15
  28. package/lib/mobile/git.js +11 -19
  29. package/lib/mobile/history-derive.js +23 -41
  30. package/lib/mobile/host-bridge/endpoints.js +4 -6
  31. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  32. package/lib/mobile/host-bridge/shims.js +12 -22
  33. package/lib/mobile/host-bridge.js +8 -20
  34. package/lib/mobile/host-settings.js +9 -19
  35. package/lib/mobile/inbox-clean.js +7 -24
  36. package/lib/mobile/local-images.js +11 -17
  37. package/lib/mobile/monitor.js +8 -17
  38. package/lib/mobile/pairing.js +7 -19
  39. package/lib/mobile/plugin-self.js +5 -22
  40. package/lib/mobile/plugin-update.js +8 -27
  41. package/lib/mobile/plugins.js +6 -24
  42. package/lib/mobile/questions.js +15 -40
  43. package/lib/mobile/references.js +11 -31
  44. package/lib/mobile/relay-control.js +6 -27
  45. package/lib/mobile/rpc.js +5 -13
  46. package/lib/mobile/schedule.js +8 -33
  47. package/lib/mobile/self-check.cjs +9 -36
  48. package/lib/mobile/session-cache.js +13 -19
  49. package/lib/mobile/session-file.js +8 -21
  50. package/lib/mobile/session-flags.js +7 -13
  51. package/lib/mobile/session-mirror.js +8 -15
  52. package/lib/mobile/session-models.js +3 -3
  53. package/lib/mobile/session-schedule-mark.js +8 -14
  54. package/lib/mobile/session-send.js +13 -21
  55. package/lib/mobile/session-snapshot.js +4 -6
  56. package/lib/mobile/sessions.js +7 -23
  57. package/lib/mobile/stats-collect.cjs +10 -24
  58. package/lib/mobile/stats-ledger.cjs +13 -20
  59. package/lib/mobile/stream.js +12 -23
  60. package/lib/mobile/subagents.js +5 -18
  61. package/lib/mobile/uploads.js +8 -17
  62. package/lib/mobile/workspaces.js +20 -8
  63. package/lib/panel/index.html +7 -7
  64. package/lib/read-receipt.cjs +10 -21
  65. package/lib/relay/lan-network.cjs +14 -28
  66. package/lib/relay/lan-proxy.js +14 -21
  67. package/lib/relay/pairing-policy.js +6 -9
  68. package/lib/relay/preview.js +8 -25
  69. package/lib/relay/protocol.js +6 -13
  70. package/lib/relay/status.js +5 -8
  71. package/lib/relay/tunnel.js +9 -14
  72. package/lib/routes/control-plane.js +28 -45
  73. package/lib/routes/mobile-api.js +19 -32
  74. package/lib/session-cleanup.cjs +16 -27
  75. package/lib/shared/credentials-entry.js +4 -11
  76. package/lib/shared/dshtoken.js +4 -4
  77. package/lib/shared/e2ee-request.js +1 -1
  78. package/lib/shared/e2ee.js +8 -14
  79. package/lib/shared/fresh-request.js +6 -22
  80. package/lib/shared/http-util.js +3 -4
  81. package/lib/shared/list-revision.js +5 -14
  82. package/lib/shared/session-signal.js +7 -16
  83. package/lib/shared/sse-write.js +14 -20
  84. package/lib/shared/workspace-guard.js +7 -15
  85. package/lib/shell/device-ota.js +4 -6
  86. package/lib/shell/manager.js +12 -27
  87. package/lib/shell/ota-package.js +7 -12
  88. package/lib/shell/runtime.js +8 -16
  89. package/lib/shell/self-check-after-swap.js +9 -23
  90. package/lib/shell/updater.js +13 -26
  91. package/lib/sidecar-facade-worker.cjs +20 -33
  92. package/lib/sidecar-facade.cjs +7 -7
  93. package/lib/sidecar-lifecycle.cjs +7 -9
  94. package/lib/update-install.cjs +14 -59
  95. package/lib/update-registry.cjs +6 -18
  96. package/package.json +8 -5
@@ -1,20 +1,13 @@
1
1
  /**
2
2
  * 设备级事件流(GET /api/mobile/v1/events/stream):手机上侧栏那条 SSE。
3
3
  *
4
- * 它只被当成「有东西变了」的信号,绝不当作数据源(真正的数据还要 App 自己去拉列表),
5
- * 所以这里把每条宿主帧削到只剩能表示「列表需要重拉」的部分(见 listRelevantPayload)。
6
- * 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
7
- * 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
8
- * 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
9
- * device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
10
- * device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
11
- * 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
12
- * 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
4
+ * 它只是「有东西变了」的信号,绝不是数据源,所以每条宿主帧都削到只剩「列表需要重拉」的
5
+ * 部分。0.2.0 兼容桥只订阅 workspace/follow + session/control,会话状态变化会被丢掉;插件
6
+ * 因此自产四类帧:device/list-changed、device/session-activity、device/desktop-view-changed
7
+ * 与 device/heartbeat,App 靠它们判断流还活着并决定是否降频兜底轮询。
13
8
  *
14
- * 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
15
- * 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
16
- * 各一帧 device/session-activity(见 replayKnownSignals)。补的是断流窗口,不是新变更:
17
- * 只读信号总线的快照,不 bump、不前移基准,App 侧对同 revision / 同水位幂等。
9
+ * 这条流没有 Last-Event-ID 续传,所以连上时立刻补一帧当前 revision 与每个已跟踪会话的水位;
10
+ * 补的是断流窗口,不是新变更。
18
11
  */
19
12
  'use strict';
20
13
 
@@ -30,11 +23,8 @@ const DEDUP_WINDOW = 2048;
30
23
  const HEARTBEAT_MS = 15_000;
31
24
 
32
25
  /**
33
- * 诊断日志。这条流过去的失败路径是一个空 catch(里面只有一句「客户端断开或流已结束」
34
- * 的注释),于是「连着却一句话都不说」在宿主侧没有任何痕迹:手机把兜底轮询降成 60s,
35
- * 而宿主日志里查不到这条流是正常收尾、被客户端断开,还是循环抛错。会话级流
36
- * (mobile/stream.js)早就有 logStream,这里是它的设备级对偶——任何一条异常路径都必须
37
- * 留下可 grep 的行。
26
+ * 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 60s,宿主日志里却查不到这条流是
27
+ * 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
38
28
  */
39
29
  function logDeviceStream(message, error) {
40
30
  const detail = error instanceof Error ? (error.stack || error.message) : (error == null ? '' : String(error));
@@ -46,11 +36,9 @@ function logDeviceStream(message, error) {
46
36
  /**
47
37
  * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
48
38
  *
49
- * 这条流的策略:软水位**一律** shed —— 但 shed 不能当成功:设备流没有 Last-Event-ID
50
- * 续传,丢了帧又继续读,App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须
51
- * 立即 `abort` 断流,让客户端重连后走 `replayKnownSignals()` 全量补水位。
52
- * 这比把帧堆在写缓冲里好(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞
53
- * 4GB 上限被杀)。不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
39
+ * 这条流的软水位一律 shed,而 shed 不能当成功:没有 Last-Event-ID 续传,丢了帧又继续读,
40
+ * App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须立即断流,让客户端重连后
41
+ * 走 `replayKnownSignals()` 全量补水位 —— 这比把帧堆在写缓冲里好。
54
42
  *
55
43
  * 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉(调用方必须断流),
56
44
  * `needDrain:true` 写进去了但底下已经写不动。
@@ -60,7 +48,7 @@ function writeSse(res, payload, frame, sealFrame) {
60
48
  frame,
61
49
  namespace: 'device',
62
50
  // 协议 v2 的会话上这条流也逐帧封装:`App` 判定「这条会话是加密的」之后,收到一帧
63
- // 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询(2026-10-24 现场)。
51
+ // 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询。
64
52
  sealFrame: typeof sealFrame === 'function' ? sealFrame : null,
65
53
  shedWhenBuffered: () => true,
66
54
  });
@@ -87,14 +75,10 @@ async function* mergeHostAndMux(ctx, signal) {
87
75
  }
88
76
 
89
77
  /**
90
- * Trim a device-wide stream payload to the metadata the mobile session-list
91
- * actually consumes. `session/event` frames carry the FULL message content
92
- * (payload.event.data includes the whole assistant/user message); forwarding
93
- * them to every paired phone floods the relay and the client with megabytes
94
- * whenever a session is actively generating. The device-wide stream is only
95
- * used by the phone to refresh the session list (turn/start/end/title), so it
96
- * needs the event identity and inner type, not the content. Other frame types
97
- * (summary/queue/projection/question/approval/host) are small and kept intact.
78
+ * Trim a device-wide stream payload to the metadata the mobile session-list consumes.
79
+ * `session/event` frames carry the full message content; forwarding them to every paired
80
+ * phone floods the relay whenever a session is generating. Only the event identity and
81
+ * inner type are needed. Other frame types are small and kept intact.
98
82
  */
99
83
  function lightPayload(payload) {
100
84
  if (!payload || typeof payload !== 'object' || payload.type !== 'session/event') return payload;
@@ -174,15 +158,13 @@ async function handleDeviceStream(ctx, req, res) {
174
158
  return !result.needDrain;
175
159
  };
176
160
  /**
177
- * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
161
+ * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
178
162
  *
179
- * 设备级流此前只有宿主事件这一个来源,而宿主的 0.2.0 兼容桥只订阅 workspace/follow +
180
- * session/control,会话状态变化(session/projection)会被 listRelevantPayload 丢掉 ——
181
- * 手机于是「连着却收不到任何会话状态变化」,兜底轮询又被降成 60s。这里补上真源。
163
+ * 设备级流此前只有宿主事件这一个来源,而 0.2.0 兼容桥的订阅里没有 session/projection,
164
+ * 会话状态变化会被丢掉,手机于是「连着却收不到任何变化」。
182
165
  *
183
- * 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再
184
- * 等于「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听(隧道断着也照常判定),
185
- * 收尾时必须注销,否则它永远以为有人听。
166
+ * 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再等于
167
+ * 「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听,收尾时必须注销。
186
168
  */
187
169
  const releaseSignal = sharedListSignal.watch();
188
170
  const unsubscribeSignal = sharedListSignal.onBump((event) => {
@@ -192,17 +174,12 @@ async function handleDeviceStream(ctx, req, res) {
192
174
  /**
193
175
  * 自产的「某个会话的正文水位涨了」帧,与「清单变了」是两条独立的通知。
194
176
  *
195
- * 清单信号只说「列表内容变了」,手机据此重拉列表;但列表摘要里的 lastMessage 只是
196
- * 一句话,正文(回复的长文本、提问的选项)在会话缓存里。没有这条帧时,只有**打开着**
197
- * 那个会话的手机才会实时收到正文(session/stream 是按会话订阅的),没打开的会话要等
198
- * 用户点进去才拉 —— 而这正好是「点开对话要等一会儿才看到内容」的来源。
177
+ * 清单信号只说列表内容变了;而正文在会话缓存里,没有这条帧时只有打开着那个会话的手机才
178
+ * 会实时收到正文,没打开的会话要等点进去才拉 —— 这正是「点开对话要等一会儿才看到内容」
179
+ * 的来源。
199
180
  *
200
- * 水位由镜像调度器**算出后广播**,与镜像是否真的推进去解耦(见 lib/mirror/pusher.cjs
201
- * 的 announceSessionActivity:隧道未连、内容未变跳过、推送成功三条路径都会广播)——
202
- * 它说的是「本机正文到哪了」,不是「中转镜像里有到哪了」。手机收到帧后立刻带 afterSeq
203
- * 去 /session/messages 增量拉:走中转的手机读到的是刚推上去的镜像,走局域网直连的手机
204
- * 读到的是 sidecar 本地那份快照(/tunnel/send 先落盘再尝试发),两条路都拿得到。
205
- * 同样必须直订 + 收尾注销,理由与上面那条一致。
181
+ * 水位由镜像调度器算出后广播,说的是「本机正文到哪了」,不是「中转镜像里有到哪了」,与
182
+ * 镜像是否真的推成功解耦。同样必须直订 + 收尾注销。
206
183
  */
207
184
  const unsubscribeActivity = sharedSessionSignal.onBump((event) => {
208
185
  if (ac.signal.aborted) return;
@@ -211,14 +188,12 @@ async function handleDeviceStream(ctx, req, res) {
211
188
  /**
212
189
  * 自产的「桌面侧栏折叠态变了」帧。
213
190
  *
214
- * 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
215
- * 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
216
- * 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
217
- * 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
191
+ * groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报落在
192
+ * lib/desktop-view.cjs;手机要拿它只能问 GET /desktop-view。过去 App 按「跟列表刷新走 +
193
+ * 5 分钟最短间隔」去猜它变没变,而插件早就知道它哪一刻变了。
218
194
  *
219
- * 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
220
- * 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
221
- * 同样必须直订 + 收尾注销,理由与上面两条一致。
195
+ * 只发 updatedAt、不发内容:手机据此重拉那一发,不必让信号流承担数据同步。
196
+ * 同样必须直订 + 收尾注销。
222
197
  */
223
198
  const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
224
199
  if (ac.signal.aborted) return;
@@ -227,16 +202,11 @@ async function handleDeviceStream(ctx, req, res) {
227
202
  /**
228
203
  * 连上就先补一帧「当前状态」。
229
204
  *
230
- * 设备级流没有会话级流那套 Last-Event-ID 续传(App 对这条流不做断点续传,它只把帧
231
- * 当「有东西变了」的信号),所以断流窗口里发生的清单变更与正文水位在重连后必须补上。
232
- * 不补的后果:手机重连后 state 是空的,而侧栏兜底轮询已经因为「流活着」被降到 60s ——
233
- * 中间这几分钟的变化要等到下一次真实变更才可见。
205
+ * 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
206
+ * 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 60s。
234
207
  *
235
- * 这里只读两条信号总线**已经广播过**的最近值(snapshot),既不 bump 也不前移任何基准,
236
- * 所以不会制造新变更、不影响推送侧的幂等。App 侧对同 revision / 同水位是幂等的
237
- * (app 的 handleDeviceSignal 按 revision 与 seq 判重),重连补发不会引发重复刷新。
238
- * 帧形状与实时帧完全一致(同为 device/event 信封包 device/list-changed 或
239
- * device/session-activity),App 不需要任何改动。
208
+ * 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
209
+ * App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
240
210
  */
241
211
  const replayKnownSignals = () => {
242
212
  const list = sharedListSignal.snapshot();
@@ -3,15 +3,12 @@
3
3
  /**
4
4
  * 扩展卡的宿主侧:卡状态账本、扩展动作登记表,以及给模型用的 `extension_present` 工具。
5
5
  *
6
- * 为什么卡状态要落在宿主家里:一张卡「现在长什么样」不能只靠声明它的那一次工具调用。
7
- * 扩展页回写标题与说明之后,手机上重进会话、刷新列表都会从历史重新拼卡 —— 没有这份账本,
8
- * 卡会一直停在声明那一刻的样子(卡的状态得由宿主认账)。账本与会话
9
- * 日志无关,清理会话不连带它,与统计账本同口径。
6
+ * 卡状态要落在宿主家里:扩展页回写标题与说明之后,手机重进会话、刷新列表都会从历史重新
7
+ * 拼卡,没有这份账本,卡就停在声明那一刻的样子。账本与会话日志无关,清理会话不连带它。
10
8
  *
11
- * 为什么动作名是一张硬编码白名单:扩展是网页,它能让宿主做的事必须由宿主预先枚举,而不是
12
- * 由扩展在消息里现拼一个动作名交给宿主执行。这条边界和移动 API 的能力面是同一条 —— 移动
13
- * API 不暴露 shell、系统设置、任意文件读写,这里也一个都不开。**登记表现在是空的**:宿主动作
14
- * 这一档还没有对任何扩展放开,所以任何动作名都必须被拒。
9
+ * 动作名是一张硬编码白名单:扩展能让宿主做的事必须由宿主预先枚举,不能在消息里现拼一个
10
+ * 动作名。这条边界与移动 API 的能力面相同——不暴露 shell、系统设置、任意文件读写。
11
+ * 登记表现在是空的,所以任何动作名都必须被拒。
15
12
  */
16
13
 
17
14
  const fs = require('node:fs');
@@ -3,23 +3,14 @@
3
3
  /**
4
4
  * 项目目录的 Git 仓库识别:回传「这里有没有仓库、仓库根在哪、当前在哪个分支」。
5
5
  *
6
- * 为什么由插件做:手机看不到电脑上的目录,侧栏那个 Git 入口的可见性只能靠宿主回传的
7
- * 标记决定(docs/git-manager.md 第 2 节)。判据不许 App 猜,也不许等用户点进去才发现
8
- * 是空页 —— 字段缺失一律按「不是仓库」处理。
6
+ * 手机看不到电脑上的目录,侧栏 Git 入口的可见性只能靠宿主回传的标记;判据不许 App 猜,
7
+ * 字段缺失一律按「不是仓库」处理。
9
8
  *
10
- * 为什么只读文件、不起 git 进程:这段代码跟着工作区清单一轮一轮地跑(镜像推送每
11
- * 500ms 构建一次清单),起进程太贵;而且宿主上未必装了 git,装没装不该影响「这个项目
12
- * 是不是仓库」这个事实。仓库的形状就是 `.git` 这个入口加上里面的 HEAD,读到就够答了。
9
+ * 只读文件、不起 git 进程:这段代码跟着工作区清单一轮轮跑,起进程太贵,而且宿主未必装了
10
+ * git。三种都算有仓库:项目根自己就是仓库、项目落在某个仓库的子目录里(往上找最近的根)、
11
+ * 项目下面并列着若干仓库。往上走到主目录为止,往下只扫两层、条数封顶。
13
12
  *
14
- * 认哪些(三种都算「有仓库」):
15
- * 1. 项目根自己就是仓库根;
16
- * 2. 项目目录落在某个仓库的子目录里 —— 往上找最近的那个根,操作按它执行;
17
- * 3. 项目下面并列着若干个仓库(第 1、2 层,含 `.git` 是文件的子模块与 worktree)。
18
- * 往上走到主目录为止不再往上:主目录本身是个 dotfiles 仓库时,再往上是把每个项目都
19
- * 判成仓库,那不是这些项目的意思。往下只扫两层、条数封顶,扫满置 `truncated`。
20
- *
21
- * 结果按目录缓存一会儿(GIT_CACHE_MS):下拉刷新带 `fresh` 跳过缓存,见
22
- * lib/mobile/workspaces.js 的 handleWorkspaces。
13
+ * 结果按目录缓存一会儿,下拉刷新带 `fresh` 跳过。
23
14
  */
24
15
 
25
16
  const { readdir, readFile, stat } = require('node:fs/promises');
package/lib/mobile/git.js CHANGED
@@ -1,26 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端 Git 管理器:十一条 `/api/mobile/v1/git/*` 的 handler。
4
+ * 移动端 Git 管理器:`/api/mobile/v1/git/*` 的 handler。
5
5
  *
6
- * 能力面刻意收成**固定命名的动词**,不接受「传一条 git 命令」:这条 API 的边界是不暴露
7
- * shell,而 `git -c core.pager=less ...` 这种透传等于把 shell 交出去。所以这里只认
8
- * 「仓库摘要 / 变更清单 / 单个文件的 diff / 历史 / 单次提交 / 分支 / 概览」加上
9
- * 「暂存 / 取消暂存 / 提交 / 切分支 / 新建分支 / 推送到上游」这些动作,参数只有仓库、
10
- * 相对路径、分支名、提交信息四类值(push 连分支名都不收:只推当前分支到它自己的上游)。
6
+ * 能力面刻意收成固定命名的动词,不接受「传一条 git 命令」:这条 API 的边界是不暴露 shell,
7
+ * 透传等于把 shell 交出去。参数只有仓库、相对路径、分支名、提交信息四类值。
11
8
  *
12
- * 归属校验在这一层做两遍:① `workspaceId` 必须是这台设备已注册的工作区(宿主 `workspace.list`
13
- * 里找得到);② 请求里的仓库根必须是 `lib/mobile/git-repos.js` 给这个项目识别出来的那几个根
14
- * 之一 —— 于是「随便传一个路径」这条路根本走不通,`..`、绝对路径、仓库外的目录都进不来。
9
+ * 归属校验做两遍:workspaceId 必须是本设备已注册的工作区;仓库根必须是 git-repos.js 给这个
10
+ * 项目识别出来的根之一,于是随意传路径、`..`、仓库外目录都进不来。写操作按仓库串行,避免
11
+ * 并发写撞 `.git/index.lock`,写完顺手回一份新的变更清单。
15
12
  *
16
- * 写操作按仓库串行(`withRepoLock`):手机上连点两下就是两个并发写,撞上 `.git/index.lock`
17
- * 会互相踩死;写完顺手回一份新的变更清单,省掉前端一次往返。
18
- *
19
- * 明确不做:合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 /
20
- * 子模块管理 / fetch、pull。**push 只做最窄的一档**(2026-10-22):推当前分支到它已有的上游,
21
- * 不接受 `--force`、不收分支名参数 —— 远端分叉、没有上游一律拒掉并叫用户回电脑处理,
22
- * 因为「拉下来再合」这件事本来就留在电脑上。丢弃改动也没做 —— 那是不可恢复的删工作,
23
- * 接口留白比给一个能被误按的按钮安全。
13
+ * 明确不做合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 / 子模块 /
14
+ * fetch、pull;push 只推当前分支到已有上游,远端分叉或没有上游一律拒掉。丢弃改动也没做,
15
+ * 接口留白比一个能被误按的按钮安全。
24
16
  */
25
17
 
26
18
  const { isAbsolute, resolve } = require('node:path');
@@ -434,8 +426,8 @@ function cleanCommitMessage(raw) {
434
426
  * `llm` 服务(跟渠道包、agent loop 同一条流式路径),让它写一行 Conventional Commits 标题填进
435
427
  * 提交框 —— 只是**填框**,不提交、不推送。跟 VSCode 那颗一致:没暂存也要能写。
436
428
  *
437
- * 边界:模型要由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒(没有改动可总结,让模型凭空写一条比报错更糟)。(没有改动可总结,
438
- * 让模型凭空写一条比报错更糟)。这里只读 `git diff --cached`,不写盘。
429
+ * 边界:模型由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒,让模型凭空写一条
430
+ * 比报错更糟。这里只读 diff,不写盘。
439
431
  */
440
432
  async function handleGitCommitMessage(ctx, req, res) {
441
433
  const body = await bodyJson(req);
@@ -48,15 +48,12 @@ function capDiffText(text) {
48
48
  /**
49
49
  * 收窄 tool/result 的 `data.meta`(write/edit 的 FsDiffMeta)为可回传的 diff 列表。
50
50
  *
51
- * 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在
52
- * `result.meta` 上(dsh-tool-fs 的 computeHunkDiffs,DIFF_CONTEXT=3),
53
- * agent loop 原样写进 tool/result 事件(dsh-agent-loop/lib/index.js:313
54
- * `...result.meta !== void 0 ? { meta: result.meta } : {}`),并随会话日志持久化。
55
- * 此前移动端投影只读 message.content,把 meta 整个丢掉——于是 App 里 write/edit
56
- * 只剩一坨 JSON 参数,而桌面显示的是真正改了什么。
51
+ * 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在 `result.meta`
52
+ * 上,随 tool/result 事件持久化。投影只读 message.content 的话 meta 会整个丢掉,App 里
53
+ * write/edit 只剩一坨 JSON 参数。
57
54
  *
58
- * 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),
59
- * 畸形数据返回 undefined 让调用方退回通用卡片,绝不抛错。
55
+ * 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),畸形数据
56
+ * 返回 undefined 让调用方退回通用卡片,绝不抛错。
60
57
  */
61
58
  function normalizeResultDiffs(meta) {
62
59
  if (!meta || typeof meta !== 'object' || !Array.isArray(meta.diffs)) return undefined;
@@ -328,19 +325,14 @@ function presentedPathsOf(events) {
328
325
 
329
326
  /**
330
327
  * 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
331
- * - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
332
- * - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done'|'failed', error?, turn?,
333
- * summary?, shadowedItemCount?, shadowedTokenCount? };
334
- * 压缩检查点 user/message(source.kind==='plugin' && source.plugin==='compact')绝不渲染成用户气泡,
335
- * 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
336
- * compaction/end 给这次尝试落定(带 error → state='failed' + error 原文,否则 'done');
337
- * **同一轮里的多次尝试只占一行**(新尝试原地顶替还没落定的那条)—— 宿主一步重试一次,
338
- * 不并就是几十条「正在压缩…」;
339
- * - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
340
- * - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
341
- * - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
342
- * (command/run 发出行,command/done 原地升级:name 保 run 的,resultKind/text 取 done 的)。
343
- * 结构化项与消息按事件 seq 升序混排返回;旧载荷字段缺失时跳过对应项,绝不抛错。
328
+ * - 普通消息:{ id, seq, time, role, text, blocks?, images?, clientMessageId? };
329
+ * - compaction:渲染成 compaction 行而不是用户气泡,id=compactionId;同 compactionId 的
330
+ * compaction/summary 补 counts,compaction/end 落定 state(带 error 即 failed);
331
+ * 同一轮的多次尝试只占一行,新尝试原地顶替还没落定的那条;
332
+ * - retry:{ kind:'retry', id: retryId, status:'scheduled'|'started', retry, maxRetries, … };
333
+ * - turn-error / max-tokens:id 为 `turn-<turn>`;
334
+ * - command:command/run 发出行,command/done 原地升级。
335
+ * 结构化项与消息按 seq 升序混排;旧载荷字段缺失时跳过对应项,绝不抛错。
344
336
  */
345
337
  function deriveMessages(events) {
346
338
  const out = [];
@@ -357,7 +349,7 @@ function deriveMessages(events) {
357
349
  // 交付声明预扫描(`deliverables/presented`):模型调 `present` 声明「这几份请你收下」时,
358
350
  // 宿主的 dsh-tool-present 会往会话日志追一条这个事件。桌面那张「交付文件」卡只认它 ——
359
351
  // **不是**「这一轮改过哪些文件」。手机第一版从 write/edit 反推,于是「每改一次都冒卡、
360
- // 命令行改的却一张都没有」(2026-10-22 用户报的正是这个)。这里按 **turn** 归并,
352
+ // 命令行改的却一张都没有」。这里按 **turn** 归并,
361
353
  // 同一个路径后声明的覆盖先声明的(桌面 `presentedForClosing` 的 `files.set` 同口径)。
362
354
  const presentedByTurn = new Map();
363
355
  for (const entry of events) {
@@ -383,8 +375,7 @@ function deriveMessages(events) {
383
375
  * 这一轮**被 write / edit 动过**的文件,按轮攒。
384
376
  *
385
377
  * 为什么按轮而不是按单条消息:一份文件是一轮里某一步写的,而收尾答复那条消息自己一个
386
- * 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的(2026-10-25
387
- * 现场:手机上那个文件名一直是灰的,数一遍 54 条消息只有 4 条带得动这份清单)。
378
+ * 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的。
388
379
  * 桌面 `dsh-client-ui-deliverables` 的 `selectProducedFiles(owner)` 也是按**整轮**取。
389
380
  *
390
381
  * 攒完在循环之后回填:同一轮里先发的消息也要能看到这一轮后几步才写出来的文件。
@@ -804,25 +795,16 @@ function deriveMessages(events) {
804
795
  * 入口窗口的「内容化」裁剪(`GET /session/messages?thin=1`,只对不带 beforeSeq / afterSeq 的
805
796
  * 一次性入口窗口生效;往上翻页与增量补拉保持完整)。
806
797
  *
807
- * 为什么:入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息
808
- * (推理块 + 工具调用块)。一次长回合几十上百步,最后 40 条可能全是这些过程消息 ——
809
- * 用户自己那句话被挤出窗口,进对话只剩半轮,看起来像「我说的话凭空消失」。
798
+ * 入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息,长回合最后
799
+ * 几十条可能全是过程消息,用户自己那句话被挤出窗口。现在按「轮」分组,只留用户消息、助手
800
+ * 正文、带图消息与结构化行;过程明细不下发,每轮裁掉多少项挂在开头那条用户消息的
801
+ * `turnProcess` 上,点开时按 startSeq / endSeq 回头要那一轮的事件。
810
802
  *
811
- * 现在入口按「轮」分组、只留用户看得见的内容:
812
- * - 用户消息 / 定时任务唤醒行(原样);
813
- * - 助手正文(中途汇报与最终答复都算)与带图的消息;
814
- * - 结构化行(压缩、重试、本轮失败等,体量小,且带着这一轮的状态)。
815
- * 过程明细(推理块、工具调用块、只有过程没有文字的消息)不下发,开启某一轮时再逐轮补取。
816
- * 每轮被裁掉多少项、范围到哪,挂在**这一轮开头那条用户消息**的 `turnProcess` 上:App 用它
817
- * 渲染「还有 N 项处理过程」,点开时按 startSeq / endSeq 回头要那一轮的事件。
803
+ * 窗口最老那一组没有起点(截断正好切在一轮中间)时整组丢掉,否则会画出「凭空冒出的 AI
804
+ * 发言」,它会被「加载更早」整轮补回。
818
805
  *
819
- * 窗口最老那一组没有起点(宿主按 maxMessages 截断时正好切在一轮中间)时整组丢掉:
820
- * 半轮的消息没有用户消息打头,画出来就是「凭空冒出的 AI 发言」;它会被「加载更早」整轮补回。
821
- *
822
- * 三种「出卡」工具的调用块要留一条(定时任务 / 计划 / 扩展卡):轮尾那几张卡是从工具块
823
- * 现算的(`services/viewmodels/turn-tail-cards` 的 scheduleTailCard / planTailCard /
824
- * extensionTailCard),整块丢掉会让卡在收口时凭空不见。它们体量很小。
825
- * **新增一种轮尾卡时要同步这里**,否则那张卡只在「点开过程之后」才出现。
806
+ * 三种「出卡」工具(定时任务 / 计划 / 扩展卡)的调用块要留一条,轮尾那几张卡是从工具块现算
807
+ * 的,整块丢掉会让卡凭空不见。新增一种轮尾卡时要同步这里。
826
808
  */
827
809
  const ENTRY_OUTCOME_TOOLS = new Set(['schedule_create', 'exit_plan_mode', 'extension_present']);
828
810
 
@@ -3,12 +3,10 @@
3
3
  /**
4
4
  * 端点表:旧 `apiProxy.<ns>.<method>` 的键 ⇄ 宿主 wire 名与参数形状。
5
5
  *
6
- * ⚠️ 这张表是人肉对照宿主 descriptor 校验的,**没有任何机器可校验机制**,而它错一次的
7
- * 后果是静默的:`sessions.projections` 曾少包一层 `request`,宿主每次都回 400,
8
- * 而 App 侧把 400 catch 掉了,于是整个统计 UI 凭空消失。args 的键名必须**恰好**是
9
- * descriptor 的 wire 名(多一个少一个都报 gateway/arguments-invalid,dropUnexpectedArgs
10
- * 只摘多余的键,救不了「少一个」)。改动前请先在 test/host-bridge-endpoints.test.cjs
11
- * 里补一条参数化用例。
6
+ * 这张表是人肉对照宿主 descriptor 校验的,没有任何机器校验,错的后果是静默的:少一层
7
+ * `request` 时宿主只回 400,而 App 把 400 catch 掉,整个页面凭空消失。args 的键名必须恰好
8
+ * 是 descriptor 的 wire 名,多一个少一个都报 gateway/arguments-invalid。改动前先在
9
+ * test/host-bridge-endpoints.test.cjs 里补一条参数化用例。
12
10
  */
13
11
 
14
12
  const { invoke, invokeEnvelope, ok, fail, pick, requestId, unsupported, warn } = require('./transport.js');
@@ -52,10 +52,9 @@ function sessionHint(request) {
52
52
  /**
53
53
  * 这条单会话流该按哪种地址订阅。
54
54
  *
55
- * 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主 `validateAddress` 对
56
- * `origin === 'subagent'` 的会话拿普通地址一律回 agent-busy,subscribe 直接失败 ——
57
- * 表现就是「子代理页面打得开,但内容永远不自己更新」。请求体里带上 `parentSessionId`
58
- * 就是「这是个子会话」的信号(与历史那条同源,见 stream.js 的 handleStream)。
55
+ * 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主对 `origin === 'subagent'` 的会话
56
+ * 拿普通地址一律回 agent-busy,subscribe 直接失败,表现是「子代理页面打得开,但内容永远
57
+ * 不自己更新」。请求体里的 `parentSessionId` 就是「这是个子会话」的信号。
59
58
  */
60
59
  function sessionAddress(payload) {
61
60
  const sessionId = payload && payload.sessionId;
@@ -86,11 +85,9 @@ const BASELINE_DELTA_TYPES = new Set(['text-delta', 'reasoning-delta', 'tool-cal
86
85
  /**
87
86
  * 展开 follow 首帧里那条「正在跑的那一步已生成的内容」。
88
87
  *
89
- * 宿主的 follow 第一帧是 `{type:'snapshot', cursor, assistantStream:{ revision, activeAttempt }}`,
90
- * `activeAttempt.stream` 是一份**压缩过的**增量记录(连续同块 delta 被压成
91
- * `{type:'text-chunks', index, time0, dt, texts}` 这类一条记录多个成员),
92
- * `nextIndex` 是「下一帧实时增量应有的密排位置」,也就是这条前缀展开后应有的成员数。
93
- * 中途进入会话的人只有这一份,宿主不会把已发过的实时帧补给他。
88
+ * 首帧的 `assistantStream.activeAttempt.stream` 是一份压缩过的增量记录(连续同块 delta 被
89
+ * 压成一条记录多个成员),`nextIndex` 是展开后应有的成员数。中途进入会话的人只有这一份,
90
+ * 宿主不会把已发过的实时帧补给他。
94
91
  *
95
92
  * 返回 `[{ time, chunk }]`;形状认不出就跳过那一条记录,绝不让整条流挂掉。
96
93
  */
@@ -166,20 +163,15 @@ function coalesceBaselineStream(members) {
166
163
  /**
167
164
  * follow 流的一帧 → 0..n 个 mux 帧(事件原样转发;实时增量通知合成 assistant/chunk)。
168
165
  *
169
- * 0.2.0 的宿主把逐块流式增量从「会话事件」里拿了出去:正文/推理的每个 delta 只以 follow 流的
170
- * `assistant-stream` 通知下发,而且**要显式传 `assistantStream: true` 才发**,事件流里只剩
171
- * 「一步落定」的 `assistant/message`。不订阅通知的话,正文与运行中明细都只能在一步结束时整块
172
- * 出现(真机故障 2026-10-21:回复整段蹦出来、运行中那一行没有实时明细)。
166
+ * 0.2.0 把逐块流式增量从会话事件里拿了出去:每个 delta 只以 follow 流的 `assistant-stream`
167
+ * 通知下发,而且要显式传 `assistantStream: true` 才发,事件流里只剩「一步落定」的
168
+ * `assistant/message`。不订阅通知的话,正文与运行中明细都只能在一步结束时整块出现。
173
169
  *
174
- * 合成事件用的是移动协议早已认识的 `assistant/chunk` 形状,但**不带 seq** —— 它没有持久化
175
- * 事件的身份,不该参与客户端的 seq 序门、去重与续传水位(App 侧对无 seq 帧一律放行),
176
- * turn/step 由最近一次 start 通知补上。设备级推送只看 turn/start、turn/end、session/title
177
- * (`lib/mobile/events.js` 的 LIST_EVENT_INNER),合成增量到不了那条通道,不会形成推送风暴。
170
+ * 合成的 `assistant/chunk` 不带 seq:它没有持久化事件的身份,不该参与客户端的 seq 序门、
171
+ * 去重与续传水位(App 对无 seq 帧一律放行),turn/step 由最近一次 start 通知补上。
178
172
  *
179
- * `state` 是这条流的记账({ turn, step, streaming }),由调用方持有 —— 一帧的处理要看
180
- * 「之前收到过什么」。历史正文走 session.history,但首帧 snapshot 里那份
181
- * `assistantStream.activeAttempt` 不能丢:中途进入会话的人拿不到开跑之前的实时增量,
182
- * 那一段只存在于这份基线里(见 expandBaselineStream)。
173
+ * `state` 是这条流的记账({ turn, step, streaming }),由调用方持有。首帧 snapshot 里那份
174
+ * `assistantStream.activeAttempt` 不能丢:中途进入会话的人拿不到开跑之前的实时增量。
183
175
  */
184
176
  function* followFramePayloads(frame, sessionId, state) {
185
177
  if (!frame) return;
@@ -293,22 +285,16 @@ function projectionState() {
293
285
  }
294
286
 
295
287
  /**
296
- * 宿主投影 `inbox` → 移动端 `session/queue` 帧的**平铺 items**。
288
+ * 宿主投影 `inbox` → 移动端 `session/queue` 帧的平铺 items。
297
289
  *
298
290
  * 形状差是这条链上唯一的坑:宿主把 inbox 定义成按键分组的对象
299
- * `{ 'next-turn': Message[], 'next-step': Message[] }`(桌面 bundle 里
300
- * `@deepseek-ai/dsh-agent-loop/inbox` 的 `key: 'inbox'` + `wire.view: (state) => state`,
301
- * 桌面 QueueDock 读的也正是 `inbox['next-turn']`),而移动端帧的约定是数组
302
- * `[{ id, placement, message }]`(`app/src/types/chatStream.ts` 的 QueueItem、
303
- * `app/src/hooks/streamFrame.ts` 的 `session/queue` 分支)。原样转发对象时 App 的
304
- * `Array.isArray(items)` 判定失败、整帧被静默丢弃 —— 排队条一次都不显示。
291
+ * `{ 'next-turn': Message[], 'next-step': Message[] }`,而移动端帧的约定是数组
292
+ * `[{ id, placement, message }]`。原样转发对象时 App 的 `Array.isArray(items)` 判定失败、
293
+ * 整帧被静默丢弃,排队条一次都不显示。
305
294
  *
306
- * `next-turn`(等下一轮才开始的消息)→ `placement: 'queued'`,QueueDock 只认这一个值。
307
- * `next-step`(已收下、等本步末尾注入的插话)→ `placement: 'steering'`:排队条与正文列表都
308
- * 只认 `queued`,所以它不会多画一行;它的用处是「宿主已经收下了」这条回执 —— 插话从发出去
309
- * 到真被插进去中间可能隔着好几个工具往返,此前手机上只挂着一颗「发送中…」。页脚那排插话
310
- * 气泡会把本机自己发的那几条排掉(`steeringForFooter`),不会与待发气泡双画。
311
- * `message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
295
+ * `next-turn` → `placement: 'queued'`(排队条只认这个值);`next-step` → `'steering'`,
296
+ * 它只是「宿主已经收下了」的回执,不会多画一行。`message.id` 用宿主行自己的 id,App 靠它
297
+ * 与正文去重。
312
298
  */
313
299
  function queueItems(inbox, ctx) {
314
300
  const items = [];
@@ -385,7 +371,7 @@ function muxFrames(ctx, request, signal) {
385
371
  // **设备级这一支不能跟着宿主那几条流一起结束**:这条 SSE 还捎带着插件自己产的
386
372
  // 清单变更、正文水位与 15s 心跳(由 `handleDeviceStream` 直接写这条连接)。
387
373
  // 迭代器一返回,SSE 立刻收尾,手机端只会看到「连几秒就断」:信号永远不「活着」,
388
- // 兜底轮询降不下去,每次重连还要重放一遍水位(2026-10-24 现场:17 帧后 loopEnded)。
374
+ // 兜底轮询降不下去,每次重连还要重放一遍水位。
389
375
  // 单会话那一支不适用 —— 会话结束了就该收尾。
390
376
  if (!signal || signal.aborted) return;
391
377
  await new Promise((resolve) => {
@@ -72,24 +72,16 @@ async function historyViaPage(ctx, payload, signal) {
72
72
  /**
73
73
  * `session.models` → `session.modelCatalog` + 会话自己的 `session/projections`。
74
74
  *
75
- * 宿主改成全局目录了(args 为空),字段也换了名:default → current、
76
- * routableProviders 变成「能不能用这个 provider」。移动端要的 groups 结构两边一致,
77
- * 原样带过去即可。
75
+ * 宿主改成全局目录了(args 为空),字段也换了名:default → current;移动端要的 groups 结构
76
+ * 两边一致,原样带过去即可。
78
77
  *
79
- * 但 `default` 是**全局**默认,不是「这个会话实际用的模型」。会话自己的模型记在
80
- * `session/projections` 的 `modelSelection` 投影里(`next` 是「下一次请求要用」,
81
- * 落到 `lastUsed` 是「最近一次真正跑过的」);只有会话从没选过模型时,投影里才没有
82
- * 这一项,此时才回落到全局默认。桌面端「切到某个对话显示那个对话的模型」读的正是
83
- * 这份会话级投影,不是全局默认。移动端这里也要对同一口径,否则 A、B 两个会话各选
84
- * 了不同模型,手机却都显示同一个「默认模型」。
78
+ * `default` 是全局默认,不是「这个会话实际用的模型」。会话自己的模型记在
79
+ * `session/projections` 的 `modelSelection` 投影里(`next` 是下一次请求要用,`lastUsed` 是
80
+ * 最近一次真正跑过的);从没选过时才回落到全局默认。读不到投影不是错误,退回全局默认即可,
81
+ * 别让整个模型面板消失。
85
82
  *
86
- * 读不到投影(会话不存在 / 宿主没挂投影能力)时不升级成错误:退回全局默认即可,
87
- * 让模型目录照常下发,宁可少一次会话级精修也不让整个模型面板消失。
88
- *
89
- * `defaultModel` 单独回一份**全局默认**,与 `current` 分开:宿主的新会话读的就是
90
- * 「最近一次成功保存的默认值」(`session.create` 不带模型时落这一条),所以 App 要
91
- * 显示「新对话会用哪个模型」只能看它 —— 拿任何一个会话的 `current` 去顶都是猜,
92
- * 会话各自选过的模型未必是默认。旧 App 忽略这个字段,行为不变。
83
+ * `defaultModel` 单独回一份全局默认:宿主的新会话读的就是最近一次成功保存的默认值,所以
84
+ * App 要显示「新对话会用哪个模型」只能看它,拿任何一个会话的 `current` 去顶都是猜。
93
85
  */
94
86
  async function modelCatalog(ctx, payload, signal) {
95
87
  const sessionId = payload && typeof payload.sessionId === 'string' ? payload.sessionId : '';
@@ -208,13 +200,11 @@ async function projectionBaseline(ctx, signal) {
208
200
  /**
209
201
  * `subagents.list` → `session.control` 投影基线 + `session.list`。
210
202
  *
211
- * 0.2.0 撤掉了 subagent.list 这个 RPC,但目录数据一条没少:它变成了会话投影里的
212
- * `subagentCatalog` 键,桌面端 @deepseek-ai/dsh-client-ui-subagent 的 CatalogDropdown
213
- * 就是读 `state.projectionsBySession` 渲染的。投影基线一次给出**全部**会话的 values,
214
- * 父子两层的目录在同一帧里 —— hasChildren 因此不需要按子会话再发 N 次投影查询。
203
+ * 0.2.0 撤掉了 subagent.list,但目录数据没少:它变成会话投影里的 `subagentCatalog` 键。
204
+ * 投影基线一次给出全部会话的 values,父子两层的目录在同一帧里,hasChildren 不需要再按子
205
+ * 会话逐个查。
215
206
  *
216
- * 投影里没有的是运行时状态:跑没在跑、父会话在不在线。桌面端也是拿 sessions.list 的
217
- * summaries 合成 activity 的,这里同口径再取一次会话列表,两边判定完全一致。
207
+ * 投影里没有运行时状态(跑没在跑、父会话在不在线),所以同口径再取一次会话列表的 summaries。
218
208
  */
219
209
  async function subagentCatalog(ctx, payload, signal) {
220
210
  const parentSessionId = payload && payload.parentSessionId;
@@ -3,23 +3,13 @@
3
3
  /**
4
4
  * 0.2.0 兼容桥:旧 `apiProxy` 调用形状 → 宿主 `typertGateway`。
5
5
  *
6
- * 背景:DSH 0.2.0 删掉了 `apiProxy` 服务(整个 app.asar 里零命中),宿主 RPC 全部改走
7
- * `ctx.get('typertGateway')` 的 `invoke({namespace, method, args, signal})` / `stream(...)`。
8
- * 但本插件 17 个移动端模块历史上都是这一套:
6
+ * 0.2.0 删掉了 `apiProxy` 服务,宿主 RPC 全改走 `ctx.get('typertGateway')` 的 invoke /
7
+ * stream。本插件十几个移动端模块都按旧形状调用(`ctx.apiProxy.<ns>.<method>(…)`,返回
8
+ * `{ result: { ok, value|error } }`),这里提供同名同形状的服务,内部翻译成 gateway 调用并
9
+ * 把结果包回旧信封;将来宿主若恢复真 apiProxy,删掉这个目录即可。
9
10
  *
10
- * 请求:`ctx.apiProxy.<ns>.<method>({ rpcId, method: '<ns>.<method>', payload }, signal)`
11
- * 返回:`{ result: { ok: true, value } }` 或 `{ result: { ok: false, error } }`
12
- *
13
- * 与其逐个改 55 处调用点(每处都要对上宿主的 wire 参数名,而参数名错了只在运行时
14
- * 报 `gateway/arguments-invalid`),这里提供**同名同形状**的服务,内部翻译成
15
- * gateway 调用并把结果包回旧信封 —— 17 个文件一行都不用动,将来 DSH 若恢复真
16
- * `apiProxy`,把这个目录删掉、apply 里那一行 provide 去掉即可。
17
- *
18
- * 本文件只剩装配:把端点表铺成 `apiProxy.<ns>.<method>` 的形状。实现分四份:
19
- * host-bridge/transport.js 网关传输与信封翻译(唯一知道「宿主怎么调」的地方)
20
- * host-bridge/endpoints.js 端点表(键 ⇄ 宿主 wire 名与参数形状)
21
- * host-bridge/shims.js 没有 1:1 端点、需要跨端点合成的几处
22
- * host-bridge/event-synthesis.js 旧设备级 events.mux ⇄ 0.2.0 拆开的三条流
11
+ * 本文件只剩装配:实现分四份——transport(网关传输与信封翻译)、endpoints(端点表)、
12
+ * shims(需要跨端点合成的那几处)、event-synthesis(旧 mux ⇄ 三条新流)。
23
13
  */
24
14
 
25
15
  const { ENDPOINTS } = require('./host-bridge/endpoints.js');
@@ -31,10 +21,8 @@ const { unsupported, UNAVAILABLE } = require('./host-bridge/transport.js');
31
21
  /**
32
22
  * 把端点表铺成旧的 `apiProxy.<ns>.<method>` 形状。
33
23
  *
34
- * 键表直接从 ENDPOINTS 推,不再手写一份 `<ns>` → 方法名数组:两份表迟早会对不上,
35
- * 而对不上的后果不是报错,是 `ctx.apiProxy.<ns>.<method> is not a function` 这种在
36
- * 别的模块、别的请求里炸出来的 TypeError(新增 sessions.projections 时就漏登记过一次,
37
- * /session/stats 会跟着一起挂)。现在新增端点只改 ENDPOINTS 一处。
24
+ * 键表直接从 ENDPOINTS 推,不再手写一份:两份表对不上时的症状不是报错,而是别的模块里炸
25
+ * 出 `ctx.apiProxy.<ns>.<method> is not a function`。新增端点只改 ENDPOINTS 一处。
38
26
  */
39
27
  function createApiProxy(ctx) {
40
28
  const namespaces = {};