dsh-plugin-mobile-gateway 0.7.1 → 0.7.3

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
@@ -45,6 +45,12 @@
45
45
  // { "type": "directory-create", "path", "name" } -> create one child directory
46
46
  // { "type": "workspace-create", "path" } -> create workspace over a dir
47
47
  // { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
48
+ // { "type": "session-cancel", "sessionId" } -> stop the active turn and retain queued work
49
+ // { "type": "queue-update", "sessionId", "itemId",
50
+ // "action": "edit"|"remove"|"steer", "text"? }
51
+ // -> edit/remove one pending item or move it into the current turn
52
+ // { "type": "session-archive", "sessionId" } -> hide a session from workspace surfaces
53
+ // { "type": "session-rename", "sessionId", "title" } -> persist a user-owned session title
48
54
  // { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
49
55
  // { "type": "providers" } -> configurable provider list (live/dormant)
50
56
  // { "type": "commands", "sessionId" } -> slash-command catalog for this session
@@ -68,7 +74,7 @@
68
74
  // { "type": "question-cancel", "rpcId", "sessionId" }
69
75
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
70
76
  // "outcome": "allowed-once"|"rejected" }
71
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"],
77
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
72
78
  // "authenticated", "device"?, "port", "clients" }
73
79
  // { "kind": "pong", "at" }
74
80
  // { "kind": "subscribed", "sessionId" }
@@ -88,6 +94,14 @@
88
94
  // "path", "name", "mediaType", "size", "chunkBytes" }
89
95
  // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
90
96
  // { "kind": "file-download-cancelled", "transferId" }
97
+ // { "kind": "session-cancelled", "sessionId", "accepted": true }
98
+ // { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
99
+ // { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
100
+ // { "kind": "session-queue", "sessionId", "items": [...] }
101
+ // { "kind": "session-archived", "sessionId", "archivedSessionIds" }
102
+ // { "kind": "session-renamed", "sessionId", "title", "seq" }
103
+ // { "kind": "session-archives", "archivedSessionIds" }
104
+ // { "kind": "session-title-changed", "sessionId", "title", "seq", "time", "source"? }
91
105
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
92
106
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
93
107
  // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
@@ -115,6 +129,7 @@ import Schema from '@deepseek-ai/schemastery'
115
129
  import { WebSocketServer } from 'ws'
116
130
  import devicesModule from './devices.js'
117
131
  import { createDshHostAdapter } from './dsh-host-adapter.mjs'
132
+ import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
118
133
  import QRCode from 'qrcode'
119
134
 
120
135
  const { createRegistry } = devicesModule
@@ -187,6 +202,10 @@ const Config = Schema.object({
187
202
  path: Schema.string().default(DEFAULT_WS_PATH),
188
203
  requireAuth: Schema.boolean().default(true),
189
204
  gatewayEnabled: Schema.boolean().default(false),
205
+ gatewayMode: Schema.union(GATEWAY_MODES),
206
+ gatewayStateFile: Schema.string().default(''),
207
+ gatewayName: Schema.string().default(''),
208
+ endpoints: Schema.array(Schema.string()).default([]),
190
209
  gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
191
210
  maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
192
211
  fileDownloadsEnabled: Schema.boolean().default(true),
@@ -284,6 +303,14 @@ function buildWireEvent(session, event) {
284
303
  event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
285
304
  })
286
305
  }
306
+ case 'session/title':
307
+ return Object.assign(base, {
308
+ event: {
309
+ type: 'session/title',
310
+ title: d.title,
311
+ ...(d.source ? { source: d.source } : {}),
312
+ },
313
+ })
287
314
  case 'tool/call':
