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/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
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images"],
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
- // Handle one mobile -> agent message through the official host API. Returns
238
- // the wire frame to send back, or null when nothing should be sent.
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 message can contain at most 20 images' }
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 questionMuxAbort = new AbortController()
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 broadcastQuestionFrame = (frame) => {
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 === 'select-model' ||
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
- for (const pending of pendingQuestions.values()) {
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 questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
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
- questionMuxAbort.signal,
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
- broadcastQuestionFrame(questionFrameFor(rpcId, pending))
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
- broadcastQuestionFrame({
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 (!questionMuxAbort.signal.aborted) {
1534
- log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
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 (questionMuxTask) log('api gateway question mux attached')
1540
- else log('api gateway question mux unavailable; human-in-the-loop disabled')
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
- questionMuxAbort.abort()
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()