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
@@ -3,40 +3,17 @@
3
3
  /**
4
4
  * 手机端审批桥(DSH 0.2.x)。
5
5
  *
6
- * ≤0.1.x 的宿主把审批当成 apiProxy 事件流上的 server-request:
7
- * `{rpcId, payload:{type:'approval/requested', approvalId, toolName, reason}}`,手机凭**帧级
8
- * rpcId** 回答(`/approval/respond` 的 rpcId 就是宿主 `pendingApprovals` 表的键)。0.2.x
9
- * 把 apiProxy 整个删了:一次审批 = 会话日志里的 `approval/asked` / `approval/decided` 事件
10
- * 对 + 一个**进程内** waterfall 钩子 `approval/request`(`dsh-user-approval` 发起,
11
- * `dsh-api-remotes` 把它转给桌面窗口那类 owner 连接,`dsh-acp` 也接同一个钩子)。
12
- * 帧级 rpcId 就此消失,而 App 的审批面板**刻意**只认带 rpcId 的帧(不给一个按了必然 409
13
- * 的按钮),于是「桌面有卡、手机上一片空白,连一条 409 都没有」。
6
+ * 0.2.x 删掉了 apiProxy,审批变成会话日志里的 `approval/asked` / `approval/decided` 事件对
7
+ * 加一个进程内 waterfall 钩子 `approval/request`,帧级 rpcId 就此消失,而 App 的审批面板
8
+ * 只认带 rpcId 的帧。这里按新形状把那半条通道补起来,App 契约一行不改:听 `approval/asked`
9
+ * 自己铸 rpcId 推卡,同时挂 `approval/request` 把手机挂成候选人,`/approval/respond` 按
10
+ * rpcId 找到挂起项把结论交回钩子。
14
11
  *
15
- * 这里按 0.2.x 的形状把那半条通道补起来,**手机端契约一行不改**(App 仍然收
16
- * `approval/requested` + 帧级 rpcId,仍然 POST `/approval/respond`):
12
+ * 钩子必须 prepend:宿主自己的转发器在桌面窗口开着时会一直等桌面回答、不调用 `next()`,
13
+ * 老实排在注册顺序后面等于「桌面开着时手机永远收不到」。
17
14
  *
18
- * 1. 听 `session/event` 的 `approval/asked`:宿主在发起审批前**必写**这条事件(决定走
19
- * 哪条回答通道都不影响它),本桥据此铸一个自己的 rpcId 并把卡推给正在看这个会话的
20
- * 手机。approvalId 直接用宿主自己的 id —— App 靠它把 `approval/decided` 对上、自动
21
- * 收掉那张卡。
22
- * 2. 听 `approval/request` 钩子(**prepend**,见下):把这台手机挂成候选人。手机没在看
23
- * 这个会话时与下游竞速:下游(桌面窗口 / ACP / 宿主默认的 fail-closed)照旧拿到请求、
24
- * 卡片照旧出现,手机答得早就用手机的,桌面答得早就用桌面的,输的那张卡由结算帧收掉。
25
- * 手机**正在看**这个会话时则不转发,由手机独家作答 —— 桌面那张卡挂在被转发的请求上,
26
- * 本桥没有替它收尾的口子,转发出去、手机先答,桌面那张就永远悬着(2026-10-23,
27
- * 与提问桥同一条坑)。
28
- * 3. `/approval/respond` 拿 rpcId 找到挂起项,把结论交回钩子 —— 这才是真正让 agent
29
- * 继续往下跑的那一步。
30
- *
31
- * ⚠️ 钩子上**没有**宿主 id(既没有 approvalId 也没有 rpcId),而且它和 `approval/asked`
32
- * 谁先到取决于宿主的派发顺序(事件在 `request()` 里先 append、钩子随后派发,但两者都走
33
- * 微任务)。所以两个来源共用一张挂起项表:先到的建项,后到的补字段(事件补真实
34
- * approvalId,钩子补 displayReason 与 signal)。卡片在任一路径上都不等对方。
35
- *
36
- * ⚠️ 为什么必须 prepend:宿主自己的转发器(`dsh-api-remotes`)在桌面窗口开着时会一直等
37
- * 桌面回答、**不调用 next()**,而且那些核心包必然先于本插件装载。老实按注册顺序排在后面
38
- * 等于「桌面开着时手机永远收不到」,正是这次要修的现象。prepend 是本桥能生效的前提,
39
- * 不是优化。
15
+ * 钩子上没有宿主 id,且与 `approval/asked` 的到达顺序不定,所以两个来源共用一张挂起项表:
16
+ * 先到的建项,后到的补字段。
40
17
  */
41
18
 
42
19
  const { randomUUID } = require('node:crypto');
