@sparkelf/dsh-plugin-mobile-gateway 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs ADDED
@@ -0,0 +1,3197 @@
1
+ import { stringifyWireFrame } from './wire-json.mjs'
2
+ // dsh-plugin-mobile-gateway — persistent Host plugin.
3
+ //
4
+ // Two directions:
5
+ // 1. Agent -> mobile: registers a WebSocket endpoint at /ws/mobile on the dsh
6
+ // web server and forwards agent session output (the same `session/event`
7
+ // feed the browser UI consumes) to connected mobile clients as curated
8
+ // JSON text frames.
9
+ // 2. Mobile -> agent: clients send a message through the same socket; the
10
+ // plugin admits it through the DSH Remote Gateway Host API.
11
+ // so it enters the target session exactly like a browser-submitted prompt.
12
+ //
13
+ // Wire protocol (JSON text frames):
14
+ // first pair: Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-pair.<one-time-code>
15
+ // X-DSH-Device-ID: <stable installation UUID>
16
+ // -> { "kind": "paired", "token", "device" } (exactly once)
17
+ // later connects: Authorization: Bearer <device-token>
18
+ // or Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-auth.<device-token>
19
+ // client -> server: { "type": "ping" }
20
+ // { "type": "subscribe", "sessionId": "..." } (optional filter)
21
+ // { "type": "unsubscribe" }
22
+ // { "type": "message", "sessionId"?, "text"?, "images"?:
23
+ // [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
24
+ // "workspaceId"?, "cwd"?, "clientTimeZone"? }
25
+ // sessionId omitted -> a new session is created first; the
26
+ // new session can be placed in a workspace via workspaceId
27
+ // (or cwd; at most one, workspaceId wins)
28
+ // { "type": "workspaces" } -> workspace list
29
+ // { "type": "sessions" } -> session list
30
+ // { "type": "history", "sessionId", "beforeSeq"?, "maxMessages"?,
31
+ // "maxBytes"?, "view"?: "conversation" }
32
+ // -> raw SessionEvent page (scheme A), capped at maxBytes
33
+ // (default 4 MiB); hasMore + nextBeforeSeq page backward;
34
+ // view:"conversation" trims chunk/header events and tool output
35
+ // { "type": "attachment", "sessionId", "attachmentId" }
36
+ // -> verified image metadata + canonical base64 bytes
37
+ // { "type": "file-list", "requestId"?, "sessionId", "path"? }
38
+ // { "type": "file-download-open", "requestId", "sessionId", "path" }
39
+ // { "type": "file-download-read", "transferId", "offset" }
40
+ // { "type": "file-download-cancel", "transferId" }
41
+ // { "type": "search", "query" } -> session search
42
+ // { "type": "host" } -> host.describe snapshot (incl. default provider/model)
43
+ // { "type": "default-model" } -> agentDefaultModel.currentSelection()
44
+ // { "type": "save-default-model", "provider", "model", "reasoningEffort"? }
45
+ // { "type": "directories", "path"? } -> server dir listing (fs-based)
46
+ // { "type": "directory-create", "path", "name" } -> create one child directory
47
+ // { "type": "workspace-create", "path" } -> create workspace over a dir
48
+ // { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
49
+ // { "type": "session-cancel", "sessionId" } -> stop the active turn and retain queued work
50
+ // { "type": "queue-update", "sessionId", "itemId",
51
+ // "action": "edit"|"remove"|"steer", "text"? }
52
+ // -> edit/remove one pending item or move it into the current turn
53
+ // { "type": "session-archive", "sessionId" } -> hide a session from workspace surfaces
54
+ // { "type": "session-rename", "sessionId", "title" } -> persist a user-owned session title
55
+ // { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
56
+ // { "type": "providers" } -> configurable provider list (live/dormant)
57
+ // { "type": "commands", "sessionId" } -> slash-command catalog for this session
58
+ // { "type": "command-execute", "sessionId", "line", "images"? } -> execute a Host command
59
+ // { "type": "command-options", "sessionId", "command" } -> normalized submenu options
60
+ // { "type": "command-select", "sessionId", "command", "optionId" } -> apply submenu option
61
+ // { "type": "select-model", "sessionId", "provider", "model", "reasoningEffort"? }
62
+ // { "type": "permission-options", "sessionId"? } -> permission presets (+ session knobs)
63
+ // { "type": "permission", "sessionId", "name" } -> switch preset via /permission command
64
+ // { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
65
+ // { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
66
+ // (the input-box stats strip source)
67
+ // { "type": "tasks", "sessionId" } -> current todo-list projection
68
+ // { "type": "goal", "sessionId" } -> current goal projection
69
+ // { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
70
+ // { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
71
+ // { "type": "agent-presets" } -> preset roster (+ isDefault)
72
+ // { "type": "defaults" } -> default agent preset + default permission
73
+ // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
74
+ // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
75
+ // { "type": "question-cancel", "rpcId", "sessionId" }
76
+ // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
77
+ // "outcome": "allowed-once"|"rejected" }
78
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
79
+ // "authenticated", "device"?, "port", "clients" }
80
+ // { "kind": "pong", "at" }
81
+ // { "kind": "subscribed", "sessionId" }
82
+ // { "kind": "sent", "sessionId", "mode", "command"? }
83
+ // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
84
+ // { "kind": "commands", "sessionId", "groups": [
85
+ // { "id", "title", "items": [{ "id", "name", "description", "ui" }] }
86
+ // ] }
87
+ // { "kind": "command-options", "sessionId", "command", "options": [
88
+ // { "id", "label", "detail"?, "description"?, "selected" }
89
+ // ] }
90
+ // { "kind": "command-executed", "sessionId", "line", "commandId", "result" }
91
+ // { "kind": "command-selected", "sessionId", "command", "selected" }
92
+ // { "kind": "attachment", "sessionId", "attachment", "data" }
93
+ // { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
94
+ // { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
95
+ // "path", "name", "mediaType", "size", "chunkBytes" }
96
+ // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
97
+ // { "kind": "file-download-cancelled", "transferId" }
98
+ // { "kind": "session-cancelled", "sessionId", "accepted": true }
99
+ // { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
100
+ // { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
101
+ // { "kind": "session-queue", "sessionId", "items": [...] }
102
+ // { "kind": "session-archived", "sessionId", "archivedSessionIds" }
103
+ // { "kind": "session-renamed", "sessionId", "title", "seq" }
104
+ // { "kind": "session-archives", "archivedSessionIds" }
105
+ // { "kind": "session-title-changed", "sessionId", "title", "seq", "time", "source"? }
106
+ // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
107
+ // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
108
+ // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
109
+ // { "kind": "goal", "sessionId", "asOfSeq", "goal" }
110
+ // { "kind": "goal-edit"|"goal-pause"|"goal-resume", "sessionId", "ref" }
111
+ // { "kind": "goal-clear", "sessionId", "cleared": true }
112
+ // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
113
+ // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
114
+ // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
115
+ // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
116
+ // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
117
+ // { "kind": "approval-requested", "rpcId", "sessionId", "approvalId",
118
+ // "toolName", "callId"?, "reason"?, "replay"? }
119
+ // { "kind": "approval-response", "rpcId", "sessionId", "approvalId",
120
+ // "outcome", "accepted", "reason"? }
121
+ // { "kind": "approval-resolved", "rpcId", "sessionId", "approvalId", "outcome" }
122
+ import fs from 'node:fs'
123
+ import fsp from 'node:fs/promises'
124
+ import http from 'node:http'
125
+ import net from 'node:net'
126
+ import os from 'node:os'
127
+ import path from 'node:path'
128
+ import crypto from 'node:crypto'
129
+ import Schema from '@deepseek-ai/schemastery'
130
+ import { WebSocketServer } from 'ws'
131
+ import devicesModule from './devices.js'
132
+ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
133
+ import { createSessionFollower } from './session-follower.mjs'
134
+ import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
135
+ import QRCode from 'qrcode'
136
+
137
+ const { createRegistry } = devicesModule
138
+
139
+ const PLUGIN_VERSION = (() => {
140
+ try {
141
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
142
+ } catch {
143
+ return 'unknown'
144
+ }
145
+ })()
146
+ const MAX_PREVIEW = 400
147
+ const LOG_FILE = '/tmp/mobile-gateway.log'
148
+ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
149
+ const DEFAULT_WS_PATH = '/ws/mobile'
150
+ const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
151
+ const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
152
+ const DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
153
+ const DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES = 512 * 1024
154
+ const DEFAULT_FILE_DOWNLOAD_IDLE_MS = 2 * 60 * 1000
155
+ const DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS = 4
156
+ const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
157
+ // DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
158
+ // the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
159
+ // configurable so deployments may choose a lower transport ceiling.
160
+ const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
161
+ const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
162
+ const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
163
+ const COMMAND_UI_OVERRIDES = Object.freeze({
164
+ permission: Object.freeze({
165
+ kind: 'select',
166
+ optionsRequest: 'command-options',
167
+ selectionRequest: 'command-select',
168
+ }),
169
+ model: Object.freeze({
170
+ kind: 'select',
171
+ optionsRequest: 'command-options',
172
+ selectionRequest: 'command-select',
173
+ }),
174
+ })
175
+ const COMMAND_CATALOG_COPY = Object.freeze({
176
+ zh: Object.freeze({
177
+ locale: 'zh-CN',
178
+ commandsTitle: '命令',
179
+ skillsTitle: '技能',
180
+ userOnly: '仅用户',
181
+ modelDescription: '选择本会话使用的模型',
182
+ hints: Object.freeze({
183
+ plan: '描述你的任务以生成计划',
184
+ goal: '输入目标,智能体将持续执行',
185
+ }),
186
+ }),
187
+ en: Object.freeze({
188
+ locale: 'en',
189
+ commandsTitle: 'Commands',
190
+ skillsTitle: 'Skills',
191
+ userOnly: 'user-only',
192
+ modelDescription: 'Select the model for this conversation',
193
+ hints: Object.freeze({
194
+ plan: 'describe your task to generate plan',
195
+ goal: 'describe the objective for a long-running task',
196
+ }),
197
+ }),
198
+ })
199
+
200
+ // Cordis validates this schema at plugin load and fills these defaults. Keep
201
+ // the defaults conservative: installing the bundle must never create an
202
+ // unauthenticated network control plane.
203
+ const Config = Schema.object({
204
+ path: Schema.string().default(DEFAULT_WS_PATH),
205
+ requireAuth: Schema.boolean().default(true),
206
+ gatewayEnabled: Schema.boolean().default(false),
207
+ gatewayMode: Schema.union(GATEWAY_MODES),
208
+ gatewayStateFile: Schema.string().default(''),
209
+ gatewayName: Schema.string().default(''),
210
+ endpoints: Schema.array(Schema.string()).default([]),
211
+ gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
212
+ maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
213
+ fileDownloadsEnabled: Schema.boolean().default(true),
214
+ fileDownloadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_MAX_BYTES),
215
+ fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
216
+ fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
217
+ fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
218
+ adminLoopbackOnly: Schema.boolean().default(true),
219
+ publicUrl: Schema.string().default(''),
220
+ deviceFile: Schema.string().default(''),
221
+ pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
222
+ allowQueryToken: Schema.boolean().default(false),
223
+ publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
224
+ lanEnabled: Schema.boolean().default(false),
225
+ lanHost: Schema.string().default('0.0.0.0'),
226
+ lanPort: Schema.natural().min(1).max(65535).default(3081),
227
+ lanAdvertiseHost: Schema.string().default(''),
228
+ })
229
+
230
+ function log(line) {
231
+ const msg = `[${new Date().toISOString()}] ${line}`
232
+ console.log('mobile-gateway:', msg)
233
+ try {
234
+ fs.appendFileSync(LOG_FILE, msg + '\n')
235
+ } catch (error) {
236
+ // never let logging break the gateway
237
+ }
238
+ }
239
+
240
+ // Extract plain text from a ContentBlock[] (text blocks only).
241
+ function textOf(blocks) {
242
+ let text = ''
243
+ for (const block of blocks) {
244
+ if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
245
+ }
246
+ return text
247
+ }
248
+
249
+ function imagesOf(blocks) {
250
+ const images = []
251
+ for (const block of blocks) {
252
+ if (!block || block.type !== 'image' || !block.attachment) continue
253
+ const attachment = block.attachment
254
+ images.push({
255
+ attachmentId: attachment.attachmentId,
256
+ mediaType: attachment.mediaType,
257
+ bytes: attachment.bytes,
258
+ width: attachment.width,
259
+ height: attachment.height,
260
+ ...(attachment.name ? { name: attachment.name } : {}),
261
+ })
262
+ }
263
+ return images
264
+ }
265
+
266
+ // Build the small, owned JSON wire record for one session event. Reads only
267
+ // leaf fields of the live SessionEvent — never serializes live objects.
268
+ function buildWireEvent(session, event) {
269
+ const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time,
270
+ ...(event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }),
271
+ ...(event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }),
272
+ }
273
+ const d = event.data || {}
274
+ switch (event.type) {
275
+ case 'user/message': {
276
+ const images = imagesOf(d.content || [])
277
+ return Object.assign(base, {
278
+ event: {
279
+ type: 'user/message',
280
+ text: textOf(d.content || []),
281
+ source: d.source && d.source.kind,
282
+ ...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
283
+ ...(images.length ? { images } : {}),
284
+ },
285
+ })
286
+ }
287
+ case 'assistant/message': {
288
+ const blocks = (d.message && d.message.content) || []
289
+ let text = ''
290
+ let reasoning = ''
291
+ const toolCalls = []
292
+ const images = imagesOf(blocks)
293
+ for (const block of blocks) {
294
+ if (!block) continue
295
+ if (block.type === 'text' && typeof block.text === 'string') text += block.text
296
+ else if (block.type === 'reasoning' && typeof block.text === 'string') reasoning += block.text
297
+ else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
298
+ }
299
+ return Object.assign(base, {
300
+ event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
301
+ ...(d.interrupted === true ? { interrupted: true } : {}),
302
+ ...(d.usage === undefined ? {} : { usage: d.usage }),
303
+ ...(images.length ? { images } : {}),
304
+ },
305
+ })
306
+ }
307
+ case 'assistant/attempt':
308
+ return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
309
+ case 'session/title':
310
+ return Object.assign(base, {
311
+ event: {
312
+ type: 'session/title',
313
+ title: d.title,
314
+ ...(d.source ? { source: d.source } : {}),
315
+ },
316
+ })
317
+ case 'agent-preset/selected':
318
+ return Object.assign(base, { event: { type: event.type, agentPreset: d.agentPreset } })
319
+ case 'tool/call':
320
+ return Object.assign(base, {
321
+ event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
322
+ })
323
+ case 'tool/result': {
324
+ let preview = ''
325
+ for (const block of (d.message && d.message.content) || []) {
326
+ for (const inner of (block && block.content) || []) {
327
+ if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
328
+ }
329
+ }
330
+ if (preview.length > MAX_PREVIEW) preview = preview.slice(0, MAX_PREVIEW) + '…'
331
+ return Object.assign(base, {
332
+ event: {
333
+ type: 'tool/result',
334
+ turn: d.turn,
335
+ step: d.step,
336
+ callId: d.message && d.message.source && d.message.source.callId,
337
+ isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
338
+ preview,
339
+ },
340
+ })
341
+ }
342
+ case 'command/run':
343
+ return Object.assign(base, {
344
+ event: {
345
+ type: 'command/run',
346
+ commandId: d.commandId,
347
+ name: d.name,
348
+ ...(typeof d.args === 'string' ? { args: d.args } : {}),
349
+ ...(d.source ? { source: d.source } : {}),
350
+ },
351
+ })
352
+ case 'command/done':
353
+ return Object.assign(base, {
354
+ event: {
355
+ type: 'command/done',
356
+ commandId: d.commandId,
357
+ outcome: d.kind,
358
+ ...(typeof d.text === 'string' ? { text: d.text } : {}),
359
+ ...(typeof d.sourceEventSeq === 'number' ? { sourceEventSeq: d.sourceEventSeq } : {}),
360
+ },
361
+ })
362
+ case 'compaction/start':
363
+ return Object.assign(base, {
364
+ event: {
365
+ type: 'compaction/start',
366
+ compactionId: d.compactionId,
367
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
368
+ turn: d.turn ?? null,
369
+ },
370
+ })
371
+ case 'compaction/summary':
372
+ return Object.assign(base, {
373
+ event: {
374
+ type: 'compaction/summary',
375
+ compactionId: d.compactionId,
376
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
377
+ shadowedItemCount: Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.length : null,
378
+ shadowedTokenCount: typeof d.shadowedTokenCount === 'number' ? d.shadowedTokenCount : null,
379
+ },
380
+ })
381
+ case 'compaction/end':
382
+ return Object.assign(base, {
383
+ event: {
384
+ type: 'compaction/end',
385
+ compactionId: d.compactionId,
386
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
387
+ turn: d.turn ?? null,
388
+ ...(typeof d.error === 'string' ? { error: d.error } : {}),
389
+ },
390
+ })
391
+ case 'turn/start':
392
+ case 'turn/end':
393
+ case 'step/start':
394
+ case 'step/end':
395
+ return Object.assign(base, {
396
+ event: { type: event.type, turn: d.turn, step: d.step, reason: d.reason && d.reason.kind },
397
+ })
398
+ default:
399
+ return Object.assign(base, { event: { type: event.type } })
400
+ }
401
+ }
402
+
403
+ function parseWireImages(rawImages, requestType) {
404
+ const images = rawImages === undefined ? [] : rawImages
405
+ if (!Array.isArray(images)) {
406
+ return { error: { kind: 'error', code: 'bad-request', message: 'images must be an array', requestType } }
407
+ }
408
+ if (images.length > 20) {
409
+ return { error: { kind: 'error', code: 'bad-request', message: 'a request can contain at most 20 images', requestType } }
410
+ }
411
+ const imageParts = []
412
+ for (let index = 0; index < images.length; index++) {
413
+ const image = images[index]
414
+ if (!image || typeof image !== 'object') {
415
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object`, requestType } }
416
+ }
417
+ if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
418
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported`, requestType } }
419
+ }
420
+ if (typeof image.data !== 'string' || image.data.length === 0) {
421
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string`, requestType } }
422
+ }
423
+ if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
424
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters`, requestType } }
425
+ }
426
+ imageParts.push({
427
+ type: 'image',
428
+ mediaType: image.mediaType,
429
+ data: image.data,
430
+ ...(image.name ? { name: image.name } : {}),
431
+ })
432
+ }
433
+ return { value: imageParts }
434
+ }
435
+
436
+ function commandNameOf(line) {
437
+ const trimmed = line.trim()
438
+ if (!trimmed.startsWith('/')) return null
439
+ const end = trimmed.search(/\s/)
440
+ return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
441
+ }
442
+
443
+ async function listHostCommands(host, sessionId) {
444
+ const listed = await host.commands.list(sessionId, new AbortController().signal)
445
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
446
+ return listed
447
+ }
448
+
449
+ async function executeHostCommand(host, sessionId, line, images, requestType) {
450
+ try {
451
+ const execution = await host.commands.execute(sessionId, line, images, new AbortController().signal)
452
+ if (execution === undefined || execution === null) {
453
+ return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
454
+ }
455
+ log(`command executed: session=${sessionId} line=${JSON.stringify(line)} commandId=${execution.commandId} outcome=${execution.result.kind}`)
456
+ return {
457
+ kind: 'command-executed',
458
+ sessionId,
459
+ line,
460
+ commandId: execution.commandId,
461
+ result: execution.result,
462
+ }
463
+ } catch (error) {
464
+ const code = error && error.code ? error.code : 'internal'
465
+ const message = error && error.message ? error.message : String(error)
466
+ log(`command rejected: session=${sessionId} ${code}: ${message}`)
467
+ return { kind: 'error', code, message, requestType, sessionId }
468
+ }
469
+ }
470
+
471
+ async function admitCommand(host, msg) {
472
+ const sessionId = requireSessionId(msg)
473
+ if (sessionId.error) return sessionId.error
474
+ const line = typeof msg.line === 'string' ? msg.line.trim() : ''
475
+ const name = commandNameOf(line)
476
+ if (!name) {
477
+ return { kind: 'error', code: 'bad-request', message: 'command-execute requires a slash-prefixed line', requestType: 'command-execute', sessionId: sessionId.value }
478
+ }
479
+ const parsedImages = parseWireImages(msg.images, 'command-execute')
480
+ if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
481
+ try {
482
+ const listed = await listHostCommands(host, sessionId.value)
483
+ const descriptor = listed.find((command) => command && command.name === name)
484
+ if (!descriptor) {
485
+ return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
486
+ }
487
+ if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
488
+ return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
489
+ }
490
+ return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
491
+ } catch (error) {
492
+ const code = error && error.code ? error.code : 'internal'
493
+ const message = error && error.message ? error.message : String(error)
494
+ return { kind: 'error', code, message, requestType: 'command-execute', sessionId: sessionId.value }
495
+ }
496
+ }
497
+
498
+ // Handle one mobile -> agent message through the official host API. Returns
499
+ // the wire frame to send back, or null when nothing should be sent.
500
+ async function admitMessage(api, msg) {
501
+ const preset = parseAgentPreset(msg, 'message')
502
+ if (preset.error) return preset.error
503
+ if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
504
+ return { kind: 'error', code: 'bad-request', requestType: 'message',
505
+ message: 'use select-agent-preset before sending a message to an existing session' }
506
+ }
507
+ const text = typeof msg.text === 'string' ? msg.text.trim() : ''
508
+ const parsedImages = parseWireImages(msg.images, 'message')
509
+ if (parsedImages.error) return parsedImages.error
510
+ const imageParts = parsedImages.value
511
+ if (!text && imageParts.length === 0) {
512
+ return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
513
+ }
514
+ const mode = msg.mode === 'steer' ? 'steer' : 'queue'
515
+
516
+ let sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : undefined
517
+ try {
518
+ if (sessionId === undefined) {
519
+ // New session: optionally place it in a workspace (at most one of
520
+ // workspaceId / cwd, per the host create contract; workspaceId wins).
521
+ const createPayload = {}
522
+ if (preset.value !== undefined) createPayload.agentPreset = preset.value
523
+ if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim() !== '') {
524
+ createPayload.workspaceId = msg.workspaceId.trim()
525
+ }
526
+ if (typeof msg.cwd === 'string' && msg.cwd.trim() !== '') {
527
+ if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
528
+ else createPayload.cwd = msg.cwd.trim()
529
+ }
530
+ const created = await api.sessions.create(createPayload)
531
+ sessionId = created.sessionId
532
+ log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
533
+ }
534
+
535
+ const resp = await api.sessions.prompt({
536
+ sessionId,
537
+ mode,
538
+ // Match the official WebUI ordering: images first, optional text last.
539
+ content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
540
+ ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
541
+ })
542
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
543
+ return {
544
+ kind: 'sent',
545
+ sessionId,
546
+ mode,
547
+ ...(resp.command ? { command: resp.command } : {}),
548
+ }
549
+ } catch (error) {
550
+ const code = error && error.code ? error.code : 'internal'
551
+ const message = error && error.message ? error.message : String(error)
552
+ log(`mobile message failed: ${message}`)
553
+ return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
554
+ }
555
+ }
556
+
557
+ // Proxy one read-only query to the official host API (the same surface the
558
+ // browser uses). Success returns `{ kind: <type>, ...value }`; failure returns
559
+ // the uniform `{ kind: 'error', code, message, requestType }` frame.
560
+ async function proxyQuery(api, type, method, payload, signal) {
561
+ try {
562
+ const value = signal ? await method(payload, signal) : await method(payload)
563
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
564
+ throw new Error(`${type} returned an invalid response`)
565
+ }
566
+ log(`query ok: ${type}`)
567
+ return { kind: type, ...value }
568
+ } catch (error) {
569
+ const code = error && error.code ? error.code : 'internal'
570
+ const message = error && error.message ? error.message : String(error)
571
+ log(`query failed: ${type} -> ${code}: ${message}`)
572
+ return { kind: 'error', code, message, requestType: type }
573
+ }
574
+ }
575
+
576
+ // Projection queries must keep the caller's request identity on failure.
577
+ // They need the opening cut and projections, not the full conversation body.
578
+ async function querySessionProjection(api, requestType, sessionId) {
579
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
580
+ return history.kind === 'error' ? { ...history, requestType, sessionId } : history
581
+ }
582
+
583
+ function fileTransferError(code, message, requestType, sessionId) {
584
+ return {
585
+ kind: 'error',
586
+ code,
587
+ message,
588
+ requestType,
589
+ ...(sessionId ? { sessionId } : {}),
590
+ }
591
+ }
592
+
593
+ function fileMediaType(filePath) {
594
+ switch (path.extname(filePath).toLowerCase()) {
595
+ case '.apk': return 'application/vnd.android.package-archive'
596
+ case '.doc': return 'application/msword'
597
+ case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
598
+ case '.ipa': return 'application/octet-stream'
599
+ case '.json': return 'application/json'
600
+ case '.pdf': return 'application/pdf'
601
+ case '.ppt': return 'application/vnd.ms-powerpoint'
602
+ case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
603
+ case '.txt': return 'text/plain'
604
+ case '.xls': return 'application/vnd.ms-excel'
605
+ case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
606
+ case '.zip': return 'application/zip'
607
+ default: return 'application/octet-stream'
608
+ }
609
+ }
610
+
611
+ function workspaceRelativePath(root, target) {
612
+ const relative = path.relative(root, target)
613
+ return relative ? relative.split(path.sep).join('/') : '.'
614
+ }
615
+
616
+ function isInsideWorkspace(root, target) {
617
+ const relative = path.relative(root, target)
618
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
619
+ }
620
+
621
+ function normalizedWorkspaceInput(value, { allowEmpty = false } = {}) {
622
+ if (value === undefined && allowEmpty) return '.'
623
+ if (typeof value !== 'string') return null
624
+ const trimmed = value.trim()
625
+ if (!trimmed && allowEmpty) return '.'
626
+ if (!trimmed || trimmed.includes('\0') || path.isAbsolute(trimmed)) return null
627
+ if (trimmed.split(/[\\/]+/).includes('..')) return null
628
+ return trimmed
629
+ }
630
+
631
+ // Owns only workspace-scoped, regular-file downloads. The host API has an
632
+ // image-only attachment read path, so arbitrary document/archive bytes must be
633
+ // guarded here instead of accepting a host path from the mobile client.
634
+ function createFileTransferManager(api, options) {
635
+ const transfers = new Map()
636
+
637
+ const closeTransfer = async (transferId) => {
638
+ const transfer = transfers.get(transferId)
639
+ if (!transfer) return false
640
+ transfers.delete(transferId)
641
+ try {
642
+ await transfer.handle.close()
643
+ } catch {
644
+ // A failed close cannot make a completed or cancelled transfer usable.
645
+ }
646
+ return true
647
+ }
648
+
649
+ const resolveSessionRoot = async (sessionId, requestType) => {
650
+ const sessions = await proxyQuery(api, requestType, api.sessions.list.bind(api.sessions), {})
651
+ if (sessions.kind === 'error') return sessions
652
+ const item = Array.isArray(sessions.items)
653
+ ? sessions.items.find((entry) => entry && String(entry.sessionId) === sessionId)
654
+ : null
655
+ if (!item) return fileTransferError('session-not-found', 'no such session', requestType, sessionId)
656
+ if (typeof item.cwd !== 'string' || !item.cwd.trim()) {
657
+ return fileTransferError('file-workspace-unavailable', 'the session has no working directory', requestType, sessionId)
658
+ }
659
+ try {
660
+ const root = await fsp.realpath(item.cwd)
661
+ const stat = await fsp.stat(root)
662
+ if (!stat.isDirectory()) {
663
+ return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
664
+ }
665
+ return { root }
666
+ } catch {
667
+ return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
668
+ }
669
+ }
670
+
671
+ const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
672
+ const requested = normalizedWorkspaceInput(rawPath, inputOptions)
673
+ if (!requested) {
674
+ return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
675
+ }
676
+ const rootResult = await resolveSessionRoot(sessionId, requestType)
677
+ if (rootResult.kind === 'error') return rootResult
678
+ const candidate = path.resolve(rootResult.root, requested)
679
+ if (!isInsideWorkspace(rootResult.root, candidate)) {
680
+ return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
681
+ }
682
+ try {
683
+ const target = await fsp.realpath(candidate)
684
+ if (!isInsideWorkspace(rootResult.root, target)) {
685
+ return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
686
+ }
687
+ const stat = await fsp.stat(target)
688
+ return { root: rootResult.root, target, stat }
689
+ } catch (error) {
690
+ if (error && error.code === 'ENOENT') {
691
+ return fileTransferError('file-not-found', 'file does not exist', requestType, sessionId)
692
+ }
693
+ return fileTransferError('file-unreadable', 'file cannot be read', requestType, sessionId)
694
+ }
695
+ }
696
+
697
+ const fileList = async (msg) => {
698
+ const session = requireSessionId(msg)
699
+ if (session.error) return session.error
700
+ const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-list', { allowEmpty: true })
701
+ if (resolved.kind === 'error') return resolved
702
+ if (!resolved.stat.isDirectory()) {
703
+ return fileTransferError('file-not-directory', 'path is not a directory', 'file-list', session.value)
704
+ }
705
+ try {
706
+ const dirents = await fsp.readdir(resolved.target, { withFileTypes: true })
707
+ const entries = []
708
+ for (const dirent of dirents) {
709
+ if (dirent.isSymbolicLink()) continue
710
+ const child = path.join(resolved.target, dirent.name)
711
+ if (dirent.isDirectory()) {
712
+ entries.push({ name: dirent.name, path: workspaceRelativePath(resolved.root, child), kind: 'directory' })
713
+ } else if (dirent.isFile()) {
714
+ const stat = await fsp.stat(child)
715
+ entries.push({
716
+ name: dirent.name,
717
+ path: workspaceRelativePath(resolved.root, child),
718
+ kind: 'file',
719
+ bytes: stat.size,
720
+ modifiedAt: stat.mtimeMs,
721
+ mediaType: fileMediaType(child),
722
+ })
723
+ }
724
+ }
725
+ entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1))
726
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
727
+ return {
728
+ kind: 'file-list',
729
+ ...(requestId ? { requestId } : {}),
730
+ sessionId: session.value,
731
+ path: workspaceRelativePath(resolved.root, resolved.target),
732
+ entries,
733
+ }
734
+ } catch {
735
+ return fileTransferError('file-unreadable', 'directory cannot be read', 'file-list', session.value)
736
+ }
737
+ }
738
+
739
+ const open = async (client, msg) => {
740
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
741
+ const session = requireSessionId(msg)
742
+ if (!requestId) return fileTransferError('bad-request', 'file-download-open requires a requestId', 'file-download-open')
743
+ if (session.error) return session.error
744
+ if (transfers.size >= options.fileDownloadMaxTransfers) {
745
+ return fileTransferError('file-transfer-limit', 'too many active file downloads', 'file-download-open', session.value)
746
+ }
747
+ const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-download-open')
748
+ if (resolved.kind === 'error') return resolved
749
+ if (!resolved.stat.isFile()) {
750
+ return fileTransferError('file-not-regular', 'only regular files can be downloaded', 'file-download-open', session.value)
751
+ }
752
+ if (resolved.stat.size > options.fileDownloadMaxBytes) {
753
+ return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-download-open', session.value)
754
+ }
755
+
756
+ let handle
757
+ try {
758
+ // Reject a last-moment leaf symlink replacement after the realpath
759
+ // containment check above. macOS and Linux both support O_NOFOLLOW.
760
+ handle = await fsp.open(resolved.target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
761
+ const opened = await handle.stat()
762
+ if (!opened.isFile() || opened.size !== resolved.stat.size) {
763
+ await handle.close()
764
+ return fileTransferError('file-changed', 'file changed before download could start', 'file-download-open', session.value)
765
+ }
766
+ } catch {
767
+ return fileTransferError('file-unreadable', 'file cannot be opened', 'file-download-open', session.value)
768
+ }
769
+
770
+ const transferId = crypto.randomUUID()
771
+ const transfer = {
772
+ client,
773
+ handle,
774
+ sessionId: session.value,
775
+ path: workspaceRelativePath(resolved.root, resolved.target),
776
+ name: path.basename(resolved.target),
777
+ mediaType: fileMediaType(resolved.target),
778
+ size: resolved.stat.size,
779
+ offset: 0,
780
+ hash: crypto.createHash('sha256'),
781
+ reading: false,
782
+ lastActiveAt: Date.now(),
783
+ }
784
+ transfers.set(transferId, transfer)
785
+ log(`file download opened: transfer=${transferId} session=${transfer.sessionId} bytes=${transfer.size}`)
786
+ return {
787
+ kind: 'file-download-opened',
788
+ requestId,
789
+ transferId,
790
+ sessionId: transfer.sessionId,
791
+ path: transfer.path,
792
+ name: transfer.name,
793
+ mediaType: transfer.mediaType,
794
+ size: transfer.size,
795
+ chunkBytes: options.fileDownloadChunkBytes,
796
+ }
797
+ }
798
+
799
+ const read = async (client, msg) => {
800
+ const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
801
+ if (!transferId) return fileTransferError('bad-request', 'file-download-read requires a transferId', 'file-download-read')
802
+ const transfer = transfers.get(transferId)
803
+ if (!transfer || transfer.client !== client) {
804
+ return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-read')
805
+ }
806
+ if (!Number.isSafeInteger(msg.offset) || msg.offset !== transfer.offset) {
807
+ return fileTransferError('file-transfer-offset', 'offset must match the next unread byte', 'file-download-read', transfer.sessionId)
808
+ }
809
+ if (transfer.reading) {
810
+ return fileTransferError('file-transfer-busy', 'a chunk read is already in progress', 'file-download-read', transfer.sessionId)
811
+ }
812
+
813
+ transfer.reading = true
814
+ try {
815
+ const remaining = transfer.size - transfer.offset
816
+ const bytesToRead = Math.min(options.fileDownloadChunkBytes, remaining)
817
+ const chunk = Buffer.allocUnsafe(bytesToRead)
818
+ const { bytesRead } = bytesToRead === 0
819
+ ? { bytesRead: 0 }
820
+ : await transfer.handle.read(chunk, 0, bytesToRead, transfer.offset)
821
+ if (bytesRead !== bytesToRead) {
822
+ await closeTransfer(transferId)
823
+ return fileTransferError('file-changed', 'file changed during download', 'file-download-read', transfer.sessionId)
824
+ }
825
+ const payload = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
826
+ transfer.hash.update(payload)
827
+ const offset = transfer.offset
828
+ transfer.offset += bytesRead
829
+ transfer.lastActiveAt = Date.now()
830
+ const eof = transfer.offset === transfer.size
831
+ const frame = {
832
+ kind: 'file-download-chunk',
833
+ transferId,
834
+ offset,
835
+ data: payload.toString('base64'),
836
+ eof,
837
+ }
838
+ if (eof) {
839
+ frame.sha256 = transfer.hash.digest('hex')
840
+ await closeTransfer(transferId)
841
+ log(`file download completed: transfer=${transferId} bytes=${transfer.size}`)
842
+ }
843
+ return frame
844
+ } catch {
845
+ await closeTransfer(transferId)
846
+ return fileTransferError('file-unreadable', 'file cannot be read', 'file-download-read', transfer.sessionId)
847
+ } finally {
848
+ transfer.reading = false
849
+ }
850
+ }
851
+
852
+ const cancel = async (client, msg) => {
853
+ const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
854
+ if (!transferId) return fileTransferError('bad-request', 'file-download-cancel requires a transferId', 'file-download-cancel')
855
+ const transfer = transfers.get(transferId)
856
+ if (!transfer || transfer.client !== client) {
857
+ return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-cancel')
858
+ }
859
+ await closeTransfer(transferId)
860
+ log(`file download cancelled: transfer=${transferId}`)
861
+ return { kind: 'file-download-cancelled', transferId }
862
+ }
863
+
864
+ const expire = async () => {
865
+ const cutoff = Date.now() - options.fileDownloadIdleMs
866
+ for (const [transferId, transfer] of transfers) {
867
+ if (transfer.lastActiveAt < cutoff && !transfer.reading) {
868
+ await closeTransfer(transferId)
869
+ log(`file download expired: transfer=${transferId}`)
870
+ }
871
+ }
872
+ }
873
+
874
+ return {
875
+ async handle(client, msg) {
876
+ if (!options.fileDownloadsEnabled) {
877
+ return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
878
+ }
879
+ if (msg.type === 'file-list') return fileList(msg)
880
+ if (msg.type === 'file-download-open') return open(client, msg)
881
+ if (msg.type === 'file-download-read') return read(client, msg)
882
+ if (msg.type === 'file-download-cancel') return cancel(client, msg)
883
+ return null
884
+ },
885
+ async closeClient(client) {
886
+ for (const [transferId, transfer] of transfers) {
887
+ if (transfer.client === client) await closeTransfer(transferId)
888
+ }
889
+ },
890
+ async dispose() {
891
+ for (const transferId of [...transfers.keys()]) await closeTransfer(transferId)
892
+ },
893
+ expire,
894
+ }
895
+ }
896
+
897
+ // List one directory level directly with node:fs, mirroring the official
898
+ // browse backend's semantics (crumbs + name-sorted child directories). Works
899
+ // on every deployment regardless of the composed picker capability, which
900
+ // `host.listDirectory` would otherwise gate behind the `browse` capability.
901
+ async function listServerDirectory(target) {
902
+ try {
903
+ const home = os.homedir()
904
+ const requested = target ? path.resolve(target) : home
905
+ const crumbs = []
906
+ let cur = requested
907
+ for (;;) {
908
+ crumbs.unshift({ name: cur === path.parse(cur).root ? cur : path.basename(cur), path: cur, hidden: false })
909
+ const parent = path.dirname(cur)
910
+ if (parent === cur) break
911
+ cur = parent
912
+ }
913
+ const entries = []
914
+ const dirents = await fsp.readdir(requested, { withFileTypes: true })
915
+ for (const dirent of dirents) {
916
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
917
+ entries.push({ name: dirent.name, path: path.join(requested, dirent.name), hidden: dirent.name.startsWith('.') })
918
+ }
919
+ entries.sort((a, b) => a.name.localeCompare(b.name))
920
+ log(`query ok: directories (${requested}, ${entries.length} entries)`)
921
+ return { kind: 'directories', path: requested, home, crumbs, entries, truncated: false }
922
+ } catch (error) {
923
+ const message = error && error.message ? error.message : String(error)
924
+ log(`query failed: directories -> ${message}`)
925
+ return { kind: 'error', code: 'directory-unreadable', message, requestType: 'directories' }
926
+ }
927
+ }
928
+
929
+ function directoryCreateError(code, message) {
930
+ return { kind: 'error', code, message, requestType: 'directory-create' }
931
+ }
932
+
933
+ // Create one child directory directly on the host filesystem. This deliberately
934
+ // shares the same backend as `directories`: macOS native Directory Picker only
935
+ // exposes its system dialog and does not implement remote createDirectory RPCs.
936
+ async function createServerDirectory(parentPath, directoryName) {
937
+ if (typeof parentPath !== 'string' || parentPath.trim() === '') {
938
+ return directoryCreateError('bad-request', 'directory-create requires an absolute parent path')
939
+ }
940
+ const parent = path.resolve(parentPath.trim())
941
+ if (!path.isAbsolute(parentPath.trim())) {
942
+ return directoryCreateError('bad-request', 'directory-create path must be absolute')
943
+ }
944
+
945
+ if (typeof directoryName !== 'string') {
946
+ return directoryCreateError('bad-request', 'directory-create requires a folder name')
947
+ }
948
+ const name = directoryName.trim()
949
+ if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
950
+ return directoryCreateError('bad-request', 'directory-create name must be a single non-empty folder name')
951
+ }
952
+
953
+ try {
954
+ const stat = await fsp.stat(parent)
955
+ if (!stat.isDirectory()) {
956
+ return directoryCreateError('directory-create-failed', 'parent path is not a directory')
957
+ }
958
+ } catch (error) {
959
+ const message = error && error.message ? error.message : String(error)
960
+ log(`query failed: directory-create -> ${message}`)
961
+ return directoryCreateError('directory-create-failed', message)
962
+ }
963
+
964
+ const target = path.join(parent, name)
965
+ try {
966
+ await fsp.mkdir(target)
967
+ log(`query ok: directory-create (${target})`)
968
+ return { kind: 'directory-create', path: target }
969
+ } catch (error) {
970
+ if (error && error.code === 'EEXIST') {
971
+ return directoryCreateError('directory-exists', 'directory already exists')
972
+ }
973
+ const message = error && error.message ? error.message : String(error)
974
+ log(`query failed: directory-create -> ${message}`)
975
+ return directoryCreateError('directory-create-failed', message)
976
+ }
977
+ }
978
+
979
+ // Validate one sessionId field; returns { value } or { error }.
980
+ function requireSessionId(msg) {
981
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
982
+ if (!sessionId) {
983
+ return { error: { kind: 'error', code: 'bad-request', message: 'this request requires a sessionId', requestType: typeof msg.type === 'string' ? msg.type : 'query' } }
984
+ }
985
+ return { value: sessionId }
986
+ }
987
+
988
+ // Goal mutations use DSH's compare-and-set reference. Returning a stale
989
+ // revision is intentionally rejected by DSH instead of overwriting a newer
990
+ // WebUI/mobile update.
991
+ function requireGoalRef(msg, sessionId) {
992
+ const ref = msg.ref
993
+ const id = ref && typeof ref.id === 'string' && ref.id.trim() !== '' ? ref.id.trim() : null
994
+ const revision = ref && typeof ref.revision === 'number' && Number.isSafeInteger(ref.revision) && ref.revision > 0
995
+ ? ref.revision
996
+ : null
997
+ if (!id || revision === null) {
998
+ return {
999
+ error: {
1000
+ kind: 'error',
1001
+ code: 'bad-request',
1002
+ message: `${typeof msg.type === 'string' ? msg.type : 'goal mutation'} requires ref.id and a positive integer ref.revision`,
1003
+ requestType: typeof msg.type === 'string' ? msg.type : 'goal',
1004
+ sessionId,
1005
+ },
1006
+ }
1007
+ }
1008
+ return { value: { id, revision } }
1009
+ }
1010
+
1011
+ async function readSessionProjection(api, type, sessionId, key) {
1012
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1013
+ if (history.kind !== 'history') return history
1014
+ const values = (history.projections && history.projections.values) || {}
1015
+ return {
1016
+ kind: type,
1017
+ sessionId,
1018
+ asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
1019
+ [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
1020
+ }
1021
+ }
1022
+
1023
+ async function mutateGoal(api, type, method, payload) {
1024
+ if (!api.goals || typeof api.goals[method] !== 'function') {
1025
+ return {
1026
+ kind: 'error',
1027
+ code: 'unsupported',
1028
+ message: 'goal management is unavailable in this DSH host',
1029
+ requestType: type,
1030
+ sessionId: payload.sessionId,
1031
+ }
1032
+ }
1033
+ return proxyQuery(api, type, api.goals[method].bind(api.goals), payload)
1034
+ }
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // History frame sizing: raw session logs can be huge (trajectory, tool output,
1038
+ // context), so a single history response must never blow the client's
1039
+ // WebSocket frame limit. We cap the serialized size per frame, keep the NEWEST
1040
+ // suffix within the budget, and hand the client `hasMore` + `nextBeforeSeq` so
1041
+ // it can page backward. An optional `view: "conversation"` trims events that
1042
+ // are not needed for a chat page (token-level chunks, system-prompt headers)
1043
+ // and truncates oversized tool-result text.
1044
+ // ---------------------------------------------------------------------------
1045
+ const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
1046
+ // Opening snapshots should be readable before older history is requested.
1047
+ // Keep the newest indivisible message even when it alone exceeds this budget.
1048
+ const HISTORY_OPENING_MAX_BYTES = 256 * 1024
1049
+ const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
1050
+
1051
+ function eventBytes(event) {
1052
+ return Buffer.byteLength(JSON.stringify(event), 'utf8')
1053
+ }
1054
+
1055
+ // Trim one event for the conversation page: null drops it, otherwise a shallow
1056
+ // copy with oversized payloads truncated. The events come from the host RPC as
1057
+ // plain JSON, so copying is safe.
1058
+ function trimConversationEvent(event) {
1059
+ switch (event.type) {
1060
+ case 'assistant/chunk':
1061
+ case 'request/header':
1062
+ case 'request/context':
1063
+ case 'system/message':
1064
+ // token-level replay / system-prompt header: not rendered on the chat page
1065
+ return null
1066
+ case 'assistant/message':
1067
+ case 'assistant/attempt': {
1068
+ const { stream, ...data } = event.data || {}
1069
+ return { ...event, data }
1070
+ }
1071
+ case 'tool/result': {
1072
+ const d = event.data || {}
1073
+ const message = d.message
1074
+ if (!message || !Array.isArray(message.content)) return event
1075
+ let changed = false
1076
+ const truncateBlock = (block) => {
1077
+ if (!block || typeof block !== 'object') return block
1078
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
1079
+ changed = true
1080
+ return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
1081
+ }
1082
+ if (Array.isArray(block.content)) {
1083
+ return { ...block, content: block.content.map(truncateBlock) }
1084
+ }
1085
+ return block
1086
+ }
1087
+ const content = message.content.map(truncateBlock)
1088
+ if (!changed) return event
1089
+ return { ...event, data: { ...d, message: { ...message, content } } }
1090
+ }
1091
+ default:
1092
+ return event
1093
+ }
1094
+ }
1095
+
1096
+ // Keep the newest suffix of `events` (ascending seq) within `maxBytes`.
1097
+ // Always keeps the newest event even if it alone exceeds the budget (an event
1098
+ // cannot be split); drops older events beyond the budget. Returns the kept
1099
+ // events, their total serialized bytes, and whether anything was dropped.
1100
+ function capHistoryEvents(events, maxBytes, trim) {
1101
+ const processed = trim ? events.map(trimConversationEvent).filter(Boolean) : events
1102
+ if (processed.length === 0) return { events: [], bytes: 0, dropped: 0 }
1103
+ let total = 0
1104
+ let keptStart = processed.length
1105
+ for (let i = processed.length - 1; i >= 0; i--) {
1106
+ const size = eventBytes(processed[i])
1107
+ if (keptStart === processed.length) {
1108
+ // always keep the newest event
1109
+ total = size
1110
+ keptStart = i
1111
+ continue
1112
+ }
1113
+ if (total + size > maxBytes) break
1114
+ total += size
1115
+ keptStart = i
1116
+ }
1117
+ return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
1118
+ }
1119
+
1120
+ function validateHistoryCursor(msg, sessionId, field) {
1121
+ if (msg[field] === undefined && msg.historyFormatVersion === undefined) return null
1122
+ if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
1123
+ return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
1124
+ historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
1125
+ message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
1126
+ }
1127
+ if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
1128
+ return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
1129
+ message: `${field} must be a non-negative safe integer` }
1130
+ }
1131
+ return null
1132
+ }
1133
+
1134
+ function historyPage(value, msg) {
1135
+ const rawEvents = value.events.map(entry => entry.event)
1136
+ const maxBytes = Number.isSafeInteger(msg.maxBytes) && msg.maxBytes > 0 ? msg.maxBytes : HISTORY_DEFAULT_MAX_BYTES
1137
+ const trim = msg.view === 'conversation'
1138
+ const capped = capHistoryEvents(rawEvents, maxBytes, trim)
1139
+ const hasMore = value.hasMore === true || capped.dropped > 0
1140
+ // An all-hidden page still needs a cursor so callers can reach older text.
1141
+ const oldest = capped.events[0]?.seq ?? rawEvents[0]?.seq
1142
+ return { ...value, kind: 'history', sessionId: msg.sessionId,
1143
+ events: capped.events, bytes: capped.bytes, hasMore,
1144
+ ...(trim ? { view: 'conversation' } : {}),
1145
+ ...(hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
1146
+ }
1147
+ }
1148
+
1149
+ function resolveCommandCatalogCopy(locale) {
1150
+ return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
1151
+ ? COMMAND_CATALOG_COPY.en
1152
+ : COMMAND_CATALOG_COPY.zh
1153
+ }
1154
+
1155
+ // DSH 0.1.5-rc.2 command descriptors use input.attachments.
1156
+ function commandAcceptsAttachments(command) {
1157
+ return command?.input?.attachments === true
1158
+ }
1159
+
1160
+ function commandUiDescriptor(command, copy) {
1161
+ const override = COMMAND_UI_OVERRIDES[command.name]
1162
+ if (override) return { ...override, insertText: `/${command.name}` }
1163
+ if (command.input && typeof command.input.hint === 'string') {
1164
+ const displayHint = copy.hints[command.name]
1165
+ return {
1166
+ kind: 'input',
1167
+ insertText: `/${command.name} `,
1168
+ hint: command.input.hint,
1169
+ ...(displayHint ? { displayHint } : {}),
1170
+ images: commandAcceptsAttachments(command),
1171
+ submitRequest: 'command-execute',
1172
+ }
1173
+ }
1174
+ return { kind: 'immediate', submitRequest: 'command-execute', submitText: `/${command.name}` }
1175
+ }
1176
+
1177
+ function skillUiDescriptor(skill) {
1178
+ return {
1179
+ kind: 'input',
1180
+ insertText: `/${skill.name} `,
1181
+ images: true,
1182
+ submitRequest: 'message',
1183
+ }
1184
+ }
1185
+
1186
+ function modelCommandOptionId(provider, model) {
1187
+ return Buffer.from(JSON.stringify([provider, model]), 'utf8').toString('base64url')
1188
+ }
1189
+
1190
+ function decodeModelCommandOptionId(optionId) {
1191
+ try {
1192
+ const value = JSON.parse(Buffer.from(optionId, 'base64url').toString('utf8'))
1193
+ if (!Array.isArray(value) || value.length !== 2 || value.some((part) => typeof part !== 'string' || part === '')) return null
1194
+ return { provider: value[0], model: value[1] }
1195
+ } catch {
1196
+ return null
1197
+ }
1198
+ }
1199
+
1200
+ async function loadCommandOptions(api, command, sessionId) {
1201
+ if (command === 'model') {
1202
+ const frame = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1203
+ if (frame.kind !== 'models') return frame
1204
+ const options = []
1205
+ for (const group of frame.groups || []) {
1206
+ for (const model of group.models || []) {
1207
+ options.push({
1208
+ id: modelCommandOptionId(String(group.id), String(model.id)),
1209
+ label: String(model.name || model.id),
1210
+ detail: String(group.name || group.id),
1211
+ ...(model.description ? { description: String(model.description) } : {}),
1212
+ selected: frame.current && frame.current.provider === group.id && frame.current.model === model.id,
1213
+ })
1214
+ }
1215
+ }
1216
+ return { kind: 'command-options', sessionId, command, options }
1217
+ }
1218
+
1219
+ if (command === 'permission') {
1220
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1221
+ if (history.kind !== 'history') return history
1222
+ const permissions = history.projections && history.projections.values && history.projections.values.permissions
1223
+ if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
1224
+ return {
1225
+ kind: 'error',
1226
+ code: 'command-options-unavailable',
1227
+ message: 'permission options are unavailable for this session',
1228
+ requestType: 'command-options',
1229
+ sessionId,
1230
+ }
1231
+ }
1232
+ return {
1233
+ kind: 'command-options',
1234
+ sessionId,
1235
+ command,
1236
+ options: permissions.options.map((option) => ({
1237
+ id: String(option.value),
1238
+ label: String(option.name || option.value),
1239
+ ...(option.description ? { description: String(option.description) } : {}),
1240
+ selected: option.value === permissions.currentValue,
1241
+ })),
1242
+ }
1243
+ }
1244
+
1245
+ return {
1246
+ kind: 'error',
1247
+ code: 'bad-request',
1248
+ message: `command does not provide selectable options: ${command}`,
1249
+ requestType: 'command-options',
1250
+ sessionId,
1251
+ }
1252
+ }
1253
+
1254
+ async function selectCommandOption(api, host, command, sessionId, optionId) {
1255
+ const catalog = await loadCommandOptions(api, command, sessionId)
1256
+ if (catalog.kind !== 'command-options') return catalog
1257
+ const option = catalog.options.find((candidate) => candidate.id === optionId)
1258
+ if (!option) {
1259
+ return {
1260
+ kind: 'error',
1261
+ code: 'bad-request',
1262
+ message: `unknown option for ${command}: ${optionId}`,
1263
+ requestType: 'command-select',
1264
+ sessionId,
1265
+ }
1266
+ }
1267
+
1268
+ if (command === 'permission') {
1269
+ try {
1270
+ const execution = await host.commands.execute(
1271
+ sessionId,
1272
+ '/permission ' + optionId,
1273
+ [],
1274
+ new AbortController().signal,
1275
+ )
1276
+ if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
1277
+ if (execution.result.kind === 'error') {
1278
+ return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
1279
+ }
1280
+ return { kind: 'command-selected', sessionId, command, selected: { ...option, selected: true } }
1281
+ } catch (error) {
1282
+ const code = error && error.code ? error.code : 'internal'
1283
+ const message = error && error.message ? error.message : String(error)
1284
+ return { kind: 'error', code, message, requestType: 'command-select', sessionId }
1285
+ }
1286
+ }
1287
+
1288
+ if (command === 'model') {
1289
+ const selection = decodeModelCommandOptionId(optionId)
1290
+ if (!selection) {
1291
+ return { kind: 'error', code: 'bad-request', message: 'invalid model option id', requestType: 'command-select', sessionId }
1292
+ }
1293
+ const models = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1294
+ if (models.kind !== 'models') return models
1295
+ const group = (models.groups || []).find((candidate) => candidate.id === selection.provider)
1296
+ const model = group && (group.models || []).find((candidate) => candidate.id === selection.model)
1297
+ if (!model) {
1298
+ return { kind: 'error', code: 'bad-request', message: 'model option is no longer available', requestType: 'command-select', sessionId }
1299
+ }
1300
+ const payload = { sessionId, ...selection }
1301
+ const reasoningEffort = models.current && models.current.provider === selection.provider && models.current.model === selection.model
1302
+ ? models.current.reasoningEffort
1303
+ : model.reasoning && model.reasoning.defaultEffort
1304
+ if (reasoningEffort) payload.reasoningEffort = reasoningEffort
1305
+ const selected = await proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1306
+ if (selected.kind !== 'select-model') return selected
1307
+ return {
1308
+ kind: 'command-selected',
1309
+ sessionId,
1310
+ command,
1311
+ selected: { ...option, selected: true },
1312
+ value: selected.selected,
1313
+ }
1314
+ }
1315
+
1316
+ return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
1317
+ }
1318
+
1319
+ function parseAgentPreset(msg, requestType, required = false) {
1320
+ if (msg.agentPreset === undefined && !required) return {}
1321
+ if (typeof msg.agentPreset !== 'string' || !msg.agentPreset.trim()) {
1322
+ return { error: { kind: 'error', code: 'bad-request', requestType,
1323
+ message: 'agentPreset must be a non-empty preset ID' } }
1324
+ }
1325
+ return { value: msg.agentPreset.trim() }
1326
+ }
1327
+
1328
+ // Dispatch one mobile query frame; returns the wire frame to send back.
1329
+ async function handleQuery(api, host, agentDefaultModel, msg) {
1330
+ if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1331
+ const session = requireSessionId(msg)
1332
+ const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
1333
+ const requestId = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
1334
+ if (session.error || preset.error) return { ...(session.error || preset.error), ...requestId }
1335
+ const method = msg.type === 'session-agent-preset' ? 'session' : 'select'
1336
+ const frame = await proxyQuery(api, msg.type, api.agentPresets[method].bind(api.agentPresets), {
1337
+ sessionId: session.value, ...(preset.value === undefined ? {} : { agentPreset: preset.value }),
1338
+ })
1339
+ return { ...frame, sessionId: session.value, ...requestId }
1340
+ }
1341
+ if (msg.type === 'session-create') {
1342
+ if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
1343
+ return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
1344
+ }
1345
+ const payload = {}
1346
+ const preset = parseAgentPreset(msg, msg.type)
1347
+ if (preset.error) return { ...preset.error, requestId: msg.requestId }
1348
+ if (preset.value !== undefined) payload.agentPreset = preset.value
1349
+ if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
1350
+ payload.workspaceId = msg.workspaceId.trim()
1351
+ } else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
1352
+ payload.cwd = msg.cwd.trim()
1353
+ }
1354
+ const frame = await proxyQuery(api, 'session-created', api.sessions.create.bind(api.sessions), payload)
1355
+ return { ...frame, ...(frame.kind === 'error' ? { requestType: 'session-create' } : {}), requestId: msg.requestId }
1356
+ }
1357
+ if (msg.type === 'workspaces') {
1358
+ return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1359
+ }
1360
+ if (msg.type === 'sessions') {
1361
+ return proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
1362
+ }
1363
+ if (msg.type === 'history') {
1364
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1365
+ if (!sessionId) {
1366
+ return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
1367
+ }
1368
+ const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
1369
+ if (cursorError) return cursorError
1370
+ const payload = { sessionId }
1371
+ if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
1372
+ if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
1373
+ const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
1374
+ if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
1375
+ return frame
1376
+ }
1377
+ if (msg.type === 'attachment') {
1378
+ const sessionId = requireSessionId(msg)
1379
+ if (sessionId.error) return sessionId.error
1380
+ const attachmentId = typeof msg.attachmentId === 'string' && msg.attachmentId.trim() !== '' ? msg.attachmentId.trim() : null
1381
+ if (!attachmentId) {
1382
+ return { kind: 'error', code: 'bad-request', message: 'attachment requires an attachmentId', requestType: 'attachment', sessionId: sessionId.value }
1383
+ }
1384
+ const frame = await proxyQuery(api, 'attachment', api.sessions.attachment.bind(api.sessions), {
1385
+ sessionId: sessionId.value,
1386
+ attachmentId,
1387
+ })
1388
+ if (frame.kind === 'attachment') frame.sessionId = sessionId.value
1389
+ return frame
1390
+ }
1391
+ if (msg.type === 'search') {
1392
+ const query = typeof msg.query === 'string' ? msg.query.trim() : ''
1393
+ if (!query) {
1394
+ return { kind: 'error', code: 'bad-request', message: 'search requires a query', requestType: 'search' }
1395
+ }
1396
+ return proxyQuery(api, 'search', api.sessions.search.bind(api.sessions), { query }, new AbortController().signal)
1397
+ }
1398
+ if (msg.type === 'host') {
1399
+ return proxyQuery(api, 'host', api.host.describe.bind(api.host), {})
1400
+ }
1401
+ if (msg.type === 'default-model') {
1402
+ try {
1403
+ const selection = agentDefaultModel.currentSelection()
1404
+ log(`default-model queried: ${selection.provider}/${selection.model}`)
1405
+ return { kind: 'default-model', selection }
1406
+ } catch (error) {
1407
+ const message = error && error.message ? error.message : String(error)
1408
+ return { kind: 'error', code: 'internal', message, requestType: 'default-model' }
1409
+ }
1410
+ }
1411
+ if (msg.type === 'save-default-model') {
1412
+ const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
1413
+ const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
1414
+ if (!provider || !model) {
1415
+ return { kind: 'error', code: 'bad-request', message: 'save-default-model requires provider and model', requestType: 'save-default-model' }
1416
+ }
1417
+ const selection = { provider, model }
1418
+ if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') selection.reasoningEffort = msg.reasoningEffort.trim()
1419
+ try {
1420
+ await agentDefaultModel.saveSelection(selection)
1421
+ log(`default model saved: ${provider}/${model}${selection.reasoningEffort ? ' (effort=' + selection.reasoningEffort + ')' : ''}`)
1422
+ return { kind: 'save-default-model', saved: selection }
1423
+ } catch (error) {
1424
+ const message = error && error.message ? error.message : String(error)
1425
+ log(`save-default-model failed: ${message}`)
1426
+ return { kind: 'error', code: 'internal', message, requestType: 'save-default-model' }
1427
+ }
1428
+ }
1429
+ if (msg.type === 'fork') {
1430
+ const sessionId = requireSessionId(msg)
1431
+ if (sessionId.error) return sessionId.error
1432
+ const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
1433
+ if (cursorError) return cursorError
1434
+ const payload = { sessionId: sessionId.value }
1435
+ if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
1436
+ const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
1437
+ if (frame.kind === 'fork') {
1438
+ log(`forked session ${sessionId.value} -> ${frame.sessionId}${payload.atSeq !== undefined ? ' at seq ' + payload.atSeq : ''}`)
1439
+ }
1440
+ return frame
1441
+ }
1442
+ if (msg.type === 'session-cancel') {
1443
+ const sessionId = requireSessionId(msg)
1444
+ if (sessionId.error) return sessionId.error
1445
+ const frame = await proxyQuery(
1446
+ api,
1447
+ 'session-cancel',
1448
+ api.sessions.cancel.bind(api.sessions),
1449
+ { sessionId: sessionId.value },
1450
+ )
1451
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1452
+ return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
1453
+ }
1454
+ if (msg.type === 'queue-update') {
1455
+ const sessionId = requireSessionId(msg)
1456
+ if (sessionId.error) return sessionId.error
1457
+ const itemId = typeof msg.itemId === 'string' && msg.itemId.trim() ? msg.itemId.trim() : null
1458
+ const actionKind = typeof msg.action === 'string' ? msg.action.trim() : ''
1459
+ if (!itemId || !['edit', 'remove', 'steer'].includes(actionKind)) {
1460
+ return {
1461
+ kind: 'error',
1462
+ code: 'bad-request',
1463
+ message: 'queue-update requires itemId and action edit, remove, or steer',
1464
+ requestType: 'queue-update',
1465
+ sessionId: sessionId.value,
1466
+ }
1467
+ }
1468
+ let action
1469
+ if (actionKind === 'edit') {
1470
+ if (typeof msg.text !== 'string' || !msg.text.trim()) {
1471
+ return {
1472
+ kind: 'error',
1473
+ code: 'bad-request',
1474
+ message: 'queue-update edit requires non-empty text',
1475
+ requestType: 'queue-update',
1476
+ sessionId: sessionId.value,
1477
+ itemId,
1478
+ }
1479
+ }
1480
+ action = { kind: 'edit', content: [{ type: 'text', text: msg.text }] }
1481
+ } else {
1482
+ action = { kind: actionKind }
1483
+ }
1484
+ const frame = await proxyQuery(
1485
+ api,
1486
+ 'queue-update',
1487
+ api.sessions.updateQueue.bind(api.sessions),
1488
+ { sessionId: sessionId.value, itemId, action },
1489
+ )
1490
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value, itemId }
1491
+ return {
1492
+ ...frame,
1493
+ kind: 'queue-item-updated',
1494
+ sessionId: sessionId.value,
1495
+ itemId,
1496
+ action: actionKind,
1497
+ }
1498
+ }
1499
+ if (msg.type === 'session-archive') {
1500
+ const sessionId = requireSessionId(msg)
1501
+ if (sessionId.error) return sessionId.error
1502
+ const frame = await proxyQuery(
1503
+ api,
1504
+ 'session-archive',
1505
+ api.workspace.archiveSession.bind(api.workspace),
1506
+ { sessionId: sessionId.value },
1507
+ )
1508
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1509
+ return { ...frame, kind: 'session-archived', sessionId: sessionId.value }
1510
+ }
1511
+ if (msg.type === 'session-rename') {
1512
+ const sessionId = requireSessionId(msg)
1513
+ if (sessionId.error) return sessionId.error
1514
+ if (typeof msg.title !== 'string' || !msg.title.trim()) {
1515
+ return {
1516
+ kind: 'error',
1517
+ code: 'bad-request',
1518
+ message: 'session-rename requires a non-empty title',
1519
+ requestType: 'session-rename',
1520
+ sessionId: sessionId.value,
1521
+ }
1522
+ }
1523
+ const frame = await proxyQuery(
1524
+ api,
1525
+ 'session-rename',
1526
+ api.sessions.rename.bind(api.sessions),
1527
+ { sessionId: sessionId.value, title: msg.title },
1528
+ )
1529
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1530
+ return { ...frame, kind: 'session-renamed', sessionId: sessionId.value }
1531
+ }
1532
+ if (msg.type === 'workspace-create') {
1533
+ const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
1534
+ if (!wsPath) {
1535
+ return { kind: 'error', code: 'bad-request', message: 'workspace-create requires a path', requestType: 'workspace-create' }
1536
+ }
1537
+ return proxyQuery(api, 'workspace-create', api.workspace.create.bind(api.workspace), { path: wsPath })
1538
+ }
1539
+ if (msg.type === 'directories') {
1540
+ return listServerDirectory(typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : undefined)
1541
+ }
1542
+ if (msg.type === 'directory-create') {
1543
+ return createServerDirectory(msg.path, msg.name)
1544
+ }
1545
+ if (msg.type === 'models') {
1546
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1547
+ if (sessionId) {
1548
+ // per-session catalog: current selection + routable + full groups
1549
+ return proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1550
+ }
1551
+ // global catalog: every provider route, no session needed
1552
+ return proxyQuery(api, 'models', api.llm.models.bind(api.llm), {})
1553
+ }
1554
+ if (msg.type === 'providers') {
1555
+ return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
1556
+ }
1557
+ if (msg.type === 'command-execute') {
1558
+ return admitCommand(host, msg)
1559
+ }
1560
+ if (msg.type === 'commands') {
1561
+ const sessionId = requireSessionId(msg)
1562
+ if (sessionId.error) return sessionId.error
1563
+ const copy = resolveCommandCatalogCopy(msg.locale)
1564
+ try {
1565
+ // This is the same session-scoped catalog used by the official Web UI.
1566
+ // Copy only the stable descriptor fields so no live Typert object crosses
1567
+ // the mobile wire. The Web UI also contributes /model client-side; expose
1568
+ // the equivalent action here so mobile clients can render the same menu.
1569
+ const [listed, skillFrame] = await Promise.all([
1570
+ listHostCommands(host, sessionId.value),
1571
+ api.skills && typeof api.skills.list === 'function'
1572
+ ? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
1573
+ : Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
1574
+ ])
1575
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
1576
+ const commands = listed
1577
+ .filter((command) => command && typeof command.name === 'string' && typeof command.description === 'string')
1578
+ .map((command) => ({
1579
+ id: `command:${command.name}`,
1580
+ name: command.name,
1581
+ description: command.description,
1582
+ source: 'host',
1583
+ action: 'execute',
1584
+ ui: commandUiDescriptor(command, copy),
1585
+ ...(command.input && typeof command.input.hint === 'string'
1586
+ ? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
1587
+ : {}),
1588
+ }))
1589
+ if (!commands.some((command) => command.name === 'model')) {
1590
+ commands.push({
1591
+ id: 'command:model',
1592
+ name: 'model',
1593
+ description: copy.modelDescription,
1594
+ source: 'client',
1595
+ action: 'select-model',
1596
+ ui: commandUiDescriptor({ name: 'model' }, copy),
1597
+ })
1598
+ }
1599
+ const skills = skillFrame.kind === 'skills' && Array.isArray(skillFrame.skills)
1600
+ ? skillFrame.skills
1601
+ .filter((skill) => skill && typeof skill.name === 'string' && typeof skill.description === 'string')
1602
+ .map((skill) => ({
1603
+ id: `skill:${skill.name}`,
1604
+ name: skill.name,
1605
+ description: skill.modelInvocable === true ? skill.description : `${copy.userOnly} · ${skill.description}`,
1606
+ source: 'skill',
1607
+ action: 'insert',
1608
+ modelInvocable: skill.modelInvocable === true,
1609
+ ...(typeof skill.whenToUse === 'string' && skill.whenToUse !== '' ? { whenToUse: skill.whenToUse } : {}),
1610
+ ui: skillUiDescriptor(skill),
1611
+ }))
1612
+ : []
1613
+ const groups = [
1614
+ { id: 'commands', title: copy.commandsTitle, items: commands },
1615
+ ...(skills.length > 0 ? [{ id: 'skills', title: copy.skillsTitle, items: skills }] : []),
1616
+ ]
1617
+ const warnings = skillFrame.kind === 'error'
1618
+ ? [{ source: 'skills', code: skillFrame.code, message: skillFrame.message }]
1619
+ : []
1620
+ log(`command catalog queried: session=${sessionId.value} commands=${commands.length} skills=${skills.length}`)
1621
+ return {
1622
+ kind: 'commands',
1623
+ sessionId: sessionId.value,
1624
+ locale: copy.locale,
1625
+ groups,
1626
+ ...(warnings.length > 0 ? { warnings } : {}),
1627
+ }
1628
+ } catch (error) {
1629
+ const code = error && error.code ? error.code : 'internal'
1630
+ const message = error && error.message ? error.message : String(error)
1631
+ log(`command catalog failed: session=${sessionId.value} ${code}: ${message}`)
1632
+ return { kind: 'error', code, message, requestType: 'commands', sessionId: sessionId.value }
1633
+ }
1634
+ }
1635
+ if (msg.type === 'command-options') {
1636
+ const sessionId = requireSessionId(msg)
1637
+ if (sessionId.error) return sessionId.error
1638
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1639
+ if (!command) return { kind: 'error', code: 'bad-request', message: 'command-options requires a command', requestType: 'command-options', sessionId: sessionId.value }
1640
+ return loadCommandOptions(api, command, sessionId.value)
1641
+ }
1642
+ if (msg.type === 'command-select') {
1643
+ const sessionId = requireSessionId(msg)
1644
+ if (sessionId.error) return sessionId.error
1645
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1646
+ const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
1647
+ if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
1648
+ return selectCommandOption(api, host, command, sessionId.value, optionId)
1649
+ }
1650
+ if (msg.type === 'select-model') {
1651
+ const sessionId = requireSessionId(msg)
1652
+ if (sessionId.error) return sessionId.error
1653
+ const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
1654
+ const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
1655
+ if (!provider || !model) {
1656
+ return { kind: 'error', code: 'bad-request', message: 'select-model requires provider and model', requestType: 'select-model' }
1657
+ }
1658
+ const payload = { sessionId: sessionId.value, provider, model }
1659
+ if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') payload.reasoningEffort = msg.reasoningEffort.trim()
1660
+ return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1661
+ }
1662
+ if (msg.type === 'permission-options') {
1663
+ const frame = await proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {})
1664
+ if (frame.kind !== 'permission-options') return frame
1665
+ const out = {
1666
+ kind: 'permission-options',
1667
+ namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
1668
+ }
1669
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1670
+ if (sessionId) {
1671
+ out.sessionId = sessionId
1672
+ const hist = await querySessionProjection(api, msg.type, sessionId)
1673
+ if (hist.kind === 'error') return hist
1674
+ if (hist.kind === 'history' && hist.projections && hist.projections.values) {
1675
+ out.sessionPermissions = hist.projections.values.permissions || null
1676
+ }
1677
+ }
1678
+ return out
1679
+ }
1680
+ if (msg.type === 'permission') {
1681
+ const sessionId = requireSessionId(msg)
1682
+ if (sessionId.error) return sessionId.error
1683
+ const name = typeof msg.name === 'string' && msg.name.trim() !== '' ? msg.name.trim() : null
1684
+ if (!name) {
1685
+ return { kind: 'error', code: 'bad-request', message: 'permission requires a name', requestType: 'permission' }
1686
+ }
1687
+ // Execute the /permission command through the Typert Remote gateway — the
1688
+ // same endpoint the browser calls (remote.commands.execute). This runs the
1689
+ // command registry handler WITHOUT sending anything to the model, and the
1690
+ // api-remotes agent lookup resumes cold sessions just like prompt does.
1691
+ try {
1692
+ // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1693
+ // 权限斜杠命令没有附件,因此显式传空数组。
1694
+ const execution = await host.commands.execute(
1695
+ sessionId.value,
1696
+ '/permission ' + name,
1697
+ [],
1698
+ new AbortController().signal,
1699
+ )
1700
+ if (execution === undefined || execution === null) {
1701
+ return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
1702
+ }
1703
+ log(`permission switched: session=${sessionId.value} preset=${name} (commandId=${execution.commandId})`)
1704
+ return {
1705
+ kind: 'permission',
1706
+ sessionId: sessionId.value,
1707
+ set: name,
1708
+ commandId: execution.commandId,
1709
+ result: execution.result,
1710
+ }
1711
+ } catch (error) {
1712
+ const code = error && error.code ? error.code : 'internal'
1713
+ const message = error && error.message ? error.message : String(error)
1714
+ log(`permission rejected: ${code}: ${message}`)
1715
+ return { kind: 'error', code, message, requestType: 'permission', sessionId: sessionId.value }
1716
+ }
1717
+ }
1718
+ if (msg.type === 'context-usage') {
1719
+ const sessionId = requireSessionId(msg)
1720
+ if (sessionId.error) return sessionId.error
1721
+ const hist = await querySessionProjection(api, msg.type, sessionId.value)
1722
+ if (hist.kind !== 'history') return hist
1723
+ const values = (hist.projections && hist.projections.values) || {}
1724
+ return {
1725
+ kind: 'context-usage',
1726
+ sessionId: sessionId.value,
1727
+ asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
1728
+ tokenUsage: values.tokenUsage || null,
1729
+ contextPressure: values.contextPressure || null,
1730
+ }
1731
+ }
1732
+ if (msg.type === 'session-stats') {
1733
+ const sessionId = requireSessionId(msg)
1734
+ if (sessionId.error) return sessionId.error
1735
+ const hist = await querySessionProjection(api, msg.type, sessionId.value)
1736
+ if (hist.kind !== 'history') return hist
1737
+ const values = (hist.projections && hist.projections.values) || {}
1738
+ return {
1739
+ kind: 'session-stats',
1740
+ sessionId: sessionId.value,
1741
+ asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
1742
+ sessionStats: values.sessionStats || null,
1743
+ tokenUsage: values.tokenUsage || null,
1744
+ contextPressure: values.contextPressure || null,
1745
+ }
1746
+ }
1747
+ if (msg.type === 'tasks') {
1748
+ const sessionId = requireSessionId(msg)
1749
+ if (sessionId.error) return sessionId.error
1750
+ return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1751
+ }
1752
+ if (msg.type === 'goal') {
1753
+ const sessionId = requireSessionId(msg)
1754
+ if (sessionId.error) return sessionId.error
1755
+ return readSessionProjection(api, 'goal', sessionId.value, 'goal')
1756
+ }
1757
+ if (msg.type === 'goal-edit') {
1758
+ const sessionId = requireSessionId(msg)
1759
+ if (sessionId.error) return sessionId.error
1760
+ const ref = requireGoalRef(msg, sessionId.value)
1761
+ if (ref.error) return ref.error
1762
+ const payload = { sessionId: sessionId.value, ref: ref.value }
1763
+ if (typeof msg.objective === 'string' && msg.objective.trim() !== '') payload.objective = msg.objective.trim()
1764
+ if (typeof msg.maxGoalRounds === 'number' && Number.isSafeInteger(msg.maxGoalRounds) && msg.maxGoalRounds > 0) {
1765
+ payload.maxGoalRounds = msg.maxGoalRounds
1766
+ }
1767
+ if (payload.objective === undefined && payload.maxGoalRounds === undefined) {
1768
+ return { kind: 'error', code: 'bad-request', message: 'goal-edit requires a non-empty objective or a positive maxGoalRounds', requestType: 'goal-edit', sessionId: sessionId.value }
1769
+ }
1770
+ return mutateGoal(api, 'goal-edit', 'edit', payload)
1771
+ }
1772
+ if (msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
1773
+ const sessionId = requireSessionId(msg)
1774
+ if (sessionId.error) return sessionId.error
1775
+ const ref = requireGoalRef(msg, sessionId.value)
1776
+ if (ref.error) return ref.error
1777
+ const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1778
+ return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1779
+ }
1780
+ if (msg.type === 'agent-presets') {
1781
+ return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1782
+ }
1783
+ if (msg.type === 'defaults') {
1784
+ const frame = await proxyQuery(api, 'defaults', api.settings.describe.bind(api.settings), {})
1785
+ if (frame.kind !== 'defaults') return frame
1786
+ const agentPresetNs = (frame.namespaces || []).find((n) => n.ns === 'agent-presets')
1787
+ const permissionNs = (frame.namespaces || []).find((n) => n.ns === 'permission')
1788
+ return {
1789
+ kind: 'defaults',
1790
+ agentPresetDefault: agentPresetNs && agentPresetNs.value && agentPresetNs.value.default !== undefined ? agentPresetNs.value.default : null,
1791
+ permissionDefault: permissionNs && permissionNs.value && permissionNs.value.defaultPreset !== undefined ? permissionNs.value.defaultPreset : null,
1792
+ }
1793
+ }
1794
+ if (msg.type === 'set-default') {
1795
+ const target = typeof msg.target === 'string' && msg.target.trim() !== '' ? msg.target.trim() : null
1796
+ const value = typeof msg.value === 'string' && msg.value.trim() !== '' ? msg.value.trim() : null
1797
+ if (target !== 'agent-preset' && target !== 'permission') {
1798
+ return { kind: 'error', code: 'bad-request', message: 'set-default target must be "agent-preset" or "permission"', requestType: 'set-default' }
1799
+ }
1800
+ if (!value) {
1801
+ return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
1802
+ }
1803
+ const ns = target === 'agent-preset' ? 'agent-presets' : 'permission'
1804
+ const patch = target === 'agent-preset' ? { default: value } : { defaultPreset: value }
1805
+ const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
1806
+ if (updated.kind !== 'set-default') return updated
1807
+ log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
1808
+ return { kind: 'set-default', target, value, applied: true, namespace: updated }
1809
+ }
1810
+ return null
1811
+ }
1812
+
1813
+ // ---------------------------------------------------------------------------
1814
+ // Device auth: a QR carries a short-lived one-time pairing code. Claiming it
1815
+ // over WebSocket yields a long-lived bearer token exactly once. The registry
1816
+ // stores only the token digest. Management endpoints are local-machine only by
1817
+ // default and mutating requests also require a same-origin browser context.
1818
+ // ---------------------------------------------------------------------------
1819
+ function isLoopback(req) {
1820
+ const addr = req.socket && req.socket.remoteAddress
1821
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
1822
+ }
1823
+
1824
+ function parseProtocols(req) {
1825
+ const value = req.headers['sec-websocket-protocol']
1826
+ if (typeof value !== 'string') return []
1827
+ return value.split(',').map((entry) => entry.trim()).filter(Boolean)
1828
+ }
1829
+
1830
+ function extractCredential(req, allowQueryToken) {
1831
+ const authorization = req.headers.authorization
1832
+ if (typeof authorization === 'string') {
1833
+ const match = /^Bearer\s+([A-Za-z0-9_-]{43})$/i.exec(authorization.trim())
1834
+ if (match) return { kind: 'token', value: match[1] }
1835
+ }
1836
+
1837
+ for (const protocol of parseProtocols(req)) {
1838
+ if (protocol.startsWith('dsh-auth.')) return { kind: 'token', value: protocol.slice('dsh-auth.'.length) }
1839
+ if (protocol.startsWith('dsh-pair.')) return { kind: 'pairing', value: protocol.slice('dsh-pair.'.length) }
1840
+ }
1841
+
1842
+ try {
1843
+ const query = new URL(req.url || '/', 'http://localhost').searchParams
1844
+ const pairing = query.get('pairingCode')
1845
+ if (pairing) return { kind: 'pairing', value: pairing }
1846
+ const token = allowQueryToken && query.get('token')
1847
+ if (token) return { kind: 'token', value: token }
1848
+ } catch (error) { /* ignore */ }
1849
+ return undefined
1850
+ }
1851
+
1852
+ function extractClientDeviceId(req) {
1853
+ const value = req.headers['x-dsh-device-id']
1854
+ if (typeof value !== 'string') return undefined
1855
+ const normalized = value.trim()
1856
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
1857
+ }
1858
+
1859
+ function rejectUpgrade(socket, status, message) {
1860
+ const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
1861
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
1862
+ socket.destroy()
1863
+ }
1864
+
1865
+ function isLocalHostname(hostname) {
1866
+ const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
1867
+ return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
1868
+ }
1869
+
1870
+ function isPrivateNetworkHostname(hostname) {
1871
+ let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
1872
+ if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
1873
+ if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
1874
+ const octets = normalized.split('.').map((part) => Number(part))
1875
+ if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
1876
+ return octets[0] === 10 ||
1877
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
1878
+ (octets[0] === 192 && octets[1] === 168) ||
1879
+ (octets[0] === 169 && octets[1] === 254)
1880
+ }
1881
+ return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
1882
+ }
1883
+
1884
+ function privateLanAddresses() {
1885
+ const physical = []
1886
+ const fallback = []
1887
+ const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
1888
+ for (const [name, records] of Object.entries(os.networkInterfaces())) {
1889
+ for (const record of records || []) {
1890
+ if (!record || record.internal || record.family !== 'IPv4' || !isPrivateNetworkHostname(record.address)) continue
1891
+ const target = virtualInterface.test(name) ? fallback : physical
1892
+ if (!target.includes(record.address)) target.push(record.address)
1893
+ }
1894
+ }
1895
+ return physical.length ? physical : fallback
1896
+ }
1897
+
1898
+ function lanWebSocketUrls(options, wsPath, boundPort) {
1899
+ if (!options.lanEnabled) return []
1900
+ const advertised = typeof options.lanAdvertiseHost === 'string' ? options.lanAdvertiseHost.trim() : ''
1901
+ const hosts = advertised
1902
+ ? [advertised]
1903
+ : options.lanHost && options.lanHost !== '0.0.0.0' && options.lanHost !== '::'
1904
+ ? [options.lanHost]
1905
+ : privateLanAddresses()
1906
+ return hosts
1907
+ .filter((host) => isPrivateNetworkHostname(host))
1908
+ .map((host) => `ws://${host.includes(':') ? `[${host}]` : host}:${boundPort}${wsPath}`)
1909
+ }
1910
+
1911
+ function badRequest(message) {
1912
+ const error = new Error(message)
1913
+ error.status = 400
1914
+ return error
1915
+ }
1916
+
1917
+ function normalizePublicUrl(value, req, wsPath) {
1918
+ let raw = typeof value === 'string' ? value.trim() : ''
1919
+ if (!raw) {
1920
+ const host = req.headers.host || `127.0.0.1`
1921
+ raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
1922
+ }
1923
+ let url
1924
+ try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
1925
+ if (url.protocol === 'https:') url.protocol = 'wss:'
1926
+ if (url.protocol === 'http:') url.protocol = 'ws:'
1927
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
1928
+ if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
1929
+ if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
1930
+ if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
1931
+ return url.toString()
1932
+ }
1933
+
1934
+ function isSameOrigin(req) {
1935
+ const origin = req.headers.origin
1936
+ if (origin === undefined) return true
1937
+ if (typeof origin !== 'string' || typeof req.headers.host !== 'string') return false
1938
+ try {
1939
+ return new URL(origin).host === req.headers.host
1940
+ } catch (error) {
1941
+ return false
1942
+ }
1943
+ }
1944
+
1945
+ function readBody(req) {
1946
+ return new Promise((resolve, reject) => {
1947
+ let data = ''
1948
+ let bytes = 0
1949
+ let oversized = false
1950
+ req.on('data', (chunk) => {
1951
+ bytes += chunk.length
1952
+ if (bytes > MAX_MANAGEMENT_BODY_BYTES) oversized = true
1953
+ else data += chunk
1954
+ })
1955
+ req.on('end', () => {
1956
+ if (oversized) {
1957
+ const error = new Error('request body is too large')
1958
+ error.status = 413
1959
+ reject(error)
1960
+ return
1961
+ }
1962
+ try {
1963
+ const body = data === '' ? {} : JSON.parse(data)
1964
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
1965
+ resolve(body)
1966
+ } catch (cause) {
1967
+ const error = new Error('request body must be a JSON object', { cause })
1968
+ error.status = 400
1969
+ reject(error)
1970
+ }
1971
+ })
1972
+ req.on('error', reject)
1973
+ })
1974
+ }
1975
+
1976
+ function securityHeaders(contentType) {
1977
+ return {
1978
+ 'Content-Type': contentType,
1979
+ 'Cache-Control': 'no-store',
1980
+ 'X-Content-Type-Options': 'nosniff',
1981
+ 'Referrer-Policy': 'no-referrer',
1982
+ }
1983
+ }
1984
+
1985
+ function sendJson(res, status, value) {
1986
+ const body = JSON.stringify(value)
1987
+ res.writeHead(status, { ...securityHeaders('application/json; charset=utf-8'), 'Content-Length': Buffer.byteLength(body) })
1988
+ res.end(body)
1989
+ }
1990
+
1991
+ function callSystemHelper(message, timeoutMs = 2_000) {
1992
+ return new Promise((resolve, reject) => {
1993
+ let settled = false
1994
+ let body = ''
1995
+ const finish = (error, value) => {
1996
+ if (settled) return
1997
+ settled = true
1998
+ socket.destroy()
1999
+ if (error) reject(error)
2000
+ else resolve(value)
2001
+ }
2002
+ const socket = net.createConnection(HELPER_SOCKET)
2003
+ socket.setEncoding('utf8')
2004
+ socket.setTimeout(timeoutMs)
2005
+ socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`))
2006
+ socket.on('data', (chunk) => {
2007
+ body += chunk
2008
+ if (body.length > 1024 * 1024) return finish(new Error('system helper response is too large'))
2009
+ const newline = body.indexOf('\n')
2010
+ if (newline < 0) return
2011
+ try {
2012
+ const response = JSON.parse(body.slice(0, newline))
2013
+ if (!response || response.ok !== true) throw new Error(response && response.error ? response.error : 'system helper request failed')
2014
+ finish(null, response.result)
2015
+ } catch (error) {
2016
+ finish(error)
2017
+ }
2018
+ })
2019
+ socket.on('timeout', () => finish(new Error('system helper request timed out')))
2020
+ socket.on('error', (error) => finish(error))
2021
+ socket.on('end', () => {
2022
+ if (!settled) finish(new Error('system helper closed without a response'))
2023
+ })
2024
+ })
2025
+ }
2026
+
2027
+ async function systemHelperStatus() {
2028
+ try {
2029
+ return await callSystemHelper({ action: 'status' })
2030
+ } catch (error) {
2031
+ return {
2032
+ installed: false,
2033
+ configured: false,
2034
+ error: error && error.message ? error.message : String(error),
2035
+ }
2036
+ }
2037
+ }
2038
+
2039
+ const plugin = {
2040
+ name: 'mobile-gateway',
2041
+ Config,
2042
+ // Hard dependencies: the gateway registers on the host web server and
2043
+ // admits mobile messages through the Host Remote Gateway. Cordis must not
2044
+ // activate this row before either service exists.
2045
+ inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
2046
+ // exported for local tests; Cordis ignores unknown plugin fields
2047
+ admitMessage,
2048
+ handleQuery,
2049
+ apply(ctx, config) {
2050
+ const webServer = ctx.webServer
2051
+ const api = createDshHostAdapter(ctx.typertGateway)
2052
+ const host = api
2053
+ const agentDefaultModel = ctx.agentDefaultModel
2054
+ const options = {
2055
+ path: DEFAULT_WS_PATH,
2056
+ requireAuth: true,
2057
+ gatewayEnabled: false,
2058
+ gatewayStateFile: '',
2059
+ gatewayName: '',
2060
+ endpoints: [],
2061
+ gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
2062
+ maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
2063
+ fileDownloadsEnabled: true,
2064
+ fileDownloadMaxBytes: DEFAULT_FILE_DOWNLOAD_MAX_BYTES,
2065
+ fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
2066
+ fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
2067
+ fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
2068
+ adminLoopbackOnly: true,
2069
+ publicUrl: '',
2070
+ deviceFile: '',
2071
+ pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
2072
+ allowQueryToken: false,
2073
+ publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
2074
+ lanEnabled: false,
2075
+ lanHost: '0.0.0.0',
2076
+ lanPort: 3081,
2077
+ lanAdvertiseHost: '',
2078
+ ...(config || {}),
2079
+ }
2080
+ const wsPath = options.path
2081
+ if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
2082
+ throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
2083
+ }
2084
+ if (options.lanEnabled === true && (typeof options.lanHost !== 'string' || !options.lanHost.trim())) {
2085
+ throw new Error('mobile-gateway: lanHost must be a non-empty listen address')
2086
+ }
2087
+ if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
2088
+ throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
2089
+ }
2090
+ let requireAuth = options.requireAuth !== false
2091
+ const adminLoopbackOnly = options.adminLoopbackOnly !== false
2092
+ const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
2093
+ if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
2094
+ if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
2095
+ const normalizeEndpoints = (values) => {
2096
+ if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
2097
+ throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
2098
+ }
2099
+ return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
2100
+ }
2101
+ const configuredEndpoints = normalizeEndpoints(options.endpoints)
2102
+ const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
2103
+ const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
2104
+ const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
2105
+ const wss = new WebSocketServer({
2106
+ noServer: true,
2107
+ maxPayload: options.maxPayloadBytes,
2108
+ // Authentication data may ride in requested subprotocols, but a secret
2109
+ // must never be echoed as the negotiated protocol.
2110
+ handleProtocols(protocols) {
2111
+ return protocols.has('dsh-mobile-v1') ? 'dsh-mobile-v1' : false
2112
+ },
2113
+ })
2114
+ const clients = new Set()
2115
+ const pendingQuestions = new Map()
2116
+ const pendingApprovals = new Map()
2117
+ const fileTransfers = createFileTransferManager(api, options)
2118
+ const fileTransferExpiryTimer = setInterval(() => {
2119
+ fileTransfers.expire().catch((error) => {
2120
+ log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
2121
+ })
2122
+ }, Math.min(options.fileDownloadIdleMs, 30_000))
2123
+ const backgroundStreamAbort = new AbortController()
2124
+ let archivedSessionIds = null
2125
+ let sessionQueues = null
2126
+ let sessionProjectionBaselines = {}
2127
+ let counter = 0
2128
+ let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
2129
+ let gatewayEnabled = gatewayMode !== 'disabled'
2130
+ let waitExpiresAt = null
2131
+ let waitTimer = null
2132
+ let connectedSinceEnabled = false
2133
+ let lastAuthRejectLogAt = 0
2134
+ let suppressedAuthRejects = 0
2135
+ let lanServer = null
2136
+ let lanListening = false
2137
+ let lanListenError = null
2138
+ let lanBoundPort = options.lanPort
2139
+ const configuredPublicUrl = () => {
2140
+ if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
2141
+ if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
2142
+ try {
2143
+ return fs.readFileSync(options.publicUrlFile, 'utf8')
2144
+ .split(/\r?\n/)
2145
+ .map((line) => line.trim())
2146
+ .find((line) => line && !line.startsWith('#')) || ''
2147
+ } catch (error) {
2148
+ if (!error || error.code !== 'ENOENT') log(`unable to read publicUrlFile: ${error && error.message ? error.message : String(error)}`)
2149
+ return ''
2150
+ }
2151
+ }
2152
+
2153
+ const advertisedEndpoints = (primary, extra = []) => {
2154
+ const publicUrl = configuredPublicUrl()
2155
+ return normalizeEndpoints([...new Set([
2156
+ ...(primary ? [primary] : []),
2157
+ ...extra,
2158
+ ...configuredEndpoints,
2159
+ ...(publicUrl ? [publicUrl] : []),
2160
+ ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2161
+ ])])
2162
+ }
2163
+
2164
+ const setGatewayMode = (mode, reason, persist = true) => {
2165
+ if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
2166
+ // Commit before changing the live service, so a failed save never reports success.
2167
+ if (persist) gatewayState.setMode(mode)
2168
+ if (waitTimer) clearTimeout(waitTimer)
2169
+ waitTimer = null
2170
+ gatewayMode = mode
2171
+ gatewayEnabled = mode !== 'disabled'
2172
+ waitExpiresAt = null
2173
+ connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
2174
+ if (mode === 'temporary' && !connectedSinceEnabled) {
2175
+ waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
2176
+ waitTimer = setTimeout(() => {
2177
+ waitTimer = null
2178
+ if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
2179
+ gatewayEnabled = false
2180
+ gatewayMode = 'disabled'
2181
+ waitExpiresAt = null
2182
+ try { gatewayState.setMode('disabled') } catch (error) {
2183
+ log(`failed to persist automatic gateway disable: ${error.message}`)
2184
+ }
2185
+ log('mobile gateway automatically disabled: no device connected before timeout')
2186
+ }, options.gatewayWaitTimeoutMs)
2187
+ } else if (mode === 'disabled') {
2188
+ for (const client of clients) client.close(4004, 'mobile gateway disabled')
2189
+ }
2190
+ log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
2191
+ }
2192
+
2193
+ const logAuthRejected = (req) => {
2194
+ const now = Date.now()
2195
+ if (now - lastAuthRejectLogAt >= 30_000) {
2196
+ const suffix = suppressedAuthRejects ? ` (${suppressedAuthRejects} repeated attempts suppressed)` : ''
2197
+ const remote = (req.socket && req.socket.remoteAddress) || 'unknown'
2198
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin.slice(0, 160) : '-'
2199
+ const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 160) : '-'
2200
+ log(`auth rejected: missing or invalid device credential; remote=${remote}; origin=${origin}; user-agent=${userAgent}${suffix}`)
2201
+ lastAuthRejectLogAt = now
2202
+ suppressedAuthRejects = 0
2203
+ } else {
2204
+ suppressedAuthRejects += 1
2205
+ }
2206
+ }
2207
+
2208
+ const questionFrameFor = (rpcId, payload, replay = false) => ({
2209
+ kind: 'question-requested',
2210
+ rpcId: String(rpcId),
2211
+ sessionId: String(payload.sessionId),
2212
+ questions: payload.questions,
2213
+ ...(replay ? { replay: true } : {}),
2214
+ })
2215
+
2216
+ const approvalFrameFor = (rpcId, payload, replay = false) => ({
2217
+ kind: 'approval-requested',
2218
+ rpcId: String(rpcId),
2219
+ sessionId: String(payload.sessionId),
2220
+ approvalId: String(payload.approvalId),
2221
+ toolName: payload.toolName,
2222
+ ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2223
+ ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2224
+ ...(replay ? { replay: true } : {}),
2225
+ })
2226
+
2227
+ const broadcastInteractionFrame = (frame) => {
2228
+ const wire = stringifyWireFrame(frame)
2229
+ for (const client of clients) {
2230
+ if (client.mobileChannel === 'conversation') continue
2231
+ if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
2232
+ if (client.readyState === 1) client.send(wire)
2233
+ }
2234
+ }
2235
+
2236
+ // Session-list metadata must reach every connected client even when it is
2237
+ // subscribed to one conversation. Subscriptions only scope the heavier
2238
+ // conversation and interaction streams.
2239
+ const broadcastSessionMetadataFrame = (frame) => {
2240
+ const wire = stringifyWireFrame(frame)
2241
+ for (const client of clients) {
2242
+ if (client.mobileChannel === 'conversation') continue
2243
+ if (client.readyState === 1) client.send(wire)
2244
+ }
2245
+ }
2246
+
2247
+ const replayPendingInteractions = (ws, trigger) => {
2248
+ // In split-channel mode `subscribe` belongs to the conversation socket,
2249
+ // while interaction frames belong to the control socket. Bridge a
2250
+ // conversation subscription to every live control/legacy socket so
2251
+ // reopening a Session restores its still-pending cards. Reducers dedupe
2252
+ // by rpcId, so replaying to another trusted device is harmless and keeps
2253
+ // all active clients in sync.
2254
+ // The companion control socket already receives a full replay when it
2255
+ // connects, so a bare conversation connect must not duplicate it again.
2256
+ if (ws.mobileChannel === 'conversation' && trigger !== 'subscribe') {
2257
+ return { questionCount: 0, approvalCount: 0 }
2258
+ }
2259
+ const recipients = ws.mobileChannel === 'conversation'
2260
+ ? [...clients].filter((client) => client.readyState === 1 && client.mobileChannel !== 'conversation')
2261
+ : [ws]
2262
+ if (recipients.length === 0) return { questionCount: 0, approvalCount: 0 }
2263
+ const sessionId = ws.filterSessionId
2264
+ let questionCount = 0
2265
+ let approvalCount = 0
2266
+ for (const pending of pendingQuestions.values()) {
2267
+ if (sessionId && sessionId !== pending.sessionId) continue
2268
+ const wire = stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true))
2269
+ for (const recipient of recipients) {
2270
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2271
+ recipient.send(wire)
2272
+ }
2273
+ questionCount += 1
2274
+ }
2275
+ for (const pending of pendingApprovals.values()) {
2276
+ if (sessionId && sessionId !== pending.sessionId) continue
2277
+ const wire = stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true))
2278
+ for (const recipient of recipients) {
2279
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2280
+ recipient.send(wire)
2281
+ }
2282
+ approvalCount += 1
2283
+ }
2284
+ log(`interaction replay: trigger=${trigger} channel=${ws.mobileChannel} filtered=${Boolean(sessionId)} recipients=${recipients.length} questions=${questionCount} approvals=${approvalCount}`)
2285
+ return { questionCount, approvalCount }
2286
+ }
2287
+
2288
+ const hasInteractionClient = (sessionId) => [...clients].some((client) => (
2289
+ client.readyState === 1
2290
+ && client.mobileChannel !== 'conversation'
2291
+ && (!client.filterSessionId || client.filterSessionId === sessionId)
2292
+ ))
2293
+
2294
+ const abortDownstreamInteraction = (pending, kind) => {
2295
+ if (pending.downstreamAbort.signal.aborted) return
2296
+ // The Host API gateway fans this abort out to every WebUI delivery of
2297
+ // the same Remote event. Its existing cancel handling removes each
2298
+ // pending BottomSheet; Mobile peers receive the resolved frame below.
2299
+ pending.downstreamAbort.abort(new Error(`${kind} completed outside WebUI`))
2300
+ }
2301
+
2302
+ const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED', cancellationError, source = 'mobile') => {
2303
+ if (pendingQuestions.get(pending.rpcId) !== pending) return false
2304
+ pendingQuestions.delete(pending.rpcId)
2305
+ pending.cleanup()
2306
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'question')
2307
+ if (action === 'answer') pending.resolve(answer)
2308
+ else {
2309
+ const error = cancellationError ?? new Error('question cancelled by mobile user')
2310
+ if (!error.name || error.name === 'Error') error.name = 'UserQuestionError'
2311
+ if (!error.code) error.code = cancellationCode
2312
+ pending.reject(error)
2313
+ }
2314
+ broadcastInteractionFrame({
2315
+ kind: 'question-resolved',
2316
+ rpcId: pending.rpcId,
2317
+ sessionId: pending.sessionId,
2318
+ outcome: action === 'answer' ? 'answered' : 'cancelled',
2319
+ })
2320
+ return true
2321
+ }
2322
+
2323
+ const normalizeQuestionAnswers = (pending, answers) => {
2324
+ if (answers.length !== pending.questions.length) return { error: 'answers must cover every question exactly once' }
2325
+ const normalized = []
2326
+ for (let index = 0; index < pending.questions.length; index += 1) {
2327
+ const question = pending.questions[index]
2328
+ const answer = answers[index]
2329
+ if (!answer || typeof answer !== 'object' || answer.id !== question.id || !Array.isArray(answer.selected)) {
2330
+ return { error: 'answers must preserve question order, ids, and selected arrays' }
2331
+ }
2332
+ if (answer.selected.some((label) => typeof label !== 'string')) return { error: 'selected values must be strings' }
2333
+ if (new Set(answer.selected).size !== answer.selected.length) return { error: 'selected values must not repeat' }
2334
+ const offered = new Set((question.options || []).map((option) => option.label))
2335
+ if (answer.selected.some((label) => !offered.has(label))) return { error: 'selected values must match offered option labels' }
2336
+ if (question.multiSelect !== true && answer.selected.length > 1) return { error: 'single-select questions accept at most one selection' }
2337
+ let custom
2338
+ if (answer.custom !== undefined) {
2339
+ if (typeof answer.custom !== 'string' || !answer.custom.trim()) return { error: 'custom answers must be non-empty strings' }
2340
+ custom = answer.custom.trim()
2341
+ }
2342
+ if (question.multiSelect !== true && custom !== undefined && answer.selected.length > 0) {
2343
+ return { error: 'single-select custom answers cannot accompany a selection' }
2344
+ }
2345
+ normalized.push({ id: answer.id, selected: [...answer.selected], ...(custom === undefined ? {} : { custom }) })
2346
+ }
2347
+ return { value: { answers: normalized } }
2348
+ }
2349
+
2350
+ const completeApproval = (pending, outcome, source = 'mobile') => {
2351
+ if (pendingApprovals.get(pending.rpcId) !== pending) return false
2352
+ pendingApprovals.delete(pending.rpcId)
2353
+ pending.cleanup()
2354
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'approval')
2355
+ pending.resolve(outcome)
2356
+ broadcastInteractionFrame({
2357
+ kind: 'approval-resolved',
2358
+ rpcId: pending.rpcId,
2359
+ sessionId: pending.sessionId,
2360
+ approvalId: pending.approvalId,
2361
+ outcome,
2362
+ })
2363
+ return true
2364
+ }
2365
+
2366
+ const startDownstreamQuestion = (pending) => {
2367
+ let downstream
2368
+ try {
2369
+ downstream = pending.next()
2370
+ } catch (error) {
2371
+ pending.downstream = { kind: 'rejected', error }
2372
+ return
2373
+ }
2374
+ pending.downstream = { kind: 'pending' }
2375
+ Promise.resolve(downstream).then((answer) => {
2376
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2377
+ if (answer && Array.isArray(answer.answers)) {
2378
+ completeQuestion(pending, 'answer', answer, undefined, undefined, 'downstream')
2379
+ log(`question resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2380
+ } else {
2381
+ // A downstream answerer may report that no WebUI currently owns the
2382
+ // request. Keep Mobile active; the value is used only if every Mobile
2383
+ // client later disconnects.
2384
+ pending.downstream = { kind: 'unavailable', value: answer }
2385
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2386
+ }
2387
+ }, (error) => {
2388
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2389
+ if (error?.code === 'ASK_CANCELLED' || error?.code === 'ASK_ABORTED') {
2390
+ completeQuestion(pending, 'cancel', undefined, error.code, error, 'downstream')
2391
+ log(`question cancelled by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2392
+ } else {
2393
+ pending.downstream = { kind: 'rejected', error }
2394
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2395
+ }
2396
+ })
2397
+ }
2398
+
2399
+ const startDownstreamApproval = (pending) => {
2400
+ let downstream
2401
+ try {
2402
+ downstream = pending.next()
2403
+ } catch (error) {
2404
+ pending.downstream = { kind: 'rejected', error }
2405
+ return
2406
+ }
2407
+ pending.downstream = { kind: 'pending' }
2408
+ Promise.resolve(downstream).then((outcome) => {
2409
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2410
+ if (outcome === 'allowed-once' || outcome === 'rejected') {
2411
+ completeApproval(pending, outcome, 'downstream')
2412
+ log(`approval resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId} outcome=${outcome}`)
2413
+ } else {
2414
+ pending.downstream = { kind: 'unavailable', value: outcome }
2415
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2416
+ }
2417
+ }, (error) => {
2418
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2419
+ pending.downstream = { kind: 'rejected', error }
2420
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2421
+ })
2422
+ }
2423
+
2424
+ const fallbackQuestion = (pending) => {
2425
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2426
+ if (pending.downstream?.kind === 'pending') return
2427
+ pendingQuestions.delete(pending.rpcId)
2428
+ pending.cleanup()
2429
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2430
+ else pending.resolve(pending.downstream?.value)
2431
+ }
2432
+
2433
+ const fallbackApproval = (pending) => {
2434
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2435
+ if (pending.downstream?.kind === 'pending') return
2436
+ pendingApprovals.delete(pending.rpcId)
2437
+ pending.cleanup()
2438
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2439
+ else pending.resolve(pending.downstream?.value)
2440
+ }
2441
+
2442
+ const releaseUnclaimedInteractions = () => {
2443
+ for (const pending of [...pendingQuestions.values()]) {
2444
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2445
+ }
2446
+ for (const pending of [...pendingApprovals.values()]) {
2447
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2448
+ }
2449
+ }
2450
+
2451
+ const respondToQuestion = async (msg, cancel = false) => {
2452
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
2453
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
2454
+ if (!rpcId || !sessionId) {
2455
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
2456
+ }
2457
+
2458
+ const pending = pendingQuestions.get(rpcId)
2459
+ if (!pending) {
2460
+ return { kind: 'question-response', rpcId, sessionId, action: cancel ? 'cancel' : 'answer', accepted: false, reason: 'not-pending' }
2461
+ }
2462
+ if (pending.sessionId !== sessionId) {
2463
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
2464
+ }
2465
+ if (!cancel && !Array.isArray(msg.answers)) {
2466
+ return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
2467
+ }
2468
+ const normalized = cancel ? null : normalizeQuestionAnswers(pending, msg.answers)
2469
+ if (normalized?.error) {
2470
+ return { kind: 'error', code: 'bad-response', message: normalized.error, requestType: msg.type, sessionId }
2471
+ }
2472
+ const answer = normalized?.value
2473
+ const accepted = completeQuestion(pending, cancel ? 'cancel' : 'answer', answer)
2474
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${accepted}`)
2475
+ return {
2476
+ kind: 'question-response',
2477
+ rpcId,
2478
+ sessionId,
2479
+ action: cancel ? 'cancel' : 'answer',
2480
+ accepted,
2481
+ ...(!accepted ? { reason: 'not-pending' } : {}),
2482
+ }
2483
+ }
2484
+
2485
+ const respondToApproval = async (msg) => {
2486
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
2487
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
2488
+ const approvalId = typeof msg.approvalId === 'string' && msg.approvalId.trim() ? msg.approvalId.trim() : null
2489
+ const outcome = msg.outcome
2490
+ if (!rpcId || !sessionId || !approvalId) {
2491
+ return { kind: 'error', code: 'bad-request', message: 'approval-response requires rpcId, sessionId, and approvalId', requestType: msg.type }
2492
+ }
2493
+ if (outcome !== 'allowed-once' && outcome !== 'rejected') {
2494
+ return { kind: 'error', code: 'bad-request', message: 'approval-response outcome must be "allowed-once" or "rejected"', requestType: msg.type, sessionId }
2495
+ }
2496
+
2497
+ const pending = pendingApprovals.get(rpcId)
2498
+ if (!pending) {
2499
+ return { kind: 'approval-response', rpcId, sessionId, approvalId, outcome, accepted: false, reason: 'not-pending' }
2500
+ }
2501
+ if (pending.sessionId !== sessionId || pending.approvalId !== approvalId) {
2502
+ return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
2503
+ }
2504
+ const accepted = completeApproval(pending, outcome)
2505
+ log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${accepted}`)
2506
+ return {
2507
+ kind: 'approval-response',
2508
+ rpcId,
2509
+ sessionId,
2510
+ approvalId,
2511
+ outcome,
2512
+ accepted,
2513
+ ...(!accepted ? { reason: 'not-pending' } : {}),
2514
+ }
2515
+ }
2516
+
2517
+ // Saved user choice wins; a temporary startup gets a fresh first-connection window.
2518
+ setGatewayMode(gatewayMode, 'startup', false)
2519
+
2520
+ log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2521
+
2522
+ // ---- device management routes (loopback-gated admin surface) ----
2523
+ const disposeMgmt = webServer.register({
2524
+ kind: 'prefix',
2525
+ path: '/mgw',
2526
+ handler: async (req, res) => {
2527
+ try {
2528
+ if (adminLoopbackOnly && !isLoopback(req)) {
2529
+ sendJson(res, 403, { error: 'forbidden', message: 'management API is loopback-only' })
2530
+ return
2531
+ }
2532
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !isSameOrigin(req)) {
2533
+ sendJson(res, 403, { error: 'forbidden', message: 'cross-origin management request rejected' })
2534
+ return
2535
+ }
2536
+ const url = new URL(req.url || '/', 'http://x')
2537
+ const p = url.pathname
2538
+ if (req.method === 'GET' && p === '/mgw/status') {
2539
+ sendJson(res, 200, {
2540
+ version: PLUGIN_VERSION,
2541
+ requireAuth,
2542
+ gatewayEnabled,
2543
+ gatewayMode,
2544
+ ...gatewayIdentity,
2545
+ endpoints: advertisedEndpoints(),
2546
+ waitExpiresAt,
2547
+ connectedClients: clients.size,
2548
+ webPort: webServer.port,
2549
+ wsPath,
2550
+ publicUrl: configuredPublicUrl() || null,
2551
+ lan: {
2552
+ enabled: options.lanEnabled === true,
2553
+ listening: lanListening,
2554
+ host: options.lanHost,
2555
+ port: lanBoundPort,
2556
+ urls: lanWebSocketUrls(options, wsPath, lanBoundPort),
2557
+ requireAuth: true,
2558
+ error: lanListenError,
2559
+ },
2560
+ pairingTtlMs: options.pairingTtlMs,
2561
+ queryTokenAllowed: !!options.allowQueryToken,
2562
+ })
2563
+ } else if (req.method === 'GET' && p === '/mgw/public-setup') {
2564
+ sendJson(res, 200, await systemHelperStatus())
2565
+ } else if (req.method === 'POST' && p === '/mgw/public-setup') {
2566
+ const body = await readBody(req)
2567
+ if (typeof body.publicIp !== 'string' || !body.publicIp.trim()) throw badRequest('publicIp is required')
2568
+ const result = await callSystemHelper({
2569
+ action: 'configure',
2570
+ publicIp: body.publicIp.trim(),
2571
+ backendPort: webServer.port,
2572
+ }, 15 * 60 * 1000)
2573
+ log(`public endpoint configured: ${result.publicUrl} -> 127.0.0.1:${result.backendPort}`)
2574
+ sendJson(res, 200, result)
2575
+ } else if (req.method === 'POST' && p === '/mgw/gateway') {
2576
+ const body = await readBody(req)
2577
+ if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
2578
+ if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2579
+ setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2580
+ sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
2581
+ } else if (req.method === 'POST' && p === '/mgw/auth') {
2582
+ const body = await readBody(req)
2583
+ if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
2584
+ requireAuth = body.enabled
2585
+ let disconnected = 0
2586
+ if (requireAuth) {
2587
+ for (const client of clients) {
2588
+ if (!client.deviceId) {
2589
+ disconnected += 1
2590
+ client.close(4003, 'authentication enabled')
2591
+ }
2592
+ }
2593
+ }
2594
+ log(`device authentication ${requireAuth ? 'enabled' : 'disabled'} from management UI`)
2595
+ sendJson(res, 200, { requireAuth, disconnected })
2596
+ } else if (req.method === 'GET' && p === '/mgw/devices') {
2597
+ sendJson(res, 200, { devices: registry.list() })
2598
+ } else if (req.method === 'POST' && p === '/mgw/pair') {
2599
+ if (!gatewayEnabled) {
2600
+ const error = new Error('enable the mobile gateway before creating a pairing code')
2601
+ error.status = 409
2602
+ throw error
2603
+ }
2604
+ const body = await readBody(req)
2605
+ const name = typeof body.name === 'string' ? body.name : undefined
2606
+ const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
2607
+ const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
2608
+ const pairing = registry.createPairing(name)
2609
+ const payload = {
2610
+ version: 2,
2611
+ publicUrl,
2612
+ pairingCode: pairing.code,
2613
+ expiresAt: pairing.expiresAt,
2614
+ ...gatewayIdentity,
2615
+ endpoints,
2616
+ }
2617
+ // QR/manual pairing has one canonical wire representation: the
2618
+ // UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
2619
+ // copy-safe and QR-safe (`+`, `/`, and `=` never appear), but is
2620
+ // encoding rather than encryption; secrecy still comes from the
2621
+ // short TTL and single-use pairing code.
2622
+ const qrPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
2623
+ const svg = await QRCode.toString(qrPayload, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' })
2624
+ log(`pairing created: ${pairing.name} (${pairing.id}), expires=${new Date(pairing.expiresAt).toISOString()}`)
2625
+ sendJson(res, 201, {
2626
+ pairing: { id: pairing.id, name: pairing.name, expiresAt: pairing.expiresAt },
2627
+ payload,
2628
+ qrPayload,
2629
+ svg,
2630
+ })
2631
+ } else if (req.method === 'POST' && p.startsWith('/mgw/devices/') && p.endsWith('/revoke')) {
2632
+ const id = decodeURIComponent(p.slice('/mgw/devices/'.length, -'/revoke'.length))
2633
+ const revoked = registry.revoke(id)
2634
+ if (revoked) {
2635
+ for (const client of clients) {
2636
+ if (client.deviceId === id) client.close(4003, 'device revoked')
2637
+ }
2638
+ log(`device revoked: ${id}`)
2639
+ }
2640
+ sendJson(res, revoked ? 200 : 404, { revoked })
2641
+ } else if (req.method === 'GET' && p === '/mgw') {
2642
+ res.writeHead(302, { ...securityHeaders('text/plain; charset=utf-8'), Location: '/' })
2643
+ res.end('Open the device manager from the DSH sidebar.')
2644
+ } else {
2645
+ sendJson(res, 404, { error: 'not-found' })
2646
+ }
2647
+ } catch (error) {
2648
+ log(`mgmt route failed: ${error && error.message ? error.message : String(error)}`)
2649
+ const status = Number.isInteger(error && error.status) ? error.status : (error instanceof TypeError ? 400 : 500)
2650
+ sendJson(res, status, { error: status < 500 ? 'bad-request' : 'internal', message: error && error.message ? error.message : String(error) })
2651
+ }
2652
+ },
2653
+ })
2654
+ log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
2655
+
2656
+ const handleMobileUpgrade = (req, socket, head, transport = {}) => {
2657
+ if (!gatewayEnabled) {
2658
+ rejectUpgrade(socket, 503, 'mobile gateway is disabled')
2659
+ return
2660
+ }
2661
+ let device
2662
+ let paired
2663
+ const credential = extractCredential(req, !!options.allowQueryToken)
2664
+ if (credential && credential.kind === 'pairing') {
2665
+ const clientDeviceId = extractClientDeviceId(req)
2666
+ if (!clientDeviceId) {
2667
+ log('pairing rejected: missing X-DSH-Device-ID (outdated client)')
2668
+ rejectUpgrade(socket, 400, 'pairing requires X-DSH-Device-ID; update the iOS client')
2669
+ return
2670
+ }
2671
+ paired = registry.claimPairing(credential.value, clientDeviceId)
2672
+ device = paired && paired.device
2673
+ if (!device) {
2674
+ log('pairing rejected: invalid, expired, or already-used code')
2675
+ rejectUpgrade(socket, 401, 'invalid or expired pairing code')
2676
+ return
2677
+ }
2678
+ } else if (credential && credential.kind === 'token') {
2679
+ device = registry.authenticate(credential.value, extractClientDeviceId(req))
2680
+ }
2681
+
2682
+ // The separately exposed LAN listener is always authenticated. The
2683
+ // management UI's debug auth switch only affects the loopback DSH
2684
+ // listener and can never turn a LAN endpoint into an open control
2685
+ // plane.
2686
+ if ((requireAuth || transport.lan === true) && !device) {
2687
+ logAuthRejected(req)
2688
+ rejectUpgrade(socket, 401, 'missing or invalid device credential')
2689
+ return
2690
+ }
2691
+
2692
+ wss.handleUpgrade(req, socket, head, (ws) => {
2693
+ const id = ++counter
2694
+ ws.filterSessionId = undefined
2695
+ const requestedChannel = req.headers['x-dsh-channel']
2696
+ ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2697
+ ws.deviceId = device && device.id
2698
+ clients.add(ws)
2699
+ if (device) registry.connected(device.id)
2700
+ if (!connectedSinceEnabled) {
2701
+ connectedSinceEnabled = true
2702
+ waitExpiresAt = null
2703
+ if (waitTimer) clearTimeout(waitTimer)
2704
+ waitTimer = null
2705
+ log('mobile gateway wait completed: device connected')
2706
+ }
2707
+
2708
+ const sendFrame = frame => {
2709
+ if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2710
+ }
2711
+ ws.sessionFollower = createSessionFollower(api, {
2712
+ onFrame(frame, context) {
2713
+ if (frame.type === 'snapshot') {
2714
+ sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
2715
+ kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
2716
+ } else if (frame.type === 'event') {
2717
+ sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
2718
+ } else {
2719
+ sendFrame({ kind: 'assistant-stream', ...context, frame: frame.frame })
2720
+ }
2721
+ },
2722
+ onError(error, context) {
2723
+ sendFrame({ kind: 'session-stream-reset', ...context,
2724
+ code: error.code || 'stream-interrupted', message: error.message })
2725
+ },
2726
+ })
2727
+
2728
+ ws.on('message', (data) => {
2729
+ let msg
2730
+ try {
2731
+ msg = JSON.parse(data.toString())
2732
+ } catch (error) {
2733
+ ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
2734
+ return
2735
+ }
2736
+ if (!msg || typeof msg.type !== 'string') return
2737
+ const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2738
+ if ((ws.mobileChannel === 'control' && conversationRequest) ||
2739
+ (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2740
+ ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2741
+ return
2742
+ }
2743
+
2744
+ if (msg.type === 'ping') {
2745
+ ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
2746
+ } else if (msg.type === 'subscribe') {
2747
+ if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
2748
+ sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
2749
+ return
2750
+ }
2751
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : undefined
2752
+ if (msg.assistantStream === true && !sessionId) {
2753
+ sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream requires a sessionId' })
2754
+ return
2755
+ }
2756
+ ws.sessionFollower.stop()
2757
+ ws.filterSessionId = sessionId
2758
+ ws.sessionFollowerActive = msg.assistantStream === true
2759
+ const subscriptionId = crypto.randomUUID()
2760
+ sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
2761
+ if (ws.sessionFollowerActive) {
2762
+ void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
2763
+ log(`session follower failed: ${error.message}`)
2764
+ if (ws.readyState === 1) ws.close(1011, 'session follower failed')
2765
+ })
2766
+ }
2767
+ replayPendingInteractions(ws, 'subscribe')
2768
+ } else if (msg.type === 'unsubscribe') {
2769
+ ws.sessionFollower.stop()
2770
+ ws.sessionFollowerActive = false
2771
+ ws.filterSessionId = undefined
2772
+ sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
2773
+ } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
2774
+ respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
2775
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2776
+ })
2777
+ } else if (msg.type === 'approval-response') {
2778
+ respondToApproval(msg).then((frame) => {
2779
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2780
+ })
2781
+ } else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
2782
+ msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
2783
+ fileTransfers.handle(ws, msg).then((frame) => {
2784
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2785
+ })
2786
+ } else if (msg.type === 'message') {
2787
+ admitMessage(api, msg).then((frame) => {
2788
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2789
+ })
2790
+ } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
2791
+ msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
2792
+ msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2793
+ msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
2794
+ msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2795
+ msg.type === 'agent-presets' || msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset' ||
2796
+ msg.type === 'defaults' || msg.type === 'set-default' ||
2797
+ msg.type === 'session-stats' || msg.type === 'default-model' ||
2798
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2799
+ msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2800
+ msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2801
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2802
+ handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2803
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2804
+ })
2805
+ } else {
2806
+ ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
2807
+ }
2808
+ })
2809
+
2810
+ ws.on('close', () => {
2811
+ ws.sessionFollower.stop()
2812
+ clients.delete(ws)
2813
+ if (ws.deviceId) registry.disconnected(ws.deviceId)
2814
+ releaseUnclaimedInteractions()
2815
+ fileTransfers.closeClient(ws).catch((error) => {
2816
+ log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
2817
+ })
2818
+ log(`client disconnected (id=${id}, remaining=${clients.size})`)
2819
+ })
2820
+
2821
+ log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
2822
+ if (paired) {
2823
+ ws.send(stringifyWireFrame({
2824
+ kind: 'paired',
2825
+ token: paired.token,
2826
+ device: paired.device,
2827
+ ...gatewayIdentity,
2828
+ }))
2829
+ }
2830
+ ws.send(stringifyWireFrame({
2831
+ kind: 'hello',
2832
+ ...gatewayIdentity,
2833
+ protocol: 3,
2834
+ dshVersion: DSH_VERSION,
2835
+ historyFormatVersion: SESSION_FORMAT_VERSION,
2836
+ capabilities: [
2837
+ 'split-channels',
2838
+ 'assistant-stream-v1',
2839
+ 'history-format-version',
2840
+ 'projection-baseline',
2841
+ 'images',
2842
+ 'session-create',
2843
+ 'session-agent-preset',
2844
+ 'commands',
2845
+ 'tasks',
2846
+ 'goals',
2847
+ 'session-cancel',
2848
+ 'queue-control',
2849
+ 'session-archive',
2850
+ 'session-rename',
2851
+ ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
2852
+ ],
2853
+ port: transport.port || webServer.port,
2854
+ clients: clients.size,
2855
+ authenticated: !!device,
2856
+ ...(device ? { device: { id: device.id, name: device.name } } : {}),
2857
+ }))
2858
+ if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
2859
+ ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
2860
+ }
2861
+ if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2862
+ ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2863
+ }
2864
+ if (ws.mobileChannel !== 'conversation') {
2865
+ sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
2866
+ }
2867
+ replayPendingInteractions(ws, 'connect')
2868
+ })
2869
+ }
2870
+
2871
+ const disposeUpgrade = webServer.registerUpgrade({
2872
+ path: wsPath,
2873
+ handler(req, socket, head) {
2874
+ handleMobileUpgrade(req, socket, head, { port: webServer.port })
2875
+ },
2876
+ })
2877
+ log(`upgrade route registered: ${wsPath}`)
2878
+
2879
+ // DSH intentionally keeps its own WebUI listener on loopback. When LAN
2880
+ // mode is enabled, expose a second, narrowly scoped listener that accepts
2881
+ // only the authenticated mobile WebSocket path. It does not serve the
2882
+ // WebUI or any /mgw management route.
2883
+ if (options.lanEnabled === true) {
2884
+ lanServer = http.createServer((req, res) => {
2885
+ sendJson(res, 404, { error: 'not-found' })
2886
+ })
2887
+ lanServer.on('upgrade', (req, socket, head) => {
2888
+ let pathname
2889
+ try {
2890
+ pathname = new URL(req.url || '/', 'http://lan.invalid').pathname
2891
+ } catch (error) {
2892
+ rejectUpgrade(socket, 400, 'invalid WebSocket path')
2893
+ return
2894
+ }
2895
+ if (pathname !== wsPath) {
2896
+ rejectUpgrade(socket, 404, 'not found')
2897
+ return
2898
+ }
2899
+ if (!req.socket || !req.socket.remoteAddress || !isPrivateNetworkHostname(req.socket.remoteAddress)) {
2900
+ rejectUpgrade(socket, 403, 'LAN listener accepts private-network clients only')
2901
+ return
2902
+ }
2903
+ handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
2904
+ })
2905
+ lanServer.on('error', (error) => {
2906
+ lanListening = false
2907
+ lanListenError = error && error.message ? error.message : String(error)
2908
+ log(`LAN listener failed: ${lanListenError}`)
2909
+ })
2910
+ lanServer.listen(options.lanPort, options.lanHost, () => {
2911
+ lanListening = true
2912
+ lanListenError = null
2913
+ const address = lanServer.address()
2914
+ if (address && typeof address === 'object') lanBoundPort = address.port
2915
+ const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
2916
+ log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
2917
+ })
2918
+ }
2919
+
2920
+ const disposeEvents = ctx.on('session/event', (session, event) => {
2921
+ api.observeSessionEvent(String(session.id), event)
2922
+ if (clients.size === 0) return
2923
+ const wire = buildWireEvent(session, event)
2924
+ if (!wire) return
2925
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
2926
+ broadcastSessionMetadataFrame({
2927
+ kind: 'session-title-changed',
2928
+ sessionId: String(session.id),
2929
+ title: event.data.title,
2930
+ seq: event.seq,
2931
+ time: event.time,
2932
+ ...(event.data.source ? { source: event.data.source } : {}),
2933
+ })
2934
+ }
2935
+ if (event.type === 'agent-preset/selected' || event.type === 'turn/start') {
2936
+ broadcastSessionMetadataFrame({
2937
+ kind: 'session-agent-preset-updated', sessionId: String(session.id), seq: event.seq, time: event.time,
2938
+ ...(event.type === 'turn/start' ? { locked: true } : { agentPreset: event.data.agentPreset }),
2939
+ })
2940
+ }
2941
+ const payload = stringifyWireFrame(wire)
2942
+ for (const client of clients) {
2943
+ if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
2944
+ if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
2945
+ if (client.readyState === 1) client.send(payload)
2946
+ }
2947
+ })
2948
+ log('session/event listener attached')
2949
+
2950
+ const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2951
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2952
+ if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
2953
+ const rpcId = crypto.randomUUID()
2954
+ return new Promise((resolve, reject) => {
2955
+ const requestSignal = request.signal
2956
+ const downstreamAbort = new AbortController()
2957
+ request.signal = requestSignal
2958
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
2959
+ : downstreamAbort.signal
2960
+ const onAbort = () => {
2961
+ const pending = pendingQuestions.get(rpcId)
2962
+ if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
2963
+ }
2964
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2965
+ const pending = {
2966
+ rpcId,
2967
+ sessionId,
2968
+ questions: request.questions,
2969
+ next,
2970
+ downstreamAbort,
2971
+ resolve,
2972
+ reject,
2973
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
2974
+ }
2975
+ pendingQuestions.set(rpcId, pending)
2976
+ broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2977
+ startDownstreamQuestion(pending)
2978
+ log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
2979
+ })
2980
+ }, { prepend: true })
2981
+
2982
+ const disposeApprovals = ctx.on('approval/request', (request, next) => {
2983
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2984
+ if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
2985
+ const rpcId = crypto.randomUUID()
2986
+ const approvalId = crypto.randomUUID()
2987
+ return new Promise((resolve, reject) => {
2988
+ const requestSignal = request.signal
2989
+ const downstreamAbort = new AbortController()
2990
+ request.signal = requestSignal
2991
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
2992
+ : downstreamAbort.signal
2993
+ const onAbort = () => {
2994
+ const pending = pendingApprovals.get(rpcId)
2995
+ if (pending) completeApproval(pending, 'cancelled')
2996
+ }
2997
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2998
+ const pending = {
2999
+ rpcId,
3000
+ sessionId,
3001
+ approvalId,
3002
+ toolName: request.toolName,
3003
+ ...(request.callId !== undefined ? { callId: request.callId } : {}),
3004
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
3005
+ next,
3006
+ downstreamAbort,
3007
+ resolve,
3008
+ reject,
3009
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
3010
+ }
3011
+ pendingApprovals.set(rpcId, pending)
3012
+ broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
3013
+ startDownstreamApproval(pending)
3014
+ log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
3015
+ })
3016
+ }, { prepend: true })
3017
+
3018
+ const waitForBackgroundRetry = () => new Promise((resolve) => {
3019
+ if (backgroundStreamAbort.signal.aborted) {
3020
+ resolve()
3021
+ return
3022
+ }
3023
+ const finish = () => {
3024
+ clearTimeout(timer)
3025
+ backgroundStreamAbort.signal.removeEventListener('abort', finish)
3026
+ resolve()
3027
+ }
3028
+ const timer = setTimeout(finish, 1_000)
3029
+ backgroundStreamAbort.signal.addEventListener('abort', finish, { once: true })
3030
+ })
3031
+
3032
+ const installArchivedSessionIds = (value) => {
3033
+ if (!Array.isArray(value)) throw new Error('workspace/follow returned invalid archivedSessionIds')
3034
+ const next = value.map((sessionId) => String(sessionId))
3035
+ if (archivedSessionIds !== null
3036
+ && archivedSessionIds.length === next.length
3037
+ && archivedSessionIds.every((sessionId, index) => sessionId === next[index])) return
3038
+ archivedSessionIds = next
3039
+ broadcastSessionMetadataFrame({ kind: 'session-archives', archivedSessionIds: next })
3040
+ log(`session archive state forwarded: count=${next.length}`)
3041
+ }
3042
+
3043
+ const normalizeQueueItems = (value, endpoint) => {
3044
+ if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
3045
+ return value.map((candidate) => {
3046
+ const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
3047
+ ? candidate.message
3048
+ : null
3049
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
3050
+ || typeof candidate.id !== 'string' || !candidate.id
3051
+ || !['queued', 'steering', 'context'].includes(candidate.placement)
3052
+ || !message || typeof message !== 'object' || Array.isArray(message)
3053
+ || typeof message.id !== 'string' || !message.id
3054
+ || !Array.isArray(message.content)
3055
+ || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
3056
+ throw new Error(`${endpoint} returned an invalid queue item`)
3057
+ }
3058
+ return {
3059
+ id: candidate.id,
3060
+ placement: candidate.placement,
3061
+ ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
3062
+ message: { id: message.id, content: message.content },
3063
+ }
3064
+ })
3065
+ }
3066
+
3067
+ const installSessionQueueBaseline = (value) => {
3068
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
3069
+ throw new Error('session/control baseline returned invalid queues')
3070
+ }
3071
+ const next = new Map()
3072
+ for (const [sessionId, items] of Object.entries(value)) {
3073
+ next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
3074
+ }
3075
+ sessionQueues = next
3076
+ broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
3077
+ log(`session queue baseline forwarded: sessions=${next.size}`)
3078
+ }
3079
+
3080
+ const installSessionQueue = (sessionIdValue, itemsValue) => {
3081
+ const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
3082
+ if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
3083
+ const items = normalizeQueueItems(itemsValue, 'session/control queue')
3084
+ if (sessionQueues === null) sessionQueues = new Map()
3085
+ sessionQueues.set(sessionId, items)
3086
+ broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
3087
+ log(`session queue forwarded: session=${sessionId} items=${items.length}`)
3088
+ }
3089
+
3090
+ const projectionFrame = (sessionId, key, value, seq) => ({
3091
+ kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
3092
+ [key]: value,
3093
+ })
3094
+
3095
+ const installProjectionBaseline = value => {
3096
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
3097
+ const next = {}
3098
+ for (const [sessionId, baseline] of Object.entries(value)) {
3099
+ if (!Number.isSafeInteger(baseline?.asOfSeq) || !baseline.values || typeof baseline.values !== 'object' || Array.isArray(baseline.values)) {
3100
+ throw new Error('invalid session projection baseline')
3101
+ }
3102
+ Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
3103
+ value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
3104
+ }
3105
+ const previous = sessionProjectionBaselines
3106
+ sessionProjectionBaselines = next
3107
+ broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
3108
+ for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
3109
+ const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
3110
+ for (const key of ['todos', 'goal']) {
3111
+ broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
3112
+ baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
3113
+ }
3114
+ }
3115
+ }
3116
+
3117
+ const workspaceTask = (async () => {
3118
+ while (!backgroundStreamAbort.signal.aborted) {
3119
+ try {
3120
+ const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
3121
+ for await (const frame of stream) {
3122
+ if (frame?.type === 'baseline') {
3123
+ installArchivedSessionIds(frame.value?.archivedSessionIds)
3124
+ } else if (frame?.type === 'archived') {
3125
+ installArchivedSessionIds(frame.archivedSessionIds)
3126
+ }
3127
+ }
3128
+ if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
3129
+ } catch (error) {
3130
+ if (!backgroundStreamAbort.signal.aborted) {
3131
+ log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
3132
+ }
3133
+ }
3134
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
3135
+ }
3136
+ })()
3137
+ void workspaceTask
3138
+
3139
+ const controlTask = (async () => {
3140
+ while (!backgroundStreamAbort.signal.aborted) {
3141
+ try {
3142
+ const stream = await api.openControlStream(backgroundStreamAbort.signal)
3143
+ for await (const frame of stream) {
3144
+ if (frame?.type === 'baseline') {
3145
+ installProjectionBaseline(frame.value?.projections)
3146
+ installSessionQueueBaseline(frame.value?.queues)
3147
+ } else if (frame?.type === 'queue') {
3148
+ installSessionQueue(frame.sessionId, frame.items)
3149
+ } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3150
+ const sessionId = String(frame.sessionId)
3151
+ const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
3152
+ Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
3153
+ value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
3154
+ broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
3155
+ log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
3156
+ }
3157
+ }
3158
+ if (!backgroundStreamAbort.signal.aborted) log('session control stream ended; retrying')
3159
+ } catch (error) {
3160
+ if (!backgroundStreamAbort.signal.aborted) {
3161
+ log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
3162
+ }
3163
+ }
3164
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
3165
+ }
3166
+ })()
3167
+ void controlTask
3168
+ log('Host waterfall interaction listeners, workspace stream, and session control stream attached')
3169
+
3170
+ ctx.effect(() => () => {
3171
+ if (waitTimer) clearTimeout(waitTimer)
3172
+ clearInterval(fileTransferExpiryTimer)
3173
+ backgroundStreamAbort.abort()
3174
+ for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
3175
+ for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
3176
+ fileTransfers.dispose().catch((error) => {
3177
+ log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
3178
+ })
3179
+ disposeUpgrade()
3180
+ disposeMgmt()
3181
+ disposeEvents()
3182
+ disposeQuestions()
3183
+ disposeApprovals()
3184
+ if (lanServer) lanServer.close()
3185
+ for (const client of clients) {
3186
+ client.sessionFollower?.stop()
3187
+ client.terminate()
3188
+ }
3189
+ clients.clear()
3190
+ wss.close()
3191
+ log('plugin stopped, all sockets closed')
3192
+ })
3193
+ },
3194
+ }
3195
+
3196
+ export { Config, admitMessage, handleQuery }
3197
+ export default plugin