dsh-plugin-mobile-gateway 0.4.2 → 0.6.0

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/client.js CHANGED
@@ -37,13 +37,14 @@ window.__ModuleLoader__.load({
37
37
  },
38
38
  panel: {
39
39
  width: 380, maxWidth: 'calc(100vw - 24px)', height: '100%', overflowY: 'auto',
40
+ display: 'flex', flexDirection: 'column',
40
41
  background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
41
42
  boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
42
43
  fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
43
44
  },
44
- header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 },
45
+ header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, flexShrink: 0 },
45
46
  title: { margin: 0, fontSize: 18, fontWeight: 650 },
46
- card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12 },
47
+ card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12, flexShrink: 0 },
47
48
  label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
48
49
  input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
49
50
  pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
@@ -59,6 +60,7 @@ window.__ModuleLoader__.load({
59
60
  switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
60
61
  switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
61
62
  switchKnob: { position: 'absolute', top: 3, width: 18, height: 18, borderRadius: 99, background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,.35)', transition: 'left .18s ease', pointerEvents: 'none' },
63
+ version: { flexShrink: 0, marginTop: 'auto', padding: '18px 2px 0', color: colors.muted, fontSize: 11, lineHeight: 1.4, textAlign: 'right', letterSpacing: '.02em' },
62
64
  }
63
65
 
64
66
  async function request(path, options) {
@@ -373,7 +375,10 @@ window.__ModuleLoader__.load({
373
375
  device,
374
376
  onRevoke: revoke,
375
377
  revoking: revokingIds.has(device.id),
376
- })))))
378
+ }))),
379
+ status && status.version
380
+ ? React.createElement('div', { style: styles.version, 'aria-label': `插件版本 ${status.version}` }, `v${status.version}`)
381
+ : null))
377
382
  }
378
383
 
379
384
  function FooterButton(props) {
package/lib/index.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
  // client -> server: { "type": "ping" }
19
19
  // { "type": "subscribe", "sessionId": "..." } (optional filter)
20
20
  // { "type": "unsubscribe" }
21
- // { "type": "message", "sessionId"?, "text", "mode"?: "queue"|"steer",
22
- // "workspaceId"?, "cwd"? }
21
+ // { "type": "message", "sessionId"?, "text"?, "images"?:
22
+ // [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
23
+ // "workspaceId"?, "cwd"?, "clientTimeZone"? }
23
24
  // sessionId omitted -> a new session is created first; the
24
25
  // new session can be placed in a workspace via workspaceId
25
26
  // (or cwd; at most one, workspaceId wins)
@@ -30,6 +31,8 @@
30
31
  // -> raw SessionEvent page (scheme A), capped at maxBytes
31
32
  // (default 4 MiB); hasMore + nextBeforeSeq page backward;
32
33
  // view:"conversation" trims chunk/header events and tool output
34
+ // { "type": "attachment", "sessionId", "attachmentId" }
35
+ // -> verified image metadata + canonical base64 bytes
33
36
  // { "type": "search", "query" } -> session search
34
37
  // { "type": "host" } -> host.describe snapshot (incl. default provider/model)
35
38
  // { "type": "default-model" } -> agentDefaultModel.currentSelection()
@@ -48,13 +51,20 @@
48
51
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
49
52
  // { "type": "defaults" } -> default agent preset + default permission
50
53
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
51
- // server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
54
+ // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
55
+ // { "type": "question-cancel", "rpcId", "sessionId" }
56
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images"],
57
+ // "authenticated", "device"?, "port", "clients" }
52
58
  // { "kind": "pong", "at" }
53
59
  // { "kind": "subscribed", "sessionId" }
54
60
  // { "kind": "sent", "sessionId", "mode", "command"? }
55
61
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
62
+ // { "kind": "attachment", "sessionId", "attachment", "data" }
56
63
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
57
64
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
65
+ // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
66
+ // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
67
+ // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
58
68
  import fs from 'node:fs'
59
69
  import fsp from 'node:fs/promises'
60
70
  import http from 'node:http'
@@ -68,12 +78,24 @@ import QRCode from 'qrcode'
68
78
 
69
79
  const { createRegistry } = devicesModule
70
80
 
81
+ const PLUGIN_VERSION = (() => {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
84
+ } catch {
85
+ return 'unknown'
86
+ }
87
+ })()
71
88
  const MAX_PREVIEW = 400
72
89
  const LOG_FILE = '/tmp/mobile-gateway.log'
73
90
  const DEFAULT_WS_PATH = '/ws/mobile'
