dsh-plugin-mobile-gateway 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -128,7 +128,9 @@ import crypto from 'node:crypto'
128
128
  import Schema from '@deepseek-ai/schemastery'
129
129
  import { WebSocketServer } from 'ws'
130
130
  import devicesModule from './devices.js'
131
- import { createDshHostAdapter } from './dsh-host-adapter.mjs'
131
+ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
132
+ import { createSessionFollower } from './session-follower.mjs'
133
+ import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
132
134
  import QRCode from 'qrcode'
133
135
 
134
136
  const { createRegistry } = devicesModule
@@ -201,6 +203,10 @@ const Config = Schema.object({
201
203
  path: Schema.string().default(DEFAULT_WS_PATH),
202
204
  requireAuth: Schema.boolean().default(true),
203
205
  gatewayEnabled: Schema.boolean().default(false),
206
+ gatewayMode: Schema.union(GATEWAY_MODES),
207
+ gatewayStateFile: Schema.string().default(''),
208
+ gatewayName: Schema.string().default(''),
209
+ endpoints: Schema.array(Schema.string()).default([]),
204
210
  gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
205
211
  maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
206
212
  fileDownloadsEnabled: Schema.boolean().default(true),
@@ -259,7 +265,10 @@ function imagesOf(blocks) {
259
265
  // Build the small, owned JSON wire record for one session event. Reads only
260
266
  // leaf fields of the live SessionEvent — never serializes live objects.
261
267
  function buildWireEvent(session, event) {
262
- const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time }
268
+ const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time,
269
+ ...(event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }),
270
+ ...(event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }),
271
+ }
263
272
  const d = event.data || {}
264
273
  switch (event.type) {
265
274
  case 'user/message': {
@@ -273,15 +282,6 @@ function buildWireEvent(session, event) {
273
282
  },
274
283
  })
275
284
  }
276
- case 'assistant/chunk': {
277
- const chunk = d.chunk || {}
278
- const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
279
- if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') ev.text = chunk.text
280
- if (chunk.type === 'tool-call-delta') ev.tool = { id: chunk.id, name: chunk.name, argumentsDelta: chunk.argumentsDelta }
281
- if (chunk.type === 'usage') ev.usage = chunk.usage
282
- if (chunk.type === 'finish') ev.finish = { kind: chunk.reason && chunk.reason.kind }
283
- return Object.assign(base, { event: ev })
284
- }
285
285
  case 'assistant/message': {
286
286
  const blocks = (d.message && d.message.content) || []
287
287
  let text = ''
@@ -295,9 +295,15 @@ function buildWireEvent(session, event) {
295
295
  else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
296
296
  }
297
297
  return Object.assign(base, {
298
- event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
298
+ event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
299
+ ...(d.interrupted === true ? { interrupted: true } : {}),
300
+ ...(d.usage === undefined ? {} : { usage: d.usage }),
301
+ ...(images.length ? { images } : {}),
302
+ },
299
303
  })
300
304
  }
305
+ case 'assistant/attempt':
306
+ return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
301
307
  case 'session/title':