@@ -266,14 +243,12 @@ function bindRequest(entry, request) {
266
243
  /**
267
244
  * 挂起这条请求直到有人给结论。
268
245
  *
269
- * 传了 `next` 就与下游竞速(手机没在看这个会话时的路径):`next()` 一调,桌面窗口那张卡
270
- * 照旧出现。手机答得早就用手机的;下游答得早就用下游的 —— 谁先给结论谁算数,输的那张卡
271
- * 由结算帧收掉。两边都没人答时这里会一直挂着,和今天宿主自己的行为一致(审批本来就等人工)。
246
+ * 传了 `next` 就与下游竞速:`next()` 一调,桌面窗口那张卡照旧出现,谁先给结论谁算数,
247
+ * 输的那张卡由结算帧收掉;两边都没人答就一直挂着。
272
248
  *
273
- * **不传 `next`(手机正在看这个会话)就是手机独家作答**:不往下游转发,桌面从源头就不弹卡
274
- * ——桌面那张卡挂在「被转发的请求」上,而本桥没有替那条请求收尾的口子(收尾权在宿主网关
275
- * 手里),手机答完它只会永远悬着,与提问桥同一条坑(2026-10-23)。独家作答后结论照常走
276
- * `approval/decided` 落进会话记录,桌面看得到裁决结果,只是没有可点的卡。
249
+ * 不传 `next`(手机正在看这个会话)就是手机独家作答:不往下游转发,桌面从源头就不弹卡。
250
+ * 桌面那张卡挂在被转发的请求上,本桥没有替它收尾的口子,转发出去手机先答,它只会永远
251
+ * 悬着。独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
277
252
  */
278
253
  function holdForPhone(entry, next) {
279
254
  const downstream = next === undefined
@@ -369,12 +344,11 @@ async function handleApprovalRespond(ctx, req, res) {
369
344
  }
370
345
 
371
346
  /**
372
- * 把结论交回旧通道的 `ctx.apiProxy.respond`,**Promise 与同步对象两种返回都收**。
347
+ * 把结论交回旧通道的 `ctx.apiProxy.respond`,Promise 与同步对象两种返回都收。
373
348
  *
374
- * 两条通道形态不同:0.1.x 的真 apiProxy 回 Promise,0.2.x 的兼容桥
375
- * (`mobile/host-bridge.js`)回同步的 `{result:{ok:false,error:{code:'host-unsupported'}}}`——
376
- * 同步对象上没有 `.catch`,早先直接链 Promise 会在 0.2.x 抛 TypeError、被路由守卫兜成 500。
377
- * 抛错与「通道说不认」一律折算成 null,调用方据此如实回 409。
349
+ * 两条通道形态不同:0.1.x 的真 apiProxy 回 Promise,0.2.x 的兼容桥回同步对象。同步对象上
350
+ * 没有 `.catch`,直接链 Promise 会抛 TypeError 被路由守卫兜成 500。抛错与「通道说不认」
351
+ * 一律折算成 null,调用方据此如实回 409。
378
352
  */
379
353
  async function respondViaLegacyProxy(ctx, message) {
380
354
  const proxy = ctx && ctx.apiProxy;
@@ -1,18 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,把它们排进待删队列。
4
+ * 移动端「清理所有已归档的对话」:按宿主归档集合确定范围,把它们排进待删队列。
5
5
  *
6
- * 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
7
- * 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
6
+ * 范围取 workspace.list 的 archivedSessionIds,不用可能过时的列表状态缩小范围;请求必须
7
+ * 显式带 confirm: true,磁盘删除不可恢复。
8
8
  *
9
- * **这里不删磁盘**:只把 id 排进 session-cleanup.cjs 的待删队列,日志留到宿主下次启动时删
10
- * (见那个文件的 deleteQueuedArchivedSessions)。宿主没有「删除会话」接口,进程活着的时候
11
- * 它随时可能去打开那些文件 —— 文件不在就是一次未捕获的 ENOENT,宿主整个退出、桌面端闪退
12
- * (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
13
- *
14
- * 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
15
- * 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
9
+ * 这里不删磁盘,也不擦宿主的归档集合:只把 id 排进 session-cleanup.cjs 的待删队列,由宿主
10
+ * 下次启动时删。活着的宿主随时可能去打开那些文件,文件不在就是一次未捕获的 ENOENT,会带走
11
+ * 整个进程。
16
12
  */
17
13
 
18
14
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -26,16 +22,11 @@ const { queueArchivedClearIds } = require('../session-cleanup.cjs');
26
22
  /**
27
23
  * 清理前这次补采的预算。
28
24
  *
29
- * 统计页那次只给 `DEFAULT_BUDGET_MS`(1.5 秒)是为了别让用户点开统计转好久;清理是
30
- * 不可恢复的删除,值得多等几秒把账补全。上限卡在 App 单发请求的 8 秒总预算之内
31
- * (`app/src/api/core.ts` 的 `REQUEST_TIMEOUT_MS`),否则手机先报超时、清理由这边照旧
32
- * 执行,用户会以为没成功。
33
- *
34
- * 同时这次补采跳过「最后一句」预览(`skipPreview`):那一步要读会话日志,是整轮里
35
- * 最重的一趟。删日志前先把数字记住,比给即将消失的卡片多留一行摘要重要。
25
+ * 统计页只给 1.5 秒是为了别让用户转好久;清理是不可恢复的删除,值得多等几秒把账补全,
26
+ * 但不能超过 App 单发请求的 8 秒总预算,否则手机先报超时、这边照旧执行。
36
27
  *
37
- * 极端多的归档(几千条)仍可能到点停手 —— 彻底兜住要把补采挪到请求之外,那是另一套
38
- * 生命周期;这里先把「几百条」这个真实量级覆盖掉,不让它再沿用统计页的 1.5 秒。
28
+ * 这次补采跳过「最后一句」预览:那一步要读会话日志,是整轮最重的一趟。删日志前先把数字
29
+ * 记住,比给即将消失的卡片多留一行摘要重要。
39
30
  */
40
31
  const CLEAR_ARCHIVED_COLLECT_BUDGET_MS = 6_000;
41
32
 
@@ -3,53 +3,28 @@
3
3
  /**
4
4
  * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节。
5
5
  *
6
- * 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
7
- * assistant chunk 的 block,以及更深的嵌套 content。全部在这里归一,
8
- * 这样历史与 SSE 暴露给手机端的图片形状是同一个。
6
+ * 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、assistant chunk 的
7
+ * block,以及更深的嵌套 content;全部在这里归一,历史与 SSE 暴露给手机的图片形状才一致。
9
8
  *
10
- * **默认(引用模式 `opts.referencesOnly`)不取字节**:图片块原样留着 `attachment`
11
- * 引用,由 history-derive 投影成 `{mediaType, name?, attachmentId}`,手机端要画的时候
12
- * 按 attachmentId 单张去取(`/session/attachment`)。此前这里把每张图都换成内联 base64,
13
- * 一页 200 条带十几张截图的响应接近 10MB,每次进会话都要重传一遍。
9
+ * 默认(`referencesOnly`)不取字节:图片块留着 `attachment` 引用,由 history-derive 投影成
10
+ * `{mediaType, name?, attachmentId}`,手机按 attachmentId 单张去取。每张都内联 base64 会把
11
+ * 一页 200 条带截图的响应推到近 10MB。只有显式 `referencesOnly: false` 才取回字节,取不到
12
+ * 的打 `dataUnavailable` 标。
14
13
  *
15
- * 只有显式传 `referencesOnly: false` 时才回到「经 session attachment RPC 取回字节」的
16
- * 老行为;取不回字节的块打 `dataUnavailable` 标(语义不变,见 dataUnavailableBlock)。
17
- *
18
- * 另外一类图片在正文里:`![](/abs/path.png)` 这种指向**宿主本机文件**的写法
19
- * (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
20
- * markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
21
- * 这条与附件引用是两件事:它走的是正文里的路径、没有 attachmentId,预算与行为保持原样。
14
+ * 正文里 `![](/abs/path.png)` 这类指向宿主本机文件的写法另有内联(见 local-images.js),
15
+ * 与附件引用是两件事:它没有 attachmentId。
22
16
  */
23
17
 
24
18
  const { unwrap } = require('./envelope.js');
25
19
  const { callSessions } = require('./rpc.js');
26
20
  const { inlineTextImages, createTextImageBudget } = require('./local-images.js');
27
21
 
28
- /**
29
- * Resolve durable image references before exposing messages to mobile clients.
30
- *
31
- * Session history stores `{type:'image', attachment}` rather than inline bytes.
32
- * By default (`referencesOnly`) the block is left as a reference and the mobile
33
- * client fetches the bytes per attachment (`/session/attachment`); no host RPC
34
- * happens on this path at all. Pass `referencesOnly: false` for the legacy
35
- * inline-bytes shape.
36
- *
37
- * Host events can carry content in several places: message/content, inserted
38
- * context items, or an assistant/chunk block. Keep this normalization here so
39
- * history and SSE expose the same image shape to mobile clients.
40
- *
41
- * Text blocks additionally get local-file image syntax inlined as data URIs
42
- * (see local-images.js); `opts.budget` is a shared per-response budget so one
43
- * paged history response or mirror snapshot stays bounded.
44
- */
45
22
  /**
46
23
  * 取不到字节的图片块:原样留着,只加一个标记。
47
24
  *
48
- * 这里**绝不能抛,也绝不能返回 null**:resolveBlock 的结果会直接摊进整段历史响应或
49
- * 一整帧 SSE,一个 null/异常会让整页消息一起挂掉(见 dev-map A3-33 的刻意设计)。
50
- * 加标记是因为「附件取不到」和「这条消息本来就没有这张图」在 App 侧长得一样(都只有
51
- * attachment 没有 data),不加标记就只能跟着静默消失——用户既不知道有过一张图,
52
- * 也没有任何重试入口。
25
+ * 这里绝不能抛,也绝不能返回 null:结果会直接摊进整段历史响应或一整帧 SSE,一个 null
26
+ * 或异常会让整页消息一起挂掉。加标记是因为「附件取不到」和「本来就没有这张图」在 App 侧
27
+ * 长得一样,不加标记就只能静默消失,用户也不知道有过一张图。
53
28
  */
54
29
  function dataUnavailableBlock(block) {
55
30
  return { ...block, dataUnavailable: true };
@@ -3,35 +3,15 @@
3
3
  /**
4
4
  * 移动端「渠道管理」API:列出这台宿主上的渠道,并增删「本机自建」那一类。
5
5
  *
6
- * ## 两类渠道,只有一类能改
6
+ * 判据不是「谁建的」,而是这条 provider 在不在 `llm-pi-ai` 的 `providers` 里:在 = 本机配置
7
+ * 的、可增删(设置走 `settings.mutate`,Key 走 `credentials.set` / `unset`);不在 = 别的
8
+ * 插件提供的、只读,移动端一个写操作都不做。
7
9
  *
8
- * 手机上的模型目录(`/session/models`、`/llm/models`)把两种来源混在一起:
9
- * - **插件 / 宿主注册的提供方**:账号登录、渠道开关都由它自己的插件说了算
10
- * (免费模型插件那十几家、官方 DeepSeek 等)。移动端**一个写操作都不做**——
11
- * 那不是我们的渠道,改它就是越界。App 那边只保留「本机显示 / 不显示」的开关。
12
- * - **本机自建渠道**:`llm-pi-ai` 设置命名空间里 `providers` 下的每一条路由
13
- * (协议 + 地址 + 一把 Key + 模型清单)。桌面端免费模型面板的「添加渠道」写的
14
- * 就是这一层,所以手机照同一份口径做:`settings.mutate` 按路径 set / unset,
15
- * 凭据走 `credentials.set` / `unset`。
10
+ * 不读桌面面板另存的那份自建渠道元数据:那是另一个插件的私有状态,两个进程抢同一个文件
11
+ * 只会写出半截。代价是两边列表的标记会对不上,但真源都是 `llm-pi-ai` 的路由表。
16
12
  *
17
- * 判据不是「谁建的」,而是**这条 provider 在不在 `llm-pi-ai` 的 providers 里**:
18
- * 在 = 本机配置的、可增删;不在 = 别的插件提供的、只读。所以用户手写在
19
- * `cordis.patch.yml` 里的 pi-ai 路由也会被列成可删——那是他自己机器上的配置,
20
- * App 在删除前会二次确认并写出渠道名与地址。
21
- *
22
- * ## 为什么不用「免费模型插件的元数据」
23
- *
24
- * 桌面面板另存了一份 `$DSH_HOME/our-free-model/custom.json`(描述、介绍、关掉的模型)。
25
- * 手机不读也不写:那是另一个插件的私有状态,两个进程抢同一个文件只会写出半截。
26
- * 代价是手机上删掉的渠道,桌面面板的「自建渠道」列表里会显示成「路由缺失」;反过来
27
- * 手机上建的渠道,桌面面板不会把它当成自己建的。两边都不影响模型选择器——真源是
28
- * `llm-pi-ai` 的路由表。
29
- *
30
- * ## 获取模型为什么在宿主侧发
31
- *
32
- * 渠道端点常常在内网 / 只有这台电脑能直连(本机网关、局域网模型服务)。手机直接发
33
- * 请求既可能不通,也绕过了宿主自己的协议适配。这里借宿主 `llm.discoverModels`
34
- * (桌面面板同一条路)在电脑上问一次,只回模型 id / 名字,不落盘。
13
+ * 获取模型在宿主侧发:渠道端点常常只有这台电脑能直连,这里借宿主 `llm.discoverModels` 问
14
+ * 一次,只回 id 与名字;手机直接发既可能不通,也绕过宿主的协议适配。
35
15
  */
36
16
 
37
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -146,15 +126,12 @@ function normalizeModels(raw) {
146
126
  /**
147
127
  * 一条渠道 → pi-ai 的 provider profile。
148
128
  *
149
- * 只写「必须显式说」的字段,与桌面端 `profileOf` 一致:自建路由没有内置目录,协议必须
150
- * 点名;思考档位不写就是「不思考」,所以这里给默认四档而不是省略。
129
+ * 只写必须显式说的字段,与桌面端 `profileOf` 一致:自建路由没有内置目录,协议必须点名;
130
+ * 思考档位不写就是「不思考」,所以这里给默认四档。
151
131
  *
152
- * **编辑时按已有那一条做基底**(`existing`):手机上的表单只有名称 / 协议 / 地址 /
153
- * 模型 id,能力字段(上下文、输出上限、图片、思考档位)根本不在界面上。直接按缺省值
154
- * 重写,等于用户改一次名字就把桌面面板里精细调过的参数全冲掉。所以:
155
- * - 同一模型仍在清单里 → 沿用它的能力字段;
156
- * - 新加的模型 → 表单报了就用表单的,没报才落缺省;
157
- * - 其它不认识的字段(用户手写的)原样带回去。
132
+ * 编辑时按已有那一条做基底:手机表单只有名称 / 协议 / 地址 / 模型 id,能力字段不在界面上,
133
+ * 直接按缺省值重写等于改一次名字就把桌面调过的参数全冲掉。同一模型沿用原能力字段,新加的
134
+ * 模型用表单值或缺省,不认识的字段原样带回去。
158
135
  */
159
136
  function profileOf(channel, existing) {
160
137
  const previous = new Map();
@@ -413,12 +390,9 @@ function takenIdsOf(registered, providers) {
413
390
  * { id?, name, protocol, baseUrl, apiKey?, models: [{id, name?, contextWindow?, maxTokens?, vision?}] }
414
391
  * → { ok, channel }
415
392
  *
416
- * 带 `id` = 编辑那一条(id 不变、Key 可以留空表示沿用原来的);不带 = 新建(Key 必填)。
417
- * 写 Key → 写路由,两步都成了才回成功;路由写失败就把**这一次新写的** Key 清掉(回滚),
418
- * 不留一条「有 Key 没路由」的孤儿凭据。编辑时用户没重填 Key,就没有可回滚的东西。
419
- * **路由只能按路径写**:`settings.mutate` 的 `set path:['providers', id]` 一次只动这一段——
420
- * 整段覆盖会把用户手写的其它路由抹掉(桌面端 2026-10-19 踩过:保存一次自建渠道,
421
- * 另一条路由整段蒸发)。
393
+ * 带 `id` = 编辑那一条(id 不变、Key 留空表示沿用原来的);不带 = 新建(Key 必填)。
394
+ * 写 Key → 写路由,两步都成了才回成功;路由写失败就把这一次新写的 Key 清掉,不留孤儿凭据。
395
+ * 路由只能按路径写:整段覆盖会把用户手写的其它路由抹掉。
422
396
  */
423
397
  async function handleChannelSave(ctx, req, res) {
424
398
  const body = await readJsonBody(req, 64 * 1024);
@@ -2,24 +2,15 @@
2
2
 
3
3
  /**
4
4
  * 旧凭据原地升级:把协议 v1 的会话(HMAC 令牌 / 只带配对码的 `dshc_`)换成带设备主密钥的
5
- * T1,让一台早就配好的手机不用重新扫码就能开始加密。
5
+ * T1,早配好的手机不用重新扫码就能开始加密。
6
6
  *
7
- * **主密钥以手机为准**(2026-10-24 修):手机在本地生成 `deviceKey` 与 `keyId`,这次升级
8
- * 请求里明文带上,宿主原样存进凭据库、把同一个 `keyId` 回给手机。宿主**不能**自己生成
9
- * 一把再回标识 —— 那会让两端各拿一把钥匙,升级完手机立刻按 v2 封请求,而宿主用另一把
10
- * 验签,之后每一条请求都是 `e2ee-bad-signature`,比不升级更糟。
7
+ * 主密钥以手机为准:手机在本地生成 `deviceKey` 与 `keyId`,升级请求里明文带上,宿主原样存
8
+ * 库并把同一个 keyId 回给手机。宿主自己生成一把再回标识会让两端各拿一把钥匙,升级完每条
9
+ * 请求都是 `e2ee-bad-signature`。明文上行是一次性取舍——老会话没有别的保密通道,不这么做
10
+ * 只能重新扫码;升级后所有流量仍由这把钥匙端到端加密。
11
11
  *
12
- * 明文上行是一次性的取舍:老会话手上只有旧凭据,既没有二维码里那张票据,也没有任何别的
13
- * 保密通道(局域网直连与公网 IPv6 直连都是明文 HTTP,中转本来就按「不可信的那一跳」设计)。
14
- * 不这么做就只能让用户重新扫一次码。升级之后所有流量仍由这把钥匙端到端加密。
15
- *
16
- * 换成什么不在本次判断里:拿到完整一套(凭据 + 密钥 + 标识)才回补丁,缺一样就当没升级。
17
- *
18
- * **请求体必须现读**(2026-10-25 修):这条路服务的恰好是协议 v1 的老会话,而老会话发的
19
- * 是明文 —— 守卫只为「带信封」的那条路预先解好 `req.body`,明文请求的字节还挂在 socket 上
20
- * 没人读过。原来这里直接取 `req.body`,于是永远拿到空对象、宿主每次都回「没升级」,手机
21
- * 那边一次都没成功过(真机表现是「App 与插件都更新了,设备行仍旧写链路未加密」)。
22
- * 现在与其它移动端路由同一条读法(`readJsonBody`:`req.body` 有就用,没有就读流)。
12
+ * 请求体必须现读:这条路服务的恰好是发明文的老会话,守卫只替带信封的请求解好 `req.body`,
13
+ * 直接取只会永远拿到空对象。拿到完整一套(凭据 + 密钥 + 标识)才算升级。
23
14
  */
24
15
 
25
16
  const crypto = require('node:crypto');
@@ -3,28 +3,12 @@
3
3
  /**
4
4
  * 移动端「宿主凭据」API:把密钥的查看 / 写入 / 清除补齐到手机上。
5
5
  *
6
- * 桌面端的设置面板能往宿主写 API Key(dsh-client-ui-settings-plugins/lib/client.js
7
- * 的 writeKey() → credentials.set,写完再 credentials.describe 复核),手机此前
8
- * 只能在设置页看到一句「· 1 项密钥未设置」,却没有任何入口去填——这正是本轮补的缺口。
6
+ * 三条接口只转发宿主的 credentials 域:describe / set / unset。密钥值只在手机 → 宿主这个
7
+ * 方向出现一次,宿主从不回传值,所以响应里永远没有 value。
9
8
  *
10
- * 三条接口都只是转发宿主的 credentials 域:
11
- * - GET /credentials?refs=A,B → credentials.describe
12
- * - POST /credentials/set → credentials.set
13
- * - POST /credentials/unset → credentials.unset
14
- *
15
- * 密钥值只在这条路上出现一次(手机 → 宿主)。宿主从不回传值:describe 只给
16
- * {configured, source?, writable},所以这里的响应里也永远不会有 value 字段。
17
- *
18
- * 宿主 describe **回的就是一张平表**:键是引用名、值是上面那个视图
19
- * (`dsh-api-settings-controller/lib/types/credentials.js` 的
20
- * `Object.fromEntries(branded.map(...))`,0.2.0 的网关与旧 apiProxy 两条路都打到
21
- * 同一个 Remote 命名空间)。曾经在这里多拆一层 `value.credentials` —— 那一层从来
22
- * 不存在,于是每个引用都落成「未配置」,设置页永远显示「密钥未设置」,而测试假体
23
- * 照抄了同一个错形状,绿的。读了宿主源码才看出来。
24
- *
25
- * 「能不能写」由宿主的 credentials 服务说了算:如果某个引用是启动环境变量提供的,
26
- * 宿主会回 writable:false,set 则回 credential-rejected("supplied read-only by the
27
- * launching environment")。这里不自己判断,一律如实透传,让 App 显示宿主的原话。
9
+ * 宿主 describe 回的是平表(键是引用名、值是 `{configured, source?, writable}`),不要再拆
10
+ * 一层;多拆一层会让每个引用都落成「未配置」。能不能写由宿主说了算:环境变量提供的引用回
11
+ * writable:false、set 回 credential-rejected,这里如实透传,让 App 显示宿主原话。
28
12
  */
29
13
 
30
14
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -1,35 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 手机端目录浏览的**兜底实现**:宿主组合出的目录选择器只服务 native 时,由插件自己
5
- * 把「列一层目录 / 建一个子目录」补上。
4
+ * 手机端目录浏览的兜底实现:宿主组合出的目录选择器只服务 native 时,由插件自己把
5
+ * 「列一层目录 / 建一个子目录」补上。
6
6
  *
7
- * 为什么需要它:宿主那套由 `dsh-host-directory-picker-auto` 在启动时二选一
8
- * (`resolveDirectoryPickerBackend`)——只有「宿主 web 服务绑定到非 127.0.0.1」或者
9
- * 「平台不是 darwin/win32」才挂 browse。无头移动宿主由本包的 cordis.mobile-host.yml
10
- * 把那一行钉成 browse,桌面 profile 宿主则被解析成 native。于是
11
- * **「桌面 profile 宿主 + 手机远程连」**这一格谁都盖不到:宿主按「操作者就在机器前」
12
- * 选了原生对话框,而手机是远程客户端,看不到也点不到那个框 —— 点「添加工作区」只能
13
- * 看到 `host.listDirectory needs the browse capability; the composed picker serves
14
- * "native"`,一个条目都列不出来,「新建工作文件夹」也从根上走不通。
7
+ * 「桌面 profile 宿主 + 手机远程连」这一格谁都盖不到:宿主按「操作者就在机器前」选了原生
8
+ * 对话框,手机是远程客户端,点「添加工作区」只能看到 `needs the browse capability`。不能把
9
+ * mobile-host.yml 里钉 browse 的两行搬进 bundle 层——那会把桌面操作者的原生选择框也换掉。
10
+ * 所以宿主只要服务 browse 就整条走它,只有它明确只服务 native 时才用这份。
15
11
  *
16
- * 为什么兜底放在这里,而不是把 cordis.mobile-host.yml 里钉 browse 的两行搬进
17
- * cordis.patch.yml:后者两种宿主都会加载,而桌面操作者就坐在机器前,把它的原生选择框
18
- * 换成 browse 是纯粹的退步 —— 守卫 `test/directory-picker.test.cjs` 与
19
- * docs/dev-map.md 的 A3-07 都把「那两行不许留在 bundle 层」钉成了规格。插件自带一份
20
- * 原语则谁也不动:宿主只要服务 browse,就仍然整条走宿主(策略与 maxEntries 保持单一
21
- * 真源);只有宿主**明确**只服务 native(或没有 browse 能力)时,才用这一份把手机那条
22
- * 路补上。判定用同进程直查 `ctx.get('directoryPicker').capability()`,不额外打一趟网关。
23
- *
24
- * 语义与 `dsh-host-directory-picker-browse` 逐条对齐(那份是这条 seam 的规范实现):
25
- * - 隐藏项**标记但返回**(`hidden: name.startsWith('.')`),过滤交给客户端;
26
- * - **跟随符号链接**:能进入的才列出来,断链/成环的静静跳过;
27
- * - **全文件系统范围**,没有白名单 —— 这与宿主 browse 后端一致,能力面没有扩大:
28
- * 手机端本来就经 `workspace.create` 采纳任意已存在目录;
29
- * - 名字升序有界窗口,默认上限 `maxEntries = 1000`(对齐 GitHub 网页版),满了置
30
- * `truncated`;
31
- * - 回 `resolve(path)`,**不是 realpath**(macOS 上 /var 是 /private/var 的软链,
32
- * 回 realpath 会让面包屑和客户端手里的路径对不上)。
12
+ * 语义与宿主 browse 后端逐条对齐:隐藏项标记但返回、跟随符号链接、全文件系统无白名单、
13
+ * 名字升序有界窗口(默认 1000,满了置 `truncated`)、回 `resolve(path)` 而不是 realpath。
33
14
  */
34
15
 
35
16
  const { mkdir, opendir, stat } = require('node:fs/promises');
@@ -1,32 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 手机端右栏「文件浏览器」的目录列表:**文件与文件夹都列**,每条带类型、大小、修改时间。
4
+ * 手机端右栏「文件浏览器」的目录列表:文件与文件夹都列,每条带类型、大小、修改时间。
5
5
  *
6
- * 与 `/directory/list` 的分工:那条是「为设备挑一个工作区目录」用的,走宿主 directoryPicker,
7
- * 只列**能进去的目录**、每条只有 `{name,path,hidden}`(见 lib/mobile/workspaces.js)。右栏
8
- * 要的是「这个目录里有什么」——文件也是内容,而且要知道类型、大小、修改时间,所以那条路
9
- * 一个字段都对不上。老那条**一个字都不改**:选目录那条流程还挂在它上面。
6
+ * 与 `/directory/list` 的分工:那条为设备挑工作区目录,走宿主 directoryPicker,只列能进去
7
+ * 的目录、只有 `{name,path,hidden}`;右栏要知道「这个目录里有什么」,字段对不上,而选目录
8
+ * 那条流程还挂着,一个字都不改。
10
9
  *
11
- * 这条直接读宿主自己的文件系统,**不经 directoryPicker 网关**。能力面没有因此变大:宿主
12
- * 那份 browse 后端与本地兜底原语(lib/mobile/directory-browse.js)本来都是全文件系统范围、
13
- * 没有白名单,`workspace.create` 也接受任意已存在目录;而「桌面 profile 宿主 + 手机远程连」
14
- * 这一格宿主只服务 native,经网关只会拿到那句 `needs the browse capability`,右栏一个条目
15
- * 都没有。策略原语(隐藏项标记、上限、面包屑)直接 import 那份副本,不抄第二份。
10
+ * 这条直接读宿主文件系统,不经 directoryPicker 网关。能力面没变大:宿主 browse 后端与本地
11
+ * 兜底本来都是全文件系统范围,而「桌面 profile 宿主 + 手机远程连」那一格经网关只会拿到
12
+ * `needs the browse capability`。策略原语直接 import 那份副本,不抄第二份。
16
13
  *
17
- * 契约(App 侧 app/src/services/viewmodels/apiPaths.ts 的 directoryListing 同时按这份改):
18
- * - `path` 可选、绝对路径;缺省 / 空串 / 全空白 = 被控端主目录(os.homedir())。相对路径
19
- * 直接 400 —— 不拿宿主进程的当前目录去猜,那是另一个语义;
20
- * - entries 名字升序(**大小写不敏感**),文件与文件夹混在一份里,分组由客户端做;
21
- * - hidden = 名字以 '.' 开头,照旧标记但返回,过滤交给客户端;
22
- * - kind 由 stat 得出(**跟随符号链接**):目录是 'folder',其余是 'file',指向目录的
23
- * 链接算 'folder';
24
- * - size(字节)/ modified(epoch 毫秒)**只对文件给**;单条 stat 失败(断链、权限)按
25
- * 'file' 返回且不带这两格 —— 一个坏条目不许把整条列表带走;
26
- * - 上限复用 directory-browse.js 的 DEFAULT_MAX_ENTRIES,超了截断并置 truncated = true;
27
- * - crumbs 复用 directory-browse.js 的 ancestryCrumbs(根 → 当前),形状与选目录那条一致。
28
- * 失败一律 400 + `{ ok:false, error:{ code, message } }`,code 取 bad-request / not-found /
29
- * not-directory / permission,message 是简短英文,人话由 App 侧自己的字典翻。
14
+ * 契约:path 可选且必须绝对(缺省为主目录,相对路径 400);名字升序、大小写不敏感;hidden
15
+ * 标记但返回;kind 由 stat 得出并跟随符号链接;size / modified 只对文件给,单条 stat 失败
16
+ * 按 file 返回;上限复用 DEFAULT_MAX_ENTRIES,超了置 truncated;失败统一 400 + code。
30
17
  */
31
18
 
32
19
  const { opendir, stat } = require('node:fs/promises');
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 盘符层:Windows 上「此电脑」里那一排盘,供手机端「添加工作区」跨盘浏览。
5
+ *
6
+ * 为什么需要这一层:宿主 browse 后端只列**某一层目录的内容**、从不枚举盘符,而目录浏览器
7
+ * 一打开就落在被控端主目录 —— Windows 上它永远在 C 盘。于是 D/E 盘从来不是「读不到」,
8
+ * 而是客户端根本没有任何入口能跳过去(手输完整路径能进,但那条路藏起来了)。
9
+ *
10
+ * 为什么是探而不是查:本插件没有 shell 能力面,也不该为了 26 次 stat 去开一个。A–Z 逐个
11
+ * `stat('X:\\')`,能 stat 成目录的才算那块盘存在;不存在的盘符会很快返回 ENOENT,整个过程
12
+ * 只有一次网络请求的开销。
13
+ *
14
+ * 非 Windows 返回根目录一项:调用方只需处理「一排根」这一种形状,不必自己判平台;只有一项
15
+ * 时客户端不显示这一层,所以 Mac/Linux 的界面一个像素都不变。
16
+ */
17
+
18
+ const { stat } = require('node:fs/promises');
19
+
20
+ /** 盘符顺序取 A–Z,`Promise.all` 保序,所以结果天然按字母排。 */
21
+ const DRIVE_LETTERS = Object.freeze('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
22
+
23
+ /** 一块盘在客户端眼里的样子:与目录行同形状(`hidden` 恒 false)。 */
24
+ function driveRoot(letter) {
25
+ return { name: `${letter}:`, path: `${letter}:\\`, hidden: false };
26
+ }
27
+
28
+ /**
29
+ * Windows:真实存在的盘符。软盘 A:/B: 在现代机器上 stat 一下就 ENOENT,不会卡住。
30
+ *
31
+ * @param {(path: string) => Promise<{ isDirectory(): boolean }>} statFile 注入点,测试用。
32
+ * @returns 盘符行数组(按盘符升序,探不到的跳过)
33
+ */
34
+ async function listWindowsRoots({ statFile = stat } = {}) {
35
+ const rows = await Promise.all(
36
+ DRIVE_LETTERS.map(async (letter) => {
37
+ const root = driveRoot(letter);
38
+ try {
39
+ return (await statFile(root.path)).isDirectory() ? root : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }),
44
+ );
45
+ return rows.filter((row) => row !== null);
46
+ }
47
+
48
+ /**
49
+ * 这台被控端可跳的根:Windows 是各块盘,其余平台是文件系统根。
50
+ *
51
+ * @param {{ platform?: string, statFile?: typeof stat }} [options] 平台与 stat 注入点(测试用)。
52
+ * @returns 根行数组
53
+ */
54
+ async function listRoots({ platform = process.platform, statFile = stat } = {}) {
55
+ if (platform === 'win32') return listWindowsRoots({ statFile });
56
+ return [{ name: '/', path: '/', hidden: false }];
57
+ }
58
+
59
+ module.exports = { DRIVE_LETTERS, listRoots, listWindowsRoots };
@@ -3,22 +3,11 @@
3
3
  /**
4
4
  * 宿主 apiProxy 的回执信封。
5
5
  *
6
- * 宿主所有 `apiProxy.*` 调用都回同一个形状:
7
- * 成功 `{ result: { ok: true, value } }`
8
- * 失败 `{ result: { ok: false, error: { code, message, details? } } }`
9
- * 异常(只有 envelope.js 之外的调用方会碰到)`{ result: { ok: true, value } }` 的缺位形态
6
+ * 所有 `apiProxy.*` 调用都回同一个形状:成功 `{ result: { ok: true, value } }`,失败
7
+ * `{ result: { ok: false, error: { code, message, details? } } }`。
10
8
  *
11
- * 以前这个拆信封的动作在仓库里有 6 种写法:history-derive.js / workspaces.js /
12
- * agent-presets.js 三份逐字相同;session-cleanup.cjs 是「只取 value」的变体;
13
- * plugins.js / references.js 在此之上又叠了各自的字段校验与错误码
14
- * (那两个是有意的领域解码,不算重复);auto-archive.cjs 则干脆绕过信封直接读
15
- * `response?.value?.items`——它读的那一层压根不是信封,是 bug。
16
- *
17
- * 把唯一实现收到这里,是因为 5 个模块为了拿这 5 行去 require 一个 1400 行、
18
- * 带 9 个模块级 WeakMap 的 sessions.js——那才是 sessions.js 拆不动的真正原因:
19
- * 它同时扮演了会话领域、缓存中心、RPC 封装和公共工具库四个角色。
20
- *
21
- * 依赖方向:envelope.js 是叶子模块,不 require 任何东西。
9
+ * 拆信封只有这一份实现:此前几个模块各写一遍,还要为了这 5 行去 require 一个 1400 行的
10
+ * sessions.js。envelope.js 是叶子模块,不 require 任何东西。
22
11
  */
23
12
 
24
13
  /**