dsh-mobile-plugin 0.4.0 → 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.
- package/dsh-mobile-credentials/lib/credentials.js +8 -8
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -8
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +6 -27
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-file.js +8 -21
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
package/lib/mobile/questions.js
CHANGED
|
@@ -3,31 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 手机端提问桥(DSH 0.2.x):`ask_user_question` 与「计划评审」。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* `question/
|
|
10
|
+
* 钩子必须 prepend:宿主的转发器在桌面窗口开着时会一直等桌面回答、不调用 `next()`。下游
|
|
11
|
+
* 总是拿到请求,桌面那张卡与手机那张同时存在,谁先答用谁的,落定后广播 `question/resolved`。
|
|
15
12
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
302
|
-
*
|
|
285
|
+
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 真 apiProxy 的归宿;0.2.x
|
|
286
|
+
* 兼容桥会回 `host-unsupported`,落到下面那条 409)。
|
|
303
287
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
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
|
-
*
|
|
359
|
-
*
|
|
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;
|
package/lib/mobile/references.js
CHANGED
|
@@ -3,29 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 移动端「引用发现」API:把桌面端输入框里的 @文件 / @会话 补全补齐到手机上。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 结果按「文件在前、会话在后」的确定性顺序分组展示;选中文件插入普通 @路径 文本,
|
|
9
|
-
* 选中会话插入宿主给的规范 mention(@[label](dsh-session:…))。
|
|
6
|
+
* 两个宿主服务(fileReferences、sessionReferenceResolver)不在 apiProxy 上,是 Typert
|
|
7
|
+
* Remote 服务,手机侧经 host-bridge/transport.js 的 `invoke` 到达。
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
57
|
-
* 要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,手机端 @ 补全整块失效。
|
|
41
|
+
* 走 host-bridge/transport.js 的 `invoke`:Peer 身份与「服务/方法不存在」的收敛都在那里。
|
|
42
|
+
* 不要自己直连网关 —— 0.2.1 起每个请求都要自带 Peer,绕过去就是对 undefined 读 `.signal`。
|
|
58
43
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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,35 +3,14 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 移动端「这台设备的中转与连接配置」:读写、配对码、吊销、重连、端口自检、地址候选。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 这些项不会让插件代码变,最坏结果只是这台电脑从网上找不到,而重连的主动权在手机这一侧,
|
|
7
|
+
* 所以能力面有意放开给已配对手机;控制面那条 `localOnly` 路由防的是「中转进来的人」,不能
|
|
8
|
+
* 复用,这里直接持有同一份 settings 与 schema 校验,两个入口写同一份真值。
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 于是「手机上发现连不上、想改个中转地址」这件事必须回电脑前坐下才能做,而那往往正是
|
|
11
|
-
* 人不在电脑前的时候。
|
|
10
|
+
* 改地址 / 关遥控 / 吊销 / 重连 / 重启后台服务会掐断承载当前请求的隧道,所以一律先把响应
|
|
11
|
+
* 吐完再动手,手机看到 200 只代表「已受理」,结果靠重连后读状态。
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* 网上找不到 —— 而重连的主动权在手机这一侧(换地址、重新配对、同网走局域网),不会像
|
|
15
|
-
* 装坏插件那样死锁。所以这一层的能力面是**有意放开给已配对手机**的。
|
|
16
|
-
*
|
|
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
|
-
* 状态里只报「设没设」。手机上看不到它,也就没有「读出来再抄走」这条路。
|
|
13
|
+
* 注册密钥只进不出:状态里只报「设没设」,手机看不到它。
|
|
35
14
|
*/
|
|
36
15
|
|
|
37
16
|
const { randomBytes } = require('node:crypto');
|
package/lib/mobile/rpc.js
CHANGED
|
@@ -3,20 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 调宿主 apiProxy 的两扇门:`sessions.*` 与 `agentPresets.*`。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 一旦 sessions.js 被拆分,21 处调用就得跟着搬家。
|
|
6
|
+
* 抽出来是因为两扇门此前各有一份逐字相同的实现,而 callSessions 是 sessions.js 里二十几处
|
|
7
|
+
* 的公共依赖。这里收的不只是转发,还有两件必须跟着走的语义:
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
*/
|
package/lib/mobile/schedule.js
CHANGED
|
@@ -3,42 +3,17 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 移动端「定时任务」API:把宿主 `ScheduleService` 的四件事转给手机。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 什么时候」或者删掉一条排错的任务。本文件是宿主 `schedule` 服务的移动端代理。
|
|
6
|
+
* 服务走同进程直查 `ctx.get('schedule')`,不写进 `inject`:它是 Typert Remote 服务、不在
|
|
7
|
+
* apiProxy 命名空间里,写进 inject 会因为老宿主没有这个服务而挡住整个插件。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 里,写进 `inject` 反而会因为老宿主没有这个服务而挡住整个插件。
|
|
9
|
+
* 四条接口:catalog(全部任务)、list(该会话的任务)、history(一条任务的运行记录)、
|
|
10
|
+
* delete(硬删)。update 不做:手机端没有编辑入口,接一条没人调的路只会多一份参数校验。
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
* 分不出「插件推的镜像在堆」「遗弃的流订阅在堆」还是「宿主自己」。所以这里把三件事
|
|
8
|
-
* 一次性报出来:两个**进程**的内存、各路**活着的流数**、以及插件自己那几个
|
|
9
|
-
* 只增不减的容器有多大。
|
|
6
|
+
* 一次性报出两个进程的内存、各路活着的流数,以及插件自己那几个只增不减的容器有多大。
|
|
10
7
|
*
|
|
11
|
-
*
|
|
8
|
+
* 口径:插件住在宿主进程里,没有独立内存可测。`hostProcess` 报的是整个宿主进程的
|
|
9
|
+
* `process.memoryUsage()`,不是「插件占了多少」;侧车是另一个进程,记在 `sidecarProcess`
|
|
10
|
+
* 下,两个数不能相加成「插件内存」。字段名与 `caveat` 按这个口径写死。
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 全在这个数里。它不是「插件占了多少」。要判断「插件占多少」只能拿它和流数、镜像
|
|
16
|
-
* 缓存估算字节一起看趋势(插件侧唯一能量化的是 `mirror.cachedBytes`,那还是估算)。
|
|
17
|
-
* 字段名与 `caveat` 都按这个口径写死,别把它改名成 pluginMemory 之类。
|
|
12
|
+
* 数据全走既有通道:本进程 memoryUsage、worker 的 `/status` 快照(没上报就报 null 并记进
|
|
13
|
+
* `unavailable`,不拿 0 顶替)、局域网 `stats()`、镜像推送记账与缓存。
|
|
18
14
|
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
202
|
-
*
|
|
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`
|
|
321
|
-
*
|
|
322
|
-
* 的动作上看到的是最长 10 秒前的旧数据——真机上「重命名会话」保存后侧栏那一行纹丝不
|
|
323
|
-
* 动、「归档会话」按下去那一行照旧留着,看着都像没生效(宿主的标题其实早就改了)。
|
|
319
|
+
* `/sessions` 的完整响应缓存 10 秒,本意是压住手机轮询对桌面的冲击;但凡是改变列表内容的
|
|
320
|
+
* 动作都必须立刻丢掉它,否则 App 在自己刚做完的动作上看到的是最长 10 秒前的旧数据。
|
|
324
321
|
*
|
|
325
|
-
* 只清 `value`
|
|
326
|
-
*
|
|
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
|
-
*
|
|
343
|
+
* 丢弃会话列表缓存,但不抬 completion generation。
|
|
348
344
|
*
|
|
349
|
-
* 与 invalidateSessionList
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* `confirmCompletionFromHistory` 再也执行不到 —— 侧栏的 completed / 红点会彻底不亮。
|
|
345
|
+
* 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态一起
|
|
346
|
+
* 作废;这个用于「宿主的清单内容变了,镜像推送调度器先看见了」。后者每 500ms 就会发生一次,
|
|
347
|
+
* 若也抬 generation,`confirmCompletionFromHistory` 就再也执行不到,侧栏 completed / 红点会
|
|
348
|
+
* 彻底不亮。
|
|
354
349
|
*
|
|
355
|
-
* 所以在飞构建靠状态自带的「朝代」(droppedAt
|
|
356
|
-
*
|
|
357
|
-
* 又不会出现「变更前发起的那次构建在变更后落地,把旧列表按新时间戳缓存 10 秒」。
|
|
350
|
+
* 所以在飞构建靠状态自带的「朝代」(droppedAt)作废:建构建前记下朝代,落地时发现变了就不
|
|
351
|
+
* 把旧内容写回缓存。
|
|
358
352
|
*
|
|
359
353
|
* @param {object} ctx
|
|
360
354
|
* @param {string} [deviceKey] 只丢这一台设备的缓存;缺省丢掉该 ctx 下所有设备。
|
|
@@ -3,29 +3,16 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 交付文件的字节(POST /api/mobile/v1/session/file)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* 文件在电脑上,宿主只给了路径,App 必须先取回字节。这条路由就是那条通道。
|
|
6
|
+
* 轮尾那张「交付文件」卡在桌面上点一下就打开;手机上没有那条路,App 必须先取回字节。
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* 三条边界:
|
|
9
|
+
* 1. 放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径。前者判「解析后的落点是否
|
|
10
|
+
* 在 cwd 之下」,`../x` 落在外面就退回白名单,不在声明里就 403;后者是字符串全等的显式
|
|
11
|
+
* 声明,用于工作区之外的文件。放开工作区不等于能读这台电脑上的任意文件。
|
|
12
|
+
* 2. 相对路径按会话工作区解析:cwd 取自会话列表摘要;没有 cwd 的会话只能走 present 白名单。
|
|
13
|
+
* 3. 流式回写,不在插件进程里缓冲:几十 MB 的录屏不该整包躺在内存里。
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
* 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
|
|
13
|
-
* 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
|
|
14
|
-
* cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
|
|
15
|
-
* 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
|
|
16
|
-
* cwd 变了的老会话)。
|
|
17
|
-
*
|
|
18
|
-
* **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
|
|
19
|
-
* —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
|
|
20
|
-
* 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
|
|
21
|
-
* 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
|
|
22
|
-
* 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
|
|
23
|
-
* cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
|
|
24
|
-
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
25
|
-
* 导出同一个理由。
|
|
26
|
-
*
|
|
27
|
-
* 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
|
|
28
|
-
* 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
|
|
15
|
+
* 两种失败分开:两条口子都不认就 403(`outside-workspace`),认了但盘上没了回 404(`missing`)。
|
|
29
16
|
*/
|
|
30
17
|
|
|
31
18
|
const fs = require('node:fs');
|
|
@@ -17,21 +17,15 @@ const approvals = require('./approvals.js');
|
|
|
17
17
|
const questions = require('./questions.js');
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* 宿主的两个会话集合(同一次 `workspace.list` 快照里拿到):
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
|
|
27
|
-
* 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
|
|
28
|
-
* 否则 App 既画不出置顶标记、也排不到工作区最前。
|
|
22
|
+
* - `archivedSessionIds`:已归档的会话。`sessions.list` 自身不过滤归档,移动端必须自己读,
|
|
23
|
+
* 否则「归档」写成功了、列表却照旧把那一行发回手机。
|
|
24
|
+
* - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
|
|
25
|
+
* 也排不到工作区最前。
|
|
29
26
|
*
|
|
30
|
-
* 两个集合合并成一次 RPC
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
|
|
34
|
-
* RPC 抖动让整个会话列表凭空变空。
|
|
27
|
+
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。取不到时都回空 ——
|
|
28
|
+
* 宁可多显示几行,也不能因为一次 RPC 抖动让整个列表变空。
|
|
35
29
|
*/
|
|
36
30
|
async function workspaceSessionFlags(ctx) {
|
|
37
31
|
try {
|
|
@@ -63,12 +63,9 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
|
|
|
63
63
|
/**
|
|
64
64
|
* Build mirror warm snapshots for the host mirror push.
|
|
65
65
|
*
|
|
66
|
-
* Two modes:
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 2. Background (no sessionIds): build from summaries, one session at a time,
|
|
70
|
-
* returning after each session so the caller can push incrementally.
|
|
71
|
-
* Processes at most `maxSessions` sessions per call, using cache when fresh.
|
|
66
|
+
* Two modes: high-priority (sessionIds given) builds all specified sessions at once; background
|
|
67
|
+
* (no sessionIds) builds from summaries one at a time so the caller can push incrementally,
|
|
68
|
+
* at most `maxSessions` per call, using the cache when fresh.
|
|
72
69
|
*
|
|
73
70
|
* @param {object} ctx - harness context
|
|
74
71
|
* @param {Array} summaries - session list summaries from host
|
|
@@ -77,15 +74,11 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
|
|
|
77
74
|
* @param {string[]} [options.sessionIds] - specific sessions to build (high-priority)
|
|
78
75
|
* @param {number} [options.limit=200] - max messages per session (capped at 200)
|
|
79
76
|
* @param {number} [options.maxSessions=32] - max sessions to build in one call
|
|
80
|
-
* @param {boolean} [options.noCache=false] -
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } }
|
|
86
|
-
* In background mode (no sessionIds), also sets _moreSessions on the result when
|
|
87
|
-
* there are remaining sessions beyond maxSessions, and _builtCount for how many
|
|
88
|
-
* were actually built this call.
|
|
77
|
+
* @param {boolean} [options.noCache=false] - 完全绕开时间型缓存(既不读也不写)。事件驱动的
|
|
78
|
+
* 推送必须传它:缓存只认「宿主抬了 updatedAt」这一种变化,新消息落盘而摘要未变时会把上一份
|
|
79
|
+
* 正文当成这一份发出去。也不能靠传一个必然不同的 updatedAt 来绕:同一毫秒的两次推送指纹相同。
|
|
80
|
+
* @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } };
|
|
81
|
+
* 后台模式还会带 _moreSessions(是否还有剩余会话)与 _builtCount(本次真正构建了几个)。
|
|
89
82
|
*/
|
|
90
83
|
async function buildMirrorWarmSnapshots(ctx, summaries, activeSessionId, options = {}) {
|
|
91
84
|
const maxSessions = Math.max(1, Math.min(Number(options.maxSessions) || 32, 256));
|
|
@@ -165,9 +165,9 @@ async function buildModelsSnapshot(ctx, sessionId) {
|
|
|
165
165
|
|
|
166
166
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
167
167
|
* 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
|
|
168
|
-
* 响应:{ ok, current
|
|
169
|
-
* `current`
|
|
170
|
-
* `options`
|
|
168
|
+
* 响应:{ ok, current, defaultModel, routable, groups, options?, failures }。
|
|
169
|
+
* `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
|
|
170
|
+
* `options` 是当前渠道的附加选项行,没有就没有这个字段。
|
|
171
171
|
*/
|
|
172
172
|
async function handleModels(ctx, req, res) {
|
|
173
173
|
const url = new URL(req.url, 'http://x');
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 会话行左边那只时钟:这一条会话有没有还在生效的自动化任务。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 主状态是空闲」时才挂得上(有审批 / 有新消息 / 正在跑时,那一格留给状态点),归档行那格
|
|
9
|
-
* 留空。手机端此前没有这个标记,用户在桌面上排了定时任务,手机上那条会话看不出任何区别。
|
|
6
|
+
* 桌面上那个标记只在会话行的主状态空闲时挂得上(有审批 / 新消息 / 正在跑时那格留给状态点),
|
|
7
|
+
* 归档行留空;手机端此前没有这个标记。
|
|
10
8
|
*
|
|
11
|
-
* 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status,按
|
|
10
|
+
* `status === 'active'` 收成集合,列表每行查表。不逐行发 `schedule/list` —— 会话列表每 10 秒
|
|
11
|
+
* 重建一次,逐行请求等于每轮几十次往返。
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 会话列表失败是拿主要功能换装饰;
|
|
18
|
-
* - **读失败也当没有**,但只吞这一条,不缓存:下一个 10 秒周期照常重问,宿主的
|
|
19
|
-
* `schedule/changed` 与目录本身都是活的;
|
|
20
|
-
* - 只看 `active`:一次性任务投完就自动结束(`status` 转 inactive),桌面上那颗也跟着消失。
|
|
13
|
+
* 三条取舍:服务不在(老宿主 / 极简 profile)就当没有任务;读失败也只吞这一条、不缓存,
|
|
14
|
+
* 下个周期照常重问;只看 active,一次性任务投完转 inactive 后标记自然消失。
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
17
|
/** 目录一次最多收多少条(与 schedule.js 的 SCHEDULE_MAX_ENTRIES 同量级,这里只用来防异常宿主)。 */
|
|
@@ -43,21 +43,15 @@ function singleTextOf(content) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* 经网关执行 slash 命令(commands.execute
|
|
47
|
-
*
|
|
48
|
-
* agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
|
|
46
|
+
* 经网关执行 slash 命令(commands.execute,桌面 Web 端同一条派发路径:命令注册表命中才执行,
|
|
47
|
+
* 绝不经 session.prompt 发给模型;agent 由 agentId 经同一查找器解析,支持冷会话自动 resume)。
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* 参数 = line + submittedAttachments(encoded images 与 staged file receipts,
|
|
54
|
-
* 按提交顺序排列,纯命令调用传空数组即可)。
|
|
55
|
-
* 这里曾经写成 `images: []`——少了 submittedAttachments 又多了 images,导致
|
|
56
|
-
* /permission、/plan 等**所有** slash 命令 400;形状守卫见
|
|
57
|
-
* test/commands-execute-shape.test.cjs(改这里必须同步那张表)。
|
|
49
|
+
* args 的键名必须恰好是宿主 descriptor 的 wire 名:scope = agentId,参数 = line +
|
|
50
|
+
* submittedAttachments(encoded images 与 staged file receipts,按提交顺序,纯命令传空数组)。
|
|
51
|
+
* 多一个少一个都是 gateway/arguments-invalid,形状守卫见 test/commands-execute-shape.test.cjs。
|
|
58
52
|
*
|
|
59
|
-
* @returns {execution} 派发成功(
|
|
60
|
-
* | {
|
|
53
|
+
* @returns {execution} 派发成功(undefined=未知命令/语法不符)| {error} 查找或派发失败
|
|
54
|
+
* | {missing} 网关服务未组合(调用方回退普通消息)
|
|
61
55
|
*/
|
|
62
56
|
async function executeCommand(ctx, sessionId, line) {
|
|
63
57
|
// 走 host-bridge/transport.js 的 invoke:Peer 身份与「服务/方法不存在」的收敛都在
|
|
@@ -82,8 +76,7 @@ function promptOnce(ctx, clientMessageId, payload) {
|
|
|
82
76
|
// 把这一发认回去的身份一起送进宿主的 prompt 请求(`request.requestId`,见
|
|
83
77
|
// host-bridge/endpoints.js)。宿主要是不认识它,user 消息的 `source.rpcId` 就是那个
|
|
84
78
|
// 端点自己生成的 `mobile-prompt-<uuid>` —— App 那边 `clientMessageId` 永远对不上,
|
|
85
|
-
// 只能靠「同文本 +
|
|
86
|
-
// 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
|
|
79
|
+
// 只能靠「同文本 + 同图数」猜着认领乐观行(实时帧里的图一旦漏读,张数对不上就彻底认领不了)。
|
|
87
80
|
const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
|
|
88
81
|
if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
|
|
89
82
|
// 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
|
|
@@ -176,14 +169,13 @@ async function handleSend(ctx, req, res) {
|
|
|
176
169
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
177
170
|
}
|
|
178
171
|
|
|
179
|
-
/** GET /api/mobile/v1/commands?sessionId=…(token
|
|
180
|
-
*
|
|
172
|
+
/** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(commands.list,与桌面
|
|
173
|
+
* Web 端命令面板同源)。
|
|
181
174
|
* 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
|
|
182
|
-
*
|
|
175
|
+
* 客户端菜单据此只展示真实可执行的命令。
|
|
183
176
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* 0.2.1 要求每个请求自带 Peer,这条路径没跟上,用户看到的就是「目标、计划、指令全没了」。 */
|
|
177
|
+
* 失败一律降级成空清单是刻意设计:输入区不能因为目录拉不到就报错。代价是这个端点出问题时
|
|
178
|
+
* 手机上看不出提示,「+」菜单静默只剩媒体与文件两行。 */
|
|
187
179
|
async function handleCommands(ctx, req, res) {
|
|
188
180
|
const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
|
|
189
181
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|