dsh-plugin-mobile-gateway 0.6.5 → 0.6.9
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 +280 -12
- package/README.md +2 -2
- package/lib/index.mjs +934 -30
- package/package.json +2 -2
package/lib/index.mjs
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
// view:"conversation" trims chunk/header events and tool output
|
|
34
34
|
// { "type": "attachment", "sessionId", "attachmentId" }
|
|
35
35
|
// -> verified image metadata + canonical base64 bytes
|
|
36
|
+
// { "type": "file-list", "requestId"?, "sessionId", "path"? }
|
|
37
|
+
// { "type": "file-download-open", "requestId", "sessionId", "path" }
|
|
38
|
+
// { "type": "file-download-read", "transferId", "offset" }
|
|
39
|
+
// { "type": "file-download-cancel", "transferId" }
|
|
36
40
|
// { "type": "search", "query" } -> session search
|
|
37
41
|
// { "type": "host" } -> host.describe snapshot (incl. default provider/model)
|
|
38
42
|
// { "type": "default-model" } -> agentDefaultModel.currentSelection()
|
|
@@ -43,6 +47,10 @@
|
|
|
43
47
|
// { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
|
|
44
48
|
// { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
|
|
45
49
|
// { "type": "providers" } -> configurable provider list (live/dormant)
|
|
50
|
+
// { "type": "commands", "sessionId" } -> slash-command catalog for this session
|
|
51
|
+
// { "type": "command-execute", "sessionId", "line", "images"? } -> execute a Host command
|
|
52
|
+
// { "type": "command-options", "sessionId", "command" } -> normalized submenu options
|
|
53
|
+
// { "type": "command-select", "sessionId", "command", "optionId" } -> apply submenu option
|
|
46
54
|
// { "type": "select-model", "sessionId", "provider", "model", "reasoningEffort"? }
|
|
47
55
|
// { "type": "permission-options", "sessionId"? } -> permission presets (+ session knobs)
|
|
48
56
|
// { "type": "permission", "sessionId", "name" } -> switch preset via /permission command
|
|
@@ -54,18 +62,38 @@
|
|
|
54
62
|
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
55
63
|
// { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
|
|
56
64
|
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
57
|
-
//
|
|
65
|
+
// { "type": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
66
|
+
// "outcome": "allowed-once"|"rejected" }
|
|
67
|
+
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "file-downloads"],
|
|
58
68
|
// "authenticated", "device"?, "port", "clients" }
|
|
59
69
|
// { "kind": "pong", "at" }
|
|
60
70
|
// { "kind": "subscribed", "sessionId" }
|
|
61
71
|
// { "kind": "sent", "sessionId", "mode", "command"? }
|
|
62
72
|
// { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
|
|
73
|
+
// { "kind": "commands", "sessionId", "groups": [
|
|
74
|
+
// { "id", "title", "items": [{ "id", "name", "description", "ui" }] }
|
|
75
|
+
// ] }
|
|
76
|
+
// { "kind": "command-options", "sessionId", "command", "options": [
|
|
77
|
+
// { "id", "label", "detail"?, "description"?, "selected" }
|
|
78
|
+
// ] }
|
|
79
|
+
// { "kind": "command-executed", "sessionId", "line", "commandId", "result" }
|
|
80
|
+
// { "kind": "command-selected", "sessionId", "command", "selected" }
|
|
63
81
|
// { "kind": "attachment", "sessionId", "attachment", "data" }
|
|
82
|
+
// { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
|
|
83
|
+
// { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
|
|
84
|
+
// "path", "name", "mediaType", "size", "chunkBytes" }
|
|
85
|
+
// { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
|
|
86
|
+
// { "kind": "file-download-cancelled", "transferId" }
|
|
64
87
|
// { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
|
|
65
88
|
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
66
89
|
// { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
|
|
67
90
|
// { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
|
|
68
91
|
// { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
|
|
92
|
+
// { "kind": "approval-requested", "rpcId", "sessionId", "approvalId",
|
|
93
|
+
// "toolName", "callId"?, "reason"?, "replay"? }
|
|
94
|
+
// { "kind": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
95
|
+
// "outcome", "accepted", "reason"? }
|
|
96
|
+
// { "kind": "approval-resolved", "rpcId", "sessionId", "approvalId", "outcome" }
|
|
69
97
|
import fs from 'node:fs'
|
|
70
98
|
import fsp from 'node:fs/promises'
|
|
71
99
|
import http from 'node:http'
|
|
@@ -93,12 +121,53 @@ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
|
93
121
|
const DEFAULT_WS_PATH = '/ws/mobile'
|
|
94
122
|
const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
|
|
95
123
|
const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
|
|
124
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
|
125
|
+
const DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES = 512 * 1024
|
|
126
|
+
const DEFAULT_FILE_DOWNLOAD_IDLE_MS = 2 * 60 * 1000
|
|
127
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS = 4
|
|
128
|
+
const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
|
|
96
129
|
// DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
|
|
97
130
|
// the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
|
|
98
131
|
// configurable so deployments may choose a lower transport ceiling.
|
|
99
132
|
const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
|
|
100
133
|
const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
|
|
101
134
|
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
135
|
+
const COMMAND_UI_OVERRIDES = Object.freeze({
|
|
136
|
+
permission: Object.freeze({
|
|
137
|
+
kind: 'select',
|
|
138
|
+
optionsRequest: 'command-options',
|
|
139
|
+
selectionRequest: 'command-select',
|
|
140
|
+
}),
|
|
141
|
+
model: Object.freeze({
|
|
142
|
+
kind: 'select',
|
|
143
|
+
optionsRequest: 'command-options',
|
|
144
|
+
selectionRequest: 'command-select',
|
|
145
|
+
}),
|
|
146
|
+
})
|
|
147
|
+
const COMMAND_CATALOG_COPY = Object.freeze({
|
|
148
|
+
zh: Object.freeze({
|
|
149
|
+
locale: 'zh-CN',
|
|
150
|
+
commandsTitle: '命令',
|
|
151
|
+
skillsTitle: '技能',
|
|
152
|
+
userOnly: '仅用户',
|
|
153
|
+
modelDescription: '选择本会话使用的模型',
|
|
154
|
+
hints: Object.freeze({
|
|
155
|
+
plan: '描述你的任务以生成计划',
|
|
156
|
+
goal: '输入目标,智能体将持续执行',
|
|
157
|
+
}),
|
|
158
|
+
}),
|
|
159
|
+
en: Object.freeze({
|
|
160
|
+
locale: 'en',
|
|
161
|
+
commandsTitle: 'Commands',
|
|
162
|
+
skillsTitle: 'Skills',
|
|
163
|
+
userOnly: 'user-only',
|
|
164
|
+
modelDescription: 'Select the model for this conversation',
|
|
165
|
+
hints: Object.freeze({
|
|
166
|
+
plan: 'describe your task to generate plan',
|
|
167
|
+
goal: 'describe the objective for a long-running task',
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
102
171
|
|
|
103
172
|
// Cordis validates this schema at plugin load and fills these defaults. Keep
|
|
104
173
|
// the defaults conservative: installing the bundle must never create an
|
|
@@ -109,6 +178,11 @@ const Config = Schema.object({
|
|
|
109
178
|
gatewayEnabled: Schema.boolean().default(false),
|
|
110
179
|
gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
|
|
111
180
|
maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
|
|
181
|
+
fileDownloadsEnabled: Schema.boolean().default(true),
|
|
182
|
+
fileDownloadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_MAX_BYTES),
|
|
183
|
+
fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
|
|
184
|
+
fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
|
|
185
|
+
fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
|
|
112
186
|
adminLoopbackOnly: Schema.boolean().default(true),
|
|
113
187
|
publicUrl: Schema.string().default(''),
|
|
114
188
|
deviceFile: Schema.string().default(''),
|
|
@@ -222,6 +296,55 @@ function buildWireEvent(session, event) {
|
|
|
222
296
|
},
|
|
223
297
|
})
|
|
224
298
|
}
|
|
299
|
+
case 'command/run':
|
|
300
|
+
return Object.assign(base, {
|
|
301
|
+
event: {
|
|
302
|
+
type: 'command/run',
|
|
303
|
+
commandId: d.commandId,
|
|
304
|
+
name: d.name,
|
|
305
|
+
...(typeof d.args === 'string' ? { args: d.args } : {}),
|
|
306
|
+
...(d.source ? { source: d.source } : {}),
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
case 'command/done':
|
|
310
|
+
return Object.assign(base, {
|
|
311
|
+
event: {
|
|
312
|
+
type: 'command/done',
|
|
313
|
+
commandId: d.commandId,
|
|
314
|
+
outcome: d.kind,
|
|
315
|
+
...(typeof d.text === 'string' ? { text: d.text } : {}),
|
|
316
|
+
...(typeof d.sourceEventSeq === 'number' ? { sourceEventSeq: d.sourceEventSeq } : {}),
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
case 'compaction/start':
|
|
320
|
+
return Object.assign(base, {
|
|
321
|
+
event: {
|
|
322
|
+
type: 'compaction/start',
|
|
323
|
+
compactionId: d.compactionId,
|
|
324
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
325
|
+
turn: d.turn ?? null,
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
case 'compaction/summary':
|
|
329
|
+
return Object.assign(base, {
|
|
330
|
+
event: {
|
|
331
|
+
type: 'compaction/summary',
|
|
332
|
+
compactionId: d.compactionId,
|
|
333
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
334
|
+
shadowedItemCount: Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.length : null,
|
|
335
|
+
shadowedTokenCount: typeof d.shadowedTokenCount === 'number' ? d.shadowedTokenCount : null,
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
case 'compaction/end':
|
|
339
|
+
return Object.assign(base, {
|
|
340
|
+
event: {
|
|
341
|
+
type: 'compaction/end',
|
|
342
|
+
compactionId: d.compactionId,
|
|
343
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
344
|
+
turn: d.turn ?? null,
|
|
345
|
+
...(typeof d.error === 'string' ? { error: d.error } : {}),
|
|
346
|
+
},
|
|
347
|
+
})
|
|
225
348
|
case 'turn/start':
|
|
226
349
|
case 'turn/end':
|
|
227
350
|
case 'step/start':
|
|
@@ -234,31 +357,28 @@ function buildWireEvent(session, event) {
|
|
|
234
357
|
}
|
|
235
358
|
}
|
|
236
359
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async function admitMessage(api, msg) {
|
|
240
|
-
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
241
|
-
const images = msg.images === undefined ? [] : msg.images
|
|
360
|
+
function parseWireImages(rawImages, requestType) {
|
|
361
|
+
const images = rawImages === undefined ? [] : rawImages
|
|
242
362
|
if (!Array.isArray(images)) {
|
|
243
|
-
return { kind: 'error', code: 'bad-request', message: 'images must be an array' }
|
|
363
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'images must be an array', requestType } }
|
|
244
364
|
}
|
|
245
365
|
if (images.length > 20) {
|
|
246
|
-
return { kind: 'error', code: 'bad-request', message: 'a
|
|
366
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'a request can contain at most 20 images', requestType } }
|
|
247
367
|
}
|
|
248
368
|
const imageParts = []
|
|
249
369
|
for (let index = 0; index < images.length; index++) {
|
|
250
370
|
const image = images[index]
|
|
251
371
|
if (!image || typeof image !== 'object') {
|
|
252
|
-
return { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object
|
|
372
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object`, requestType } }
|
|
253
373
|
}
|
|
254
374
|
if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
|
|
255
|
-
return { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported
|
|
375
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported`, requestType } }
|
|
256
376
|
}
|
|
257
377
|
if (typeof image.data !== 'string' || image.data.length === 0) {
|
|
258
|
-
return { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string
|
|
378
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string`, requestType } }
|
|
259
379
|
}
|
|
260
380
|
if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
|
|
261
|
-
return { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters
|
|
381
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters`, requestType } }
|
|
262
382
|
}
|
|
263
383
|
imageParts.push({
|
|
264
384
|
type: 'image',
|
|
@@ -267,6 +387,88 @@ async function admitMessage(api, msg) {
|
|
|
267
387
|
...(image.name ? { name: image.name } : {}),
|
|
268
388
|
})
|
|
269
389
|
}
|
|
390
|
+
return { value: imageParts }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function commandNameOf(line) {
|
|
394
|
+
const trimmed = line.trim()
|
|
395
|
+
if (!trimmed.startsWith('/')) return null
|
|
396
|
+
const end = trimmed.search(/\s/)
|
|
397
|
+
return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function listHostCommands(typertGateway, sessionId) {
|
|
401
|
+
const listed = await typertGateway.invoke({
|
|
402
|
+
namespace: 'commands',
|
|
403
|
+
method: 'list',
|
|
404
|
+
args: { agentId: sessionId },
|
|
405
|
+
signal: new AbortController().signal,
|
|
406
|
+
})
|
|
407
|
+
if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
|
|
408
|
+
return listed
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function executeHostCommand(typertGateway, sessionId, line, images, requestType) {
|
|
412
|
+
try {
|
|
413
|
+
const execution = await typertGateway.invoke({
|
|
414
|
+
namespace: 'commands',
|
|
415
|
+
method: 'execute',
|
|
416
|
+
args: { agentId: sessionId, line, images },
|
|
417
|
+
signal: new AbortController().signal,
|
|
418
|
+
})
|
|
419
|
+
if (execution === undefined || execution === null) {
|
|
420
|
+
return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
|
|
421
|
+
}
|
|
422
|
+
log(`command executed: session=${sessionId} line=${JSON.stringify(line)} commandId=${execution.commandId} outcome=${execution.result.kind}`)
|
|
423
|
+
return {
|
|
424
|
+
kind: 'command-executed',
|
|
425
|
+
sessionId,
|
|
426
|
+
line,
|
|
427
|
+
commandId: execution.commandId,
|
|
428
|
+
result: execution.result,
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
const code = error && error.code ? error.code : 'internal'
|
|
432
|
+
const message = error && error.message ? error.message : String(error)
|
|
433
|
+
log(`command rejected: session=${sessionId} ${code}: ${message}`)
|
|
434
|
+
return { kind: 'error', code, message, requestType, sessionId }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function admitCommand(typertGateway, msg) {
|
|
439
|
+
const sessionId = requireSessionId(msg)
|
|
440
|
+
if (sessionId.error) return sessionId.error
|
|
441
|
+
const line = typeof msg.line === 'string' ? msg.line.trim() : ''
|
|
442
|
+
const name = commandNameOf(line)
|
|
443
|
+
if (!name) {
|
|
444
|
+
return { kind: 'error', code: 'bad-request', message: 'command-execute requires a slash-prefixed line', requestType: 'command-execute', sessionId: sessionId.value }
|
|
445
|
+
}
|
|
446
|
+
const parsedImages = parseWireImages(msg.images, 'command-execute')
|
|
447
|
+
if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
|
|
448
|
+
try {
|
|
449
|
+
const listed = await listHostCommands(typertGateway, sessionId.value)
|
|
450
|
+
const descriptor = listed.find((command) => command && command.name === name)
|
|
451
|
+
if (!descriptor) {
|
|
452
|
+
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
453
|
+
}
|
|
454
|
+
if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
|
|
455
|
+
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
456
|
+
}
|
|
457
|
+
return executeHostCommand(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const code = error && error.code ? error.code : 'internal'
|
|
460
|
+
const message = error && error.message ? error.message : String(error)
|
|
461
|
+
return { kind: 'error', code, message, requestType: 'command-execute', sessionId: sessionId.value }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Handle one mobile -> agent message through the official host API. Returns
|
|
466
|
+
// the wire frame to send back, or null when nothing should be sent.
|
|
467
|
+
async function admitMessage(api, msg) {
|
|
468
|
+
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
469
|
+
const parsedImages = parseWireImages(msg.images, 'message')
|
|
470
|
+
if (parsedImages.error) return parsedImages.error
|
|
471
|
+
const imageParts = parsedImages.value
|
|
270
472
|
if (!text && imageParts.length === 0) {
|
|
271
473
|
return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
|
|
272
474
|
}
|
|
@@ -351,6 +553,320 @@ async function proxyQuery(api, type, method, payload, signal) {
|
|
|
351
553
|
}
|
|
352
554
|
}
|
|
353
555
|
|
|
556
|
+
function fileTransferError(code, message, requestType, sessionId) {
|
|
557
|
+
return {
|
|
558
|
+
kind: 'error',
|
|
559
|
+
code,
|
|
560
|
+
message,
|
|
561
|
+
requestType,
|
|
562
|
+
...(sessionId ? { sessionId } : {}),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function fileMediaType(filePath) {
|
|
567
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
568
|
+
case '.apk': return 'application/vnd.android.package-archive'
|
|
569
|
+
case '.doc': return 'application/msword'
|
|
570
|
+
case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
571
|
+
case '.ipa': return 'application/octet-stream'
|
|
572
|
+
case '.json': return 'application/json'
|
|
573
|
+
case '.pdf': return 'application/pdf'
|
|
574
|
+
case '.ppt': return 'application/vnd.ms-powerpoint'
|
|
575
|
+
case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
576
|
+
case '.txt': return 'text/plain'
|
|
577
|
+
case '.xls': return 'application/vnd.ms-excel'
|
|
578
|
+
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
579
|
+
case '.zip': return 'application/zip'
|
|
580
|
+
default: return 'application/octet-stream'
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function workspaceRelativePath(root, target) {
|
|
585
|
+
const relative = path.relative(root, target)
|
|
586
|
+
return relative ? relative.split(path.sep).join('/') : '.'
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isInsideWorkspace(root, target) {
|
|
590
|
+
const relative = path.relative(root, target)
|
|
591
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function normalizedWorkspaceInput(value, { allowEmpty = false } = {}) {
|
|
595
|
+
if (value === undefined && allowEmpty) return '.'
|
|
596
|
+
if (typeof value !== 'string') return null
|
|
597
|
+
const trimmed = value.trim()
|
|
598
|
+
if (!trimmed && allowEmpty) return '.'
|
|
599
|
+
if (!trimmed || trimmed.includes('\0') || path.isAbsolute(trimmed)) return null
|
|
600
|
+
if (trimmed.split(/[\\/]+/).includes('..')) return null
|
|
601
|
+
return trimmed
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Owns only workspace-scoped, regular-file downloads. The host API has an
|
|
605
|
+
// image-only attachment read path, so arbitrary document/archive bytes must be
|
|
606
|
+
// guarded here instead of accepting a host path from the mobile client.
|
|
607
|
+
function createFileTransferManager(api, options) {
|
|
608
|
+
const transfers = new Map()
|
|
609
|
+
|
|
610
|
+
const closeTransfer = async (transferId) => {
|
|
611
|
+
const transfer = transfers.get(transferId)
|
|
612
|
+
if (!transfer) return false
|
|
613
|
+
transfers.delete(transferId)
|
|
614
|
+
try {
|
|
615
|
+
await transfer.handle.close()
|
|
616
|
+
} catch {
|
|
617
|
+
// A failed close cannot make a completed or cancelled transfer usable.
|
|
618
|
+
}
|
|
619
|
+
return true
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const resolveSessionRoot = async (sessionId, requestType) => {
|
|
623
|
+
const sessions = await proxyQuery(api, requestType, api.sessions.list.bind(api.sessions), {})
|
|
624
|
+
if (sessions.kind === 'error') return sessions
|
|
625
|
+
const item = Array.isArray(sessions.items)
|
|
626
|
+
? sessions.items.find((entry) => entry && String(entry.sessionId) === sessionId)
|
|
627
|
+
: null
|
|
628
|
+
if (!item) return fileTransferError('session-not-found', 'no such session', requestType, sessionId)
|
|
629
|
+
if (typeof item.cwd !== 'string' || !item.cwd.trim()) {
|
|
630
|
+
return fileTransferError('file-workspace-unavailable', 'the session has no working directory', requestType, sessionId)
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const root = await fsp.realpath(item.cwd)
|
|
634
|
+
const stat = await fsp.stat(root)
|
|
635
|
+
if (!stat.isDirectory()) {
|
|
636
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
637
|
+
}
|
|
638
|
+
return { root }
|
|
639
|
+
} catch {
|
|
640
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
|
|
645
|
+
const requested = normalizedWorkspaceInput(rawPath, inputOptions)
|
|
646
|
+
if (!requested) {
|
|
647
|
+
return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
|
|
648
|
+
}
|
|
649
|
+
const rootResult = await resolveSessionRoot(sessionId, requestType)
|
|
650
|
+
if (rootResult.kind === 'error') return rootResult
|
|
651
|
+
const candidate = path.resolve(rootResult.root, requested)
|
|
652
|
+
if (!isInsideWorkspace(rootResult.root, candidate)) {
|
|
653
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
const target = await fsp.realpath(candidate)
|
|
657
|
+
if (!isInsideWorkspace(rootResult.root, target)) {
|
|
658
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
659
|
+
}
|
|
660
|
+
const stat = await fsp.stat(target)
|
|
661
|
+
return { root: rootResult.root, target, stat }
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error && error.code === 'ENOENT') {
|
|
664
|
+
return fileTransferError('file-not-found', 'file does not exist', requestType, sessionId)
|
|
665
|
+
}
|
|
666
|
+
return fileTransferError('file-unreadable', 'file cannot be read', requestType, sessionId)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const fileList = async (msg) => {
|
|
671
|
+
const session = requireSessionId(msg)
|
|
672
|
+
if (session.error) return session.error
|
|
673
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-list', { allowEmpty: true })
|
|
674
|
+
if (resolved.kind === 'error') return resolved
|
|
675
|
+
if (!resolved.stat.isDirectory()) {
|
|
676
|
+
return fileTransferError('file-not-directory', 'path is not a directory', 'file-list', session.value)
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const dirents = await fsp.readdir(resolved.target, { withFileTypes: true })
|
|
680
|
+
const entries = []
|
|
681
|
+
for (const dirent of dirents) {
|
|
682
|
+
if (dirent.isSymbolicLink()) continue
|
|
683
|
+
const child = path.join(resolved.target, dirent.name)
|
|
684
|
+
if (dirent.isDirectory()) {
|
|
685
|
+
entries.push({ name: dirent.name, path: workspaceRelativePath(resolved.root, child), kind: 'directory' })
|
|
686
|
+
} else if (dirent.isFile()) {
|
|
687
|
+
const stat = await fsp.stat(child)
|
|
688
|
+
entries.push({
|
|
689
|
+
name: dirent.name,
|
|
690
|
+
path: workspaceRelativePath(resolved.root, child),
|
|
691
|
+
kind: 'file',
|
|
692
|
+
bytes: stat.size,
|
|
693
|
+
modifiedAt: stat.mtimeMs,
|
|
694
|
+
mediaType: fileMediaType(child),
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1))
|
|
699
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
700
|
+
return {
|
|
701
|
+
kind: 'file-list',
|
|
702
|
+
...(requestId ? { requestId } : {}),
|
|
703
|
+
sessionId: session.value,
|
|
704
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
705
|
+
entries,
|
|
706
|
+
}
|
|
707
|
+
} catch {
|
|
708
|
+
return fileTransferError('file-unreadable', 'directory cannot be read', 'file-list', session.value)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const open = async (client, msg) => {
|
|
713
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
714
|
+
const session = requireSessionId(msg)
|
|
715
|
+
if (!requestId) return fileTransferError('bad-request', 'file-download-open requires a requestId', 'file-download-open')
|
|
716
|
+
if (session.error) return session.error
|
|
717
|
+
if (transfers.size >= options.fileDownloadMaxTransfers) {
|
|
718
|
+
return fileTransferError('file-transfer-limit', 'too many active file downloads', 'file-download-open', session.value)
|
|
719
|
+
}
|
|
720
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-download-open')
|
|
721
|
+
if (resolved.kind === 'error') return resolved
|
|
722
|
+
if (!resolved.stat.isFile()) {
|
|
723
|
+
return fileTransferError('file-not-regular', 'only regular files can be downloaded', 'file-download-open', session.value)
|
|
724
|
+
}
|
|
725
|
+
if (resolved.stat.size > options.fileDownloadMaxBytes) {
|
|
726
|
+
return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-download-open', session.value)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let handle
|
|
730
|
+
try {
|
|
731
|
+
// Reject a last-moment leaf symlink replacement after the realpath
|
|
732
|
+
// containment check above. macOS and Linux both support O_NOFOLLOW.
|
|
733
|
+
handle = await fsp.open(resolved.target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
734
|
+
const opened = await handle.stat()
|
|
735
|
+
if (!opened.isFile() || opened.size !== resolved.stat.size) {
|
|
736
|
+
await handle.close()
|
|
737
|
+
return fileTransferError('file-changed', 'file changed before download could start', 'file-download-open', session.value)
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
return fileTransferError('file-unreadable', 'file cannot be opened', 'file-download-open', session.value)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const transferId = crypto.randomUUID()
|
|
744
|
+
const transfer = {
|
|
745
|
+
client,
|
|
746
|
+
handle,
|
|
747
|
+
sessionId: session.value,
|
|
748
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
749
|
+
name: path.basename(resolved.target),
|
|
750
|
+
mediaType: fileMediaType(resolved.target),
|
|
751
|
+
size: resolved.stat.size,
|
|
752
|
+
offset: 0,
|
|
753
|
+
hash: crypto.createHash('sha256'),
|
|
754
|
+
reading: false,
|
|
755
|
+
lastActiveAt: Date.now(),
|
|
756
|
+
}
|
|
757
|
+
transfers.set(transferId, transfer)
|
|
758
|
+
log(`file download opened: transfer=${transferId} session=${transfer.sessionId} bytes=${transfer.size}`)
|
|
759
|
+
return {
|
|
760
|
+
kind: 'file-download-opened',
|
|
761
|
+
requestId,
|
|
762
|
+
transferId,
|
|
763
|
+
sessionId: transfer.sessionId,
|
|
764
|
+
path: transfer.path,
|
|
765
|
+
name: transfer.name,
|
|
766
|
+
mediaType: transfer.mediaType,
|
|
767
|
+
size: transfer.size,
|
|
768
|
+
chunkBytes: options.fileDownloadChunkBytes,
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const read = async (client, msg) => {
|
|
773
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
774
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-read requires a transferId', 'file-download-read')
|
|
775
|
+
const transfer = transfers.get(transferId)
|
|
776
|
+
if (!transfer || transfer.client !== client) {
|
|
777
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-read')
|
|
778
|
+
}
|
|
779
|
+
if (!Number.isSafeInteger(msg.offset) || msg.offset !== transfer.offset) {
|
|
780
|
+
return fileTransferError('file-transfer-offset', 'offset must match the next unread byte', 'file-download-read', transfer.sessionId)
|
|
781
|
+
}
|
|
782
|
+
if (transfer.reading) {
|
|
783
|
+
return fileTransferError('file-transfer-busy', 'a chunk read is already in progress', 'file-download-read', transfer.sessionId)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
transfer.reading = true
|
|
787
|
+
try {
|
|
788
|
+
const remaining = transfer.size - transfer.offset
|
|
789
|
+
const bytesToRead = Math.min(options.fileDownloadChunkBytes, remaining)
|
|
790
|
+
const chunk = Buffer.allocUnsafe(bytesToRead)
|
|
791
|
+
const { bytesRead } = bytesToRead === 0
|
|
792
|
+
? { bytesRead: 0 }
|
|
793
|
+
: await transfer.handle.read(chunk, 0, bytesToRead, transfer.offset)
|
|
794
|
+
if (bytesRead !== bytesToRead) {
|
|
795
|
+
await closeTransfer(transferId)
|
|
796
|
+
return fileTransferError('file-changed', 'file changed during download', 'file-download-read', transfer.sessionId)
|
|
797
|
+
}
|
|
798
|
+
const payload = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
|
|
799
|
+
transfer.hash.update(payload)
|
|
800
|
+
const offset = transfer.offset
|
|
801
|
+
transfer.offset += bytesRead
|
|
802
|
+
transfer.lastActiveAt = Date.now()
|
|
803
|
+
const eof = transfer.offset === transfer.size
|
|
804
|
+
const frame = {
|
|
805
|
+
kind: 'file-download-chunk',
|
|
806
|
+
transferId,
|
|
807
|
+
offset,
|
|
808
|
+
data: payload.toString('base64'),
|
|
809
|
+
eof,
|
|
810
|
+
}
|
|
811
|
+
if (eof) {
|
|
812
|
+
frame.sha256 = transfer.hash.digest('hex')
|
|
813
|
+
await closeTransfer(transferId)
|
|
814
|
+
log(`file download completed: transfer=${transferId} bytes=${transfer.size}`)
|
|
815
|
+
}
|
|
816
|
+
return frame
|
|
817
|
+
} catch {
|
|
818
|
+
await closeTransfer(transferId)
|
|
819
|
+
return fileTransferError('file-unreadable', 'file cannot be read', 'file-download-read', transfer.sessionId)
|
|
820
|
+
} finally {
|
|
821
|
+
transfer.reading = false
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const cancel = async (client, msg) => {
|
|
826
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
827
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-cancel requires a transferId', 'file-download-cancel')
|
|
828
|
+
const transfer = transfers.get(transferId)
|
|
829
|
+
if (!transfer || transfer.client !== client) {
|
|
830
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-cancel')
|
|
831
|
+
}
|
|
832
|
+
await closeTransfer(transferId)
|
|
833
|
+
log(`file download cancelled: transfer=${transferId}`)
|
|
834
|
+
return { kind: 'file-download-cancelled', transferId }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const expire = async () => {
|
|
838
|
+
const cutoff = Date.now() - options.fileDownloadIdleMs
|
|
839
|
+
for (const [transferId, transfer] of transfers) {
|
|
840
|
+
if (transfer.lastActiveAt < cutoff && !transfer.reading) {
|
|
841
|
+
await closeTransfer(transferId)
|
|
842
|
+
log(`file download expired: transfer=${transferId}`)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
async handle(client, msg) {
|
|
849
|
+
if (!options.fileDownloadsEnabled) {
|
|
850
|
+
return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
|
|
851
|
+
}
|
|
852
|
+
if (msg.type === 'file-list') return fileList(msg)
|
|
853
|
+
if (msg.type === 'file-download-open') return open(client, msg)
|
|
854
|
+
if (msg.type === 'file-download-read') return read(client, msg)
|
|
855
|
+
if (msg.type === 'file-download-cancel') return cancel(client, msg)
|
|
856
|
+
return null
|
|
857
|
+
},
|
|
858
|
+
async closeClient(client) {
|
|
859
|
+
for (const [transferId, transfer] of transfers) {
|
|
860
|
+
if (transfer.client === client) await closeTransfer(transferId)
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
async dispose() {
|
|
864
|
+
for (const transferId of [...transfers.keys()]) await closeTransfer(transferId)
|
|
865
|
+
},
|
|
866
|
+
expire,
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
354
870
|
// List one directory level directly with node:fs, mirroring the official
|
|
355
871
|
// browse backend's semantics (crumbs + name-sorted child directories). Works
|
|
356
872
|
// on every deployment regardless of the composed picker capability, which
|
|
@@ -516,6 +1032,171 @@ function capHistoryEvents(events, maxBytes, trim) {
|
|
|
516
1032
|
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
517
1033
|
}
|
|
518
1034
|
|
|
1035
|
+
function resolveCommandCatalogCopy(locale) {
|
|
1036
|
+
return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
|
|
1037
|
+
? COMMAND_CATALOG_COPY.en
|
|
1038
|
+
: COMMAND_CATALOG_COPY.zh
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function commandUiDescriptor(command, copy) {
|
|
1042
|
+
const override = COMMAND_UI_OVERRIDES[command.name]
|
|
1043
|
+
if (override) return { ...override, insertText: `/${command.name}` }
|
|
1044
|
+
if (command.input && typeof command.input.hint === 'string') {
|
|
1045
|
+
const displayHint = copy.hints[command.name]
|
|
1046
|
+
return {
|
|
1047
|
+
kind: 'input',
|
|
1048
|
+
insertText: `/${command.name} `,
|
|
1049
|
+
hint: command.input.hint,
|
|
1050
|
+
...(displayHint ? { displayHint } : {}),
|
|
1051
|
+
images: command.input.images === true,
|
|
1052
|
+
submitRequest: 'command-execute',
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return { kind: 'immediate', submitRequest: 'command-execute', submitText: `/${command.name}` }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function skillUiDescriptor(skill) {
|
|
1059
|
+
return {
|
|
1060
|
+
kind: 'input',
|
|
1061
|
+
insertText: `/${skill.name} `,
|
|
1062
|
+
images: true,
|
|
1063
|
+
submitRequest: 'message',
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function modelCommandOptionId(provider, model) {
|
|
1068
|
+
return Buffer.from(JSON.stringify([provider, model]), 'utf8').toString('base64url')
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function decodeModelCommandOptionId(optionId) {
|
|
1072
|
+
try {
|
|
1073
|
+
const value = JSON.parse(Buffer.from(optionId, 'base64url').toString('utf8'))
|
|
1074
|
+
if (!Array.isArray(value) || value.length !== 2 || value.some((part) => typeof part !== 'string' || part === '')) return null
|
|
1075
|
+
return { provider: value[0], model: value[1] }
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function loadCommandOptions(api, command, sessionId) {
|
|
1082
|
+
if (command === 'model') {
|
|
1083
|
+
const frame = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
1084
|
+
if (frame.kind !== 'models') return frame
|
|
1085
|
+
const options = []
|
|
1086
|
+
for (const group of frame.groups || []) {
|
|
1087
|
+
for (const model of group.models || []) {
|
|
1088
|
+
options.push({
|
|
1089
|
+
id: modelCommandOptionId(String(group.id), String(model.id)),
|
|
1090
|
+
label: String(model.name || model.id),
|
|
1091
|
+
detail: String(group.name || group.id),
|
|
1092
|
+
...(model.description ? { description: String(model.description) } : {}),
|
|
1093
|
+
selected: frame.current && frame.current.provider === group.id && frame.current.model === model.id,
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return { kind: 'command-options', sessionId, command, options }
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (command === 'permission') {
|
|
1101
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
1102
|
+
if (history.kind !== 'history') return history
|
|
1103
|
+
const permissions = history.projections && history.projections.values && history.projections.values.permissions
|
|
1104
|
+
if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
|
|
1105
|
+
return {
|
|
1106
|
+
kind: 'error',
|
|
1107
|
+
code: 'command-options-unavailable',
|
|
1108
|
+
message: 'permission options are unavailable for this session',
|
|
1109
|
+
requestType: 'command-options',
|
|
1110
|
+
sessionId,
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
kind: 'command-options',
|
|
1115
|
+
sessionId,
|
|
1116
|
+
command,
|
|
1117
|
+
options: permissions.options.map((option) => ({
|
|
1118
|
+
id: String(option.value),
|
|
1119
|
+
label: String(option.name || option.value),
|
|
1120
|
+
...(option.description ? { description: String(option.description) } : {}),
|
|
1121
|
+
selected: option.value === permissions.currentValue,
|
|
1122
|
+
})),
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return {
|
|
1127
|
+
kind: 'error',
|
|
1128
|
+
code: 'bad-request',
|
|
1129
|
+
message: `command does not provide selectable options: ${command}`,
|
|
1130
|
+
requestType: 'command-options',
|
|
1131
|
+
sessionId,
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function selectCommandOption(api, typertGateway, command, sessionId, optionId) {
|
|
1136
|
+
const catalog = await loadCommandOptions(api, command, sessionId)
|
|
1137
|
+
if (catalog.kind !== 'command-options') return catalog
|
|
1138
|
+
const option = catalog.options.find((candidate) => candidate.id === optionId)
|
|
1139
|
+
if (!option) {
|
|
1140
|
+
return {
|
|
1141
|
+
kind: 'error',
|
|
1142
|
+
code: 'bad-request',
|
|
1143
|
+
message: `unknown option for ${command}: ${optionId}`,
|
|
1144
|
+
requestType: 'command-select',
|
|
1145
|
+
sessionId,
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (command === 'permission') {
|
|
1150
|
+
try {
|
|
1151
|
+
const execution = await typertGateway.invoke({
|
|
1152
|
+
namespace: 'commands',
|
|
1153
|
+
method: 'execute',
|
|
1154
|
+
args: { agentId: sessionId, line: '/permission ' + optionId, images: [] },
|
|
1155
|
+
signal: new AbortController().signal,
|
|
1156
|
+
})
|
|
1157
|
+
if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
|
|
1158
|
+
if (execution.result.kind === 'error') {
|
|
1159
|
+
return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
|
|
1160
|
+
}
|
|
1161
|
+
return { kind: 'command-selected', sessionId, command, selected: { ...option, selected: true } }
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const code = error && error.code ? error.code : 'internal'
|
|
1164
|
+
const message = error && error.message ? error.message : String(error)
|
|
1165
|
+
return { kind: 'error', code, message, requestType: 'command-select', sessionId }
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (command === 'model') {
|
|
1170
|
+
const selection = decodeModelCommandOptionId(optionId)
|
|
1171
|
+
if (!selection) {
|
|
1172
|
+
return { kind: 'error', code: 'bad-request', message: 'invalid model option id', requestType: 'command-select', sessionId }
|
|
1173
|
+
}
|
|
1174
|
+
const models = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
1175
|
+
if (models.kind !== 'models') return models
|
|
1176
|
+
const group = (models.groups || []).find((candidate) => candidate.id === selection.provider)
|
|
1177
|
+
const model = group && (group.models || []).find((candidate) => candidate.id === selection.model)
|
|
1178
|
+
if (!model) {
|
|
1179
|
+
return { kind: 'error', code: 'bad-request', message: 'model option is no longer available', requestType: 'command-select', sessionId }
|
|
1180
|
+
}
|
|
1181
|
+
const payload = { sessionId, ...selection }
|
|
1182
|
+
const reasoningEffort = models.current && models.current.provider === selection.provider && models.current.model === selection.model
|
|
1183
|
+
? models.current.reasoningEffort
|
|
1184
|
+
: model.reasoning && model.reasoning.defaultEffort
|
|
1185
|
+
if (reasoningEffort) payload.reasoningEffort = reasoningEffort
|
|
1186
|
+
const selected = await proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
1187
|
+
if (selected.kind !== 'select-model') return selected
|
|
1188
|
+
return {
|
|
1189
|
+
kind: 'command-selected',
|
|
1190
|
+
sessionId,
|
|
1191
|
+
command,
|
|
1192
|
+
selected: { ...option, selected: true },
|
|
1193
|
+
value: selected.selected,
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
|
|
1198
|
+
}
|
|
1199
|
+
|
|
519
1200
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
520
1201
|
async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
521
1202
|
if (msg.type === 'workspaces') {
|
|
@@ -646,6 +1327,99 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
646
1327
|
if (msg.type === 'providers') {
|
|
647
1328
|
return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
|
|
648
1329
|
}
|
|
1330
|
+
if (msg.type === 'command-execute') {
|
|
1331
|
+
return admitCommand(typertGateway, msg)
|
|
1332
|
+
}
|
|
1333
|
+
if (msg.type === 'commands') {
|
|
1334
|
+
const sessionId = requireSessionId(msg)
|
|
1335
|
+
if (sessionId.error) return sessionId.error
|
|
1336
|
+
const copy = resolveCommandCatalogCopy(msg.locale)
|
|
1337
|
+
try {
|
|
1338
|
+
// This is the same session-scoped catalog used by the official Web UI.
|
|
1339
|
+
// Copy only the stable descriptor fields so no live Typert object crosses
|
|
1340
|
+
// the mobile wire. The Web UI also contributes /model client-side; expose
|
|
1341
|
+
// the equivalent action here so mobile clients can render the same menu.
|
|
1342
|
+
const [listed, skillFrame] = await Promise.all([
|
|
1343
|
+
listHostCommands(typertGateway, sessionId.value),
|
|
1344
|
+
api.skills && typeof api.skills.list === 'function'
|
|
1345
|
+
? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
|
|
1346
|
+
: Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
|
|
1347
|
+
])
|
|
1348
|
+
if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
|
|
1349
|
+
const commands = listed
|
|
1350
|
+
.filter((command) => command && typeof command.name === 'string' && typeof command.description === 'string')
|
|
1351
|
+
.map((command) => ({
|
|
1352
|
+
id: `command:${command.name}`,
|
|
1353
|
+
name: command.name,
|
|
1354
|
+
description: command.description,
|
|
1355
|
+
source: 'host',
|
|
1356
|
+
action: 'execute',
|
|
1357
|
+
ui: commandUiDescriptor(command, copy),
|
|
1358
|
+
...(command.input && typeof command.input.hint === 'string'
|
|
1359
|
+
? { input: { hint: command.input.hint, ...(command.input.images === true ? { images: true } : {}) } }
|
|
1360
|
+
: {}),
|
|
1361
|
+
}))
|
|
1362
|
+
if (!commands.some((command) => command.name === 'model')) {
|
|
1363
|
+
commands.push({
|
|
1364
|
+
id: 'command:model',
|
|
1365
|
+
name: 'model',
|
|
1366
|
+
description: copy.modelDescription,
|
|
1367
|
+
source: 'client',
|
|
1368
|
+
action: 'select-model',
|
|
1369
|
+
ui: commandUiDescriptor({ name: 'model' }, copy),
|
|
1370
|
+
})
|
|
1371
|
+
}
|
|
1372
|
+
const skills = skillFrame.kind === 'skills' && Array.isArray(skillFrame.skills)
|
|
1373
|
+
? skillFrame.skills
|
|
1374
|
+
.filter((skill) => skill && typeof skill.name === 'string' && typeof skill.description === 'string')
|
|
1375
|
+
.map((skill) => ({
|
|
1376
|
+
id: `skill:${skill.name}`,
|
|
1377
|
+
name: skill.name,
|
|
1378
|
+
description: skill.modelInvocable === true ? skill.description : `${copy.userOnly} · ${skill.description}`,
|
|
1379
|
+
source: 'skill',
|
|
1380
|
+
action: 'insert',
|
|
1381
|
+
modelInvocable: skill.modelInvocable === true,
|
|
1382
|
+
...(typeof skill.whenToUse === 'string' && skill.whenToUse !== '' ? { whenToUse: skill.whenToUse } : {}),
|
|
1383
|
+
ui: skillUiDescriptor(skill),
|
|
1384
|
+
}))
|
|
1385
|
+
: []
|
|
1386
|
+
const groups = [
|
|
1387
|
+
{ id: 'commands', title: copy.commandsTitle, items: commands },
|
|
1388
|
+
...(skills.length > 0 ? [{ id: 'skills', title: copy.skillsTitle, items: skills }] : []),
|
|
1389
|
+
]
|
|
1390
|
+
const warnings = skillFrame.kind === 'error'
|
|
1391
|
+
? [{ source: 'skills', code: skillFrame.code, message: skillFrame.message }]
|
|
1392
|
+
: []
|
|
1393
|
+
log(`command catalog queried: session=${sessionId.value} commands=${commands.length} skills=${skills.length}`)
|
|
1394
|
+
return {
|
|
1395
|
+
kind: 'commands',
|
|
1396
|
+
sessionId: sessionId.value,
|
|
1397
|
+
locale: copy.locale,
|
|
1398
|
+
groups,
|
|
1399
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1400
|
+
}
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
const code = error && error.code ? error.code : 'internal'
|
|
1403
|
+
const message = error && error.message ? error.message : String(error)
|
|
1404
|
+
log(`command catalog failed: session=${sessionId.value} ${code}: ${message}`)
|
|
1405
|
+
return { kind: 'error', code, message, requestType: 'commands', sessionId: sessionId.value }
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (msg.type === 'command-options') {
|
|
1409
|
+
const sessionId = requireSessionId(msg)
|
|
1410
|
+
if (sessionId.error) return sessionId.error
|
|
1411
|
+
const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
|
|
1412
|
+
if (!command) return { kind: 'error', code: 'bad-request', message: 'command-options requires a command', requestType: 'command-options', sessionId: sessionId.value }
|
|
1413
|
+
return loadCommandOptions(api, command, sessionId.value)
|
|
1414
|
+
}
|
|
1415
|
+
if (msg.type === 'command-select') {
|
|
1416
|
+
const sessionId = requireSessionId(msg)
|
|
1417
|
+
if (sessionId.error) return sessionId.error
|
|
1418
|
+
const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
|
|
1419
|
+
const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
|
|
1420
|
+
if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
|
|
1421
|
+
return selectCommandOption(api, typertGateway, command, sessionId.value, optionId)
|
|
1422
|
+
}
|
|
649
1423
|
if (msg.type === 'select-model') {
|
|
650
1424
|
const sessionId = requireSessionId(msg)
|
|
651
1425
|
if (sessionId.error) return sessionId.error
|
|
@@ -1015,6 +1789,11 @@ const plugin = {
|
|
|
1015
1789
|
gatewayEnabled: false,
|
|
1016
1790
|
gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
|
|
1017
1791
|
maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
|
|
1792
|
+
fileDownloadsEnabled: true,
|
|
1793
|
+
fileDownloadMaxBytes: DEFAULT_FILE_DOWNLOAD_MAX_BYTES,
|
|
1794
|
+
fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
|
|
1795
|
+
fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
|
|
1796
|
+
fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
|
|
1018
1797
|
adminLoopbackOnly: true,
|
|
1019
1798
|
publicUrl: '',
|
|
1020
1799
|
deviceFile: '',
|
|
@@ -1052,7 +1831,14 @@ const plugin = {
|
|
|
1052
1831
|
})
|
|
1053
1832
|
const clients = new Set()
|
|
1054
1833
|
const pendingQuestions = new Map()
|
|
1055
|
-
const
|
|
1834
|
+
const pendingApprovals = new Map()
|
|
1835
|
+
const fileTransfers = createFileTransferManager(api, options)
|
|
1836
|
+
const fileTransferExpiryTimer = setInterval(() => {
|
|
1837
|
+
fileTransfers.expire().catch((error) => {
|
|
1838
|
+
log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
|
|
1839
|
+
})
|
|
1840
|
+
}, Math.min(options.fileDownloadIdleMs, 30_000))
|
|
1841
|
+
const interactionMuxAbort = new AbortController()
|
|
1056
1842
|
let counter = 0
|
|
1057
1843
|
let gatewayEnabled = options.gatewayEnabled === true
|
|
1058
1844
|
let waitExpiresAt = null
|
|
@@ -1122,7 +1908,18 @@ const plugin = {
|
|
|
1122
1908
|
...(replay ? { replay: true } : {}),
|
|
1123
1909
|
})
|
|
1124
1910
|
|
|
1125
|
-
const
|
|
1911
|
+
const approvalFrameFor = (rpcId, payload, replay = false) => ({
|
|
1912
|
+
kind: 'approval-requested',
|
|
1913
|
+
rpcId: String(rpcId),
|
|
1914
|
+
sessionId: String(payload.sessionId),
|
|
1915
|
+
approvalId: String(payload.approvalId),
|
|
1916
|
+
toolName: payload.toolName,
|
|
1917
|
+
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
1918
|
+
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
1919
|
+
...(replay ? { replay: true } : {}),
|
|
1920
|
+
})
|
|
1921
|
+
|
|
1922
|
+
const broadcastInteractionFrame = (frame) => {
|
|
1126
1923
|
const wire = JSON.stringify(frame)
|
|
1127
1924
|
for (const client of clients) {
|
|
1128
1925
|
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
@@ -1130,6 +1927,23 @@ const plugin = {
|
|
|
1130
1927
|
}
|
|
1131
1928
|
}
|
|
1132
1929
|
|
|
1930
|
+
const replayPendingInteractions = (ws, trigger) => {
|
|
1931
|
+
let questionCount = 0
|
|
1932
|
+
let approvalCount = 0
|
|
1933
|
+
for (const pending of pendingQuestions.values()) {
|
|
1934
|
+
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1935
|
+
ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
|
|
1936
|
+
questionCount += 1
|
|
1937
|
+
}
|
|
1938
|
+
for (const pending of pendingApprovals.values()) {
|
|
1939
|
+
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1940
|
+
ws.send(JSON.stringify(approvalFrameFor(pending.rpcId, pending, true)))
|
|
1941
|
+
approvalCount += 1
|
|
1942
|
+
}
|
|
1943
|
+
log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
|
|
1944
|
+
return { questionCount, approvalCount }
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1133
1947
|
const respondToQuestion = async (msg, cancel = false) => {
|
|
1134
1948
|
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
1135
1949
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
@@ -1180,6 +1994,46 @@ const plugin = {
|
|
|
1180
1994
|
}
|
|
1181
1995
|
}
|
|
1182
1996
|
|
|
1997
|
+
const respondToApproval = async (msg) => {
|
|
1998
|
+
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
1999
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
2000
|
+
const approvalId = typeof msg.approvalId === 'string' && msg.approvalId.trim() ? msg.approvalId.trim() : null
|
|
2001
|
+
const outcome = msg.outcome
|
|
2002
|
+
if (!rpcId || !sessionId || !approvalId) {
|
|
2003
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response requires rpcId, sessionId, and approvalId', requestType: msg.type }
|
|
2004
|
+
}
|
|
2005
|
+
if (outcome !== 'allowed-once' && outcome !== 'rejected') {
|
|
2006
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response outcome must be "allowed-once" or "rejected"', requestType: msg.type, sessionId }
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const pending = pendingApprovals.get(rpcId)
|
|
2010
|
+
if (pending && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
|
|
2011
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
try {
|
|
2015
|
+
const receipt = await api.respond({
|
|
2016
|
+
type: 'client-response',
|
|
2017
|
+
rpcId,
|
|
2018
|
+
result: { ok: true, value: { sessionId, approvalId, outcome } },
|
|
2019
|
+
})
|
|
2020
|
+
log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
|
|
2021
|
+
return {
|
|
2022
|
+
kind: 'approval-response',
|
|
2023
|
+
rpcId,
|
|
2024
|
+
sessionId,
|
|
2025
|
+
approvalId,
|
|
2026
|
+
outcome,
|
|
2027
|
+
accepted: receipt.accepted,
|
|
2028
|
+
...(!receipt.accepted ? { reason: receipt.reason } : {}),
|
|
2029
|
+
}
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
const message = error && error.message ? error.message : String(error)
|
|
2032
|
+
log(`approval response failed: rpcId=${rpcId} ${message}`)
|
|
2033
|
+
return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1183
2037
|
// Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
|
|
1184
2038
|
// standing-service decision made by the operator: do NOT arm the no-device
|
|
1185
2039
|
// auto-close. That safety net stays attached to the ephemeral panel toggle,
|
|
@@ -1187,7 +2041,7 @@ const plugin = {
|
|
|
1187
2041
|
// so nothing else is needed here beyond the log line.
|
|
1188
2042
|
if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
|
|
1189
2043
|
|
|
1190
|
-
log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2044
|
+
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
1191
2045
|
|
|
1192
2046
|
// ---- device management routes (loopback-gated admin surface) ----
|
|
1193
2047
|
const disposeMgmt = webServer.register({
|
|
@@ -1381,6 +2235,7 @@ const plugin = {
|
|
|
1381
2235
|
} else if (msg.type === 'subscribe') {
|
|
1382
2236
|
ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
|
|
1383
2237
|
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
|
|
2238
|
+
replayPendingInteractions(ws, 'subscribe')
|
|
1384
2239
|
} else if (msg.type === 'unsubscribe') {
|
|
1385
2240
|
ws.filterSessionId = undefined
|
|
1386
2241
|
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
|
|
@@ -1388,13 +2243,23 @@ const plugin = {
|
|
|
1388
2243
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
1389
2244
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1390
2245
|
})
|
|
2246
|
+
} else if (msg.type === 'approval-response') {
|
|
2247
|
+
respondToApproval(msg).then((frame) => {
|
|
2248
|
+
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
2249
|
+
})
|
|
2250
|
+
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
2251
|
+
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
2252
|
+
fileTransfers.handle(ws, msg).then((frame) => {
|
|
2253
|
+
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
2254
|
+
})
|
|
1391
2255
|
} else if (msg.type === 'message') {
|
|
1392
2256
|
admitMessage(api, msg).then((frame) => {
|
|
1393
2257
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1394
2258
|
})
|
|
1395
2259
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
1396
2260
|
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
1397
|
-
msg.type === 'workspace-create' || msg.type === 'models' || msg.type === '
|
|
2261
|
+
msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
|
|
2262
|
+
msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
|
|
1398
2263
|
msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
|
|
1399
2264
|
msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
|
|
1400
2265
|
msg.type === 'session-stats' || msg.type === 'default-model' ||
|
|
@@ -1410,6 +2275,9 @@ const plugin = {
|
|
|
1410
2275
|
ws.on('close', () => {
|
|
1411
2276
|
clients.delete(ws)
|
|
1412
2277
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2278
|
+
fileTransfers.closeClient(ws).catch((error) => {
|
|
2279
|
+
log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
|
|
2280
|
+
})
|
|
1413
2281
|
log(`client disconnected (id=${id}, remaining=${clients.size})`)
|
|
1414
2282
|
})
|
|
1415
2283
|
|
|
@@ -1424,16 +2292,13 @@ const plugin = {
|
|
|
1424
2292
|
ws.send(JSON.stringify({
|
|
1425
2293
|
kind: 'hello',
|
|
1426
2294
|
protocol: 3,
|
|
1427
|
-
capabilities: ['images'],
|
|
2295
|
+
capabilities: ['images', 'commands', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
|
|
1428
2296
|
port: transport.port || webServer.port,
|
|
1429
2297
|
clients: clients.size,
|
|
1430
2298
|
authenticated: !!device,
|
|
1431
2299
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
1432
2300
|
}))
|
|
1433
|
-
|
|
1434
|
-
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1435
|
-
ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
|
|
1436
|
-
}
|
|
2301
|
+
replayPendingInteractions(ws, 'connect')
|
|
1437
2302
|
})
|
|
1438
2303
|
}
|
|
1439
2304
|
|
|
@@ -1498,15 +2363,16 @@ const plugin = {
|
|
|
1498
2363
|
})
|
|
1499
2364
|
log('session/event listener attached')
|
|
1500
2365
|
|
|
1501
|
-
const
|
|
2366
|
+
const interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
|
|
1502
2367
|
? (async () => {
|
|
1503
2368
|
try {
|
|
1504
2369
|
for await (const envelope of api.events.mux(
|
|
1505
2370
|
{ rpcId: crypto.randomUUID(), payload: {} },
|
|
1506
|
-
|
|
2371
|
+
interactionMuxAbort.signal,
|
|
1507
2372
|
)) {
|
|
1508
2373
|
const payload = envelope && envelope.payload
|
|
1509
2374
|
if (!payload || typeof payload.type !== 'string') continue
|
|
2375
|
+
log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
|
|
1510
2376
|
if (payload.type === 'question/requested') {
|
|
1511
2377
|
const rpcId = String(envelope.rpcId)
|
|
1512
2378
|
const pending = {
|
|
@@ -1515,34 +2381,72 @@ const plugin = {
|
|
|
1515
2381
|
questions: payload.questions,
|
|
1516
2382
|
}
|
|
1517
2383
|
pendingQuestions.set(rpcId, pending)
|
|
1518
|
-
|
|
2384
|
+
broadcastInteractionFrame(questionFrameFor(rpcId, pending))
|
|
1519
2385
|
log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
|
|
2386
|
+
} else if (payload.type === 'approval/requested') {
|
|
2387
|
+
const rpcId = String(envelope.rpcId)
|
|
2388
|
+
const pending = {
|
|
2389
|
+
rpcId,
|
|
2390
|
+
sessionId: String(payload.sessionId),
|
|
2391
|
+
approvalId: String(payload.approvalId),
|
|
2392
|
+
toolName: payload.toolName,
|
|
2393
|
+
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
2394
|
+
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
2395
|
+
}
|
|
2396
|
+
pendingApprovals.set(rpcId, pending)
|
|
2397
|
+
broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
|
|
2398
|
+
log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
|
|
1520
2399
|
} else if (payload.type === 'question/resolved') {
|
|
1521
2400
|
const questionRpcId = String(payload.questionRpcId)
|
|
1522
2401
|
pendingQuestions.delete(questionRpcId)
|
|
1523
|
-
|
|
2402
|
+
broadcastInteractionFrame({
|
|
1524
2403
|
kind: 'question-resolved',
|
|
1525
2404
|
rpcId: questionRpcId,
|
|
1526
2405
|
sessionId: String(payload.sessionId),
|
|
1527
2406
|
outcome: payload.outcome,
|
|
1528
2407
|
})
|
|
1529
2408
|
log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
|
|
2409
|
+
} else if (payload.type === 'approval/resolved') {
|
|
2410
|
+
const approvalId = String(payload.approvalId)
|
|
2411
|
+
const sessionId = String(payload.sessionId)
|
|
2412
|
+
const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
|
|
2413
|
+
if (!entry) {
|
|
2414
|
+
log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
|
|
2415
|
+
continue
|
|
2416
|
+
}
|
|
2417
|
+
const [rpcId, pending] = entry
|
|
2418
|
+
pendingApprovals.delete(rpcId)
|
|
2419
|
+
broadcastInteractionFrame({
|
|
2420
|
+
kind: 'approval-resolved',
|
|
2421
|
+
rpcId,
|
|
2422
|
+
sessionId,
|
|
2423
|
+
approvalId,
|
|
2424
|
+
outcome: payload.outcome,
|
|
2425
|
+
})
|
|
2426
|
+
log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
|
|
2427
|
+
} else {
|
|
2428
|
+
log(`interaction ignored: type=${payload.type}`)
|
|
1530
2429
|
}
|
|
1531
2430
|
}
|
|
1532
2431
|
} catch (error) {
|
|
1533
|
-
if (!
|
|
1534
|
-
log(`
|
|
2432
|
+
if (!interactionMuxAbort.signal.aborted) {
|
|
2433
|
+
log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
|
|
1535
2434
|
}
|
|
1536
2435
|
}
|
|
1537
2436
|
})()
|
|
1538
2437
|
: null
|
|
1539
|
-
if (
|
|
1540
|
-
else log('api gateway
|
|
2438
|
+
if (interactionMuxTask) log('api gateway interaction mux attached')
|
|
2439
|
+
else log('api gateway interaction mux unavailable; human-in-the-loop disabled')
|
|
1541
2440
|
|
|
1542
2441
|
ctx.effect(() => () => {
|
|
1543
2442
|
if (waitTimer) clearTimeout(waitTimer)
|
|
1544
|
-
|
|
2443
|
+
clearInterval(fileTransferExpiryTimer)
|
|
2444
|
+
interactionMuxAbort.abort()
|
|
1545
2445
|
pendingQuestions.clear()
|
|
2446
|
+
pendingApprovals.clear()
|
|
2447
|
+
fileTransfers.dispose().catch((error) => {
|
|
2448
|
+
log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
|
|
2449
|
+
})
|
|
1546
2450
|
disposeUpgrade()
|
|
1547
2451
|
disposeMgmt()
|
|
1548
2452
|
disposeEvents()
|