302
308
  return Object.assign(base, {
303
309
  event: {
@@ -324,7 +330,7 @@ function buildWireEvent(session, event) {
324
330
  turn: d.turn,
325
331
  step: d.step,
326
332
  callId: d.message && d.message.source && d.message.source.callId,
327
- isError: !!d.error,
333
+ isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
328
334
  preview,
329
335
  },
330
336
  })
@@ -474,7 +480,7 @@ async function admitCommand(host, msg) {
474
480
  if (!descriptor) {
475
481
  return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
476
482
  }
477
- if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
483
+ if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
478
484
  return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
479
485
  }
480
486
  return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
@@ -1032,8 +1038,15 @@ function trimConversationEvent(event) {
1032
1038
  switch (event.type) {
1033
1039
  case 'assistant/chunk':
1034
1040
  case 'request/header':
1041
+ case 'request/context':
1042
+ case 'system/message':
1035
1043
  // token-level replay / system-prompt header: not rendered on the chat page
1036
1044
  return null
1045
+ case 'assistant/message':
1046
+ case 'assistant/attempt': {
1047
+ const { stream, ...data } = event.data || {}
1048
+ return { ...event, data }
1049
+ }
1037
1050
  case 'tool/result': {
1038
1051
  const d = event.data || {}
1039
1052
  const message = d.message
@@ -1083,12 +1096,46 @@ function capHistoryEvents(events, maxBytes, trim) {
1083
1096
  return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
1084
1097
  }
1085
1098
 
1099
+ function validateHistoryCursor(msg, sessionId, field) {
1100
+ if (msg[field] === undefined && msg.historyFormatVersion === undefined) return null
1101
+ if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
1102
+ return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
1103
+ historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
1104
+ message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
1105
+ }
1106
+ if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
1107
+ return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
1108
+ message: `${field} must be a non-negative safe integer` }
1109
+ }
1110
+ return null
1111
+ }
1112
+
1113
+ function historyPage(value, msg) {
1114
+ const rawEvents = value.events.map(entry => entry.event)
1115
+ const maxBytes = Number.isSafeInteger(msg.maxBytes) && msg.maxBytes > 0 ? msg.maxBytes : HISTORY_DEFAULT_MAX_BYTES
1116
+ const trim = msg.view === 'conversation'
1117
+ const capped = capHistoryEvents(rawEvents, maxBytes, trim)
1118
+ const hasMore = value.hasMore === true || capped.dropped > 0
1119
+ // An all-hidden page still needs a cursor so callers can reach older text.
1120
+ const oldest = capped.events[0]?.seq ?? rawEvents[0]?.seq
1121
+ return { ...value, kind: 'history', sessionId: msg.sessionId,
1122
+ events: capped.events, bytes: capped.bytes, hasMore,
1123
+ ...(trim ? { view: 'conversation' } : {}),
1124
+ ...(hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
1125
+ }
1126
+ }
1127
+
1086
1128
  function resolveCommandCatalogCopy(locale) {
1087
1129
  return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
1088
1130
  ? COMMAND_CATALOG_COPY.en
1089
1131
  : COMMAND_CATALOG_COPY.zh
1090
1132
  }
1091
1133
 
1134
+ // DSH 0.1.5-rc.2 command descriptors use input.attachments.
1135
+ function commandAcceptsAttachments(command) {
1136
+ return command?.input?.attachments === true
1137
+ }
1138
+
1092
1139
  function commandUiDescriptor(command, copy) {
1093
1140
  const override = COMMAND_UI_OVERRIDES[command.name]
1094
1141
  if (override) return { ...override, insertText: `/${command.name}` }
@@ -1099,7 +1146,7 @@ function commandUiDescriptor(command, copy) {
1099
1146
  insertText: `/${command.name} `,
1100
1147
  hint: command.input.hint,
1101
1148
  ...(displayHint ? { displayHint } : {}),
1102
- images: command.input.images === true,
1149
+ images: commandAcceptsAttachments(command),
1103
1150
  submitRequest: 'command-execute',
1104
1151
  }
1105
1152
  }
@@ -1274,33 +1321,13 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1274
1321
  if (!sessionId) {
1275
1322
  return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
1276
1323
  }
1324
+ const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
1325
+ if (cursorError) return cursorError
1277
1326
  const payload = { sessionId }
1278
1327
  if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
1279
1328
  if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
1280
1329
  const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
1281
- if (frame.kind === 'history') {
1282
- // Scheme A base: pass the raw SessionEvent list through (drop the host
1283
- // render intent); keep the projections block and echo the sessionId.
1284
- const rawEvents = Array.isArray(frame.events) ? frame.events.map((entry) => entry.event) : []
1285
- // Byte budget + optional conversation trim — keeps the frame under the
1286
- // client's WebSocket limit and pages the rest via nextBeforeSeq.
1287
- const maxBytes = typeof msg.maxBytes === 'number' && Number.isFinite(msg.maxBytes) && msg.maxBytes > 0
1288
- ? Math.floor(msg.maxBytes)
1289
- : HISTORY_DEFAULT_MAX_BYTES
1290
- const trim = msg.view === 'conversation'
1291
- const capped = capHistoryEvents(rawEvents, maxBytes, trim)
1292
- frame.events = capped.events
1293
- frame.sessionId = sessionId
1294
- frame.bytes = capped.bytes
1295
- if (trim) frame.view = 'conversation'
1296
- // hasMore combines the host's message-count pagination with byte-drop.
1297
- const apiHasMore = frame.hasMore === true
1298
- const byteDropped = capped.dropped > 0
1299
- frame.hasMore = apiHasMore || byteDropped > 0
1300
- if (frame.hasMore && capped.events.length > 0) {
1301
- frame.nextBeforeSeq = capped.events[0].seq // oldest kept event: page back from here
1302
- }
1303
- }
1330
+ if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
1304
1331
  return frame
1305
1332
  }
1306
1333
  if (msg.type === 'attachment') {
@@ -1358,6 +1385,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1358
1385
  if (msg.type === 'fork') {
1359
1386
  const sessionId = requireSessionId(msg)
1360
1387
  if (sessionId.error) return sessionId.error
1388
+ const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
1389
+ if (cursorError) return cursorError
1361
1390
  const payload = { sessionId: sessionId.value }
1362
1391
  if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
1363
1392
  const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
@@ -1510,7 +1539,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1510
1539
  action: 'execute',
1511
1540
  ui: commandUiDescriptor(command, copy),
1512
1541
  ...(command.input && typeof command.input.hint === 'string'
1513
- ? { input: { hint: command.input.hint, ...(command.input.images === true ? { images: true } : {}) } }
1542
+ ? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
1514
1543
  : {}),
1515
1544
  }))
