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
@@ -3,31 +3,15 @@
3
3
  /**
4
4
  * 手机端提问桥(DSH 0.2.x):`ask_user_question` 与「计划评审」。
5
5
  *
6
- * ≤0.1.x 的宿主把提问当成 apiProxy 事件流上的 server-request:
7
- * `{rpcId, payload:{type:'question/requested', sessionId, questions}}`,手机凭**帧级 rpcId**
8
- * 回答(`/question/respond` 把 rpcId 回给宿主那张 pending 表)。0.2.x 把 apiProxy 删了,
9
- * 提问只剩一个**进程内** waterfall 钩子 `user-questions/request`(`dsh-user-questions`
10
- * 发起,`dsh-tool-ask-user` 与 `dsh-plan-mode` 是它的两个调用方),帧级 rpcId 随之消失,
11
- * 于是手机既收不到卡片、也没有能回传的 id。
6
+ * 0.2.x 删掉了 apiProxy,提问只剩一个进程内 waterfall 钩子 `user-questions/request`,帧级
7
+ * rpcId 随之消失,手机既收不到卡片、也没有能回传的 id。这里按新形状补起来,App 契约不变:
8
+ * prepend 钩子、自己铸 rpcId 推卡、`/question/respond` 找到挂起项把回答交回钩子。
12
9
  *
13
- * 这里按 0.2.x 的形状把这条通道补起来,**手机端契约一行不改**(App 仍然收
14
- * `question/requested` + 帧级 rpcId,仍然 POST `/question/respond`):
10
+ * 钩子必须 prepend:宿主的转发器在桌面窗口开着时会一直等桌面回答、不调用 `next()`。下游
11
+ * 总是拿到请求,桌面那张卡与手机那张同时存在,谁先答用谁的,落定后广播 `question/resolved`。
15
12
  *
16
- * 1. 听 `user-questions/request` 钩子(**prepend**,理由与审批桥同一条:宿主的转发器
17
- * `dsh-api-remotes` 在桌面窗口开着时会一直等桌面回答、不调用 `next()`)。铸一个自己的
18
- * rpcId,把卡片推给正在看这个会话的手机,然后把请求挂住。
19
- * 2. 下游**总是**拿到请求(桌面那张卡照旧出现,`dsh-plan-mode` 也一样),与手机那张
20
- * 卡同时存在:手机答得早就用手机的,桌面答得早就用桌面的,落定后广播
21
- * `question/resolved`,另一边那张卡被收掉。手机在看的时候也照转,否则桌面根本
22
- * 没有卡可显示 —— 桌面那张卡只由「被转发的请求」长出来,切回去也补不回来
23
- * (2026-10-24 用户报「APP 有卡、桌面什么都没有」的就是这条)。
24
- * 3. `/question/respond` 拿 rpcId 找到挂起项,把回答交回钩子 —— 这才是真正让 agent
25
- * 继续往下跑的那一步。
26
- *
27
- * 「计划评审」不走另一条通道:`dsh-plan-mode` 的 `exit_plan_mode` 就是拿
28
- * `ctx.userQuestions.ask()` 问的(问题带 `intent.kind='plan-review'`、`detail` 是计划正文、
29
- * 选项里有一个「批准」标签),所以这道桥同时修好它。App 的问题面板本来就渲染
30
- * `detail` 与 `options`,计划正文直接显示在详情里。
13
+ * 「计划评审」不走另一条通道:`exit_plan_mode` 就是拿 `ctx.userQuestions.ask()` 问的
14
+ * (`intent.kind='plan-review'`,detail 是计划正文),这道桥同时修好它。
31
15
  */
32
16
 
33
17
  const { randomUUID } = require('node:crypto');
