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/PROTOCOL.md +211 -17
- package/README.md +11 -4
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/docs/public-ip-deployment-guide.md +135 -0
- package/lib/client.js +8 -3
- package/lib/index.mjs +228 -13
- package/package.json +1 -1
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"
|
|
22
|
-
// "
|
|
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
|
-
//
|
|
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
|
-
|
|
195
|
-
|
|
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: {
|
|
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:
|
|
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:
|
|
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.
|
|
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": [
|