288
315
  return Object.assign(base, {
289
316
  event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
@@ -1228,6 +1255,19 @@ async function selectCommandOption(api, host, command, sessionId, optionId) {
1228
1255
 
1229
1256
  // Dispatch one mobile query frame; returns the wire frame to send back.
1230
1257
  async function handleQuery(api, host, agentDefaultModel, msg) {
1258
+ if (msg.type === 'session-create') {
1259
+ if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
1260
+ return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
1261
+ }
1262
+ const payload = {}
1263
+ if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
1264
+ payload.workspaceId = msg.workspaceId.trim()
1265
+ } else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
1266
+ payload.cwd = msg.cwd.trim()
1267
+ }
1268
+ const frame = await proxyQuery(api, 'session-created', api.sessions.create.bind(api.sessions), payload)
1269
+ return { ...frame, ...(frame.kind === 'error' ? { requestType: 'session-create' } : {}), requestId: msg.requestId }
1270
+ }
1231
1271
  if (msg.type === 'workspaces') {
1232
1272
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1233
1273
  }
@@ -1331,6 +1371,96 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1331
1371
  }
1332
1372
  return frame
1333
1373
  }
1374
+ if (msg.type === 'session-cancel') {
1375
+ const sessionId = requireSessionId(msg)
1376
+ if (sessionId.error) return sessionId.error
1377
+ const frame = await proxyQuery(
1378
+ api,
1379
+ 'session-cancel',
1380
+ api.sessions.cancel.bind(api.sessions),
1381
+ { sessionId: sessionId.value },
1382
+ )
1383
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1384
+ return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
1385
+ }
1386
+ if (msg.type === 'queue-update') {
1387
+ const sessionId = requireSessionId(msg)
1388
+ if (sessionId.error) return sessionId.error
1389
+ const itemId = typeof msg.itemId === 'string' && msg.itemId.trim() ? msg.itemId.trim() : null
1390
+ const actionKind = typeof msg.action === 'string' ? msg.action.trim() : ''
1391
+ if (!itemId || !['edit', 'remove', 'steer'].includes(actionKind)) {
1392
+ return {
1393
+ kind: 'error',
1394
+ code: 'bad-request',
1395
+ message: 'queue-update requires itemId and action edit, remove, or steer',
1396
+ requestType: 'queue-update',
1397
+ sessionId: sessionId.value,
1398
+ }
1399
+ }
1400
+ let action
1401
+ if (actionKind === 'edit') {
1402
+ if (typeof msg.text !== 'string' || !msg.text.trim()) {
1403
+ return {
1404
+ kind: 'error',
1405
+ code: 'bad-request',
1406
+ message: 'queue-update edit requires non-empty text',
1407
+ requestType: 'queue-update',
1408
+ sessionId: sessionId.value,
1409
+ itemId,
1410
+ }
1411
+ }
1412
+ action = { kind: 'edit', content: [{ type: 'text', text: msg.text }] }
1413
+ } else {
1414
+ action = { kind: actionKind }
1415
+ }
1416
+ const frame = await proxyQuery(
1417
+ api,
1418
+ 'queue-update',
1419
+ api.sessions.updateQueue.bind(api.sessions),
1420
+ { sessionId: sessionId.value, itemId, action },
1421
+ )
1422
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value, itemId }
1423
+ return {
1424
+ ...frame,
1425
+ kind: 'queue-item-updated',
1426
+ sessionId: sessionId.value,
1427
+ itemId,
1428
+ action: actionKind,
1429
+ }
1430
+ }
1431
+ if (msg.type === 'session-archive') {
1432
+ const sessionId = requireSessionId(msg)
1433
+ if (sessionId.error) return sessionId.error
1434
+ const frame = await proxyQuery(
1435
+ api,
1436
+ 'session-archive',
1437
+ api.workspace.archiveSession.bind(api.workspace),
1438
+ { sessionId: sessionId.value },
1439
+ )
1440
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1441
+ return { ...frame, kind: 'session-archived', sessionId: sessionId.value }
1442
+ }
1443
+ if (msg.type === 'session-rename') {
1444
+ const sessionId = requireSessionId(msg)
1445
+ if (sessionId.error) return sessionId.error
1446
+ if (typeof msg.title !== 'string' || !msg.title.trim()) {
1447
+ return {
1448
+ kind: 'error',
1449
+ code: 'bad-request',
1450
+ message: 'session-rename requires a non-empty title',
1451
+ requestType: 'session-rename',
1452
+ sessionId: sessionId.value,
1453
+ }
1454
+ }
1455
+ const frame = await proxyQuery(
1456
+ api,
1457
+ 'session-rename',
1458
+ api.sessions.rename.bind(api.sessions),
1459
+ { sessionId: sessionId.value, title: msg.title },
1460
+ )
1461
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1462
+ return { ...frame, kind: 'session-renamed', sessionId: sessionId.value }
1463
+ }
1334
1464
  if (msg.type === 'workspace-create') {
1335
1465
  const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
1336
1466
  if (!wsPath) {
@@ -1720,11 +1850,13 @@ function normalizePublicUrl(value, req, wsPath) {
1720
1850
  const host = req.headers.host || `127.0.0.1`
1721
1851
  raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
1722
1852
  }
1723
- const url = new URL(raw)
1853
+ let url
1854
+ try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
1724
1855
  if (url.protocol === 'https:') url.protocol = 'wss:'
1725
1856
  if (url.protocol === 'http:') url.protocol = 'ws:'
1726
1857
  if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
1727
1858
  if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
1859
+ if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
1728
1860
  if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
1729
1861
  return url.toString()
1730
1862
  }
@@ -1757,8 +1889,12 @@ function readBody(req) {
1757
1889
  reject(error)
1758
1890
  return
1759
1891
  }
1760
- try { resolve(data === '' ? {} : JSON.parse(data)) } catch (cause) {
1761
- const error = new Error('request body must be valid JSON', { cause })
1892
+ try {
1893
+ const body = data === '' ? {} : JSON.parse(data)
1894
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
1895
+ resolve(body)
1896
+ } catch (cause) {
1897
+ const error = new Error('request body must be a JSON object', { cause })
1762
1898
  error.status = 400
1763
1899
  reject(error)
1764
1900
  }
@@ -1849,6 +1985,9 @@ const plugin = {
1849
1985
  path: DEFAULT_WS_PATH,
1850
1986
  requireAuth: true,
1851
1987
  gatewayEnabled: false,
1988
+ gatewayStateFile: '',
1989
+ gatewayName: '',
1990
+ endpoints: [],
1852
1991
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
1853
1992
  maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
1854
1993
  fileDownloadsEnabled: true,
@@ -1881,6 +2020,17 @@ const plugin = {
1881
2020
  let requireAuth = options.requireAuth !== false
1882
2021
  const adminLoopbackOnly = options.adminLoopbackOnly !== false
1883
2022
  const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
2023
+ if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
2024
+ if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
2025
+ const normalizeEndpoints = (values) => {
2026
+ if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
2027
+ throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
2028
+ }
2029
+ return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
2030
+ }
2031
+ const configuredEndpoints = normalizeEndpoints(options.endpoints)
2032
+ const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
2033
+ const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
1884
2034
  const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
1885
2035
  const wss = new WebSocketServer({
1886
2036
  noServer: true,
@@ -1900,9 +2050,12 @@ const plugin = {
1900
2050
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
1901
2051
  })
1902
2052
  }, Math.min(options.fileDownloadIdleMs, 30_000))
1903
- const controlStreamAbort = new AbortController()
2053
+ const backgroundStreamAbort = new AbortController()
2054
+ let archivedSessionIds = null
2055
+ let sessionQueues = null
1904
2056
  let counter = 0
1905
- let gatewayEnabled = options.gatewayEnabled === true
2057
+ let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
2058
+ let gatewayEnabled = gatewayMode !== 'disabled'
1906
2059
  let waitExpiresAt = null
1907
2060
  let waitTimer = null
1908
2061
  let connectedSinceEnabled = false
@@ -1926,25 +2079,44 @@ const plugin = {
1926
2079
  }
1927
2080
  }
1928
2081
 
1929
- const setGatewayEnabled = (enabled, reason) => {
2082
+ const advertisedEndpoints = (primary, extra = []) => {
2083
+ const publicUrl = configuredPublicUrl()
2084
+ return normalizeEndpoints([...new Set([
2085
+ ...(primary ? [primary] : []),
2086
+ ...extra,
2087
+ ...configuredEndpoints,
2088
+ ...(publicUrl ? [publicUrl] : []),
2089
+ ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2090
+ ])])
2091
+ }
2092
+
2093
+ const setGatewayMode = (mode, reason, persist = true) => {
2094
+ if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
2095
+ // Commit before changing the live service, so a failed save never reports success.
2096
+ if (persist) gatewayState.setMode(mode)
1930
2097
  if (waitTimer) clearTimeout(waitTimer)
1931
2098
  waitTimer = null
1932
- gatewayEnabled = enabled
2099
+ gatewayMode = mode
2100
+ gatewayEnabled = mode !== 'disabled'
1933
2101
  waitExpiresAt = null
1934
- connectedSinceEnabled = false
1935
- if (enabled) {
2102
+ connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
2103
+ if (mode === 'temporary' && !connectedSinceEnabled) {
1936
2104
  waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
1937
2105
  waitTimer = setTimeout(() => {
1938
2106
  waitTimer = null
1939
- if (!gatewayEnabled || connectedSinceEnabled || clients.size > 0) return
2107
+ if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
1940
2108
  gatewayEnabled = false
2109
+ gatewayMode = 'disabled'
1941
2110
  waitExpiresAt = null
2111
+ try { gatewayState.setMode('disabled') } catch (error) {
2112
+ log(`failed to persist automatic gateway disable: ${error.message}`)
2113
+ }
1942
2114
  log('mobile gateway automatically disabled: no device connected before timeout')
1943
2115
  }, options.gatewayWaitTimeoutMs)
1944
- } else {
2116
+ } else if (mode === 'disabled') {
1945
2117
  for (const client of clients) client.close(4004, 'mobile gateway disabled')
1946
2118
  }
1947
- log(`mobile gateway ${enabled ? 'enabled' : 'disabled'}${reason ? `: ${reason}` : ''}`)
2119
+ log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
1948
2120
  }
1949
2121
 
1950
2122
  const logAuthRejected = (req) => {
@@ -1984,12 +2156,25 @@ const plugin = {
1984
2156
  const broadcastInteractionFrame = (frame) => {
1985
2157
  const wire = JSON.stringify(frame)
1986
2158
  for (const client of clients) {
2159
+ if (client.mobileChannel === 'conversation') continue
1987
2160
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
1988
2161
  if (client.readyState === 1) client.send(wire)
1989
2162
  }
1990
2163
  }
1991
2164
 
2165
+ // Session-list metadata must reach every connected client even when it is
2166
+ // subscribed to one conversation. Subscriptions only scope the heavier
2167
+ // conversation and interaction streams.
2168
+ const broadcastSessionMetadataFrame = (frame) => {
2169
+ const wire = JSON.stringify(frame)
2170
+ for (const client of clients) {
2171
+ if (client.mobileChannel === 'conversation') continue
2172
+ if (client.readyState === 1) client.send(wire)
2173
+ }
2174
+ }
2175
+
1992
2176
  const replayPendingInteractions = (ws, trigger) => {
2177
+ if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
1993
2178
  let questionCount = 0
1994
2179
  let approvalCount = 0
1995
2180
  for (const pending of pendingQuestions.values()) {
@@ -2008,6 +2193,7 @@ const plugin = {
2008
2193
 
2009
2194
  const hasInteractionClient = (sessionId) => [...clients].some((client) => (
2010
2195
  client.readyState === 1
2196
+ && client.mobileChannel !== 'conversation'
2011
2197
  && (!client.filterSessionId || client.filterSessionId === sessionId)
2012
2198
  ))
2013
2199
 
@@ -2162,12 +2348,8 @@ const plugin = {
2162
2348
  }
2163
2349
  }
2164
2350
 
2165
- // Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
2166
- // standing-service decision made by the operator: do NOT arm the no-device
2167
- // auto-close. That safety net stays attached to the ephemeral panel toggle,
2168
- // where it belongs. gatewayEnabled is already true from options at this point,
2169
- // so nothing else is needed here beyond the log line.
2170
- if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
2351
+ // Saved user choice wins; a temporary startup gets a fresh first-connection window.
2352
+ setGatewayMode(gatewayMode, 'startup', false)
2171
2353
 
2172
2354
  log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2173
2355
 
@@ -2192,6 +2374,9 @@ const plugin = {
2192
2374
  version: PLUGIN_VERSION,
2193
2375
  requireAuth,
2194
2376
  gatewayEnabled,
2377
+ gatewayMode,
2378
+ ...gatewayIdentity,
2379
+ endpoints: advertisedEndpoints(),
2195
2380
  waitExpiresAt,
2196
2381
  connectedClients: clients.size,
2197
2382
  webPort: webServer.port,
@@ -2223,9 +2408,10 @@ const plugin = {
2223
2408
  sendJson(res, 200, result)
2224
2409
  } else if (req.method === 'POST' && p === '/mgw/gateway') {
2225
2410
  const body = await readBody(req)
2226
- if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
2227
- setGatewayEnabled(body.enabled, 'changed from management UI')
2228
- sendJson(res, 200, { gatewayEnabled, waitExpiresAt, connectedClients: clients.size })
2411
+ if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
2412
+ if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2413
+ setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2414
+ sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
2229
2415
  } else if (req.method === 'POST' && p === '/mgw/auth') {
2230
2416
  const body = await readBody(req)
2231
2417
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -2252,12 +2438,15 @@ const plugin = {
2252
2438
  const body = await readBody(req)
2253
2439
  const name = typeof body.name === 'string' ? body.name : undefined
2254
2440
  const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
2441
+ const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
2255
2442
  const pairing = registry.createPairing(name)
2256
2443
  const payload = {
2257
2444
  version: 2,
2258
2445
  publicUrl,
2259
2446
  pairingCode: pairing.code,
2260
2447
  expiresAt: pairing.expiresAt,
2448
+ ...gatewayIdentity,
2449
+ endpoints,
2261
2450
  }
2262
2451
  // QR/manual pairing has one canonical wire representation: the
2263
2452
  // UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
@@ -2337,6 +2526,8 @@ const plugin = {
2337
2526
  wss.handleUpgrade(req, socket, head, (ws) => {
2338
2527
  const id = ++counter
2339
2528
  ws.filterSessionId = undefined
2529
+ const requestedChannel = req.headers['x-dsh-channel']
2530
+ ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2340
2531
  ws.deviceId = device && device.id
2341
2532
  clients.add(ws)
2342
2533
  if (device) registry.connected(device.id)
@@ -2357,6 +2548,12 @@ const plugin = {
2357
2548
  return
2358
2549
  }
2359
2550
  if (!msg || typeof msg.type !== 'string') return
2551
+ const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2552
+ if ((ws.mobileChannel === 'control' && conversationRequest) ||
2553
+ (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2554
+ ws.send(JSON.stringify({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2555
+ return
2556
+ }
2360
2557
 
2361
2558
  if (msg.type === 'ping') {
2362
2559
  ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
@@ -2386,12 +2583,13 @@ const plugin = {
2386
2583
  })
2387
2584
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
2388
2585
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
2389
- msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2586
+ msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2390
2587
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
2391
2588
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2392
2589
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2393
2590
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2394
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2591
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2592
+ msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2395
2593
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2396
2594
  msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2397
2595
  handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
@@ -2418,17 +2616,37 @@ const plugin = {
2418
2616
  kind: 'paired',
2419
2617
  token: paired.token,
2420
2618
  device: paired.device,
2619
+ ...gatewayIdentity,
2421
2620
  }))
2422
2621
  }
2423
2622
  ws.send(JSON.stringify({
2424
2623
  kind: 'hello',
2624
+ ...gatewayIdentity,
2425
2625
  protocol: 3,
2426
- capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2626
+ capabilities: [
2627
+ 'split-channels',
2628
+ 'images',
2629
+ 'session-create',
2630
+ 'commands',
2631
+ 'tasks',
2632
+ 'goals',
2633
+ 'session-cancel',
2634
+ 'queue-control',
2635
+ 'session-archive',
2636
+ 'session-rename',
2637
+ ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
2638
+ ],
2427
2639
  port: transport.port || webServer.port,
2428
2640
  clients: clients.size,
2429
2641
  authenticated: !!device,
2430
2642
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
2431
2643
  }))
2644
+ if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
2645
+ ws.send(JSON.stringify({ kind: 'session-archives', archivedSessionIds }))
2646
+ }
2647
+ if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2648
+ ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2649
+ }
2432
2650
  replayPendingInteractions(ws, 'connect')
2433
2651
  })
2434
2652
  }
@@ -2486,8 +2704,19 @@ const plugin = {
2486
2704
  if (clients.size === 0) return
2487
2705
  const wire = buildWireEvent(session, event)
2488
2706
  if (!wire) return
2707
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
2708
+ broadcastSessionMetadataFrame({
2709
+ kind: 'session-title-changed',
2710
+ sessionId: String(session.id),
2711
+ title: event.data.title,
2712
+ seq: event.seq,
2713
+ time: event.time,
2714
+ ...(event.data.source ? { source: event.data.source } : {}),
2715
+ })
2716
+ }
2489
2717
  const payload = JSON.stringify(wire)
2490
2718
  for (const client of clients) {
2719
+ if (client.mobileChannel === 'control') continue
2491
2720
  if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
2492
2721
  if (client.readyState === 1) client.send(payload)
2493
2722
  }
@@ -2548,50 +2777,138 @@ const plugin = {
2548
2777
  })
2549
2778
  }, { prepend: true })
2550
2779
 
2551
- const projectionTask = (async () => {
2552
- while (!controlStreamAbort.signal.aborted) {
2780
+ const waitForBackgroundRetry = () => new Promise((resolve) => {
2781
+ if (backgroundStreamAbort.signal.aborted) {
2782
+ resolve()
2783
+ return
2784
+ }
2785
+ const finish = () => {
2786
+ clearTimeout(timer)
2787
+ backgroundStreamAbort.signal.removeEventListener('abort', finish)
2788
+ resolve()
2789
+ }
2790
+ const timer = setTimeout(finish, 1_000)
2791
+ backgroundStreamAbort.signal.addEventListener('abort', finish, { once: true })
2792
+ })
2793
+
2794
+ const installArchivedSessionIds = (value) => {
2795
+ if (!Array.isArray(value)) throw new Error('workspace/follow returned invalid archivedSessionIds')
2796
+ const next = value.map((sessionId) => String(sessionId))
2797
+ if (archivedSessionIds !== null
2798
+ && archivedSessionIds.length === next.length
2799
+ && archivedSessionIds.every((sessionId, index) => sessionId === next[index])) return
2800
+ archivedSessionIds = next
2801
+ broadcastSessionMetadataFrame({ kind: 'session-archives', archivedSessionIds: next })
2802
+ log(`session archive state forwarded: count=${next.length}`)
2803
+ }
2804
+
2805
+ const normalizeQueueItems = (value, endpoint) => {
2806
+ if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
2807
+ return value.map((candidate) => {
2808
+ const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
2809
+ ? candidate.message
2810
+ : null
2811
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
2812
+ || typeof candidate.id !== 'string' || !candidate.id
2813
+ || !['queued', 'steering', 'context'].includes(candidate.placement)
2814
+ || !message || typeof message !== 'object' || Array.isArray(message)
2815
+ || typeof message.id !== 'string' || !message.id
2816
+ || !Array.isArray(message.content)
2817
+ || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
2818
+ throw new Error(`${endpoint} returned an invalid queue item`)
2819
+ }
2820
+ return {
2821
+ id: candidate.id,
2822
+ placement: candidate.placement,
2823
+ ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
2824
+ message: { id: message.id, content: message.content },
2825
+ }
2826
+ })
2827
+ }
2828
+
2829
+ const installSessionQueueBaseline = (value) => {
2830
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2831
+ throw new Error('session/control baseline returned invalid queues')
2832
+ }
2833
+ const next = new Map()
2834
+ for (const [sessionId, items] of Object.entries(value)) {
2835
+ next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
2836
+ }
2837
+ sessionQueues = next
2838
+ broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
2839
+ log(`session queue baseline forwarded: sessions=${next.size}`)
2840
+ }
2841
+
2842
+ const installSessionQueue = (sessionIdValue, itemsValue) => {
2843
+ const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
2844
+ if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
2845
+ const items = normalizeQueueItems(itemsValue, 'session/control queue')
2846
+ if (sessionQueues === null) sessionQueues = new Map()
2847
+ sessionQueues.set(sessionId, items)
2848
+ broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
2849
+ log(`session queue forwarded: session=${sessionId} items=${items.length}`)
2850
+ }
2851
+
2852
+ const workspaceTask = (async () => {
2853
+ while (!backgroundStreamAbort.signal.aborted) {
2553
2854
  try {
2554
- const stream = await api.openControlStream(controlStreamAbort.signal)
2855
+ const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
2555
2856
  for await (const frame of stream) {
2556
- if (frame?.type !== 'projection' || (frame.key !== 'todos' && frame.key !== 'goal')) continue
2557
- const sessionId = String(frame.sessionId)
2558
- const kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2559
- const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2560
- broadcastInteractionFrame({
2561
- kind,
2562
- sessionId,
2563
- asOfSeq: frame.seq,
2564
- [valueKey]: frame.value,
2565
- })
2566
- log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2857
+ if (frame?.type === 'baseline') {
2858
+ installArchivedSessionIds(frame.value?.archivedSessionIds)
2859
+ } else if (frame?.type === 'archived') {
2860
+ installArchivedSessionIds(frame.archivedSessionIds)
2861
+ }
2567
2862
  }
2568
- if (!controlStreamAbort.signal.aborted) log('session control stream ended; retrying')
2863
+ if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
2569
2864
  } catch (error) {
2570
- if (!controlStreamAbort.signal.aborted) {
2571
- log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2865
+ if (!backgroundStreamAbort.signal.aborted) {
2866
+ log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2572
2867
  }
2573
2868
  }
2574
- if (controlStreamAbort.signal.aborted) break
2575
- await new Promise((resolve) => {
2576
- const onAbort = () => {
2577
- clearTimeout(timer)
2578
- resolve()
2869
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2870
+ }
2871
+ })()
2872
+ void workspaceTask
2873
+
2874
+ const controlTask = (async () => {
2875
+ while (!backgroundStreamAbort.signal.aborted) {
2876
+ try {
2877
+ const stream = await api.openControlStream(backgroundStreamAbort.signal)
2878
+ for await (const frame of stream) {
2879
+ if (frame?.type === 'baseline') {
2880
+ installSessionQueueBaseline(frame.value?.queues)
2881
+ } else if (frame?.type === 'queue') {
2882
+ installSessionQueue(frame.sessionId, frame.items)
2883
+ } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
2884
+ const sessionId = String(frame.sessionId)
2885
+ const kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2886
+ const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2887
+ broadcastInteractionFrame({
2888
+ kind,
2889
+ sessionId,
2890
+ asOfSeq: frame.seq,
2891
+ [valueKey]: frame.value,
2892
+ })
2893
+ log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2894
+ }
2579
2895
  }
2580
- const timer = setTimeout(() => {
2581
- controlStreamAbort.signal.removeEventListener('abort', onAbort)
2582
- resolve()
2583
- }, 1_000)
2584
- controlStreamAbort.signal.addEventListener('abort', onAbort, { once: true })
2585
- })
2896
+ if (!backgroundStreamAbort.signal.aborted) log('session control stream ended; retrying')
2897
+ } catch (error) {
2898
+ if (!backgroundStreamAbort.signal.aborted) {
2899
+ log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2900
+ }
2901
+ }
2902
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2586
2903
  }
2587
2904
  })()
2588
- void projectionTask
2589
- log('Host waterfall interaction listeners and session control stream attached')
2905
+ void controlTask
2906
+ log('Host waterfall interaction listeners, workspace stream, and session control stream attached')
2590
2907
 
2591
2908
  ctx.effect(() => () => {
2592
2909
  if (waitTimer) clearTimeout(waitTimer)
2593
2910
  clearInterval(fileTransferExpiryTimer)
2594
- controlStreamAbort.abort()
2911
+ backgroundStreamAbort.abort()
2595
2912
  for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
2596
2913
  for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
2597
2914
  fileTransfers.dispose().catch((error) => {