@@ -298,17 +282,12 @@ function installQuestionBridge(ctx) {
298
282
  /**
299
283
  * POST /api/mobile/v1/question/respond —— `{rpcId, sessionId, answer:{answers}}`。
300
284
  *
301
- * 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
302
- * 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
285
+ * 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 真 apiProxy 的归宿;0.2.x
286
+ * 兼容桥会回 `host-unsupported`,落到下面那条 409)。
303
287
  *
304
- * ⚠️ `respond` 在两条通道上的返回形态**不一样**,这里必须两种都受得住:
305
- * - 0.1.x 的真 apiProxy → `Promise<{accepted}>`;
306
- * - 0.2.x 的兼容桥(`mobile/host-bridge.js`)→ **同步对象**
307
- * `{result:{ok:false,error:{code:'host-unsupported'}}}`,它压根不是 Promise。
308
- * 早先这里直接 `.catch()`,于是在 0.2.x 上表达缺席的那一发会抛
309
- * `TypeError: ctx.apiProxy.respond(...).catch is not a function`,被路由守卫兜成
310
- * **500 internal** 而不是下面那句 409。手机端因此只看到「提交失败」的原始异常,
311
- * 而桌面那张卡还挂着 —— 用户看到的就是「APP 提交没到 DSH,还得回桌面重答一遍」。
288
+ * respond 在两条通道上的返回形态不一样:0.1.x 是 `Promise<{accepted}>`,0.2.x 兼容桥是同步
289
+ * 对象。同步对象上没有 `.catch`,直接链 Promise 会抛 TypeError 被路由守卫兜成 500,而不是
290
+ * 409,手机只看到「提交失败」的原始异常。
312
291
  */
313
292
  async function handleQuestionRespond(ctx, req, res) {
314
293
  const body = await readJsonBody(req);
@@ -349,14 +328,10 @@ async function handleQuestionRespond(ctx, req, res) {
349
328
  }
350
329
 
351
330
  /**
352
- * 把回答交回旧通道的 `ctx.apiProxy.respond`,**Promise 与同步对象两种返回都收**。
353
- *
354
- * 两条通道的形态不同(见 `handleQuestionRespond` 的注释):0.1.x 是 Promise,0.2.x 的兼容桥
355
- * 是同步的 `{result:{ok:false,...}}`。同步对象上没有 `.catch`,所以不能在这里链 Promise。
356
- * 抛错与「通道说没有」一律折算成 null —— 调用方据此如实回 409,而不是把 500 抛给手机。
331
+ * 把回答交回旧通道的 `ctx.apiProxy.respond`,Promise 与同步对象两种返回都收。
357
332
  *
358
- * 兼容桥那种 `{result:{ok:false,error:{code:'host-unsupported'}}}` 里没有任何 `accepted`
359
- * 字段,`accepted !== true` 自然落到 409,不必单独判 code。
333
+ * 0.1.x 是 Promise,0.2.x 兼容桥是同步的 `{result:{ok:false,...}}`;同步对象上没有 `.catch`,
334
+ * 不能链 Promise。抛错与「通道说没有」一律折算成 null,调用方据此如实回 409。
360
335
  */
361
336
  async function respondViaLegacyProxy(ctx, message) {
362
337
  const proxy = ctx && ctx.apiProxy;
@@ -3,29 +3,15 @@
3
3
  /**
4
4
  * 移动端「引用发现」API:把桌面端输入框里的 @文件 / @会话 补全补齐到手机上。
5
5
  *
6
- * 桌面端由 dsh-client-ui-reference 渲染:输入 @ 后浏览器同时发起
7
- * fileReferences/list 与 sessionReferenceResolver/candidates 两个 Remote 调用,
8
- * 结果按「文件在前、会话在后」的确定性顺序分组展示;选中文件插入普通 @路径 文本,
9
- * 选中会话插入宿主给的规范 mention(@[label](dsh-session:…))。
6
+ * 两个宿主服务(fileReferences、sessionReferenceResolver)不在 apiProxy 上,是 Typert
7
+ * Remote 服务,手机侧经 host-bridge/transport.js 的 `invoke` 到达。
10
8
  *
11
- * 关键:这两个宿主服务 **不在 apiProxy 上** —— 它们是 Typert Remote 服务
12
- * (dsh-file-reference 的 fileReferences、dsh-session-reference 的
13
- * sessionReferenceResolver)。手机侧本来就能到它们:dsh-mobile-plugin 与宿主同进程,
14
- * lib/mobile/sessions.js 已经派发过 commands.execute,lib/mobile/plugins.js 也走同一条路
15
- * 读 pluginInventory —— 都经 host-bridge/transport.js 的 `invoke`,这里照搬。
9
+ * 两条接口只转发发现域,不做排序或截断之外的加工:
10
+ * - GET /file-references → fileReferences.list
11
+ * - GET /session-references → sessionReferenceResolver.candidates
16
12
  *
17
- * 两条接口(都只是转发宿主的发现域,不做任何排序或截断之外的加工):
18
- * - GET /api/mobile/v1/file-references?sessionId=…&query=… → fileReferences.list
19
- * - GET /api/mobile/v1/session-references?sessionId=…&query=… → sessionReferenceResolver.candidates
20
- *
21
- * 参数按宿主的 **wire 名** 精确校验:两个方法都是 { agentId, query },query 是普通
22
- * JSON 字符串(与 messageFeedback 的 { request } 包装不同)。
23
- *
24
- * 语义按宿主原文透传:
25
- * - query 是「@ 或 @" 后面的那段文字」;空串表示列出全部(宿主自己限量)。
26
- * - 文件候选只有 path/kind,目录候选带在路径尾部追加 / 的责任在客户端。
27
- * - 会话候选的 mention 是宿主的规范写法,客户端必须原样发送,不能拿 label 重建。
28
- * - 两个域各自独立降级:任一域不可用不影响另一个域(桌面端也是这个约定)。
13
+ * 参数按宿主 wire 名精确校验(都是 `{ agentId, query }`)。语义透传:空 query 列出全部;
14
+ * 会话候选的 mention 必须原样发送,不能拿 label 重建;两个域各自独立降级。
29
15
  */
30
16
 
31
17
  const { sendJson } = require('../shared/http-util');
@@ -52,17 +38,11 @@ function errorText(error) {
52
38
  /**
53
39
  * 经网关调一次引用发现。
54
40
  *
55
- * 走 host-bridge/transport.js 的 `invoke`:Peer 身份与「服务/方法不存在」的收敛都在
56
- * 那里,本域只做自己的 503/400 翻译。**不要自己直连网关** —— 0.2.1 起
57
- * 要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,手机端 @ 补全整块失效。
41
+ * 走 host-bridge/transport.js 的 `invoke`:Peer 身份与「服务/方法不存在」的收敛都在那里。
42
+ * 不要自己直连网关 —— 0.2.1 起每个请求都要自带 Peer,绕过去就是对 undefined 读 `.signal`。
58
43
  *
59
- * 注意返回值形状:网关回的是 **decode 后的业务结果本体**
60
- * (dsh-api-gateway/lib/index.js:112 `return decode(descriptor.result, result, …)`),
61
- * 那层 `{ok:true,value}` 外壳是浏览器传输(connection.rpc.intercept('/api'))加的,
62
- * invoke 不负责包。所以形状取决于服务声明的 result schema:
63
- * - messageFeedback 声明的是 ok/error 联合 → invoke 回来就是 {ok:true,value}
64
- * - fileReferences.list 声明的是 z.array(…) → invoke 回来就是一个裸数组
65
- * 两个都认,这样宿主以后把 schema 换成联合也不会坏。
44
+ * 网关回的是 decode 后的业务结果本体,`{ok,value}` 外壳是浏览器传输加的:messageFeedback
45
+ * 声明的是联合,回 `{ok:true,value}`;fileReferences.list 声明的是数组,回裸数组。两个都认。
66
46
  *
67
47
  * @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
68
48
  */
@@ -3,41 +3,27 @@
3
3
  /**
4
4
  * 移动端「这台设备的中转与连接配置」:读写、配对码、吊销、重连、端口自检、地址候选。
5
5
  *
6
- * ## 为什么手机要能改这些
6
+ * 这些项不会让插件代码变,最坏结果只是这台电脑从网上找不到,而重连的主动权在手机这一侧,
7
+ * 所以能力面有意放开给已配对手机;控制面那条 `localOnly` 路由防的是「中转进来的人」,不能
8
+ * 复用,这里直接持有同一份 settings 与 schema 校验,两个入口写同一份真值。
7
9
  *
8
- * 这七八项(中转地址、注册密钥、设备名、局域网开关与端口、手机优先走哪条、配对策略、
9
- * 遥控总开关)从前只有一个入口:电脑上当机面板(`/api/relay/*`,全部 `localOnly`)。
10
- * 于是「手机上发现连不上、想改个中转地址」这件事必须回电脑前坐下才能做,而那往往正是
11
- * 人不在电脑前的时候。
10
+ * 改地址 / 关遥控 / 吊销 / 重连 / 重启后台服务会掐断承载当前请求的隧道,所以一律先把响应
11
+ * 吐完再动手,手机看到 200 只代表「已受理」,结果靠重连后读状态。
12
12
  *
13
- * 这些项与「装/卸插件」不是一类东西:它们**不会让插件代码变**,最坏结果只是这台电脑从
14
- * 网上找不到 —— 而重连的主动权在手机这一侧(换地址、重新配对、同网走局域网),不会像
15
- * 装坏插件那样死锁。所以这一层的能力面是**有意放开给已配对手机**的。
13
+ * 换中转地址是唯一**不能**「先落盘再重建」的一项:填错地址的后果就是这台电脑从网上消失,
14
+ * 而改地址的入口正挂在要被拆掉的那条隧道上。所以它分成两阶段 —— `stage:"prepare"` 拿新
15
+ * 地址真连一次(不落盘),`stage:"commit"` 才改,并在旧隧道上先发 `MIGRATE` 再重建
16
+ * (顺序见 lib/relay/migrate.js)。失败台账(`apply-ledger`)在 `/diagnostics` 里报出来。
16
17
  *
17
- * ## 直接调内部实现,不绕本机 HTTP
18
- *
19
- * 控制面那几条 `localOnly` 路由是为了防「中转进来的人」而存在的:它们能读进程级错误账本
20
- * (含堆栈)、能吊销全部授权。手机这边不能复用那道口子,所以这里**直接持有同一份
21
- * `settings`(relay.json 的 config store)**,走同一套 schema 校验 —— 两个入口写的是同一
22
- * 份真值,不会出现「面板说改了、手机说没改」。
23
- *
24
- * ## 会掐断自己那条隧道的动作
25
- *
26
- * 改中转地址 / 关遥控 / 吊销全部 / 重连 / 重启后台服务:这几条一动手,承载**这一发请求**
27
- * 的那条隧道就可能立刻断。所以它们一律「先把响应吐完再动手」(`AFTER_RESPONSE_MS`),
28
- * 手机看到 200 只代表「已经受理」,结果靠断线重连之后读状态判 —— 与插件自更新那条
29
- * `apply` 同一个手感,理由也一样:响应发不出去的时候,任何「返回结果」的设计都是假的。
30
- *
31
- * ## 注册密钥只进不出
32
- *
33
- * `status()` 与二维码都不回显它(宿主那边本来就这么写),这里也照做:只接受写入,
34
- * 状态里只报「设没设」。手机上看不到它,也就没有「读出来再抄走」这条路。
18
+ * 注册密钥只进不出:状态里只报「设没设」,手机看不到它。
35
19
  */
36
20
 
37
21
  const { randomBytes } = require('node:crypto');
38
22
 
39
23
  const core = require('../relay/core.js');
40
24
  const pairingApproval = require('../relay/pairing-policy.js');
25
+ const applyLedger = require('../relay/apply-ledger.cjs');
26
+ const { probeInstanceId, verifyRelayRegistration } = require('../relay/verify.js');
41
27
  const { canListen } = require('../relay/port-allocator.cjs');
42
28
  const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
43
29
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -48,6 +34,9 @@ const CONFIG_BODY_MAX = 4 * 1024;
48
34
  /** 「先回包、再动手」的延迟:装/换一次隧道要几百毫秒,留一点让响应真正写完。 */
49
35
  const AFTER_RESPONSE_MS = 120;
50
36
 
37
+ /** 换中转地址的两个阶段(见文件头)。不传 `stage` 的老客户端按 `commit` 走。 */
38
+ const STAGES = ['prepare', 'commit'];
39
+
51
40
  /** 测速的单条上限,与控制面 `/probe-relays` 同一档(6 秒)。 */
52
41
  const PROBE_TIMEOUT_MS = 6000;
53
42
 
@@ -168,6 +157,7 @@ function createRelayControlHandlers(deps) {
168
157
  credentials = null,
169
158
  activeGuard = () => null,
170
159
  probeUrls = null,
160
+ verifyRelay = null,
171
161
  } = deps || {};
172
162
 
173
163
  /**
@@ -194,6 +184,49 @@ function createRelayControlHandlers(deps) {
194
184
  return syncSidecar().catch(() => undefined);
195
185
  }
196
186
 
187
+ /**
188
+ * 把配置推下去,并**等它真的落地**(换地址那一发要等新隧道注册完)。
189
+ *
190
+ * 与 `applyNow()` 走同一条路:先让 config store 通知订阅者(桌面面板改配置也是这一条),
191
+ * 再等这次下发的结果。失败会一路抛上来 —— 换地址失败必须如实回给调用方,而不是先回 200
192
+ * 再往台账里记一笔。
193
+ */
194
+ async function applyNowAndWait() {
195
+ if (typeof settings.notify === 'function') {
196
+ try { settings.notify(); } catch { /* 退回收 syncSidecar */ }
197
+ }
198
+ return syncSidecar();
199
+ }
200
+
201
+ /**
202
+ * 试连一次新地址:`stage:"prepare"` 的全部实现。
203
+ *
204
+ * **一行都不落盘**,也不碰正在服务的隧道:用临时实例 id(`probeInstanceId`)在一个**新**
205
+ * 连接上注册,成功立刻断开。地址形状不对、连不上、注册被拒都把原因原样带回调用方 ——
206
+ * 手机上那句错误就是它。
207
+ */
208
+ async function verifyTarget(patch) {
209
+ const snapshot = typeof status === 'function' ? status() : {};
210
+ const upstreamPort = Number(snapshot?.localPort);
211
+ if (!Number.isInteger(upstreamPort) || upstreamPort <= 0) {
212
+ return { ok: false, message: '宿主没报出本地网页端口,没法试连' };
213
+ }
214
+ const runner = typeof verifyRelay === 'function' ? verifyRelay : verifyRelayRegistration;
215
+ try {
216
+ await runner({
217
+ relayUrl: patch.relayUrl,
218
+ // 临时 id 必须与正式 id 不同:中转按实例 id 认人,拿正式 id 试连会把正在服务的那条顶掉。
219
+ instanceId: probeInstanceId(state.instanceId),
220
+ upstreamPort,
221
+ name: typeof state.name === 'string' ? state.name : '',
222
+ registerSecret: patch.registerSecret === undefined ? state.registerSecret : patch.registerSecret,
223
+ });
224
+ return { ok: true };
225
+ } catch (error) {
226
+ return { ok: false, message: String(error?.message || error) };
227
+ }
228
+ }
229
+
197
230
  /** 地址候选:与控制面 `/addresses` 同一份来源,三份分开报(理由见 lan-network.cjs)。 */
198
231
  function addressView() {
199
232
  const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort;
@@ -259,7 +292,11 @@ function createRelayControlHandlers(deps) {
259
292
  * 一次给全:手机进这一页只要一发就能把「配了什么、现在通不通、能填哪条地址」都拿到,
260
293
  * 不需要连打四五条再自己拼。
261
294
  */
262
- async function handleRelayConfig(_ctx, _req, res) {
295
+ async function handleRelayConfig(ctx, req, res) {
296
+ // POST 也收在这一条路径上:契约写的是 `POST /api/mobile/v1/relay/config`,而 App 现在打
297
+ // 的是 `/relay/config/save`(同一件事的两个名字)。宿主的注册器不按方法分派,所以这里
298
+ // 自己分一次 —— 两条入口共用下面同一份实现,不存在第二套「改配置」。
299
+ if (req && req.method === 'POST') return handleRelayConfigSave(ctx, req, res);
263
300
  if (!wired) return unavailable(res);
264
301
  sendJson(res, 200, {
265
302
  ok: true,
@@ -269,13 +306,19 @@ function createRelayControlHandlers(deps) {
269
306
  }
270
307
 
271
308
  /**
272
- * `POST /api/mobile/v1/relay/config` —— 改配置。
309
+ * `POST /api/mobile/v1/relay/config/save`(`POST /relay/config` 同一份)—— 改配置。
273
310
  *
274
- * 两档结果:
275
- * · 只改了不进 sidecar 的项(`preferredLanAddress` / `name`)→ 当场改完、回新配置,
276
- * 手机不用重连;
277
- * · 改了会让旁路重配的项(`relayUrl` / `enabled` 相关的其余项)→ 先回 200 再说,
278
- * 随后 `syncSidecar` 把新配置推下去,隧道可能断一下。
311
+ * 三档结果(`stage` 是这次新增的那一格,老客户端不传):
312
+ * · `stage:"prepare"` —— **一行都不落盘**,拿新地址用临时实例 id 真连一次中转,注册上了
313
+ * 才回 `{ok:true, verified:true, target}`,否则 400 带原因。填错地址不再等于把自己锁在
314
+ * 门外:先验证,再让用户点保存。
315
+ * · `stage:"commit"` —— 落盘并**等这次下发真的完成**:换地址时宿主会先在旧隧道上登记
316
+ * 迁移、再重建(顺序见 lib/relay/migrate.js),失败会撤销登记并如实回错误。
317
+ * · 不传 `stage` —— 老 App 的行为一字不差:先回 200 再让旁路去重配。隧道照旧「先登记
318
+ * 迁移再重建」,只是结果只能靠重连后读状态(`/diagnostics` 里有失败台账)。
319
+ *
320
+ * 只改了不进 sidecar 的项(`preferredLanAddress` / `name` / `manualLanUrl`)时当场改完、
321
+ * 回新配置,手机不用重连。
279
322
  */
280
323
  async function handleRelayConfigSave(_ctx, req, res) {
281
324
  if (!wired) return unavailable(res);
@@ -289,6 +332,45 @@ function createRelayControlHandlers(deps) {
289
332
  if (!parsed.ok) {
290
333
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: parsed.message } });
291
334
  }
335
+ const stage = body.stage === undefined ? null : body.stage;
336
+ if (stage !== null && !STAGES.includes(stage)) {
337
+ return sendJson(res, 400, {
338
+ ok: false,
339
+ error: { code: 'bad-request', message: `stage 只能是 ${STAGES.join(' / ')}` },
340
+ });
341
+ }
342
+ // ── 第一段:试连,不落盘 ──
343
+ if (stage === 'prepare') {
344
+ const target = parsed.patch.relayUrl;
345
+ if (target === undefined) {
346
+ return sendJson(res, 400, {
347
+ ok: false,
348
+ error: { code: 'bad-request', message: '试连要带新的 relayUrl(它才是要验证的那个地址)' },
349
+ });
350
+ }
351
+ if (!core.normalizeRelayUrl(target)) {
352
+ return sendJson(res, 400, {
353
+ ok: false,
354
+ verified: false,
355
+ target: '',
356
+ error: { code: 'bad-request', message: '空地址没什么可试连的:要关掉中转请直接提交' },
357
+ });
358
+ }
359
+ const probe = await verifyTarget(parsed.patch);
360
+ if (!probe.ok) {
361
+ return sendJson(res, 400, {
362
+ ok: false,
363
+ verified: false,
364
+ target: core.normalizeRelayUrl(target),
365
+ error: { code: 'relay-unverified', message: `这个地址连不上:${probe.message}` },
366
+ });
367
+ }
368
+ // 到这里为止既没调 settings.update、也没碰压载着本请求的那条隧道。
369
+ return sendJson(res, 200, { ok: true, verified: true, target: core.normalizeRelayUrl(target) });
370
+ }
371
+ // ── 第二段:落盘(`stage:"commit"` 或不传 stage)──
372
+ const urlChanged = parsed.patch.relayUrl !== undefined
373
+ && core.normalizeRelayUrl(parsed.patch.relayUrl) !== core.normalizeRelayUrl(state.relayUrl);
292
374
  try {
293
375
  // `notify: false` 是关键:订阅者(装配层那个 watch)一跑就会重配隧道,而那一发响应
294
376
  // 还没吐完。所以先只落盘,回完包再通知。
@@ -305,6 +387,20 @@ function createRelayControlHandlers(deps) {
305
387
  await resolveLanPort().catch(() => undefined);
306
388
  }
307
389
  const view = { ok: true, config: configView(state), addresses: addressView() };
390
+ // 显式 commit 且换了地址:必须等这次下发的结果。旧隧道在这期间还开着(见
391
+ // lib/relay/migrate.js 的 linger),所以这一发响应仍然送得回去。
392
+ if (stage === 'commit' && urlChanged) {
393
+ try {
394
+ await applyNowAndWait();
395
+ } catch (error) {
396
+ return sendJson(res, 502, {
397
+ ok: false,
398
+ config: configView(state),
399
+ error: { code: 'relay-switch-failed', message: String(error?.message || error) },
400
+ });
401
+ }
402
+ return sendJson(res, 200, { ...view, migrated: true });
403
+ }
308
404
  sendJson(res, 200, view);
309
405
  // 推给旁路:改地址 / 端口 / 局域网开关都会重配隧道与直连,这一下多半会断。
310
406
  afterResponse(() => applyNow());
@@ -501,6 +597,10 @@ function createRelayControlHandlers(deps) {
501
597
  * 隧道为什么断、局域网为什么没起来,答案在故障台账与端口自检这两处;两条一起给,
502
598
  * 手机上一页就能回答「这台到底怎么了」。
503
599
  *
600
+ * `apply` 那一格是「按新配置重建隧道」的失败台账:以前那一发被 `.catch(() => undefined)`
601
+ * 吞掉,换地址失败在手机上表现为「保存成功、然后永久离线」,谁也说不清为什么。它只留最近
602
+ * 一条(时间 + 一句话 + 当时要切过去的地址),不带堆栈 —— 宿主内部路径对手机没用。
603
+ *
504
604
  * **不搬堆栈**:台账的 message 只取第一行,截断 300 字。
505
605
  */
506
606
  async function handleRelayDiagnostics(_ctx, _req, res) {
@@ -509,6 +609,8 @@ function createRelayControlHandlers(deps) {
509
609
  sendJson(res, 200, {
510
610
  ok: true,
511
611
  faults: faultsView(),
612
+ /** 最近一次「按新配置重建隧道失败」的错误与时间(见上方说明)。 */
613
+ apply: applyLedger.view(),
512
614
  addresses: addressView(),
513
615
  /** 端口那两格的来历:「配置里写的」与「真的在监听的」可能不同。 */
514
616
  port: {
@@ -544,4 +646,5 @@ module.exports = {
544
646
  PROBE_MAX_URLS,
545
647
  FAULT_TAIL,
546
648
  NAME_MAX_CHARS,
649
+ STAGES,
547
650
  };
package/lib/mobile/rpc.js CHANGED
@@ -3,20 +3,12 @@
3
3
  /**
4
4
  * 调宿主 apiProxy 的两扇门:`sessions.*` 与 `agentPresets.*`。
5
5
  *
6
- * 抽出来是因为这两扇门此前各有两份实现——sessions.js 一份、agent-presets.js 一份
7
- * callAgentPresets(逐字相同),而 callSessions 是 sessions.js 内部 21 处的公共依赖,
8
- * 一旦 sessions.js 被拆分,21 处调用就得跟着搬家。
6
+ * 抽出来是因为两扇门此前各有一份逐字相同的实现,而 callSessions 是 sessions.js 里二十几处
7
+ * 的公共依赖。这里收的不只是转发,还有两件必须跟着走的语义:
9
8
  *
10
- * 这里收的不只是「转发」:callSessions 还带两件必须跟着转发走的语义。
11
- *
12
- * 1. signal 必须显式传。宿主 `sessions.<method>(request, signal)` 的第二个参数是
13
- * 取消信号,宿主内部 `isAborted(signal)` 直接读 `signal.aborted`、不做
14
- * undefined 兜底:search 这类会查 signal 的方法不传就抛
15
- * `Cannot read properties of undefined (reading 'aborted')`。
16
- * 2. list 的在途合流。sessions.list 会扫描所有压缩日志;同一个 Host 可能同时收到
17
- * 桌面 completion、移动端列表和通知监控的请求,不合流就是一次刷新被放大成 N 次
18
- * 磁盘扫描。**但写后刷新(?fresh=1)不能搭在途 list 的车**:被合流到的那个请求
19
- * 发起于写入之前,拿回来的是旧列表,于是「刚改完又显示成改之前」。
9
+ * 1. signal 必须显式传:宿主内部直接读 `signal.aborted`,不做 undefined 兜底,不传就抛。
10
+ * 2. list 的在途合流:每次 list 要扫全部压缩日志,不合流会把一次刷新放大成 N 次扫描。
11
+ * 但写后刷新(`?fresh=1`)不能搭车 —— 被合流到的请求发起于写入之前,拿回的是旧列表。
20
12
  *
21
13
  * 依赖方向:rpc.js → history-derive(只为 rpcId 前缀常量),是叶子之上的薄层。
22
14
  */
@@ -3,42 +3,17 @@
3
3
  /**
4
4
  * 移动端「定时任务」API:把宿主 `ScheduleService` 的四件事转给手机。
5
5
  *
6
- * 桌面端有一套完整的定时任务:任务挂在会话上,到点把 prompt 投进会话,运行记录留在
7
- * 宿主里。手机端此前一条都看不到 —— 既不知道这个会话排了什么,也没法读「上次跑到
8
- * 什么时候」或者删掉一条排错的任务。本文件是宿主 `schedule` 服务的移动端代理。
6
+ * 服务走同进程直查 `ctx.get('schedule')`,不写进 `inject`:它是 Typert Remote 服务、不在
7
+ * apiProxy 命名空间里,写进 inject 会因为老宿主没有这个服务而挡住整个插件。
9
8
  *
10
- * 服务走同进程直查 `ctx.get('schedule')`,与 mobile/directory-browse.js 取
11
- * `directoryPicker` 是同一个先例:它是 Typert Remote 服务,不在 apiProxy 的命名空间
12
- * 里,写进 `inject` 反而会因为老宿主没有这个服务而挡住整个插件。
9
+ * 四条接口:catalog(全部任务)、list(该会话的任务)、history(一条任务的运行记录)、
10
+ * delete(硬删)。update 不做:手机端没有编辑入口,接一条没人调的路只会多一份参数校验。
13
11
  *
14
- * 四条接口对应宿主的四个公开方法:
15
- * - GET /api/mobile/v1/schedule/catalog → `catalog()` 全部任务(active + inactive)
16
- * - GET /api/mobile/v1/schedule/list → `catalog()` 里筛出该会话的任务(**active + inactive**)
17
- * - GET /api/mobile/v1/schedule/history → `history({...})` 一条任务的运行记录
18
- * - POST /api/mobile/v1/schedule/delete → `delete({...})` 硬删
12
+ * list 走 `catalog()` 再按 sessionId 筛,不用宿主的 `list({sessionId})`:后者只回还在跑的
13
+ * 任务、行上没有 sessionId / status,一次性任务跑完就会从手机上「凭空消失」。
19
14
  *
20
- * `update` 这一批不做,别顺手接上:手机端暂时没有编辑入口,接一条没人调的路只会多一份
21
- * 要维护的参数校验。
22
- *
23
- * 宿主 catalog 的每项是「裸记录 + sessionId + status + 可选 lastDelivery」。手机只认一种
24
- * 形状:sessionId 必须带上,状态统一收成 `state`,删除 / 历史 / 修改都必须带行上的
25
- * sessionId,否则宿主恒回 not_found。
26
- *
27
- * **`list` 为什么不用宿主的 `list({sessionId})`**(2026-10-23 改):宿主那个方法只回
28
- * **还在跑**的任务、而且回的是裸记录(没有 sessionId / status)。于是手机上按对话看任务时,
29
- * 一次性任务跑完就从这个入口消失了 —— 用户看到的是「任务凭空没了」,实际只是「结束了」。
30
- * 现在改成从 `catalog()`(active + inactive 全都有、行上带 status)里按 sessionId 筛,
31
- * 手机端因此能列出这条会话的全部任务(含已结束),筛选里那三档也才真正有意义。
32
- * 代价是一次多拉这份目录,它是宿主内存里的账、不是磁盘扫描,与统一页那一发等价。
33
- *
34
- * 收敛规则与 mobile/jobs.js 同口径:必填(id / kind / title / sessionId)坏了丢整条,
35
- * 可选字段坏了只丢字段 —— 少一个「下次运行时间」只是不显示,整条消失才是信息丢失。
36
- * 超长一律截断而不是丢整条:宿主已经校验过形状,这里的上限只防异常宿主把手机端列表
37
- * 撑爆。
38
- *
39
- * 服务不存在(老宿主)必须回 503 而不是空列表:App 要能分辨「这台电脑没有这个功能」
40
- * 和「一条任务都没有」,后者画空状态、前者画「升级宿主」。宿主调用抛错走 502,把原文
41
- * 带给 App —— 定时任务这块的错误多半是宿主的,插件不该换成一句「操作失败」。
15
+ * 收敛规则与 mobile/jobs.js 同口径:必填字段坏了丢整条,可选字段坏了只丢字段,超长截断。
16
+ * 服务不存在(老宿主)回 503 而不是空列表,App 才能分辨「没有这个功能」和「一条都没有」。
42
17
  */
43
18
 
44
19
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -3,46 +3,19 @@
3
3
  /**
4
4
  * 内存自检台账:内存出问题之后,用来回答「当时到底堆在哪儿」。
5
5
  *
6
- * 起因:宿主进程内存涨到 3.5~5G,排查时手上只有 Activity Monitor 的一个总数,
7
- * 分不出「插件推的镜像在堆」「遗弃的流订阅在堆」还是「宿主自己」。所以这里把三件事
8
- * 一次性报出来:两个**进程**的内存、各路**活着的流数**、以及插件自己那几个
9
- * 只增不减的容器有多大。
6
+ * 一次性报出两个进程的内存、各路活着的流数,以及插件自己那几个只增不减的容器有多大。
10
7
  *
11
- * ## 最重要的一条:口径
8
+ * 口径:插件住在宿主进程里,没有独立内存可测。`hostProcess` 报的是整个宿主进程的
9
+ * `process.memoryUsage()`,不是「插件占了多少」;侧车是另一个进程,记在 `sidecarProcess`
10
+ * 下,两个数不能相加成「插件内存」。字段名与 `caveat` 按这个口径写死。
12
11
  *
13
- * **插件住在宿主进程里,没有独立内存可测。** `hostProcess` 报的是**整个宿主进程**
14
- * (DSH host)的 `process.memoryUsage()`——插件、宿主自身、其它插件、V8 的堆外缓冲
15
- * 全在这个数里。它不是「插件占了多少」。要判断「插件占多少」只能拿它和流数、镜像
16
- * 缓存估算字节一起看趋势(插件侧唯一能量化的是 `mirror.cachedBytes`,那还是估算)。
17
- * 字段名与 `caveat` 都按这个口径写死,别把它改名成 pluginMemory 之类。
12
+ * 数据全走既有通道:本进程 memoryUsage、worker 的 `/status` 快照(没上报就报 null 并记进
13
+ * `unavailable`,不拿 0 顶替)、局域网 `stats()`、镜像推送记账与缓存。
18
14
  *
19
- * 侧车(sidecar facade worker)是**另一个进程**(lib/sidecar-facade.cjs 的 fork),
20
- * 它的内存在 `sidecarProcess` 下,与宿主分列——两个数不能相加成「插件内存」。
15
+ * 做成 `createSelfCheck(deps)` 而不是单例:镜像缓存按 ctx 隔离,拿不到 ctx 就读不到。
21
16
  *
22
- * ## 数据从哪儿来(全走既有通道,没有新开一条)
23
- *
24
- * - 宿主进程内存:本进程 `process.memoryUsage()` / `v8.getHeapStatistics()`。
25
- * - 侧车进程内存、流数:**worker 的 `/status` 快照**(`sidecarLifecycle.getSnapshot()`,
26
- * 与 `tunnel`/`lan` 状态同一个来源)。worker 没上报对应字段时如实报 `null` 并记进
27
- * `unavailable`——**不拿 0 顶替**:0 是「一条流都没有」,与「不知道」是两件事。
28
- * - 局域网直连路的流数:由注入的 `getLanProxyStats()` 提供,约定形状
29
- * `() => ({ upstreamOpen, sseOpen }) | null`(`lan-proxy.js` 的 `stats()`)。
30
- * - 镜像:`mirror.getMirrorPush()` / `getPushFailures()`(不在本模块重复记账),
31
- * 缓存本体走 `session-cache.js` 的 `mirrorHistoryStateOf(ctx)`。
32
- * - 凭据库:`credentials.listActive().length`(凭据包只暴露「活跃」这一档)。
33
- *
34
- * ## 为什么是工厂而不是模块级单例
35
- *
36
- * `desktop-half-stats.cjs` 是单例:它只记进程级的计数。这份不一样——镜像缓存按
37
- * `ctx` 隔离(WeakMap),拿不到 ctx 就读不到。所以做成 `createSelfCheck(deps)`,
38
- * 由 control-plane 用注入的 ctx 建一次,而不是往模块级塞一个 ctx。
39
- *
40
- * ## 字节估算的口径
41
- *
42
- * `mirror.cachedBytes` 是**估算**,不是精确占用:字符串按 `String.length`(会话正文
43
- * 以 ASCII 为主,字符数≈字节)、数字 8 字节、布尔 4、每个对象/数组 24 字节固定开销。
44
- * 走查有节点预算(`MAX_WALK_NODES`),用尽时 `cachedBytesComplete` 为 false,
45
- * 此时那个数是**下限**而不是总量。
17
+ * `mirror.cachedBytes` 是估算(字符串按 length、数字 8 字节、对象固定开销);走查有节点
18
+ * 预算,用尽时 `cachedBytesComplete` 为 false,那个数是下限。
46
19
  */
47
20
 
48
21
  const v8 = require('node:v8');
@@ -198,9 +198,8 @@ function lastTurnFactsOf(ctx) {
198
198
  /**
199
199
  * 标记「这条会话读过了」:记一个水位,把当前的完成 / 失败提醒清掉,返回清掉的条数。
200
200
  *
201
- * **不删条目**(旧实现是 delete)。删掉之后重建的条目会丢掉水位,于是「读过的轮次」
202
- * 会在下一次 history 折叠里重新点亮 —— 2026-10-17 真机:桌面读过之后手机上红点 / 绿点
203
- * 还在或者反过来。水位才是「这个时刻之前的轮次不算数」的唯一凭据。
201
+ * 不删条目:删掉之后重建的条目会丢掉水位,「读过的轮次」会在下一次 history 折叠里重新点亮。
202
+ * 水位才是「这个时刻之前的轮次不算数」的唯一凭据。
204
203
  */
205
204
  function markCompletionRead(ctx, deviceKey, sessionId, at = Date.now()) {
206
205
  const state = completionStates.get(ctx)?.get(deviceKey)?.get(sessionId);
@@ -317,14 +316,11 @@ function writeIdempotencyStateOf(ctx) {
317
316
  /**
318
317
  * 丢弃会话列表缓存(含正在构建的那一次)。
319
318
  *
320
- * `/sessions` 的完整响应会缓存 SESSION_LIST_CACHE_MS(10 秒),本意是压住手机轮询对
321
- * 桌面的冲击。但凡是**改变列表内容**的动作都必须立刻把它丢掉,否则 App 在自己刚做完
322
- * 的动作上看到的是最长 10 秒前的旧数据——真机上「重命名会话」保存后侧栏那一行纹丝不
323
- * 动、「归档会话」按下去那一行照旧留着,看着都像没生效(宿主的标题其实早就改了)。
319
+ * `/sessions` 的完整响应缓存 10 秒,本意是压住手机轮询对桌面的冲击;但凡是改变列表内容的
320
+ * 动作都必须立刻丢掉它,否则 App 在自己刚做完的动作上看到的是最长 10 秒前的旧数据。
324
321
  *
325
- * 只清 `value` 不够:若此刻正有一次构建在飞,它读的是变更前的历史,落地后会把旧数据
326
- * 又写回缓存。所以连 `promise` 一起清,并用 completion generation 兜住(与
327
- * handleSessionRead 同款守卫,见那里的注释)。
322
+ * 只清 `value` 不够:正有一次构建在飞时,它读的是变更前的历史,落地后会把旧数据写回缓存。
323
+ * 所以连 `promise` 一起清,并用 completion generation 兜住。
328
324
  *
329
325
  * @param {object} ctx
330
326
  * @param {string} [deviceKey] 只清这一台设备的缓存;缺省清掉该 ctx 下所有设备。
@@ -344,17 +340,15 @@ function invalidateSessionList(ctx, deviceKey) {
344
340
  }
345
341
 
346
342
  /**
347
- * 丢弃会话列表缓存,但**不抬 completion generation**。
343
+ * 丢弃会话列表缓存,但不抬 completion generation。
348
344
  *
349
- * 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态
350
- * 一起作废;这个用于「宿主的清单内容变了,我(镜像推送调度器)先看见了」。后者在生成期
351
- * 每 500ms 就会发生一次(会话的 updatedAt 一直在动),如果也抬 generation,
352
- * buildSessionsResponse 里那句 `completionGenerationOf(...) === generation` 就永远不成立,
353
- * `confirmCompletionFromHistory` 再也执行不到 —— 侧栏的 completed / 红点会彻底不亮。
345
+ * 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态一起
346
+ * 作废;这个用于「宿主的清单内容变了,镜像推送调度器先看见了」。后者每 500ms 就会发生一次,
347
+ * 若也抬 generation,`confirmCompletionFromHistory` 就再也执行不到,侧栏 completed / 红点会
348
+ * 彻底不亮。
354
349
  *
355
- * 所以在飞构建靠状态自带的「朝代」(droppedAt)而不是 generation 来作废:handleSessions
356
- * 建构建前记下朝代,落地时发现朝代变了就不把旧内容写回缓存。这样既不需要抬 generation,
357
- * 又不会出现「变更前发起的那次构建在变更后落地,把旧列表按新时间戳缓存 10 秒」。
350
+ * 所以在飞构建靠状态自带的「朝代」(droppedAt)作废:建构建前记下朝代,落地时发现变了就不
351
+ * 把旧内容写回缓存。
358
352
  *
359
353
  * @param {object} ctx
360
354
  * @param {string} [deviceKey] 只丢这一台设备的缓存;缺省丢掉该 ctx 下所有设备。
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话的工作区目录:正文里相对路径的解析基准。
5
+ *
6
+ * 两处在用,口径必须是同一个 —— 正文里的图片(`local-images.js` 按它把 `deliverables/a.png`
7
+ * 换成内嵌字节)与点开正文里的文件链接(`session-file.js` 按它找文件)。
8
+ *
9
+ * 它是**可选的旁路**:取不到就回空串,调用方一律退回「只认绝对路径」的旧行为。这条跑在
10
+ * 历史投影与 SSE 的同一条管线上,抛一次会让整页消息或一整帧事件一起挂掉,所以这里不抛。
11
+ */
12
+
13
+ const { unwrap } = require('./envelope.js');
14
+ const { callSessions } = require('./rpc.js');
15
+
16
+ /** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
17
+ async function cwdOf(ctx, sessionId) {
18
+ try {
19
+ const { value } = unwrap(await callSessions(ctx, 'list', {}));
20
+ const items = (value && value.items) || [];
21
+ const row = items.find((item) => item && item.sessionId === sessionId);
22
+ return row && typeof row.cwd === 'string' ? row.cwd : '';
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
28
+ module.exports = { cwdOf };