74
91
  const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
75
92
  const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
93
+ // DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
94
+ // the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
95
+ // configurable so deployments may choose a lower transport ceiling.
96
+ const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
76
97
  const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
98
+ const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
77
99
 
78
100
  // Cordis validates this schema at plugin load and fills these defaults. Keep
79
101
  // the defaults conservative: installing the bundle must never create an
@@ -83,6 +105,7 @@ const Config = Schema.object({
83
105
  requireAuth: Schema.boolean().default(true),
84
106
  gatewayEnabled: Schema.boolean().default(false),
85
107
  gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
108
+ maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
86
109
  adminLoopbackOnly: Schema.boolean().default(true),
87
110
  publicUrl: Schema.string().default(''),
88
111
  deviceFile: Schema.string().default(''),
@@ -114,20 +137,40 @@ function textOf(blocks) {
114
137
  return text
115
138
  }
116
139
 
140
+ function imagesOf(blocks) {
141
+ const images = []
142
+ for (const block of blocks) {
143
+ if (!block || block.type !== 'image' || !block.attachment) continue
144
+ const attachment = block.attachment
145
+ images.push({
146
+ attachmentId: attachment.attachmentId,
147
+ mediaType: attachment.mediaType,
148
+ bytes: attachment.bytes,
149
+ width: attachment.width,
150
+ height: attachment.height,
151
+ ...(attachment.name ? { name: attachment.name } : {}),
152
+ })
153
+ }
154
+ return images
155
+ }
156
+
117
157
  // Build the small, owned JSON wire record for one session event. Reads only
118
158
  // leaf fields of the live SessionEvent — never serializes live objects.
119
159
  function buildWireEvent(session, event) {
120
- const base = { sessionId: String(session.id), seq: event.seq, time: event.time }
160
+ const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time }
121
161
  const d = event.data || {}
122
162
  switch (event.type) {
123
- case 'user/message':
163
+ case 'user/message': {
164
+ const images = imagesOf(d.content || [])
124
165
  return Object.assign(base, {
125
166
  event: {
126
167
  type: 'user/message',
127
168
  text: textOf(d.content || []),
128
169
  source: d.source && d.source.kind,
170
+ ...(images.length ? { images } : {}),
129
171
  },
130
172
  })
173
+ }
131
174
  case 'assistant/chunk': {
132
175
  const chunk = d.chunk || {}
133
176
  const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
@@ -142,6 +185,7 @@ function buildWireEvent(session, event) {
142
185
  let text = ''
143
186
  let reasoning = ''
144
187
  const toolCalls = []
188
+ const images = imagesOf(blocks)
145
189
  for (const block of blocks) {
146
190
  if (!block) continue
147
191
  if (block.type === 'text' && typeof block.text === 'string') text += block.text
@@ -149,7 +193,7 @@ function buildWireEvent(session, event) {
149
193
  else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
150
194
  }
151
195
  return Object.assign(base, {
152
- event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls },
196
+ event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
153
197
  })
154
198
  }
155
199
  case 'tool/call':
@@ -191,8 +235,37 @@ function buildWireEvent(session, event) {
191
235
  // the wire frame to send back, or null when nothing should be sent.
192
236
  async function admitMessage(api, msg) {
193
237
  const text = typeof msg.text === 'string' ? msg.text.trim() : ''
194
- if (!text) {
195
- return { kind: 'error', code: 'bad-request', message: 'text must be a non-empty string' }
238
+ const images = msg.images === undefined ? [] : msg.images
239
+ if (!Array.isArray(images)) {
240
+ return { kind: 'error', code: 'bad-request', message: 'images must be an array' }
241
+ }
242
+ if (images.length > 20) {
243
+ return { kind: 'error', code: 'bad-request', message: 'a message can contain at most 20 images' }
244
+ }
245
+ const imageParts = []
246
+ for (let index = 0; index < images.length; index++) {
247
+ const image = images[index]
248
+ if (!image || typeof image !== 'object') {
249
+ return { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object` }
250
+ }
251
+ if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
252
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported` }
253
+ }
254
+ if (typeof image.data !== 'string' || image.data.length === 0) {
255
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string` }
256
+ }
257
+ if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
258
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters` }
259
+ }
260
+ imageParts.push({
261
+ type: 'image',
262
+ mediaType: image.mediaType,
263
+ data: image.data,
264
+ ...(image.name ? { name: image.name } : {}),
265
+ })
266
+ }
267
+ if (!text && imageParts.length === 0) {
268
+ return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
196
269
  }
197
270
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
198
271
 
@@ -219,10 +292,16 @@ async function admitMessage(api, msg) {
219
292
 
220
293
  const resp = await api.sessions.prompt({
221
294
  rpcId: crypto.randomUUID(),
222
- payload: { sessionId, mode, content: [{ type: 'text', text }] },
295
+ payload: {
296
+ sessionId,
297
+ mode,
298
+ // Match the official WebUI ordering: images first, optional text last.
299
+ content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
300
+ ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
301
+ },
223
302
  })
224
303
  if (resp.result.ok) {
225
- log(`mobile message accepted: session=${sessionId} mode=${mode} text="${text.slice(0, 60)}"`)
304
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
226
305
  return {
227
306
  kind: 'sent',
228
307
  sessionId,
@@ -426,6 +505,20 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
426
505
  }
427
506
  return frame
428
507
  }
508
+ if (msg.type === 'attachment') {
509
+ const sessionId = requireSessionId(msg)
510
+ if (sessionId.error) return sessionId.error
511
+ const attachmentId = typeof msg.attachmentId === 'string' && msg.attachmentId.trim() !== '' ? msg.attachmentId.trim() : null
512
+ if (!attachmentId) {
513
+ return { kind: 'error', code: 'bad-request', message: 'attachment requires an attachmentId', requestType: 'attachment', sessionId: sessionId.value }
514
+ }
515
+ const frame = await proxyQuery(api, 'attachment', api.sessions.attachment.bind(api.sessions), {
516
+ sessionId: sessionId.value,
517
+ attachmentId,
518
+ })
519
+ if (frame.kind === 'attachment') frame.sessionId = sessionId.value
520
+ return frame
521
+ }
429
522
  if (msg.type === 'search') {
430
523
  const query = typeof msg.query === 'string' ? msg.query.trim() : ''
431
524
  if (!query) {
@@ -815,6 +908,7 @@ const plugin = {
815
908
  requireAuth: true,
816
909
  gatewayEnabled: false,
817
910
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
911
+ maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
818
912
  adminLoopbackOnly: true,
819
913
  publicUrl: '',
820
914
  deviceFile: '',
@@ -843,7 +937,7 @@ const plugin = {
843
937
  const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
844
938
  const wss = new WebSocketServer({
845
939
  noServer: true,
846
- maxPayload: 1024 * 1024,
940
+ maxPayload: options.maxPayloadBytes,
847
941
  // Authentication data may ride in requested subprotocols, but a secret
848
942
  // must never be echoed as the negotiated protocol.
849
943
  handleProtocols(protocols) {
@@ -851,6 +945,8 @@ const plugin = {
851
945
  },
852
946
  })
853
947
  const clients = new Set()
948
+ const pendingQuestions = new Map()
949
+ const questionMuxAbort = new AbortController()
854
950
  let counter = 0
855
951
  let gatewayEnabled = options.gatewayEnabled === true
856
952
  let waitExpiresAt = null
@@ -912,6 +1008,72 @@ const plugin = {
912
1008
  }
913
1009
  }
914
1010
 
1011
+ const questionFrameFor = (rpcId, payload, replay = false) => ({
1012
+ kind: 'question-requested',
1013
+ rpcId: String(rpcId),
1014
+ sessionId: String(payload.sessionId),
1015
+ questions: payload.questions,
1016
+ ...(replay ? { replay: true } : {}),
1017
+ })
1018
+
1019
+ const broadcastQuestionFrame = (frame) => {
1020
+ const wire = JSON.stringify(frame)
1021
+ for (const client of clients) {
1022
+ if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
1023
+ if (client.readyState === 1) client.send(wire)
1024
+ }
1025
+ }
1026
+
1027
+ const respondToQuestion = async (msg, cancel = false) => {
1028
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
1029
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
1030
+ if (!rpcId || !sessionId) {
1031
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
1032
+ }
1033
+
1034
+ if (cancel) {
1035
+ const pending = pendingQuestions.get(rpcId)
1036
+ if (pending && pending.sessionId !== sessionId) {
1037
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
1038
+ }
1039
+ } else if (!Array.isArray(msg.answers)) {
1040
+ return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
1041
+ }
1042
+
1043
+ const result = cancel
1044
+ ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
1045
+ : {
1046
+ ok: true,
1047
+ value: {
1048
+ sessionId,
1049
+ answer: {
1050
+ answers: msg.answers.map((answer) => ({
1051
+ id: answer && answer.id,
1052
+ selected: answer && answer.selected,
1053
+ ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
1054
+ })),
1055
+ },
1056
+ },
1057
+ }
1058
+
1059
+ try {
1060
+ const receipt = await api.respond({ type: 'client-response', rpcId, result })
1061
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
1062
+ return {
1063
+ kind: 'question-response',
1064
+ rpcId,
1065
+ sessionId,
1066
+ action: cancel ? 'cancel' : 'answer',
1067
+ accepted: receipt.accepted,
1068
+ ...(!receipt.accepted ? { reason: receipt.reason } : {}),
1069
+ }
1070
+ } catch (error) {
1071
+ const message = error && error.message ? error.message : String(error)
1072
+ log(`question response failed: rpcId=${rpcId} ${message}`)
1073
+ return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
1074
+ }
1075
+ }
1076
+
915
1077
  if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
916
1078
 
917
1079
  log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
@@ -934,6 +1096,7 @@ const plugin = {
934
1096
  const p = url.pathname
935
1097
  if (req.method === 'GET' && p === '/mgw/status') {
936
1098
  sendJson(res, 200, {
1099
+ version: PLUGIN_VERSION,
937
1100
  requireAuth,
938
1101
  gatewayEnabled,
939
1102
  waitExpiresAt,
@@ -1097,11 +1260,15 @@ const plugin = {
1097
1260
  } else if (msg.type === 'unsubscribe') {
1098
1261
  ws.filterSessionId = undefined
1099
1262
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
1263
+ } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
1264
+ respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
1265
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1266
+ })
1100
1267
  } else if (msg.type === 'message') {
1101
1268
  admitMessage(api, msg).then((frame) => {
1102
1269
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1103
1270
  })
1104
- } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' ||
1271
+ } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
1105
1272
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
1106
1273
  msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
1107
1274
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
@@ -1132,12 +1299,17 @@ const plugin = {
1132
1299
  }
1133
1300
  ws.send(JSON.stringify({
1134
1301
  kind: 'hello',
1135
- protocol: 2,
1302
+ protocol: 3,
1303
+ capabilities: ['images'],
1136
1304
  port: transport.port || webServer.port,
1137
1305
  clients: clients.size,
1138
1306
  authenticated: !!device,
1139
1307
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
1140
1308
  }))
1309
+ for (const pending of pendingQuestions.values()) {
1310
+ if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1311
+ ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1312
+ }
1141
1313
  })
1142
1314
  }
1143
1315
 
@@ -1202,8 +1374,51 @@ const plugin = {
1202
1374
  })
1203
1375
  log('session/event listener attached')
1204
1376
 
1377
+ const questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
1378
+ ? (async () => {
1379
+ try {
1380
+ for await (const envelope of api.events.mux(
1381
+ { rpcId: crypto.randomUUID(), payload: {} },
1382
+ questionMuxAbort.signal,
1383
+ )) {
1384
+ const payload = envelope && envelope.payload
1385
+ if (!payload || typeof payload.type !== 'string') continue
1386
+ if (payload.type === 'question/requested') {
1387
+ const rpcId = String(envelope.rpcId)
1388
+ const pending = {
1389
+ rpcId,
1390
+ sessionId: String(payload.sessionId),
1391
+ questions: payload.questions,
1392
+ }
1393
+ pendingQuestions.set(rpcId, pending)
1394
+ broadcastQuestionFrame(questionFrameFor(rpcId, pending))
1395
+ log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
1396
+ } else if (payload.type === 'question/resolved') {
1397
+ const questionRpcId = String(payload.questionRpcId)
1398
+ pendingQuestions.delete(questionRpcId)
1399
+ broadcastQuestionFrame({
1400
+ kind: 'question-resolved',
1401
+ rpcId: questionRpcId,
1402
+ sessionId: String(payload.sessionId),
1403
+ outcome: payload.outcome,
1404
+ })
1405
+ log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
1406
+ }
1407
+ }
1408
+ } catch (error) {
1409
+ if (!questionMuxAbort.signal.aborted) {
1410
+ log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
1411
+ }
1412
+ }
1413
+ })()
1414
+ : null
1415
+ if (questionMuxTask) log('api gateway question mux attached')
1416
+ else log('api gateway question mux unavailable; human-in-the-loop disabled')
1417
+
1205
1418
  ctx.effect(() => () => {
1206
1419
  if (waitTimer) clearTimeout(waitTimer)
1420
+ questionMuxAbort.abort()
1421
+ pendingQuestions.clear()
1207
1422
  disposeUpgrade()
1208
1423
  disposeMgmt()
1209
1424
  disposeEvents()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [