dsh-mobile-plugin 0.1.89 → 0.1.91
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/lib/impl.js +8 -1
- package/lib/index.js +2 -0
- package/lib/mirror/pusher.cjs +58 -0
- package/lib/mobile/approvals.js +350 -0
- package/lib/mobile/questions.js +267 -45
- package/lib/mobile/self-check.cjs +8 -0
- package/lib/mobile/session-send.js +3 -3
- package/lib/mobile/sessions.js +10 -4
- package/lib/mobile/stream.js +29 -0
- package/lib/routes/control-plane.js +29 -0
- package/lib/routes/mobile-api.js +2 -1
- package/lib/shell/runtime.js +17 -3
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -32,6 +32,8 @@ const { createPreviewService } = require('./relay/preview.js')
|
|
|
32
32
|
// 入口按两条盘上布局分别找(嵌在插件包内 / 与插件包同级),见 shared/credentials-entry.js。
|
|
33
33
|
const credentials = require('./shared/credentials-entry.js').credentialsEntry()
|
|
34
34
|
const { createApiProxy } = require('./mobile/host-bridge.js')
|
|
35
|
+
const { installApprovalBridge } = require('./mobile/approvals.js')
|
|
36
|
+
const { installQuestionBridge } = require('./mobile/questions.js')
|
|
35
37
|
// 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
|
|
36
38
|
const activity = require('./mobile/activity.cjs')
|
|
37
39
|
const { installAutoArchive } = require('./auto-archive.cjs')
|
|
@@ -225,7 +227,10 @@ function apply(ctx) {
|
|
|
225
227
|
// —— host-bridge.js 是翻译到 0.2.0 的 `ctx.get('typertGateway')` 的。所以按运行时
|
|
226
228
|
// 有没有这个包二选一:有就用宿主那份真 apiProxy,没有才装兼容服务。
|
|
227
229
|
// (不能靠 `ctx.get('apiProxy')` 判断:宿主是后于本插件装载的,那时它还不在。)
|
|
228
|
-
|
|
230
|
+
// 这两条二选一,判据同源:0.1.x 宿主自带真 apiProxy(审批帧与回答都还走得通),
|
|
231
|
+
// 0.2.x 才需要兼容桥,也才需要审批桥(见 mobile/approvals.js 的文件头)。
|
|
232
|
+
const hasHostApiProxy = runtimeProvidesApiProxy()
|
|
233
|
+
if (!hasHostApiProxy) ctx.reflect.provide('apiProxy', createApiProxy(ctx))
|
|
229
234
|
|
|
230
235
|
const state = core.loadState()
|
|
231
236
|
// 0.2.0 之前是 `ctx.settings.register(REMOTE_CONTROL_SETTINGS_NAMESPACE,
|
|
@@ -296,6 +301,8 @@ function apply(ctx) {
|
|
|
296
301
|
|
|
297
302
|
// Keep stale project conversations out of the host's session browser.
|
|
298
303
|
// This is deliberately Host-side so it remains active even with no operator present.
|
|
304
|
+
if (!hasHostApiProxy) ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`)
|
|
305
|
+
if (!hasHostApiProxy) ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`)
|
|
299
306
|
ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
|
|
300
307
|
ctx.effect(() => {
|
|
301
308
|
// Profiles without the optional tools injection still support core remote control.
|
package/lib/index.js
CHANGED
|
@@ -151,6 +151,8 @@ export function apply(ctx) {
|
|
|
151
151
|
check,
|
|
152
152
|
apply: applyUpdate,
|
|
153
153
|
reload: version => runtime.reload(version),
|
|
154
|
+
/** 就地把盘上这一份重新装载一遍:版本身份没变也照换(开发机改完代码用,见 runtime 的 reread)。 */
|
|
155
|
+
reread: () => runtime.reload(undefined, { reread: true }),
|
|
154
156
|
deviceOta,
|
|
155
157
|
setAutoUpdate: enabled => {
|
|
156
158
|
const value = updater.setAutoUpdate(enabled)
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -194,6 +194,8 @@ function createMirrorPusher(deps) {
|
|
|
194
194
|
|
|
195
195
|
// 镜像推送的唯一出口:所有 pushSync 都必须经此,保证状态与真实推送一致。
|
|
196
196
|
const pushMirrorPayload = async (payload) => {
|
|
197
|
+
// 每一次推上去的清单都顺手记一下规模(含短路那两条路径:手机侧栏看的就是这份清单)。
|
|
198
|
+
noteMirrorList(payload && payload.sessions);
|
|
197
199
|
if (!tunnelConnected()) {
|
|
198
200
|
recordMirrorPush(false, '隧道未连接,镜像未推送');
|
|
199
201
|
// 没推成镜像,但「正文长到哪儿了」这件事仍然要说给手机听(见 announceSessionActivity)。
|
|
@@ -256,6 +258,28 @@ function createMirrorPusher(deps) {
|
|
|
256
258
|
|
|
257
259
|
const isHighPriority = (s) => s && (s.running || (s.updatedAt && Date.now() - s.updatedAt < HIGH_PRIORITY_WINDOW_MS));
|
|
258
260
|
|
|
261
|
+
/**
|
|
262
|
+
* 镜像清单本身的规模(自检台账用):会话总数、其中活跃的、其中子会话。
|
|
263
|
+
*
|
|
264
|
+
* 这是插件侧能**诚实**报出的那部分规模——「宿主内存里挂着多少会话、正文占多少」插件看不到
|
|
265
|
+
* (它住在宿主进程里,没有独立视角,见 lib/mobile/self-check.cjs 的口径段)。清单每 tick 都
|
|
266
|
+
* 在重建,顺手记三个计数不额外花销。
|
|
267
|
+
*
|
|
268
|
+
* 只在真拿到清单时更新:拿不到时保留上一次的读数(连带 `at`),与「清单突然变成 0」区分开。
|
|
269
|
+
*/
|
|
270
|
+
let mirrorListSessions = 0;
|
|
271
|
+
let mirrorListActiveSessions = 0;
|
|
272
|
+
let mirrorListSubagents = 0;
|
|
273
|
+
let mirrorListAt = 0;
|
|
274
|
+
const noteMirrorList = (sessionsBody) => {
|
|
275
|
+
const items = sessionsBody && Array.isArray(sessionsBody.items) ? sessionsBody.items : null;
|
|
276
|
+
if (!items) return;
|
|
277
|
+
mirrorListSessions = items.length;
|
|
278
|
+
mirrorListActiveSessions = items.filter(isHighPriority).length;
|
|
279
|
+
mirrorListSubagents = items.filter((s) => s && s.origin === 'subagent').length;
|
|
280
|
+
mirrorListAt = Date.now();
|
|
281
|
+
};
|
|
282
|
+
|
|
259
283
|
// Every session snapshot fans out to three Host RPCs. Bound the number of sessions
|
|
260
284
|
// being built globally; event work gets priority, but batch/cold work gets one slot
|
|
261
285
|
// after at most three consecutive event jobs to prevent starvation.
|
|
@@ -323,6 +347,33 @@ function createMirrorPusher(deps) {
|
|
|
323
347
|
// 清单里没有这一行(比如事件先到、清单还没刷出来)时退回 Date.now():这个值只参与
|
|
324
348
|
// 缓存指纹,不影响正文内容本身。
|
|
325
349
|
const updatedAt = (summary && summary.updatedAt) || Date.now();
|
|
350
|
+
/**
|
|
351
|
+
* 子会话的正文**刻意不建**(2026-10-19)。
|
|
352
|
+
*
|
|
353
|
+
* 普通历史接口读子会话会被宿主按 durable parent address 拒掉
|
|
354
|
+
* (`session/agent-busy: subagent Sessions require their durable parent address`),
|
|
355
|
+
* 而镜像这份正文手机本来就用不到:手机打开子代理读的是子代理那条专用通道(带父会话
|
|
356
|
+
* 地址与 mode,见 lib/mobile/subagents.js 的文件头)。以前每次都照读、每次都被拒,
|
|
357
|
+
* 于是 5 秒一次的冷轮转里固定夹着一次注定失败的读取,`skippedBodies` 一路涨
|
|
358
|
+
* (真机实测 4 分钟 408 → 451),日志里那条 skip 也永远说不清到底哪坏了。
|
|
359
|
+
*
|
|
360
|
+
* 这里提前认出清单行上的 `origin === 'subagent'` 就不发那次请求,且 **skipReason 留空**:
|
|
361
|
+
* 它不是失败,不该进 `skippedBodies` 那本故障账。清单行本身照旧推给手机(App 靠行上的
|
|
362
|
+
* origin/parentSessionId 认子代理),stats/models 照旧构建,所以镜像载荷的形状与既有的
|
|
363
|
+
* 「正文缺席」那条路径完全一致。
|
|
364
|
+
*/
|
|
365
|
+
if (summary && summary.origin === 'subagent') {
|
|
366
|
+
const [stats, models] = await Promise.all([
|
|
367
|
+
sessions.buildStatsSnapshot(ctx, sessionId),
|
|
368
|
+
sessions.buildModelsSnapshot(ctx, sessionId),
|
|
369
|
+
]);
|
|
370
|
+
return {
|
|
371
|
+
messages: null,
|
|
372
|
+
skipReason: null,
|
|
373
|
+
stats: stats?.ok ? stats : null,
|
|
374
|
+
models: models?.ok ? models : null,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
326
377
|
const [messages, stats, models] = await Promise.all([
|
|
327
378
|
sessions.buildMirrorWarmSnapshots(
|
|
328
379
|
ctx,
|
|
@@ -707,6 +758,13 @@ function createMirrorPusher(deps) {
|
|
|
707
758
|
// 正文缺失而清单照推的次数与最近原因:与 ok/pushedAt 分开,避免再把
|
|
708
759
|
// 「某会话正文读不到」误读成「镜像死了」(见 recordMirrorPushSkipped)。
|
|
709
760
|
skippedBodies: mirrorBodySkipCount, skipReason: mirrorBodySkipReason,
|
|
761
|
+
// 清单规模(自检台账读它):会话总数 / 其中活跃 / 其中子会话 / 这份读数的时间。
|
|
762
|
+
// 这是「手机侧栏那份清单有多大」,**不是**「宿主内存里挂着多少会话、正文占多少」——
|
|
763
|
+
// 后者插件看不到(见 lib/mobile/self-check.cjs 的口径段)。
|
|
764
|
+
listSessions: mirrorListSessions,
|
|
765
|
+
listActiveSessions: mirrorListActiveSessions,
|
|
766
|
+
listSubagents: mirrorListSubagents,
|
|
767
|
+
listAt: mirrorListAt,
|
|
710
768
|
}),
|
|
711
769
|
/** 失败会话的定向重试账(诊断 + 守卫用):失败几次、下次什么时候补推。 */
|
|
712
770
|
getPushFailures: () => [...sessionPushFailures.entries()].map(([sessionId, entry]) => ({
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机端审批桥(DSH 0.2.x)。
|
|
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 都没有」。
|
|
14
|
+
*
|
|
15
|
+
* 这里按 0.2.x 的形状把那半条通道补起来,**手机端契约一行不改**(App 仍然收
|
|
16
|
+
* `approval/requested` + 帧级 rpcId,仍然 POST `/approval/respond`):
|
|
17
|
+
*
|
|
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
|
+
* 3. `/approval/respond` 拿 rpcId 找到挂起项,把结论交回钩子 —— 这才是真正让 agent
|
|
26
|
+
* 继续往下跑的那一步。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 钩子上**没有**宿主 id(既没有 approvalId 也没有 rpcId),而且它和 `approval/asked`
|
|
29
|
+
* 谁先到取决于宿主的派发顺序(事件在 `request()` 里先 append、钩子随后派发,但两者都走
|
|
30
|
+
* 微任务)。所以两个来源共用一张挂起项表:先到的建项,后到的补字段(事件补真实
|
|
31
|
+
* approvalId,钩子补 displayReason 与 signal)。卡片在任一路径上都不等对方。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ 为什么必须 prepend:宿主自己的转发器(`dsh-api-remotes`)在桌面窗口开着时会一直等
|
|
34
|
+
* 桌面回答、**不调用 next()**,而且那些核心包必然先于本插件装载。老实按注册顺序排在后面
|
|
35
|
+
* 等于「桌面开着时手机永远收不到」,正是这次要修的现象。prepend 是本桥能生效的前提,
|
|
36
|
+
* 不是优化。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const { randomUUID } = require('node:crypto');
|
|
40
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
41
|
+
|
|
42
|
+
/** `/approval/respond` 只认这两种结论(与 App 的两个按钮一一对应)。 */
|
|
43
|
+
const ANSWER_OUTCOMES = new Set(['allowed-once', 'rejected']);
|
|
44
|
+
/** 宿主 `approval/decided` 的结论词表(`dsh-user-approval` 的 OUTCOMES)。 */
|
|
45
|
+
const DECISIONS = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
|
|
46
|
+
|
|
47
|
+
/** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
|
|
48
|
+
const watchers = new Map();
|
|
49
|
+
/** rpcId → entry:回答时按它定位。 */
|
|
50
|
+
const entries = new Map();
|
|
51
|
+
/** sessionId → entry[](保序):把钩子上的请求和事件里的审批对上。 */
|
|
52
|
+
const bySession = new Map();
|
|
53
|
+
|
|
54
|
+
function watcherCount(sessionId) {
|
|
55
|
+
const set = watchers.get(sessionId);
|
|
56
|
+
return set ? set.size : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function openEntries(sessionId) {
|
|
60
|
+
const list = bySession.get(sessionId);
|
|
61
|
+
return list ? list.filter((entry) => !entry.settled) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 卡片帧。形状严格照 App `utils/approvalFrames.ts` 认的那一种:**帧级 rpcId** + payload 上的
|
|
66
|
+
* approvalId;reason 为空时 App 会自己落到「工具 X 请求越权执行」,与桌面同一句兜底文案。
|
|
67
|
+
*/
|
|
68
|
+
function cardFrame(entry) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'approval/requested',
|
|
71
|
+
sessionId: entry.sessionId,
|
|
72
|
+
rpcId: entry.rpcId,
|
|
73
|
+
approvalId: entry.approvalId,
|
|
74
|
+
...(entry.toolName ? { toolName: entry.toolName } : {}),
|
|
75
|
+
...(entry.reason ? { reason: entry.reason } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 结算帧。App 按 approvalId 比对(`approvalResolved`),所以这条让手机立刻收卡,不必等
|
|
81
|
+
* 宿主的 `approval/decided` 绕一圈 —— 尤其是手机自己答的那一条,桌面那条路已经不需要了。
|
|
82
|
+
*/
|
|
83
|
+
function resolvedFrame(entry, outcome) {
|
|
84
|
+
return { type: 'approval/resolved', sessionId: entry.sessionId, approvalId: entry.approvalId, outcome };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function broadcast(sessionId, frame) {
|
|
88
|
+
const set = watchers.get(sessionId);
|
|
89
|
+
if (!set) return;
|
|
90
|
+
for (const sink of set) {
|
|
91
|
+
// 单条连接写失败(客户端正好走了)不影响别的手机拿到这张卡。
|
|
92
|
+
try { sink(frame); } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pushCard(entry) {
|
|
97
|
+
if (entry.settled || watcherCount(entry.sessionId) === 0) return;
|
|
98
|
+
broadcast(entry.sessionId, cardFrame(entry));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 取(或建)一条挂起项。两个来源都走这里:按 callId → toolName 配对,配对不上的就新建。
|
|
103
|
+
*
|
|
104
|
+
* `unboundOnly` 只给钩子用:钩子绑上一条就已经接管了它(回答由那条钩子的 promise 决定),
|
|
105
|
+
* 第二条钩子请求绝不能共用同一条挂起项。反过来,事件那一侧必须能认领**已经绑上**的挂起项 ——
|
|
106
|
+
* 「钩子先到、`approval/asked` 后到」时就是靠这一步把真实的 approvalId 补进去的。
|
|
107
|
+
*/
|
|
108
|
+
function entryFor(sessionId, ask, { unboundOnly = false } = {}) {
|
|
109
|
+
const open = openEntries(sessionId).filter((entry) => !unboundOnly || !entry.bound);
|
|
110
|
+
// 两个来源拿到的是宿主**同一个** request 对象(`dsh-user-approval.request()` 把 req 原样
|
|
111
|
+
// 交给钩子,`approval/asked` 也照它记 toolName/callId),所以按 callId → toolName 配对是
|
|
112
|
+
// 精确的;配对不上就新建(宁可多一张卡,也不要两条审批共用一张卡)。
|
|
113
|
+
const match = open.find((entry) => ask.callId && entry.callId === ask.callId)
|
|
114
|
+
|| open.find((entry) => ask.toolName && entry.toolName === ask.toolName);
|
|
115
|
+
if (match) return match;
|
|
116
|
+
const entry = {
|
|
117
|
+
// rpcId 是本桥自己铸的:0.2.x 的宿主不再提供任何可作答的帧级 id,而 App 只认带 rpcId 的帧。
|
|
118
|
+
rpcId: `m-${randomUUID()}`,
|
|
119
|
+
sessionId,
|
|
120
|
+
// 钩子先到时还不知道宿主的 approvalId,先铸一个占位(只用于挂起项身份,回答不看它);
|
|
121
|
+
// `approval/asked` 一到就换成真 id,好让 App 能把 approval/decided 对上。
|
|
122
|
+
approvalId: ask.approvalId || `m-${randomUUID()}`,
|
|
123
|
+
toolName: ask.toolName,
|
|
124
|
+
callId: ask.callId,
|
|
125
|
+
reason: ask.reason,
|
|
126
|
+
bound: false,
|
|
127
|
+
settled: false,
|
|
128
|
+
wait: null,
|
|
129
|
+
release: null,
|
|
130
|
+
};
|
|
131
|
+
entry.wait = new Promise((resolve) => { entry.release = resolve; });
|
|
132
|
+
entries.set(entry.rpcId, entry);
|
|
133
|
+
let list = bySession.get(sessionId);
|
|
134
|
+
if (!list) { list = []; bySession.set(sessionId, list); }
|
|
135
|
+
list.push(entry);
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 结算一条挂起项:唤醒等待中的钩子、撤下挂起项、让手机收卡。重复调用无副作用。 */
|
|
140
|
+
function settle(entry, outcome) {
|
|
141
|
+
if (entry.settled) return false;
|
|
142
|
+
entry.settled = true;
|
|
143
|
+
entries.delete(entry.rpcId);
|
|
144
|
+
const list = bySession.get(entry.sessionId);
|
|
145
|
+
if (list) {
|
|
146
|
+
const index = list.indexOf(entry);
|
|
147
|
+
if (index >= 0) list.splice(index, 1);
|
|
148
|
+
if (list.length === 0) bySession.delete(entry.sessionId);
|
|
149
|
+
}
|
|
150
|
+
entry.release({ outcome });
|
|
151
|
+
broadcast(entry.sessionId, resolvedFrame(entry, outcome));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 手机接上/离开某条会话流。接入时补发仍然可作答的卡;断开只摘掉这一个 sink —— 挂起项本身
|
|
157
|
+
* 继续等(用户可能马上就回来),由宿主的 signal 中止或别人答掉来收尾。
|
|
158
|
+
*/
|
|
159
|
+
function attachSession(sessionId, sink) {
|
|
160
|
+
let set = watchers.get(sessionId);
|
|
161
|
+
if (!set) { set = new Set(); watchers.set(sessionId, set); }
|
|
162
|
+
set.add(sink);
|
|
163
|
+
// 补发:审批可能在手机还没看这个会话时就挂起了(用户在列表页点了进来),
|
|
164
|
+
// 而钩子上的请求还在等 —— 那时候这张卡仍然答得动。
|
|
165
|
+
for (const entry of openEntries(sessionId)) {
|
|
166
|
+
if (entry.bound) { try { sink(cardFrame(entry)); } catch { /* ignore */ } }
|
|
167
|
+
}
|
|
168
|
+
return () => {
|
|
169
|
+
set.delete(sink);
|
|
170
|
+
if (set.size === 0) watchers.delete(sessionId);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
|
|
175
|
+
function hasPending(sessionId) {
|
|
176
|
+
return openEntries(sessionId).length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
|
|
180
|
+
function resetForTest() {
|
|
181
|
+
watchers.clear();
|
|
182
|
+
entries.clear();
|
|
183
|
+
bySession.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sessionIdOf(session) {
|
|
187
|
+
if (!session || typeof session !== 'object') return null;
|
|
188
|
+
if (typeof session.id === 'string' && session.id) return session.id;
|
|
189
|
+
// 宿主自己的两处写法不一致(`dsh-api-session-controller` 用 `session.id`,
|
|
190
|
+
// `dsh-acp` 用 `session.header.id`);认不出会话 id 就等于这条审批没人能答,所以两种都认。
|
|
191
|
+
const header = session.header;
|
|
192
|
+
return header && typeof header.id === 'string' && header.id ? header.id : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sessionIdOfRequest(request) {
|
|
196
|
+
const agent = request && request.agent;
|
|
197
|
+
const session = agent && agent.session;
|
|
198
|
+
return sessionIdOf(session);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 升级卡片的文案:宿主给 `displayReason`(tz 化文案)时优先用它,与桌面同一句。 */
|
|
202
|
+
function displayReasonOf(request) {
|
|
203
|
+
const display = request && request.displayReason;
|
|
204
|
+
if (!display || typeof display !== 'object') return undefined;
|
|
205
|
+
const zh = display.zh;
|
|
206
|
+
if (typeof zh === 'string' && zh) return zh;
|
|
207
|
+
return Object.values(display).find((value) => typeof value === 'string' && value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `approval/asked`:宿主记下这次审批。details 见 `dsh-user-approval` 的 `request()`。 */
|
|
211
|
+
function noteAsk(sessionId, data) {
|
|
212
|
+
const approvalId = typeof data.id === 'string' && data.id ? data.id : null;
|
|
213
|
+
if (!approvalId) return;
|
|
214
|
+
const entry = entryFor(sessionId, {
|
|
215
|
+
approvalId,
|
|
216
|
+
toolName: typeof data.toolName === 'string' ? data.toolName : undefined,
|
|
217
|
+
callId: typeof data.callId === 'string' ? data.callId : undefined,
|
|
218
|
+
reason: typeof data.reason === 'string' ? data.reason : undefined,
|
|
219
|
+
});
|
|
220
|
+
if (entry.approvalId === approvalId) { pushCard(entry); return; }
|
|
221
|
+
// 钩子先到、铸了占位 id:换成宿主的真 id 并重发一次卡(同 rpcId,App 只是换一份内容)。
|
|
222
|
+
entry.approvalId = approvalId;
|
|
223
|
+
pushCard(entry);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `approval/decided`:这条审批已经有结论了(桌面答的、或被取消),把卡和挂起项一起收掉。 */
|
|
227
|
+
function noteDecided(sessionId, data) {
|
|
228
|
+
const approvalId = typeof data.id === 'string' && data.id ? data.id : null;
|
|
229
|
+
if (!approvalId) return;
|
|
230
|
+
const entry = openEntries(sessionId).find((candidate) => candidate.approvalId === approvalId);
|
|
231
|
+
if (!entry) return;
|
|
232
|
+
settle(entry, DECISIONS.has(data.outcome) ? data.outcome : 'unavailable');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把一条钩子请求绑到挂起项上。
|
|
237
|
+
*
|
|
238
|
+
* 绑上才算「答得动」:`/approval/respond` 只结算 bound 的挂起项,否则手机点了按钮而 agent
|
|
239
|
+
* 那头没人接(比如本桥没装上、请求走的是别的回答者),如实回 409 比假成功好。
|
|
240
|
+
*/
|
|
241
|
+
function bindRequest(entry, request) {
|
|
242
|
+
entry.bound = true;
|
|
243
|
+
entry.toolName = entry.toolName || (typeof request.toolName === 'string' ? request.toolName : undefined);
|
|
244
|
+
entry.callId = entry.callId || (typeof request.callId === 'string' ? request.callId : undefined);
|
|
245
|
+
const nicer = displayReasonOf(request) || (typeof request.reason === 'string' ? request.reason : undefined);
|
|
246
|
+
if (nicer) entry.reason = nicer;
|
|
247
|
+
// 绑上再推一次:`approval/asked` 那条路已经推过一张(可能是占位 id、也可能是没 tz 化的
|
|
248
|
+
// 原始 reason),这里换成最终那份内容。同一个 rpcId,App 只是替换面板内容。
|
|
249
|
+
pushCard(entry);
|
|
250
|
+
const signal = request.signal;
|
|
251
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
252
|
+
const onAbort = () => { settle(entry, 'cancelled'); };
|
|
253
|
+
if (signal.aborted) onAbort();
|
|
254
|
+
else {
|
|
255
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
256
|
+
// 结算时把监听撤掉:这条 signal 归调用方的 turn 所有,挂着的监听会跟到 turn 结束。
|
|
257
|
+
const release = entry.release;
|
|
258
|
+
entry.release = (result) => { signal.removeEventListener('abort', onAbort); release(result); };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 挂起这条请求直到有人给结论。
|
|
265
|
+
*
|
|
266
|
+
* 下游**先叫**:`next()` 一调,桌面窗口那张卡照旧出现(这条路径的行为一字不改)。手机答得
|
|
267
|
+
* 早就用手机的;下游答得早就用下游的 —— 谁先给结论谁算数,输的那张卡由结算帧收掉。
|
|
268
|
+
* 两边都没人答时这里会一直挂着,和今天宿主自己的行为一致(审批本来就等人工)。
|
|
269
|
+
*/
|
|
270
|
+
function holdForPhone(entry, next) {
|
|
271
|
+
const downstream = Promise.resolve().then(() => next()).then((outcome) => {
|
|
272
|
+
const value = DECISIONS.has(outcome) ? outcome : 'unavailable';
|
|
273
|
+
settle(entry, value);
|
|
274
|
+
return value;
|
|
275
|
+
});
|
|
276
|
+
return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function onApprovalRequest(request, next) {
|
|
280
|
+
const sessionId = sessionIdOfRequest(request);
|
|
281
|
+
if (!sessionId) return next();
|
|
282
|
+
const entry = entryFor(sessionId, {
|
|
283
|
+
approvalId: undefined,
|
|
284
|
+
toolName: typeof request.toolName === 'string' ? request.toolName : undefined,
|
|
285
|
+
callId: typeof request.callId === 'string' ? request.callId : undefined,
|
|
286
|
+
reason: typeof request.reason === 'string' ? request.reason : undefined,
|
|
287
|
+
}, { unboundOnly: true });
|
|
288
|
+
bindRequest(entry, request);
|
|
289
|
+
return holdForPhone(entry, next);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 装上审批桥(只在 0.2.x 那一侧装:0.1.x 宿主自带 apiProxy,帧和回答都还走得通,装上去
|
|
294
|
+
* 只会多推一张卡)。
|
|
295
|
+
*/
|
|
296
|
+
function installApprovalBridge(ctx) {
|
|
297
|
+
const disposers = [
|
|
298
|
+
ctx.on('session/event', (session, event) => {
|
|
299
|
+
const sessionId = sessionIdOf(session);
|
|
300
|
+
if (!sessionId || !event || typeof event !== 'object') return;
|
|
301
|
+
if (event.type === 'approval/asked') noteAsk(sessionId, event.data || {});
|
|
302
|
+
else if (event.type === 'approval/decided') noteDecided(sessionId, event.data || {});
|
|
303
|
+
}, { global: true }),
|
|
304
|
+
// prepend 是前提不是优化,理由见文件头。
|
|
305
|
+
ctx.on('approval/request', onApprovalRequest, { prepend: true, global: true }),
|
|
306
|
+
];
|
|
307
|
+
return () => {
|
|
308
|
+
for (const dispose of disposers) dispose();
|
|
309
|
+
for (const entry of [...entries.values()]) settle(entry, 'cancelled');
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* POST /api/mobile/v1/approval/respond —— `{rpcId, sessionId, approvalId, outcome}`。
|
|
315
|
+
*
|
|
316
|
+
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
317
|
+
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
318
|
+
*/
|
|
319
|
+
async function handleApprovalRespond(ctx, req, res) {
|
|
320
|
+
const body = await readJsonBody(req);
|
|
321
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId
|
|
322
|
+
|| typeof body.sessionId !== 'string' || !body.sessionId
|
|
323
|
+
|| typeof body.approvalId !== 'string' || !body.approvalId
|
|
324
|
+
|| !ANSWER_OUTCOMES.has(body.outcome)) {
|
|
325
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, approvalId, outcome }' } });
|
|
326
|
+
}
|
|
327
|
+
const entry = entries.get(body.rpcId);
|
|
328
|
+
if (entry && !entry.settled && entry.bound
|
|
329
|
+
&& entry.sessionId === body.sessionId && entry.approvalId === body.approvalId) {
|
|
330
|
+
settle(entry, body.outcome);
|
|
331
|
+
return sendJson(res, 200, { ok: true, accepted: true });
|
|
332
|
+
}
|
|
333
|
+
const receipt = await ctx.apiProxy.respond({
|
|
334
|
+
type: 'client-response',
|
|
335
|
+
rpcId: body.rpcId,
|
|
336
|
+
result: { ok: true, value: { sessionId: body.sessionId, approvalId: body.approvalId, outcome: body.outcome } },
|
|
337
|
+
}).catch(() => null);
|
|
338
|
+
if (!receipt || receipt.accepted !== true) {
|
|
339
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该审批已完成或不可回答' } });
|
|
340
|
+
}
|
|
341
|
+
sendJson(res, 200, { ok: true, accepted: true });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
installApprovalBridge,
|
|
346
|
+
handleApprovalRespond,
|
|
347
|
+
attachSession,
|
|
348
|
+
hasPending,
|
|
349
|
+
resetForTest,
|
|
350
|
+
};
|
package/lib/mobile/questions.js
CHANGED
|
@@ -1,71 +1,293 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 手机端提问桥(DSH 0.2.x):`ask_user_question` 与「计划评审」。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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。
|
|
12
|
+
*
|
|
13
|
+
* 这里按 0.2.x 的形状把这条通道补起来,**手机端契约一行不改**(App 仍然收
|
|
14
|
+
* `question/requested` + 帧级 rpcId,仍然 POST `/question/respond`):
|
|
15
|
+
*
|
|
16
|
+
* 1. 听 `user-questions/request` 钩子(**prepend**,理由与审批桥同一条:宿主的转发器
|
|
17
|
+
* `dsh-api-remotes` 在桌面窗口开着时会一直等桌面回答、不调用 `next()`)。铸一个自己的
|
|
18
|
+
* rpcId,把卡片推给正在看这个会话的手机,然后把请求挂住。
|
|
19
|
+
* 2. 下游照旧拿到请求(桌面那张卡照旧出现,`dsh-plan-mode` 也一样),两边竞速:
|
|
20
|
+
* 手机答得早就用手机的,桌面答得早就用桌面的。
|
|
21
|
+
* 3. `/question/respond` 拿 rpcId 找到挂起项,把回答交回钩子 —— 这才是真正让 agent
|
|
22
|
+
* 继续往下跑的那一步。落定后广播 `question/resolved`,另一边那张卡被收掉。
|
|
23
|
+
*
|
|
24
|
+
* 「计划评审」不走另一条通道:`dsh-plan-mode` 的 `exit_plan_mode` 就是拿
|
|
25
|
+
* `ctx.userQuestions.ask()` 问的(问题带 `intent.kind='plan-review'`、`detail` 是计划正文、
|
|
26
|
+
* 选项里有一个「批准」标签),所以这道桥同时修好它。App 的问题面板本来就渲染
|
|
27
|
+
* `detail` 与 `options`,计划正文直接显示在详情里。
|
|
8
28
|
*/
|
|
9
29
|
|
|
30
|
+
const { randomUUID } = require('node:crypto');
|
|
10
31
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
11
32
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
/** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
|
|
34
|
+
const watchers = new Map();
|
|
35
|
+
/** rpcId → entry:回答时按它定位。 */
|
|
36
|
+
const entries = new Map();
|
|
37
|
+
/** sessionId → entry[](保序):接入时补发、摘要里数待处理。 */
|
|
38
|
+
const bySession = new Map();
|
|
39
|
+
|
|
40
|
+
function watcherCount(sessionId) {
|
|
41
|
+
const set = watchers.get(sessionId);
|
|
42
|
+
return set ? set.size : 0;
|
|
16
43
|
}
|
|
17
44
|
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
45
|
+
function openEntries(sessionId) {
|
|
46
|
+
const list = bySession.get(sessionId);
|
|
47
|
+
return list ? list.filter((entry) => !entry.settled) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 卡片帧。形状严格照 App `streamFrame.ts` 认的那一种:**帧级 rpcId** + 顶层
|
|
52
|
+
* `sessionId` + `questions`(每题至少 `id` 与 `question` 两个字符串)。
|
|
53
|
+
* 只转发 App 认识的字段,宿主的其它内部键(如 `intent` 里的 callId)不外泄。
|
|
54
|
+
*/
|
|
55
|
+
function cardFrame(entry) {
|
|
56
|
+
return {
|
|
57
|
+
type: 'question/requested',
|
|
58
|
+
sessionId: entry.sessionId,
|
|
59
|
+
rpcId: entry.rpcId,
|
|
60
|
+
questions: entry.questions.map((question) => ({
|
|
61
|
+
id: question.id,
|
|
62
|
+
question: question.question,
|
|
63
|
+
...(question.header ? { header: question.header } : {}),
|
|
64
|
+
...(question.detail ? { detail: question.detail } : {}),
|
|
65
|
+
...(question.options ? { options: question.options } : {}),
|
|
66
|
+
...(question.multiSelect ? { multiSelect: question.multiSelect } : {}),
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 结算帧。App 按 `questionRpcId` 比对后清掉面板(`streamFrame.ts` 的
|
|
73
|
+
* `question/resolved` 分支),所以这里必须带上本桥铸的那个 rpcId。
|
|
74
|
+
*/
|
|
75
|
+
function resolvedFrame(entry) {
|
|
76
|
+
return { type: 'question/resolved', sessionId: entry.sessionId, questionRpcId: entry.rpcId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function broadcast(sessionId, frame) {
|
|
80
|
+
const set = watchers.get(sessionId);
|
|
81
|
+
if (!set) return;
|
|
82
|
+
for (const sink of set) {
|
|
83
|
+
// 单条连接写失败(客户端正好走了)不影响别的手机拿到这张卡。
|
|
84
|
+
try { sink(frame); } catch { /* ignore */ }
|
|
22
85
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pushCard(entry) {
|
|
89
|
+
if (entry.settled || watcherCount(entry.sessionId) === 0) return;
|
|
90
|
+
broadcast(entry.sessionId, cardFrame(entry));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entryFor(sessionId, request) {
|
|
94
|
+
const entry = {
|
|
95
|
+
// rpcId 是本桥自己铸的:0.2.x 的宿主不再提供任何可作答的帧级 id,而 App 只认带 rpcId 的帧。
|
|
96
|
+
rpcId: `m-${randomUUID()}`,
|
|
97
|
+
sessionId,
|
|
98
|
+
callId: request && request.wait && typeof request.wait.callId === 'string' ? request.wait.callId : undefined,
|
|
99
|
+
questions: Array.isArray(request && request.questions) ? request.questions : [],
|
|
100
|
+
bound: false,
|
|
101
|
+
settled: false,
|
|
102
|
+
signalReason: null,
|
|
103
|
+
wait: null,
|
|
104
|
+
release: null,
|
|
105
|
+
};
|
|
106
|
+
entry.wait = new Promise((resolve) => { entry.release = resolve; });
|
|
107
|
+
entries.set(entry.rpcId, entry);
|
|
108
|
+
let list = bySession.get(sessionId);
|
|
109
|
+
if (!list) { list = []; bySession.set(sessionId, list); }
|
|
110
|
+
list.push(entry);
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 结算一条挂起项:撤下挂起项、让手机收卡、唤醒等待中的钩子。
|
|
116
|
+
* `answers` 有值 = 手机给了答案;null = 手机这条路没结论(被取消 / 别人先答了)。
|
|
117
|
+
*/
|
|
118
|
+
function settle(entry, answers) {
|
|
119
|
+
if (entry.settled) return false;
|
|
120
|
+
entry.settled = true;
|
|
121
|
+
entries.delete(entry.rpcId);
|
|
122
|
+
const list = bySession.get(entry.sessionId);
|
|
123
|
+
if (list) {
|
|
124
|
+
const index = list.indexOf(entry);
|
|
125
|
+
if (index >= 0) list.splice(index, 1);
|
|
126
|
+
if (list.length === 0) bySession.delete(entry.sessionId);
|
|
26
127
|
}
|
|
128
|
+
entry.release({ answers });
|
|
129
|
+
broadcast(entry.sessionId, resolvedFrame(entry));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
27
132
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
selected: answer.selected,
|
|
39
|
-
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
40
|
-
})),
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
if (!receipt || receipt.accepted !== true) {
|
|
46
|
-
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该问题已完成或不可回答' } });
|
|
133
|
+
/**
|
|
134
|
+
* 手机接上/离开某条会话流。接入时补发仍然可作答的卡;断开只摘掉这一个 sink —— 挂起项本身
|
|
135
|
+
* 继续等(用户可能马上就回来),由宿主的 signal 中止或别人答掉来收尾。
|
|
136
|
+
*/
|
|
137
|
+
function attachSession(sessionId, sink) {
|
|
138
|
+
let set = watchers.get(sessionId);
|
|
139
|
+
if (!set) { set = new Set(); watchers.set(sessionId, set); }
|
|
140
|
+
set.add(sink);
|
|
141
|
+
for (const entry of openEntries(sessionId)) {
|
|
142
|
+
if (entry.bound) { try { sink(cardFrame(entry)); } catch { /* ignore */ } }
|
|
47
143
|
}
|
|
48
|
-
|
|
144
|
+
return () => {
|
|
145
|
+
set.delete(sink);
|
|
146
|
+
if (set.size === 0) watchers.delete(sessionId);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
|
|
151
|
+
function pendingKind(sessionId) {
|
|
152
|
+
const open = openEntries(sessionId);
|
|
153
|
+
if (open.length === 0) return undefined;
|
|
154
|
+
const planReview = open.some((entry) => entry.questions.some((question) => {
|
|
155
|
+
const intent = question && question.intent;
|
|
156
|
+
return intent && typeof intent === 'object' && intent.kind === 'plan-review';
|
|
157
|
+
}));
|
|
158
|
+
return planReview ? 'plan-review' : 'question';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
|
|
162
|
+
function resetForTest() {
|
|
163
|
+
watchers.clear();
|
|
164
|
+
entries.clear();
|
|
165
|
+
bySession.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sessionIdOf(session) {
|
|
169
|
+
if (!session || typeof session !== 'object') return null;
|
|
170
|
+
if (typeof session.id === 'string' && session.id) return session.id;
|
|
171
|
+
const header = session.header;
|
|
172
|
+
return header && typeof header.id === 'string' && header.id ? header.id : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sessionIdOfRequest(request) {
|
|
176
|
+
const agent = request && request.agent;
|
|
177
|
+
const session = agent && agent.session;
|
|
178
|
+
return sessionIdOf(session);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 把回答批次校验成宿主 `AskUserQuestionAnswer` 的形状:每题恰好一条、标题合法。 */
|
|
182
|
+
function normalizeAnswers(questions, answers) {
|
|
183
|
+
if (!Array.isArray(answers) || answers.length !== questions.length) return null;
|
|
184
|
+
const byId = new Map();
|
|
185
|
+
for (const answer of answers) {
|
|
186
|
+
if (!answer || typeof answer.id !== 'string' || byId.has(answer.id)) return null;
|
|
187
|
+
if (!Array.isArray(answer.selected) || !answer.selected.every((label) => typeof label === 'string')) return null;
|
|
188
|
+
if (answer.custom !== undefined && typeof answer.custom !== 'string') return null;
|
|
189
|
+
byId.set(answer.id, {
|
|
190
|
+
id: answer.id,
|
|
191
|
+
selected: [...answer.selected],
|
|
192
|
+
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (!questions.every((question) => byId.has(question.id))) return null;
|
|
196
|
+
return questions.map((question) => byId.get(question.id));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 绑上钩子请求:从此这条挂起项「答得动」,并接上宿主的取消信号。 */
|
|
200
|
+
function bindRequest(entry, request) {
|
|
201
|
+
entry.bound = true;
|
|
202
|
+
pushCard(entry);
|
|
203
|
+
const signal = request.signal;
|
|
204
|
+
if (!signal || typeof signal.addEventListener !== 'function') return;
|
|
205
|
+
const onAbort = () => {
|
|
206
|
+
entry.signalReason = signal.reason;
|
|
207
|
+
settle(entry, null);
|
|
208
|
+
};
|
|
209
|
+
if (signal.aborted) onAbort();
|
|
210
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
211
|
+
const release = entry.release;
|
|
212
|
+
entry.release = (result) => {
|
|
213
|
+
signal.removeEventListener('abort', onAbort);
|
|
214
|
+
release(result);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 挂起这条请求直到有人回答。
|
|
220
|
+
*
|
|
221
|
+
* 下游**先叫**:`next()` 一调,桌面窗口那张卡照旧出现(这条路径的行为一字不改)。手机答得
|
|
222
|
+
* 早就用手机的;下游答得早就用下游的。手机上那张卡由结算帧收掉。
|
|
223
|
+
*/
|
|
224
|
+
function holdForPhone(entry, next) {
|
|
225
|
+
const downstream = Promise.resolve().then(() => next()).then(
|
|
226
|
+
(answer) => { settle(entry, null); return answer; },
|
|
227
|
+
(error) => { settle(entry, null); throw error; },
|
|
228
|
+
);
|
|
229
|
+
const fromPhone = entry.wait.then((result) => {
|
|
230
|
+
if (result.answers) return { answers: result.answers };
|
|
231
|
+
// 手机这条路没给出答案(被取消):按宿主的 abort 语义把原因抛回去,
|
|
232
|
+
// 别假装成了一个空回答。
|
|
233
|
+
const reason = entry.signalReason;
|
|
234
|
+
throw reason instanceof Error ? reason : new Error('ask_user_question aborted');
|
|
235
|
+
});
|
|
236
|
+
return Promise.race([fromPhone, downstream]);
|
|
49
237
|
}
|
|
50
238
|
|
|
51
|
-
|
|
239
|
+
function onQuestionRequest(request, next) {
|
|
240
|
+
const sessionId = sessionIdOfRequest(request);
|
|
241
|
+
if (!sessionId) return next();
|
|
242
|
+
const entry = entryFor(sessionId, request);
|
|
243
|
+
bindRequest(entry, request);
|
|
244
|
+
return holdForPhone(entry, next);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 装上提问桥(只在 0.2.x 那一侧装,理由与审批桥相同)。 */
|
|
248
|
+
function installQuestionBridge(ctx) {
|
|
249
|
+
const dispose = ctx.on('user-questions/request', onQuestionRequest, { prepend: true, global: true });
|
|
250
|
+
return () => {
|
|
251
|
+
dispose();
|
|
252
|
+
for (const entry of [...entries.values()]) settle(entry, null);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* POST /api/mobile/v1/question/respond —— `{rpcId, sessionId, answer:{answers}}`。
|
|
258
|
+
*
|
|
259
|
+
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
260
|
+
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
261
|
+
*/
|
|
262
|
+
async function handleQuestionRespond(ctx, req, res) {
|
|
52
263
|
const body = await readJsonBody(req);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
264
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId || typeof body.sessionId !== 'string' || !body.sessionId) {
|
|
265
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, answer }' } });
|
|
266
|
+
}
|
|
267
|
+
const entry = entries.get(body.rpcId);
|
|
268
|
+
if (entry && !entry.settled && entry.bound && entry.sessionId === body.sessionId) {
|
|
269
|
+
const answers = normalizeAnswers(entry.questions, body.answer && body.answer.answers);
|
|
270
|
+
if (!answers) {
|
|
271
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 必须每题恰好一条、含合法 selected' } });
|
|
272
|
+
}
|
|
273
|
+
settle(entry, answers);
|
|
274
|
+
return sendJson(res, 200, { ok: true, accepted: true });
|
|
59
275
|
}
|
|
60
276
|
const receipt = await ctx.apiProxy.respond({
|
|
61
277
|
type: 'client-response',
|
|
62
278
|
rpcId: body.rpcId,
|
|
63
|
-
result: { ok: true, value: { sessionId: body.sessionId,
|
|
64
|
-
});
|
|
279
|
+
result: { ok: true, value: { sessionId: body.sessionId, answer: { answers: (body.answer && body.answer.answers) || [] } } },
|
|
280
|
+
}).catch(() => null);
|
|
65
281
|
if (!receipt || receipt.accepted !== true) {
|
|
66
|
-
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '
|
|
282
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该问题已完成或不可回答' } });
|
|
67
283
|
}
|
|
68
284
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
69
285
|
}
|
|
70
286
|
|
|
71
|
-
module.exports = {
|
|
287
|
+
module.exports = {
|
|
288
|
+
installQuestionBridge,
|
|
289
|
+
handleQuestionRespond,
|
|
290
|
+
attachSession,
|
|
291
|
+
pendingKind,
|
|
292
|
+
resetForTest,
|
|
293
|
+
};
|
|
@@ -194,6 +194,14 @@ function createSelfCheck(deps = {}) {
|
|
|
194
194
|
skipReason: known && typeof push.skipReason === 'string' ? push.skipReason : null,
|
|
195
195
|
// 待补推的会话数(pusher 的失败重试账长度)。
|
|
196
196
|
pushFailures: Array.isArray(failures) ? failures.length : null,
|
|
197
|
+
// 清单规模:会话总数 / 其中活跃(在跑或 5 分钟内动过)/ 其中子会话 / 这份读数的时间。
|
|
198
|
+
// 这是插件侧**能诚实报出的**「会话有多少」——手机侧栏读的就是这份清单。
|
|
199
|
+
// ⚠️ 它**不是**「宿主内存里挂着多少会话、正文占多少」:插件住在宿主进程里,看不到那个
|
|
200
|
+
// 口径(见文件头)。正文规模只能由 `cachedBytes` 那一格回答「插件自己握着多少」。
|
|
201
|
+
listSessions: known ? finiteNumber(push.listSessions) : null,
|
|
202
|
+
listActiveSessions: known ? finiteNumber(push.listActiveSessions) : null,
|
|
203
|
+
listSubagents: known ? finiteNumber(push.listSubagents) : null,
|
|
204
|
+
listAt: known ? (finiteNumber(push.listAt) === null ? 0 : finiteNumber(push.listAt)) : null,
|
|
197
205
|
cachedSessions: cache ? cache.entries : null,
|
|
198
206
|
cachedMessages: cache ? cache.messages : null,
|
|
199
207
|
cachedBytes: cache ? cache.bytes : null,
|
|
@@ -98,8 +98,8 @@ async function handleSend(ctx, req, res) {
|
|
|
98
98
|
if (!body || typeof body.sessionId !== 'string' || !body.sessionId) {
|
|
99
99
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, content | text }' } });
|
|
100
100
|
}
|
|
101
|
-
const mode = body.mode
|
|
102
|
-
if (mode !== 'queue' && mode !== 'steer') {
|
|
101
|
+
const mode = body.mode;
|
|
102
|
+
if (mode !== undefined && mode !== 'queue' && mode !== 'steer') {
|
|
103
103
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: "mode 必须为 'queue' 或 'steer'" } });
|
|
104
104
|
}
|
|
105
105
|
let content;
|
|
@@ -150,7 +150,7 @@ async function handleSend(ctx, req, res) {
|
|
|
150
150
|
try {
|
|
151
151
|
promptResponse = await promptOnce(ctx, typeof body.clientMessageId === 'string' ? body.clientMessageId : '', {
|
|
152
152
|
sessionId: body.sessionId,
|
|
153
|
-
mode,
|
|
153
|
+
...(mode !== undefined ? { mode } : {}),
|
|
154
154
|
content,
|
|
155
155
|
});
|
|
156
156
|
} catch (error) {
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -41,7 +41,7 @@ const {
|
|
|
41
41
|
} = require('./session-cache.js');
|
|
42
42
|
const { resolveEventAttachments } = require('./attachments.js');
|
|
43
43
|
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
44
|
-
const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
|
|
44
|
+
const { eventSeqOf, buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
|
|
45
45
|
const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
|
|
46
46
|
const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
|
|
47
47
|
const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop,
|
|
@@ -49,6 +49,8 @@ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSession
|
|
|
49
49
|
const { handleSessionSearch } = require('./session-search.js');
|
|
50
50
|
const { handleAgentPresets } = require('./session-presets.js');
|
|
51
51
|
const { handleGoalAction } = require('./session-goal.js');
|
|
52
|
+
const approvals = require('./approvals.js');
|
|
53
|
+
const questions = require('./questions.js');
|
|
52
54
|
|
|
53
55
|
const HISTORY_FOR_ENRICH = 10; // 补标题/预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)
|
|
54
56
|
const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
|
|
@@ -236,13 +238,17 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
236
238
|
// 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
|
|
237
239
|
const rows = allSummaries.map((s) => {
|
|
238
240
|
const completionState = completionStatesForContext.get(s.sessionId);
|
|
241
|
+
// 待处理标记:宿主摘要里带就用它的(0.1.x / 桌面口径);0.2.x 的宿主摘要**不带**这个
|
|
242
|
+
// 字段(桌面上那个标记是桌面客户端自己算的),这时用两道桥的账 —— 少了这一条,手机在
|
|
243
|
+
// 列表页连黄点都不会亮。审批优先(一次 turn 里审批总是挡在提问前面)。
|
|
244
|
+
const pendingInteraction = (s.pendingInteraction === 'approval' || s.pendingInteraction === 'plan-review' || s.pendingInteraction === 'question')
|
|
245
|
+
? s.pendingInteraction
|
|
246
|
+
: (approvals.hasPending(s.sessionId) ? 'approval' : questions.pendingKind(s.sessionId));
|
|
239
247
|
const base = {
|
|
240
248
|
sessionId: s.sessionId,
|
|
241
249
|
title: listTitle(s),
|
|
242
250
|
running: !!s.running,
|
|
243
|
-
...(
|
|
244
|
-
? { pendingInteraction: s.pendingInteraction }
|
|
245
|
-
: {}),
|
|
251
|
+
...(pendingInteraction ? { pendingInteraction } : {}),
|
|
246
252
|
completed: !!completionState?.completed,
|
|
247
253
|
// 上一轮以错误收尾(由 completion.js 从 history 的 turn/end reason.kind 折出)。
|
|
248
254
|
// 侧栏据此画红点:completed 只说「跑完了、待阅读」,说不了「跑挂了」。字段只在
|
package/lib/mobile/stream.js
CHANGED
|
@@ -22,11 +22,14 @@ const {
|
|
|
22
22
|
writeSse: sharedWriteSse,
|
|
23
23
|
waitForDrain,
|
|
24
24
|
ssePadding,
|
|
25
|
+
shedWithoutSeq,
|
|
25
26
|
} = require('../shared/sse-write.js');
|
|
26
27
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
27
28
|
const { callSessions } = require('./rpc.js');
|
|
28
29
|
const { unwrap } = require('./envelope.js');
|
|
29
30
|
const { normalizeJobs } = require('./jobs.js');
|
|
31
|
+
const approvals = require('./approvals.js');
|
|
32
|
+
const questions = require('./questions.js');
|
|
30
33
|
|
|
31
34
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
32
35
|
// internal progress threshold. Pad each SSE record so onprogress exposes it
|
|
@@ -74,9 +77,23 @@ function writeSse(res, payload, frame) {
|
|
|
74
77
|
return sharedWriteSse(res, payload, {
|
|
75
78
|
frame,
|
|
76
79
|
padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
|
|
80
|
+
// 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
|
|
81
|
+
// 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
|
|
82
|
+
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
83
|
+
shedWhenBuffered: (value) => !isApprovalPayload(value) && shedWithoutSeq(value),
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 审批卡与它的结算帧,外加提问卡与它的结算帧:都是「人工决策的入口」,一次就一两帧,
|
|
89
|
+
* 不是撑爆写队列的那类增量,按软水位丢掉就再也补不回来(回放不覆盖这一段)。
|
|
90
|
+
*/
|
|
91
|
+
function isApprovalPayload(payload) {
|
|
92
|
+
const type = payload && payload.type;
|
|
93
|
+
return type === 'approval/requested' || type === 'approval/resolved'
|
|
94
|
+
|| type === 'question/requested' || type === 'question/resolved';
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/**
|
|
81
98
|
* 解析断线续传位置:
|
|
82
99
|
* - `Last-Event-ID: <sessionId>:<seq>`(标准 SSE 头);
|
|
@@ -142,6 +159,16 @@ async function handleStream(ctx, req, res) {
|
|
|
142
159
|
// shutdown 就是这么做的)。测试里的 setInterval 桩没有 unref,所以先判类型。
|
|
143
160
|
if (keepAlive && typeof keepAlive.unref === 'function') keepAlive.unref();
|
|
144
161
|
|
|
162
|
+
// 审批卡由审批桥在帧循环之外推进这条连接:宿主那半条通道(`approval/request` 钩子)
|
|
163
|
+
// 跟 mux 帧流没关系,见 mobile/approvals.js。接入时它会把仍然可作答的卡补发一遍。
|
|
164
|
+
const detachApprovals = approvals.attachSession(sessionId, (frame) => {
|
|
165
|
+
writeSse(res, frame, { payload: frame });
|
|
166
|
+
});
|
|
167
|
+
// 提问卡同理(见 mobile/questions.js):`user-questions/request` 钩子与 mux 帧流无关。
|
|
168
|
+
const detachQuestions = questions.attachSession(sessionId, (frame) => {
|
|
169
|
+
writeSse(res, frame, { payload: frame });
|
|
170
|
+
});
|
|
171
|
+
|
|
145
172
|
const resumeSeq = parseResumeSeq(req, sessionId);
|
|
146
173
|
// 已交付的 seq 水位:从续传位置开始,重连时先回放、后接入实时流,并用它去重。
|
|
147
174
|
let lastDeliveredSeq = resumeSeq != null ? resumeSeq : 0;
|
|
@@ -224,6 +251,8 @@ async function handleStream(ctx, req, res) {
|
|
|
224
251
|
logStream(sessionId, 'mux loop threw', error);
|
|
225
252
|
}
|
|
226
253
|
clearInterval(keepAlive);
|
|
254
|
+
detachApprovals();
|
|
255
|
+
detachQuestions();
|
|
227
256
|
try {
|
|
228
257
|
if (!res.writableEnded) {
|
|
229
258
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
|
@@ -726,6 +726,33 @@ function registerControlPlaneRoutes(deps) {
|
|
|
726
726
|
const result = await restartSidecar()
|
|
727
727
|
sendJson(res, result.ok ? 200 : 500, { ...result, status: status() })
|
|
728
728
|
}) })
|
|
729
|
+
// 开发机专用:就地把**盘上这一份**重新装载一遍,不重启 DSH。
|
|
730
|
+
//
|
|
731
|
+
// 与 `/update/apply` 的分界线:那条是「去 npm 装新版」(新目录 → 新 realpath,天然绕开
|
|
732
|
+
// 全部缓存);这条是「版本号没变、目录没变,只是文件被改了」。默认那条换代判据看到
|
|
733
|
+
// 「版本身份一致」就直接返回,于是开发时改完代码触发一次,什么都不会发生 —— 现场就是
|
|
734
|
+
// 「我改了、也触发了、还是旧行为」。
|
|
735
|
+
//
|
|
736
|
+
// 刻意做成**本机接口**(localOnly + POST),而不是包一层开关:它能把这台机器上跑着的
|
|
737
|
+
// 插件实现换成盘上任意一份代码,权限面与「改 profile 里的文件」等价,只该给本机运维。
|
|
738
|
+
registerRoute({ kind: 'exact', path: '/api/relay/reload-implementation', handler: localOnly('POST', async (_req, res) => {
|
|
739
|
+
const runtime = pluginRuntime()
|
|
740
|
+
// 没有外壳(实现被直接挂载)时换不了:如实说清楚,别假装成功。
|
|
741
|
+
if (!runtime || typeof runtime.reread !== 'function') {
|
|
742
|
+
return sendJson(res, 409, runtimeUnavailable)
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
const result = await runtime.reread()
|
|
746
|
+
return sendJson(res, 200, { ok: true, data: result })
|
|
747
|
+
} catch (error) {
|
|
748
|
+
// 装载失败时外壳已经按老规矩回滚到上一代了,这里只把原因与回滚后的状态一起交出去。
|
|
749
|
+
return sendJson(res, 500, {
|
|
750
|
+
ok: false,
|
|
751
|
+
error: { code: 'reload_failed', message: String(error?.message || error) },
|
|
752
|
+
data: runtime.status(),
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
}) })
|
|
729
756
|
}
|
|
730
757
|
|
|
731
758
|
// 路由字符串清单放在注册区之后,避免被按路由源码切片的契约测试误认为是真正的 handler。
|
|
@@ -755,6 +782,8 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
755
782
|
'/api/relay/update/ota/check',
|
|
756
783
|
'/api/relay/update/ota/apply',
|
|
757
784
|
'/api/relay/restart-sidecar',
|
|
785
|
+
// 开发机的「就地重载」:能换掉本机跑着的实现,属于最该只允许本机调的那一类。
|
|
786
|
+
'/api/relay/reload-implementation',
|
|
758
787
|
]);
|
|
759
788
|
|
|
760
789
|
// `panelDiagnosis` 一并导出,只为让 test/panel-artifact.test.cjs 能拿临时目录跑那三条分支:
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -27,6 +27,7 @@ const agentPresets = require('../mobile/agent-presets.js');
|
|
|
27
27
|
const stream = require('../mobile/stream.js');
|
|
28
28
|
const events = require('../mobile/events.js');
|
|
29
29
|
const questions = require('../mobile/questions.js');
|
|
30
|
+
const approvals = require('../mobile/approvals.js');
|
|
30
31
|
const workspaces = require('../mobile/workspaces.js');
|
|
31
32
|
const archiveClean = require('../mobile/archive-clean.js');
|
|
32
33
|
const sessionLog = require('../mobile/session-log.js');
|
|
@@ -176,7 +177,7 @@ function registerMobileRoutes(deps) {
|
|
|
176
177
|
route('/api/mobile/v1/session/stream', stream.handleStream)
|
|
177
178
|
route('/api/mobile/v1/events/stream', events.handleDeviceStream)
|
|
178
179
|
route('/api/mobile/v1/question/respond', questions.handleQuestionRespond)
|
|
179
|
-
route('/api/mobile/v1/approval/respond',
|
|
180
|
+
route('/api/mobile/v1/approval/respond', approvals.handleApprovalRespond)
|
|
180
181
|
route('/api/mobile/v1/session/goal', sessions.handleGoalAction)
|
|
181
182
|
route('/api/mobile/v1/session/queue/update', sessions.handleQueueUpdate)
|
|
182
183
|
route('/api/mobile/v1/workspaces', workspaces.handleWorkspaces)
|
package/lib/shell/runtime.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* 卡住的请求不该拖住一次修复性更新)。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { importGeneration, resolveGeneration, resolveGenerations } from './manager.js'
|
|
22
|
+
import { importGeneration, invalidateGeneration, resolveGeneration, resolveGenerations } from './manager.js'
|
|
23
23
|
|
|
24
24
|
/** 换代前等在飞请求归零的上限。 */
|
|
25
25
|
const DRAIN_TIMEOUT_MS = 2000
|
|
@@ -118,15 +118,29 @@ export function createRuntime() {
|
|
|
118
118
|
*
|
|
119
119
|
* 旧代先停、新代后起;新代起不来就回滚把旧代装回去,并把错误留在 `lastError` 里——
|
|
120
120
|
* 「更新把自己弄挂了」必须能被看见,而不是静默少个插件。
|
|
121
|
+
*
|
|
122
|
+
* `options.reread` 是给**开发机**的那条路(2026-10-19):版本号与目录都没变,只是盘上
|
|
123
|
+
* 那几个文件被改了。默认会走上面那句「身份一致就直接返回」,于是改完代码再触发也没有
|
|
124
|
+
* 任何变化 —— 开发时看到的「改了不生效」就是这么来的。带上它就先把这一代从
|
|
125
|
+
* `imported` 里作废、再让 `importGeneration` 清掉 CJS 子树,强制重新读盘。
|
|
126
|
+
*
|
|
127
|
+
* 这条**不给市场更新用**:市场每次都是新目录(新 realpath),天然绕开全部缓存,
|
|
128
|
+
* 走 reread 只是白清一遍缓存。
|
|
121
129
|
*/
|
|
122
|
-
async function reload(preferVersion) {
|
|
130
|
+
async function reload(preferVersion, options = {}) {
|
|
123
131
|
if (detached) throw new Error('插件已经卸载,不能再换代')
|
|
124
132
|
if (swapping) return swapping
|
|
125
133
|
swapping = (async () => {
|
|
126
134
|
const target = resolveGeneration(preferVersion)
|
|
127
135
|
if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
|
|
136
|
+
const sameGeneration = current
|
|
137
|
+
&& target.version === current.version
|
|
138
|
+
&& target.source === current.source
|
|
139
|
+
&& target.dir === current.hostPath
|
|
128
140
|
// 同版本也可能从 OTA 切到 npm:来源与 realpath 不同就必须重新装载。
|
|
129
|
-
if (
|
|
141
|
+
if (sameGeneration && options.reread !== true) return status()
|
|
142
|
+
// 同一份目录里改了文件:不清缓存的话,重新 import 拿回来的还是内存里那份旧实现。
|
|
143
|
+
if (options.reread === true) invalidateGeneration(target.version)
|
|
130
144
|
|
|
131
145
|
await drain()
|
|
132
146
|
const oldFiber = fiber
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.91",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|