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.
Files changed (3) hide show
  1. package/PROTOCOL.md +16 -2
  2. package/lib/index.mjs +126 -17
  3. 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` 工具时,插件直接接入 Host 的 `user-questions/request` waterfall,并把临时请求投影给移动端。若没有可处理该 Session 的移动连接,插件调用 `next()`,由 WebUI 或后续 Host answerer 处理。
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` waterfall,并向移动端投影为 `approval-requested`。这正是 Web UI 中“等待审批”卡片对应的能力:`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时工具调用轨迹关联。
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
- if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
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 (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2253
- ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
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 (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2258
- ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
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(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
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 completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
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
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
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
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
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
- request.signal?.addEventListener('abort', onAbort, { once: true })
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: () => request.signal?.removeEventListener('abort', onAbort),
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
- request.signal?.addEventListener('abort', onAbort, { once: true })
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: () => request.signal?.removeEventListener('abort', onAbort),
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.6",
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": [