dsh-plugin-mobile-gateway 0.7.6 → 0.7.7
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/PROTOCOL.md +16 -2
- package/lib/index.mjs +126 -17
- package/package.json +1 -1
package/PROTOCOL.md
CHANGED
|
@@ -174,6 +174,8 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
|
|
|
174
174
|
`subscribed` 之后,服务端会紧接着发送该 Session 尚未处理的
|
|
175
175
|
`question-requested` / `approval-requested`,并标记 `replay: true`。客户端必须按
|
|
176
176
|
`rpcId` 去重。这保证移动端在审批产生后才打开已有 Session 时仍能显示待处理卡片。
|
|
177
|
+
启用 `split-channels` 时,`subscribe` 仍由 conversation 连接发送,但上述重放帧会
|
|
178
|
+
桥接到 control 连接;交互卡片不得发送到 conversation 连接。
|
|
177
179
|
|
|
178
180
|
---
|
|
179
181
|
|
|
@@ -186,9 +188,16 @@ Human-in-the-loop 分为两条独立通道:
|
|
|
186
188
|
|
|
187
189
|
二者都是 Host waterfall 的临时请求,不属于持久化的 `session/event`,且都必须以插件为该次请求生成的 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。该内部实现不改变移动端帧格式。
|
|
188
190
|
|
|
191
|
+
同一请求遵循“任意端先完成、所有端同步收起”的单胜方规则:Mobile 任一设备先完成时,Gateway 会结束 Host 的 WebUI 远端请求并让所有 WebUI 实例收起;任一 WebUI 先完成时,Host 会取消其余 WebUI 实例,Gateway 同时向所有 Mobile 设备广播终态。迟到响应统一视为 `not-pending`。
|
|
192
|
+
|
|
189
193
|
### 3.1 提问与回答
|
|
190
194
|
|
|
191
|
-
Agent 调用 DSH 的 `ask_user_question`
|
|
195
|
+
Agent 调用 DSH 的 `ask_user_question` 工具时,插件接入 Host 的
|
|
196
|
+
`user-questions/request` waterfall,把临时请求投影给移动端,同时继续启动 WebUI
|
|
197
|
+
或后续 Host answerer。任一端先给出有效答案即完成该请求;Gateway 会向所有移动端
|
|
198
|
+
广播 `question-resolved`。Mobile 先完成时,Gateway 同时取消 WebUI 的对应远端请求,
|
|
199
|
+
使其收起半屏弹窗;WebUI 先完成时,移动端收到终态后收起对应卡片。若 Mobile 已断开,
|
|
200
|
+
则由仍在等待的 Host answerer 继续处理。
|
|
192
201
|
|
|
193
202
|
#### `question-requested` — 服务端推送问题
|
|
194
203
|
|
|
@@ -292,7 +301,12 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件直接接入 Host 的
|
|
|
292
301
|
|
|
293
302
|
### 3.2 操作审批
|
|
294
303
|
|
|
295
|
-
当 DSH 的工具管线要求人工授权时,插件会接入一次 Host `approval/request`
|
|
304
|
+
当 DSH 的工具管线要求人工授权时,插件会接入一次 Host `approval/request`
|
|
305
|
+
waterfall,并向移动端投影为 `approval-requested`,同时保留 WebUI answerer;WebUI
|
|
306
|
+
与 Mobile 中任一端先提交的有效决定生效。Gateway 随后向所有移动端广播
|
|
307
|
+
`approval-resolved`;若 Mobile 先完成,还会取消 WebUI 的对应远端请求以收起审批弹窗。
|
|
308
|
+
`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时
|
|
309
|
+
工具调用轨迹关联。
|
|
296
310
|
|
|
297
311
|
#### `approval-requested` — 服务端推送待审批操作
|
|
298
312
|
|
package/lib/index.mjs
CHANGED
|
@@ -2245,20 +2245,43 @@ const plugin = {
|
|
|
2245
2245
|
}
|
|
2246
2246
|
|
|
2247
2247
|
const replayPendingInteractions = (ws, trigger) => {
|
|
2248
|
-
|
|
2248
|
+
// In split-channel mode `subscribe` belongs to the conversation socket,
|
|
2249
|
+
// while interaction frames belong to the control socket. Bridge a
|
|
2250
|
+
// conversation subscription to every live control/legacy socket so
|
|
2251
|
+
// reopening a Session restores its still-pending cards. Reducers dedupe
|
|
2252
|
+
// by rpcId, so replaying to another trusted device is harmless and keeps
|
|
2253
|
+
// all active clients in sync.
|
|
2254
|
+
// The companion control socket already receives a full replay when it
|
|
2255
|
+
// connects, so a bare conversation connect must not duplicate it again.
|
|
2256
|
+
if (ws.mobileChannel === 'conversation' && trigger !== 'subscribe') {
|
|
2257
|
+
return { questionCount: 0, approvalCount: 0 }
|
|
2258
|
+
}
|
|
2259
|
+
const recipients = ws.mobileChannel === 'conversation'
|
|
2260
|
+
? [...clients].filter((client) => client.readyState === 1 && client.mobileChannel !== 'conversation')
|
|
2261
|
+
: [ws]
|
|
2262
|
+
if (recipients.length === 0) return { questionCount: 0, approvalCount: 0 }
|
|
2263
|
+
const sessionId = ws.filterSessionId
|
|
2249
2264
|
let questionCount = 0
|
|
2250
2265
|
let approvalCount = 0
|
|
2251
2266
|
for (const pending of pendingQuestions.values()) {
|
|
2252
|
-
if (
|
|
2253
|
-
|
|
2267
|
+
if (sessionId && sessionId !== pending.sessionId) continue
|
|
2268
|
+
const wire = stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true))
|
|
2269
|
+
for (const recipient of recipients) {
|
|
2270
|
+
if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
|
|
2271
|
+
recipient.send(wire)
|
|
2272
|
+
}
|
|
2254
2273
|
questionCount += 1
|
|
2255
2274
|
}
|
|
2256
2275
|
for (const pending of pendingApprovals.values()) {
|
|
2257
|
-
if (
|
|
2258
|
-
|
|
2276
|
+
if (sessionId && sessionId !== pending.sessionId) continue
|
|
2277
|
+
const wire = stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true))
|
|
2278
|
+
for (const recipient of recipients) {
|
|
2279
|
+
if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
|
|
2280
|
+
recipient.send(wire)
|
|
2281
|
+
}
|
|
2259
2282
|
approvalCount += 1
|
|
2260
2283
|
}
|
|
2261
|
-
log(`interaction replay: trigger=${trigger} filtered=${Boolean(
|
|
2284
|
+
log(`interaction replay: trigger=${trigger} channel=${ws.mobileChannel} filtered=${Boolean(sessionId)} recipients=${recipients.length} questions=${questionCount} approvals=${approvalCount}`)
|
|
2262
2285
|
return { questionCount, approvalCount }
|
|
2263
2286
|
}
|
|
2264
2287
|
|
|
@@ -2268,15 +2291,24 @@ const plugin = {
|
|
|
2268
2291
|
&& (!client.filterSessionId || client.filterSessionId === sessionId)
|
|
2269
2292
|
))
|
|
2270
2293
|
|
|
2271
|
-
const
|
|
2294
|
+
const abortDownstreamInteraction = (pending, kind) => {
|
|
2295
|
+
if (pending.downstreamAbort.signal.aborted) return
|
|
2296
|
+
// The Host API gateway fans this abort out to every WebUI delivery of
|
|
2297
|
+
// the same Remote event. Its existing cancel handling removes each
|
|
2298
|
+
// pending BottomSheet; Mobile peers receive the resolved frame below.
|
|
2299
|
+
pending.downstreamAbort.abort(new Error(`${kind} completed outside WebUI`))
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED', cancellationError, source = 'mobile') => {
|
|
2272
2303
|
if (pendingQuestions.get(pending.rpcId) !== pending) return false
|
|
2273
2304
|
pendingQuestions.delete(pending.rpcId)
|
|
2274
2305
|
pending.cleanup()
|
|
2306
|
+
if (source !== 'downstream') abortDownstreamInteraction(pending, 'question')
|
|
2275
2307
|
if (action === 'answer') pending.resolve(answer)
|
|
2276
2308
|
else {
|
|
2277
|
-
const error = new Error('question cancelled by mobile user')
|
|
2278
|
-
error.name = 'UserQuestionError'
|
|
2279
|
-
error.code = cancellationCode
|
|
2309
|
+
const error = cancellationError ?? new Error('question cancelled by mobile user')
|
|
2310
|
+
if (!error.name || error.name === 'Error') error.name = 'UserQuestionError'
|
|
2311
|
+
if (!error.code) error.code = cancellationCode
|
|
2280
2312
|
pending.reject(error)
|
|
2281
2313
|
}
|
|
2282
2314
|
broadcastInteractionFrame({
|
|
@@ -2315,10 +2347,11 @@ const plugin = {
|
|
|
2315
2347
|
return { value: { answers: normalized } }
|
|
2316
2348
|
}
|
|
2317
2349
|
|
|
2318
|
-
const completeApproval = (pending, outcome) => {
|
|
2350
|
+
const completeApproval = (pending, outcome, source = 'mobile') => {
|
|
2319
2351
|
if (pendingApprovals.get(pending.rpcId) !== pending) return false
|
|
2320
2352
|
pendingApprovals.delete(pending.rpcId)
|
|
2321
2353
|
pending.cleanup()
|
|
2354
|
+
if (source !== 'downstream') abortDownstreamInteraction(pending, 'approval')
|
|
2322
2355
|
pending.resolve(outcome)
|
|
2323
2356
|
broadcastInteractionFrame({
|
|
2324
2357
|
kind: 'approval-resolved',
|
|
@@ -2330,18 +2363,80 @@ const plugin = {
|
|
|
2330
2363
|
return true
|
|
2331
2364
|
}
|
|
2332
2365
|
|
|
2366
|
+
const startDownstreamQuestion = (pending) => {
|
|
2367
|
+
let downstream
|
|
2368
|
+
try {
|
|
2369
|
+
downstream = pending.next()
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
pending.downstream = { kind: 'pending' }
|
|
2375
|
+
Promise.resolve(downstream).then((answer) => {
|
|
2376
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2377
|
+
if (answer && Array.isArray(answer.answers)) {
|
|
2378
|
+
completeQuestion(pending, 'answer', answer, undefined, undefined, 'downstream')
|
|
2379
|
+
log(`question resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
|
|
2380
|
+
} else {
|
|
2381
|
+
// A downstream answerer may report that no WebUI currently owns the
|
|
2382
|
+
// request. Keep Mobile active; the value is used only if every Mobile
|
|
2383
|
+
// client later disconnects.
|
|
2384
|
+
pending.downstream = { kind: 'unavailable', value: answer }
|
|
2385
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2386
|
+
}
|
|
2387
|
+
}, (error) => {
|
|
2388
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2389
|
+
if (error?.code === 'ASK_CANCELLED' || error?.code === 'ASK_ABORTED') {
|
|
2390
|
+
completeQuestion(pending, 'cancel', undefined, error.code, error, 'downstream')
|
|
2391
|
+
log(`question cancelled by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
|
|
2392
|
+
} else {
|
|
2393
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2394
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2395
|
+
}
|
|
2396
|
+
})
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const startDownstreamApproval = (pending) => {
|
|
2400
|
+
let downstream
|
|
2401
|
+
try {
|
|
2402
|
+
downstream = pending.next()
|
|
2403
|
+
} catch (error) {
|
|
2404
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2405
|
+
return
|
|
2406
|
+
}
|
|
2407
|
+
pending.downstream = { kind: 'pending' }
|
|
2408
|
+
Promise.resolve(downstream).then((outcome) => {
|
|
2409
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2410
|
+
if (outcome === 'allowed-once' || outcome === 'rejected') {
|
|
2411
|
+
completeApproval(pending, outcome, 'downstream')
|
|
2412
|
+
log(`approval resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId} outcome=${outcome}`)
|
|
2413
|
+
} else {
|
|
2414
|
+
pending.downstream = { kind: 'unavailable', value: outcome }
|
|
2415
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2416
|
+
}
|
|
2417
|
+
}, (error) => {
|
|
2418
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2419
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2420
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2421
|
+
})
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2333
2424
|
const fallbackQuestion = (pending) => {
|
|
2334
2425
|
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2426
|
+
if (pending.downstream?.kind === 'pending') return
|
|
2335
2427
|
pendingQuestions.delete(pending.rpcId)
|
|
2336
2428
|
pending.cleanup()
|
|
2337
|
-
|
|
2429
|
+
if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
|
|
2430
|
+
else pending.resolve(pending.downstream?.value)
|
|
2338
2431
|
}
|
|
2339
2432
|
|
|
2340
2433
|
const fallbackApproval = (pending) => {
|
|
2341
2434
|
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2435
|
+
if (pending.downstream?.kind === 'pending') return
|
|
2342
2436
|
pendingApprovals.delete(pending.rpcId)
|
|
2343
2437
|
pending.cleanup()
|
|
2344
|
-
|
|
2438
|
+
if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
|
|
2439
|
+
else pending.resolve(pending.downstream?.value)
|
|
2345
2440
|
}
|
|
2346
2441
|
|
|
2347
2442
|
const releaseUnclaimedInteractions = () => {
|
|
@@ -2857,22 +2952,29 @@ const plugin = {
|
|
|
2857
2952
|
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
2858
2953
|
const rpcId = crypto.randomUUID()
|
|
2859
2954
|
return new Promise((resolve, reject) => {
|
|
2955
|
+
const requestSignal = request.signal
|
|
2956
|
+
const downstreamAbort = new AbortController()
|
|
2957
|
+
request.signal = requestSignal
|
|
2958
|
+
? AbortSignal.any([requestSignal, downstreamAbort.signal])
|
|
2959
|
+
: downstreamAbort.signal
|
|
2860
2960
|
const onAbort = () => {
|
|
2861
2961
|
const pending = pendingQuestions.get(rpcId)
|
|
2862
2962
|
if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
|
|
2863
2963
|
}
|
|
2864
|
-
|
|
2964
|
+
requestSignal?.addEventListener('abort', onAbort, { once: true })
|
|
2865
2965
|
const pending = {
|
|
2866
2966
|
rpcId,
|
|
2867
2967
|
sessionId,
|
|
2868
2968
|
questions: request.questions,
|
|
2869
2969
|
next,
|
|
2970
|
+
downstreamAbort,
|
|
2870
2971
|
resolve,
|
|
2871
2972
|
reject,
|
|
2872
|
-
cleanup: () =>
|
|
2973
|
+
cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
|
|
2873
2974
|
}
|
|
2874
2975
|
pendingQuestions.set(rpcId, pending)
|
|
2875
2976
|
broadcastInteractionFrame(questionFrameFor(rpcId, pending))
|
|
2977
|
+
startDownstreamQuestion(pending)
|
|
2876
2978
|
log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
|
|
2877
2979
|
})
|
|
2878
2980
|
}, { prepend: true })
|
|
@@ -2883,11 +2985,16 @@ const plugin = {
|
|
|
2883
2985
|
const rpcId = crypto.randomUUID()
|
|
2884
2986
|
const approvalId = crypto.randomUUID()
|
|
2885
2987
|
return new Promise((resolve, reject) => {
|
|
2988
|
+
const requestSignal = request.signal
|
|
2989
|
+
const downstreamAbort = new AbortController()
|
|
2990
|
+
request.signal = requestSignal
|
|
2991
|
+
? AbortSignal.any([requestSignal, downstreamAbort.signal])
|
|
2992
|
+
: downstreamAbort.signal
|
|
2886
2993
|
const onAbort = () => {
|
|
2887
2994
|
const pending = pendingApprovals.get(rpcId)
|
|
2888
2995
|
if (pending) completeApproval(pending, 'cancelled')
|
|
2889
2996
|
}
|
|
2890
|
-
|
|
2997
|
+
requestSignal?.addEventListener('abort', onAbort, { once: true })
|
|
2891
2998
|
const pending = {
|
|
2892
2999
|
rpcId,
|
|
2893
3000
|
sessionId,
|
|
@@ -2896,12 +3003,14 @@ const plugin = {
|
|
|
2896
3003
|
...(request.callId !== undefined ? { callId: request.callId } : {}),
|
|
2897
3004
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
2898
3005
|
next,
|
|
3006
|
+
downstreamAbort,
|
|
2899
3007
|
resolve,
|
|
2900
3008
|
reject,
|
|
2901
|
-
cleanup: () =>
|
|
3009
|
+
cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
|
|
2902
3010
|
}
|
|
2903
3011
|
pendingApprovals.set(rpcId, pending)
|
|
2904
3012
|
broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
|
|
3013
|
+
startDownstreamApproval(pending)
|
|
2905
3014
|
log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
|
|
2906
3015
|
})
|
|
2907
3016
|
}, { prepend: true })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "Mobile gateway for dsh 0.1.5-rc.1/2 with per-session Agent preset selection before the first conversation, automatic preset locking after conversation starts, and live preset updates",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|