1516
1545
  if (!commands.some((command) => command.name === 'model')) {
@@ -1845,11 +1874,13 @@ function normalizePublicUrl(value, req, wsPath) {
1845
1874
  const host = req.headers.host || `127.0.0.1`
1846
1875
  raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
1847
1876
  }
1848
- const url = new URL(raw)
1877
+ let url
1878
+ try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
1849
1879
  if (url.protocol === 'https:') url.protocol = 'wss:'
1850
1880
  if (url.protocol === 'http:') url.protocol = 'ws:'
1851
1881
  if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
1852
1882
  if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
1883
+ if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
1853
1884
  if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
1854
1885
  return url.toString()
1855
1886
  }
@@ -1882,8 +1913,12 @@ function readBody(req) {
1882
1913
  reject(error)
1883
1914
  return
1884
1915
  }
1885
- try { resolve(data === '' ? {} : JSON.parse(data)) } catch (cause) {
1886
- const error = new Error('request body must be valid JSON', { cause })
1916
+ try {
1917
+ const body = data === '' ? {} : JSON.parse(data)
1918
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
1919
+ resolve(body)
1920
+ } catch (cause) {
1921
+ const error = new Error('request body must be a JSON object', { cause })
1887
1922
  error.status = 400
1888
1923
  reject(error)
1889
1924
  }
@@ -1974,6 +2009,9 @@ const plugin = {
1974
2009
  path: DEFAULT_WS_PATH,
1975
2010
  requireAuth: true,
1976
2011
  gatewayEnabled: false,
2012
+ gatewayStateFile: '',
2013
+ gatewayName: '',
2014
+ endpoints: [],
1977
2015
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
1978
2016
  maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
1979
2017
  fileDownloadsEnabled: true,
@@ -2006,6 +2044,17 @@ const plugin = {
2006
2044
  let requireAuth = options.requireAuth !== false
2007
2045
  const adminLoopbackOnly = options.adminLoopbackOnly !== false
2008
2046
  const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
2047
+ if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
2048
+ if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
2049
+ const normalizeEndpoints = (values) => {
2050
+ if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
2051
+ throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
2052
+ }
2053
+ return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
2054
+ }
2055
+ const configuredEndpoints = normalizeEndpoints(options.endpoints)
2056
+ const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
2057
+ const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
2009
2058
  const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
2010
2059
  const wss = new WebSocketServer({
2011
2060
  noServer: true,
@@ -2028,8 +2077,10 @@ const plugin = {
2028
2077
  const backgroundStreamAbort = new AbortController()
2029
2078
  let archivedSessionIds = null
2030
2079
  let sessionQueues = null
2080
+ let sessionProjectionBaselines = {}
2031
2081
  let counter = 0
2032
- let gatewayEnabled = options.gatewayEnabled === true
2082
+ let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
2083
+ let gatewayEnabled = gatewayMode !== 'disabled'
2033
2084
  let waitExpiresAt = null
2034
2085
  let waitTimer = null
2035
2086
  let connectedSinceEnabled = false
@@ -2053,25 +2104,44 @@ const plugin = {
2053
2104
  }
2054
2105
  }
2055
2106
 
2056
- const setGatewayEnabled = (enabled, reason) => {
2107
+ const advertisedEndpoints = (primary, extra = []) => {
2108
+ const publicUrl = configuredPublicUrl()
2109
+ return normalizeEndpoints([...new Set([
2110
+ ...(primary ? [primary] : []),
2111
+ ...extra,
2112
+ ...configuredEndpoints,
2113
+ ...(publicUrl ? [publicUrl] : []),
2114
+ ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2115
+ ])])
2116
+ }
2117
+
2118
+ const setGatewayMode = (mode, reason, persist = true) => {
2119
+ if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
2120
+ // Commit before changing the live service, so a failed save never reports success.
2121
+ if (persist) gatewayState.setMode(mode)
2057
2122
  if (waitTimer) clearTimeout(waitTimer)
2058
2123
  waitTimer = null
2059
- gatewayEnabled = enabled
2124
+ gatewayMode = mode
2125
+ gatewayEnabled = mode !== 'disabled'
2060
2126
  waitExpiresAt = null
2061
- connectedSinceEnabled = false
2062
- if (enabled) {
2127
+ connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
2128
+ if (mode === 'temporary' && !connectedSinceEnabled) {
2063
2129
  waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
2064
2130
  waitTimer = setTimeout(() => {
2065
2131
  waitTimer = null
2066
- if (!gatewayEnabled || connectedSinceEnabled || clients.size > 0) return
2132
+ if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
2067
2133
  gatewayEnabled = false
2134
+ gatewayMode = 'disabled'
2068
2135
  waitExpiresAt = null
2136
+ try { gatewayState.setMode('disabled') } catch (error) {
2137
+ log(`failed to persist automatic gateway disable: ${error.message}`)
2138
+ }
2069
2139
  log('mobile gateway automatically disabled: no device connected before timeout')
2070
2140
  }, options.gatewayWaitTimeoutMs)
2071
- } else {
2141
+ } else if (mode === 'disabled') {
2072
2142
  for (const client of clients) client.close(4004, 'mobile gateway disabled')
2073
2143
  }
2074
- log(`mobile gateway ${enabled ? 'enabled' : 'disabled'}${reason ? `: ${reason}` : ''}`)
2144
+ log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
2075
2145
  }
2076
2146
 
2077
2147
  const logAuthRejected = (req) => {
@@ -2303,12 +2373,8 @@ const plugin = {
2303
2373
  }
2304
2374
  }
2305
2375
 
2306
- // Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
2307
- // standing-service decision made by the operator: do NOT arm the no-device
2308
- // auto-close. That safety net stays attached to the ephemeral panel toggle,
2309
- // where it belongs. gatewayEnabled is already true from options at this point,
2310
- // so nothing else is needed here beyond the log line.
2311
- if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
2376
+ // Saved user choice wins; a temporary startup gets a fresh first-connection window.
2377
+ setGatewayMode(gatewayMode, 'startup', false)
2312
2378
 
2313
2379
  log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2314
2380
 
@@ -2333,6 +2399,9 @@ const plugin = {
2333
2399
  version: PLUGIN_VERSION,
2334
2400
  requireAuth,
2335
2401
  gatewayEnabled,
2402
+ gatewayMode,
2403
+ ...gatewayIdentity,
2404
+ endpoints: advertisedEndpoints(),
2336
2405
  waitExpiresAt,
2337
2406
  connectedClients: clients.size,
2338
2407
  webPort: webServer.port,
@@ -2364,9 +2433,10 @@ const plugin = {
2364
2433
  sendJson(res, 200, result)
2365
2434
  } else if (req.method === 'POST' && p === '/mgw/gateway') {
2366
2435
  const body = await readBody(req)
2367
- if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
2368
- setGatewayEnabled(body.enabled, 'changed from management UI')
2369
- sendJson(res, 200, { gatewayEnabled, waitExpiresAt, connectedClients: clients.size })
2436
+ if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
2437
+ if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2438
+ setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2439
+ sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
2370
2440
  } else if (req.method === 'POST' && p === '/mgw/auth') {
2371
2441
  const body = await readBody(req)
2372
2442
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -2393,12 +2463,15 @@ const plugin = {
2393
2463
  const body = await readBody(req)
2394
2464
  const name = typeof body.name === 'string' ? body.name : undefined
2395
2465
  const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
2466
+ const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
2396
2467
  const pairing = registry.createPairing(name)
2397
2468
  const payload = {
2398
2469
  version: 2,
2399
2470
  publicUrl,
2400
2471
  pairingCode: pairing.code,
2401
2472
  expiresAt: pairing.expiresAt,
2473
+ ...gatewayIdentity,
2474
+ endpoints,
2402
2475
  }
2403
2476
  // QR/manual pairing has one canonical wire representation: the
2404
2477
  // UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
@@ -2491,6 +2564,26 @@ const plugin = {
2491
2564
  log('mobile gateway wait completed: device connected')
2492
2565
  }
2493
2566
 
2567
+ const sendFrame = frame => {
2568
+ if (ws.readyState === 1) ws.send(JSON.stringify(frame))
2569
+ }
2570
+ ws.sessionFollower = createSessionFollower(api, {
2571
+ onFrame(frame, context) {
2572
+ if (frame.type === 'snapshot') {
2573
+ sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation' }),
2574
+ kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
2575
+ } else if (frame.type === 'event') {
2576
+ sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
2577
+ } else {
2578
+ sendFrame({ kind: 'assistant-stream', ...context, frame: frame.frame })
2579
+ }
2580
+ },
2581
+ onError(error, context) {
2582
+ sendFrame({ kind: 'session-stream-reset', ...context,
2583
+ code: error.code || 'stream-interrupted', message: error.message })
2584
+ },
2585
+ })
2586
+
2494
2587
  ws.on('message', (data) => {
2495
2588
  let msg
2496
2589
  try {
@@ -2510,12 +2603,32 @@ const plugin = {
2510
2603
  if (msg.type === 'ping') {
2511
2604
  ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
2512
2605
  } else if (msg.type === 'subscribe') {
2513
- ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
2514
- ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
2606
+ if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
2607
+ sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
2608
+ return
2609
+ }
2610
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : undefined
2611
+ if (msg.assistantStream === true && !sessionId) {
2612
+ sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream requires a sessionId' })
2613
+ return
2614
+ }
2615
+ ws.sessionFollower.stop()
2616
+ ws.filterSessionId = sessionId
2617
+ ws.sessionFollowerActive = msg.assistantStream === true
2618
+ const subscriptionId = crypto.randomUUID()
2619
+ sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
2620
+ if (ws.sessionFollowerActive) {
2621
+ void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
2622
+ log(`session follower failed: ${error.message}`)
2623
+ if (ws.readyState === 1) ws.close(1011, 'session follower failed')
2624
+ })
2625
+ }
2515
2626
  replayPendingInteractions(ws, 'subscribe')
2516
2627
  } else if (msg.type === 'unsubscribe') {
2628
+ ws.sessionFollower.stop()
2629
+ ws.sessionFollowerActive = false
2517
2630
  ws.filterSessionId = undefined
2518
- ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
2631
+ sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
2519
2632
  } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
2520
2633
  respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
2521
2634
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
@@ -2553,6 +2666,7 @@ const plugin = {
2553
2666
  })
2554
2667
 
2555
2668
  ws.on('close', () => {
2669
+ ws.sessionFollower.stop()
2556
2670
  clients.delete(ws)
2557
2671
  if (ws.deviceId) registry.disconnected(ws.deviceId)
2558
2672
  releaseUnclaimedInteractions()
@@ -2568,13 +2682,20 @@ const plugin = {
2568
2682
  kind: 'paired',
2569
2683
  token: paired.token,
2570
2684
  device: paired.device,
2685
+ ...gatewayIdentity,
2571
2686
  }))
2572
2687
  }
2573
2688
  ws.send(JSON.stringify({
2574
2689
  kind: 'hello',
2690
+ ...gatewayIdentity,
2575
2691
  protocol: 3,
2692
+ dshVersion: DSH_VERSION,
2693
+ historyFormatVersion: SESSION_FORMAT_VERSION,
2576
2694
  capabilities: [
2577
2695
  'split-channels',
2696
+ 'assistant-stream-v1',
2697
+ 'history-format-version',
2698
+ 'projection-baseline',
2578
2699
  'images',
2579
2700
  'session-create',
2580
2701
  'commands',
@@ -2597,6 +2718,9 @@ const plugin = {
2597
2718
  if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2598
2719
  ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2599
2720
  }
2721
+ if (ws.mobileChannel !== 'conversation') {
2722
+ sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
2723
+ }
2600
2724
  replayPendingInteractions(ws, 'connect')
2601
2725
  })
2602
2726
  }
@@ -2666,7 +2790,7 @@ const plugin = {
2666
2790
  }
2667
2791
  const payload = JSON.stringify(wire)
2668
2792
  for (const client of clients) {
2669
- if (client.mobileChannel === 'control') continue
2793
+ if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
2670
2794
  if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
2671
2795
  if (client.readyState === 1) client.send(payload)
2672
2796
  }
@@ -2799,6 +2923,33 @@ const plugin = {
2799
2923
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
2800
2924
  }
2801
2925
 
2926
+ const projectionFrame = (sessionId, key, value, seq) => ({
2927
+ kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
2928
+ [key]: value,
2929
+ })
2930
+
2931
+ const installProjectionBaseline = value => {
2932
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
2933
+ const next = {}
2934
+ for (const [sessionId, baseline] of Object.entries(value)) {
2935
+ if (!Number.isSafeInteger(baseline?.asOfSeq) || !baseline.values || typeof baseline.values !== 'object' || Array.isArray(baseline.values)) {
2936
+ throw new Error('invalid session projection baseline')
2937
+ }
2938
+ Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
2939
+ value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
2940
+ }
2941
+ const previous = sessionProjectionBaselines
2942
+ sessionProjectionBaselines = next
2943
+ broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
2944
+ for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
2945
+ const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
2946
+ for (const key of ['todos', 'goal']) {
2947
+ broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
2948
+ baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
2949
+ }
2950
+ }
2951
+ }
2952
+
2802
2953
  const workspaceTask = (async () => {
2803
2954
  while (!backgroundStreamAbort.signal.aborted) {
2804
2955
  try {
@@ -2827,19 +2978,16 @@ const plugin = {
2827
2978
  const stream = await api.openControlStream(backgroundStreamAbort.signal)
2828
2979
  for await (const frame of stream) {
2829
2980
  if (frame?.type === 'baseline') {
2981
+ installProjectionBaseline(frame.value?.projections)
2830
2982
  installSessionQueueBaseline(frame.value?.queues)
2831
2983
  } else if (frame?.type === 'queue') {
2832
2984
  installSessionQueue(frame.sessionId, frame.items)
2833
2985
  } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
2834
2986
  const sessionId = String(frame.sessionId)
2835
- const kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2836
- const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2837
- broadcastInteractionFrame({
2838
- kind,
2839
- sessionId,
2840
- asOfSeq: frame.seq,
2841
- [valueKey]: frame.value,
2842
- })
2987
+ const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
2988
+ Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
2989
+ value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
2990
+ broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
2843
2991
  log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2844
2992
  }
2845
2993
  }
@@ -2870,7 +3018,10 @@ const plugin = {
2870
3018
  disposeQuestions()
2871
3019
  disposeApprovals()
2872
3020
  if (lanServer) lanServer.close()
2873
- for (const client of clients) client.terminate()
3021
+ for (const client of clients) {
3022
+ client.sessionFollower?.stop()
3023
+ client.terminate()
3024
+ }
2874
3025
  clients.clear()
2875
3026
  wss.close()
2876
3027
  log('plugin stopped, all sockets closed')