dsh-plugin-mobile-gateway 0.7.0 → 0.7.2

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
@@ -6,7 +6,7 @@
6
6
  // feed the browser UI consumes) to connected mobile clients as curated
7
7
  // JSON text frames.
8
8
  // 2. Mobile -> agent: clients send a message through the same socket; the
9
- // plugin admits it through the official host API (apiProxy.sessions.prompt)
9
+ // plugin admits it through the DSH Remote Gateway Host API.
10
10
  // so it enters the target session exactly like a browser-submitted prompt.
11
11
  //
12
12
  // Wire protocol (JSON text frames):
@@ -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" }
@@ -114,6 +128,7 @@ import crypto from 'node:crypto'
114
128
  import Schema from '@deepseek-ai/schemastery'
115
129
  import { WebSocketServer } from 'ws'
116
130
  import devicesModule from './devices.js'
131
+ import { createDshHostAdapter } from './dsh-host-adapter.mjs'
117
132
  import QRCode from 'qrcode'
118
133
 
119
134
  const { createRegistry } = devicesModule
@@ -283,6 +298,14 @@ function buildWireEvent(session, event) {
283
298
  event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
284
299
  })
285
300
  }
301
+ case 'session/title':
302
+ return Object.assign(base, {
303
+ event: {
304
+ type: 'session/title',
305
+ title: d.title,
306
+ ...(d.source ? { source: d.source } : {}),
307
+ },
308
+ })
286
309
  case 'tool/call':
287
310
  return Object.assign(base, {
288
311
  event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
@@ -407,25 +430,15 @@ function commandNameOf(line) {
407
430
  return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
408
431
  }
409
432
 
410
- async function listHostCommands(typertGateway, sessionId) {
411
- const listed = await typertGateway.invoke({
412
- namespace: 'commands',
413
- method: 'list',
414
- args: { agentId: sessionId },
415
- signal: new AbortController().signal,
416
- })
433
+ async function listHostCommands(host, sessionId) {
434
+ const listed = await host.commands.list(sessionId, new AbortController().signal)
417
435
  if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
418
436
  return listed
419
437
  }
420
438
 
421
- async function executeHostCommand(typertGateway, sessionId, line, images, requestType) {
439
+ async function executeHostCommand(host, sessionId, line, images, requestType) {
422
440
  try {
423
- const execution = await typertGateway.invoke({
424
- namespace: 'commands',
425
- method: 'execute',
426
- args: { agentId: sessionId, line, images },
427
- signal: new AbortController().signal,
428
- })
441
+ const execution = await host.commands.execute(sessionId, line, images, new AbortController().signal)
429
442
  if (execution === undefined || execution === null) {
430
443
  return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
431
444
  }
@@ -445,7 +458,7 @@ async function executeHostCommand(typertGateway, sessionId, line, images, reques
445
458
  }
446
459
  }
447
460
 
448
- async function admitCommand(typertGateway, msg) {
461
+ async function admitCommand(host, msg) {
449
462
  const sessionId = requireSessionId(msg)
450
463
  if (sessionId.error) return sessionId.error
451
464
  const line = typeof msg.line === 'string' ? msg.line.trim() : ''
@@ -456,7 +469,7 @@ async function admitCommand(typertGateway, msg) {
456
469
  const parsedImages = parseWireImages(msg.images, 'command-execute')
457
470
  if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
458
471
  try {
459
- const listed = await listHostCommands(typertGateway, sessionId.value)
472
+ const listed = await listHostCommands(host, sessionId.value)
460
473
  const descriptor = listed.find((command) => command && command.name === name)
461
474
  if (!descriptor) {
462
475
  return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
@@ -464,7 +477,7 @@ async function admitCommand(typertGateway, msg) {
464
477
  if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
465
478
  return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
466
479
  }
467
- return executeHostCommand(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
480
+ return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
468
481
  } catch (error) {
469
482
  const code = error && error.code ? error.code : 'internal'
470
483
  const message = error && error.message ? error.message : String(error)
@@ -497,44 +510,30 @@ async function admitMessage(api, msg) {
497
510
  if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
498
511
  else createPayload.cwd = msg.cwd.trim()
499
512
  }
500
- const created = await api.sessions.create({ rpcId: crypto.randomUUID(), payload: createPayload })
501
- if (!created.result.ok) {
502
- return { kind: 'error', code: created.result.error.code, message: created.result.error.message }
503
- }
504
- sessionId = created.result.value.sessionId
513
+ const created = await api.sessions.create(createPayload)
514
+ sessionId = created.sessionId
505
515
  log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
506
516
  }
507
517
 
508
518
  const resp = await api.sessions.prompt({
509
- rpcId: crypto.randomUUID(),
510
- payload: {
511
- sessionId,
512
- mode,
513
- // Match the official WebUI ordering: images first, optional text last.
514
- content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
515
- ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
516
- },
519
+ sessionId,
520
+ mode,
521
+ // Match the official WebUI ordering: images first, optional text last.
522
+ content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
523
+ ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
517
524
  })
518
- if (resp.result.ok) {
519
- log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
520
- return {
521
- kind: 'sent',
522
- sessionId,
523
- mode,
524
- ...(resp.result.value.command ? { command: resp.result.value.command } : {}),
525
- }
526
- }
527
- log(`mobile message rejected: ${resp.result.error.code}: ${resp.result.error.message}`)
525
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
528
526
  return {
529
- kind: 'error',
530
- code: resp.result.error.code,
531
- message: resp.result.error.message,
532
- ...(sessionId ? { sessionId } : {}),
527
+ kind: 'sent',
528
+ sessionId,
529
+ mode,
530
+ ...(resp.command ? { command: resp.command } : {}),
533
531
  }
534
532
  } catch (error) {
533
+ const code = error && error.code ? error.code : 'internal'
535
534
  const message = error && error.message ? error.message : String(error)
536
535
  log(`mobile message failed: ${message}`)
537
- return { kind: 'error', code: 'internal', message, ...(sessionId ? { sessionId } : {}) }
536
+ return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
538
537
  }
539
538
  }
540
539
 
@@ -543,23 +542,17 @@ async function admitMessage(api, msg) {
543
542
  // the uniform `{ kind: 'error', code, message, requestType }` frame.
544
543
  async function proxyQuery(api, type, method, payload, signal) {
545
544
  try {
546
- const req = { rpcId: crypto.randomUUID(), payload }
547
- const resp = signal ? await method(req, signal) : await method(req)
548
- if (resp.result.ok) {
549
- log(`query ok: ${type}`)
550
- return { kind: type, ...resp.result.value }
551
- }
552
- log(`query rejected: ${type} -> ${resp.result.error.code}: ${resp.result.error.message}`)
553
- return {
554
- kind: 'error',
555
- code: resp.result.error.code,
556
- message: resp.result.error.message,
557
- requestType: type,
545
+ const value = signal ? await method(payload, signal) : await method(payload)
546
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
547
+ throw new Error(`${type} returned an invalid response`)
558
548
  }
549
+ log(`query ok: ${type}`)
550
+ return { kind: type, ...value }
559
551
  } catch (error) {
552
+ const code = error && error.code ? error.code : 'internal'
560
553
  const message = error && error.message ? error.message : String(error)
561
- log(`query failed: ${type} -> ${message}`)
562
- return { kind: 'error', code: 'internal', message, requestType: type }
554
+ log(`query failed: ${type} -> ${code}: ${message}`)
555
+ return { kind: 'error', code, message, requestType: type }
563
556
  }
564
557
  }
565
558
 
@@ -1190,7 +1183,7 @@ async function loadCommandOptions(api, command, sessionId) {
1190
1183
  }
1191
1184
  }
1192
1185
 
1193
- async function selectCommandOption(api, typertGateway, command, sessionId, optionId) {
1186
+ async function selectCommandOption(api, host, command, sessionId, optionId) {
1194
1187
  const catalog = await loadCommandOptions(api, command, sessionId)
1195
1188
  if (catalog.kind !== 'command-options') return catalog
1196
1189
  const option = catalog.options.find((candidate) => candidate.id === optionId)
@@ -1206,12 +1199,12 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
1206
1199
 
1207
1200
  if (command === 'permission') {
1208
1201
  try {
1209
- const execution = await typertGateway.invoke({
1210
- namespace: 'commands',
1211
- method: 'execute',
1212
- args: { agentId: sessionId, line: '/permission ' + optionId, images: [] },
1213
- signal: new AbortController().signal,
1214
- })
1202
+ const execution = await host.commands.execute(
1203
+ sessionId,
1204
+ '/permission ' + optionId,
1205
+ [],
1206
+ new AbortController().signal,
1207
+ )
1215
1208
  if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
1216
1209
  if (execution.result.kind === 'error') {
1217
1210
  return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
@@ -1256,7 +1249,20 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
1256
1249
  }
1257
1250
 
1258
1251
  // Dispatch one mobile query frame; returns the wire frame to send back.
1259
- async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1252
+ async function handleQuery(api, host, agentDefaultModel, msg) {
1253
+ if (msg.type === 'session-create') {
1254
+ if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
1255
+ return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
1256
+ }
1257
+ const payload = {}
1258
+ if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
1259
+ payload.workspaceId = msg.workspaceId.trim()
1260
+ } else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
1261
+ payload.cwd = msg.cwd.trim()
1262
+ }
1263
+ const frame = await proxyQuery(api, 'session-created', api.sessions.create.bind(api.sessions), payload)
1264
+ return { ...frame, ...(frame.kind === 'error' ? { requestType: 'session-create' } : {}), requestId: msg.requestId }
1265
+ }
1260
1266
  if (msg.type === 'workspaces') {
1261
1267
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1262
1268
  }
@@ -1360,6 +1366,96 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1360
1366
  }
1361
1367
  return frame
1362
1368
  }
1369
+ if (msg.type === 'session-cancel') {
1370
+ const sessionId = requireSessionId(msg)
1371
+ if (sessionId.error) return sessionId.error
1372
+ const frame = await proxyQuery(
1373
+ api,
1374
+ 'session-cancel',
1375
+ api.sessions.cancel.bind(api.sessions),
1376
+ { sessionId: sessionId.value },
1377
+ )
1378
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1379
+ return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
1380
+ }
1381
+ if (msg.type === 'queue-update') {
1382
+ const sessionId = requireSessionId(msg)
1383
+ if (sessionId.error) return sessionId.error
1384
+ const itemId = typeof msg.itemId === 'string' && msg.itemId.trim() ? msg.itemId.trim() : null
1385
+ const actionKind = typeof msg.action === 'string' ? msg.action.trim() : ''
1386
+ if (!itemId || !['edit', 'remove', 'steer'].includes(actionKind)) {
1387
+ return {
1388
+ kind: 'error',
1389
+ code: 'bad-request',
1390
+ message: 'queue-update requires itemId and action edit, remove, or steer',
1391
+ requestType: 'queue-update',
1392
+ sessionId: sessionId.value,
1393
+ }
1394
+ }
1395
+ let action
1396
+ if (actionKind === 'edit') {
1397
+ if (typeof msg.text !== 'string' || !msg.text.trim()) {
1398
+ return {
1399
+ kind: 'error',
1400
+ code: 'bad-request',
1401
+ message: 'queue-update edit requires non-empty text',
1402
+ requestType: 'queue-update',
1403
+ sessionId: sessionId.value,
1404
+ itemId,
1405
+ }
1406
+ }
1407
+ action = { kind: 'edit', content: [{ type: 'text', text: msg.text }] }
1408
+ } else {
1409
+ action = { kind: actionKind }
1410
+ }
1411
+ const frame = await proxyQuery(
1412
+ api,
1413
+ 'queue-update',
1414
+ api.sessions.updateQueue.bind(api.sessions),
1415
+ { sessionId: sessionId.value, itemId, action },
1416
+ )
1417
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value, itemId }
1418
+ return {
1419
+ ...frame,
1420
+ kind: 'queue-item-updated',
1421
+ sessionId: sessionId.value,
1422
+ itemId,
1423
+ action: actionKind,
1424
+ }
1425
+ }
1426
+ if (msg.type === 'session-archive') {
1427
+ const sessionId = requireSessionId(msg)
1428
+ if (sessionId.error) return sessionId.error
1429
+ const frame = await proxyQuery(
1430
+ api,
1431
+ 'session-archive',
1432
+ api.workspace.archiveSession.bind(api.workspace),
1433
+ { sessionId: sessionId.value },
1434
+ )
1435
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1436
+ return { ...frame, kind: 'session-archived', sessionId: sessionId.value }
1437
+ }
1438
+ if (msg.type === 'session-rename') {
1439
+ const sessionId = requireSessionId(msg)
1440
+ if (sessionId.error) return sessionId.error
1441
+ if (typeof msg.title !== 'string' || !msg.title.trim()) {
1442
+ return {
1443
+ kind: 'error',
1444
+ code: 'bad-request',
1445
+ message: 'session-rename requires a non-empty title',
1446
+ requestType: 'session-rename',
1447
+ sessionId: sessionId.value,
1448
+ }
1449
+ }
1450
+ const frame = await proxyQuery(
1451
+ api,
1452
+ 'session-rename',
1453
+ api.sessions.rename.bind(api.sessions),
1454
+ { sessionId: sessionId.value, title: msg.title },
1455
+ )
1456
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1457
+ return { ...frame, kind: 'session-renamed', sessionId: sessionId.value }
1458
+ }
1363
1459
  if (msg.type === 'workspace-create') {
1364
1460
  const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
1365
1461
  if (!wsPath) {
@@ -1386,7 +1482,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1386
1482
  return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
1387
1483
  }
1388
1484
  if (msg.type === 'command-execute') {
1389
- return admitCommand(typertGateway, msg)
1485
+ return admitCommand(host, msg)
1390
1486
  }
1391
1487
  if (msg.type === 'commands') {
1392
1488
  const sessionId = requireSessionId(msg)
@@ -1398,7 +1494,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1398
1494
  // the mobile wire. The Web UI also contributes /model client-side; expose
1399
1495
  // the equivalent action here so mobile clients can render the same menu.
1400
1496
  const [listed, skillFrame] = await Promise.all([
1401
- listHostCommands(typertGateway, sessionId.value),
1497
+ listHostCommands(host, sessionId.value),
1402
1498
  api.skills && typeof api.skills.list === 'function'
1403
1499
  ? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
1404
1500
  : Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
@@ -1476,7 +1572,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1476
1572
  const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1477
1573
  const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
1478
1574
  if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
1479
- return selectCommandOption(api, typertGateway, command, sessionId.value, optionId)
1575
+ return selectCommandOption(api, host, command, sessionId.value, optionId)
1480
1576
  }
1481
1577
  if (msg.type === 'select-model') {
1482
1578
  const sessionId = requireSessionId(msg)
@@ -1518,14 +1614,14 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1518
1614
  // command registry handler WITHOUT sending anything to the model, and the
1519
1615
  // api-remotes agent lookup resumes cold sessions just like prompt does.
1520
1616
  try {
1521
- const execution = await typertGateway.invoke({
1522
- namespace: 'commands',
1523
- method: 'execute',
1524
- // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1525
- // 权限斜杠命令没有附件,因此显式传空数组。
1526
- args: { agentId: sessionId.value, line: '/permission ' + name, images: [] },
1527
- signal: new AbortController().signal,
1528
- })
1617
+ // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1618
+ // 权限斜杠命令没有附件,因此显式传空数组。
1619
+ const execution = await host.commands.execute(
1620
+ sessionId.value,
1621
+ '/permission ' + name,
1622
+ [],
1623
+ new AbortController().signal,
1624
+ )
1529
1625
  if (execution === undefined || execution === null) {
1530
1626
  return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
1531
1627
  }
@@ -1863,16 +1959,16 @@ const plugin = {
1863
1959
  name: 'mobile-gateway',
1864
1960
  Config,
1865
1961
  // Hard dependencies: the gateway registers on the host web server and
1866
- // admits mobile messages through the host API gateway. Cordis must not
1962
+ // admits mobile messages through the Host Remote Gateway. Cordis must not
1867
1963
  // activate this row before either service exists.
1868
- inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
1964
+ inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
1869
1965
  // exported for local tests; Cordis ignores unknown plugin fields
1870
1966
  admitMessage,
1871
1967
  handleQuery,
1872
1968
  apply(ctx, config) {
1873
1969
  const webServer = ctx.webServer
1874
- const api = ctx.apiProxy
1875
- const typertGateway = ctx.typertGateway
1970
+ const api = createDshHostAdapter(ctx.typertGateway)
1971
+ const host = api
1876
1972
  const agentDefaultModel = ctx.agentDefaultModel
1877
1973
  const options = {
1878
1974
  path: DEFAULT_WS_PATH,
@@ -1929,7 +2025,9 @@ const plugin = {
1929
2025
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
1930
2026
  })
1931
2027
  }, Math.min(options.fileDownloadIdleMs, 30_000))
1932
- const interactionMuxAbort = new AbortController()
2028
+ const backgroundStreamAbort = new AbortController()
2029
+ let archivedSessionIds = null
2030
+ let sessionQueues = null
1933
2031
  let counter = 0
1934
2032
  let gatewayEnabled = options.gatewayEnabled === true
1935
2033
  let waitExpiresAt = null
@@ -2013,12 +2111,25 @@ const plugin = {
2013
2111
  const broadcastInteractionFrame = (frame) => {
2014
2112
  const wire = JSON.stringify(frame)
2015
2113
  for (const client of clients) {
2114
+ if (client.mobileChannel === 'conversation') continue
2016
2115
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
2017
2116
  if (client.readyState === 1) client.send(wire)
2018
2117
  }
2019
2118
  }
2020
2119
 
2120
+ // Session-list metadata must reach every connected client even when it is
2121
+ // subscribed to one conversation. Subscriptions only scope the heavier
2122
+ // conversation and interaction streams.
2123
+ const broadcastSessionMetadataFrame = (frame) => {
2124
+ const wire = JSON.stringify(frame)
2125
+ for (const client of clients) {
2126
+ if (client.mobileChannel === 'conversation') continue
2127
+ if (client.readyState === 1) client.send(wire)
2128
+ }
2129
+ }
2130
+
2021
2131
  const replayPendingInteractions = (ws, trigger) => {
2132
+ if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
2022
2133
  let questionCount = 0
2023
2134
  let approvalCount = 0
2024
2135
  for (const pending of pendingQuestions.values()) {
@@ -2035,6 +2146,97 @@ const plugin = {
2035
2146
  return { questionCount, approvalCount }
2036
2147
  }
2037
2148
 
2149
+ const hasInteractionClient = (sessionId) => [...clients].some((client) => (
2150
+ client.readyState === 1
2151
+ && client.mobileChannel !== 'conversation'
2152
+ && (!client.filterSessionId || client.filterSessionId === sessionId)
2153
+ ))
2154
+
2155
+ const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
2156
+ if (pendingQuestions.get(pending.rpcId) !== pending) return false
2157
+ pendingQuestions.delete(pending.rpcId)
2158
+ pending.cleanup()
2159
+ if (action === 'answer') pending.resolve(answer)
2160
+ else {
2161
+ const error = new Error('question cancelled by mobile user')
2162
+ error.name = 'UserQuestionError'
2163
+ error.code = cancellationCode
2164
+ pending.reject(error)
2165
+ }
2166
+ broadcastInteractionFrame({
2167
+ kind: 'question-resolved',
2168
+ rpcId: pending.rpcId,
2169
+ sessionId: pending.sessionId,
2170
+ outcome: action === 'answer' ? 'answered' : 'cancelled',
2171
+ })
2172
+ return true
2173
+ }
2174
+
2175
+ const normalizeQuestionAnswers = (pending, answers) => {
2176
+ if (answers.length !== pending.questions.length) return { error: 'answers must cover every question exactly once' }
2177
+ const normalized = []
2178
+ for (let index = 0; index < pending.questions.length; index += 1) {
2179
+ const question = pending.questions[index]
2180
+ const answer = answers[index]
2181
+ if (!answer || typeof answer !== 'object' || answer.id !== question.id || !Array.isArray(answer.selected)) {
2182
+ return { error: 'answers must preserve question order, ids, and selected arrays' }
2183
+ }
2184
+ if (answer.selected.some((label) => typeof label !== 'string')) return { error: 'selected values must be strings' }
2185
+ if (new Set(answer.selected).size !== answer.selected.length) return { error: 'selected values must not repeat' }
2186
+ const offered = new Set((question.options || []).map((option) => option.label))
2187
+ if (answer.selected.some((label) => !offered.has(label))) return { error: 'selected values must match offered option labels' }
2188
+ if (question.multiSelect !== true && answer.selected.length > 1) return { error: 'single-select questions accept at most one selection' }
2189
+ let custom
2190
+ if (answer.custom !== undefined) {
2191
+ if (typeof answer.custom !== 'string' || !answer.custom.trim()) return { error: 'custom answers must be non-empty strings' }
2192
+ custom = answer.custom.trim()
2193
+ }
2194
+ if (question.multiSelect !== true && custom !== undefined && answer.selected.length > 0) {
2195
+ return { error: 'single-select custom answers cannot accompany a selection' }
2196
+ }
2197
+ normalized.push({ id: answer.id, selected: [...answer.selected], ...(custom === undefined ? {} : { custom }) })
2198
+ }
2199
+ return { value: { answers: normalized } }
2200
+ }
2201
+
2202
+ const completeApproval = (pending, outcome) => {
2203
+ if (pendingApprovals.get(pending.rpcId) !== pending) return false
2204
+ pendingApprovals.delete(pending.rpcId)
2205
+ pending.cleanup()
2206
+ pending.resolve(outcome)
2207
+ broadcastInteractionFrame({
2208
+ kind: 'approval-resolved',
2209
+ rpcId: pending.rpcId,
2210
+ sessionId: pending.sessionId,
2211
+ approvalId: pending.approvalId,
2212
+ outcome,
2213
+ })
2214
+ return true
2215
+ }
2216
+
2217
+ const fallbackQuestion = (pending) => {
2218
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2219
+ pendingQuestions.delete(pending.rpcId)
2220
+ pending.cleanup()
2221
+ Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2222
+ }
2223
+
2224
+ const fallbackApproval = (pending) => {
2225
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2226
+ pendingApprovals.delete(pending.rpcId)
2227
+ pending.cleanup()
2228
+ Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2229
+ }
2230
+
2231
+ const releaseUnclaimedInteractions = () => {
2232
+ for (const pending of [...pendingQuestions.values()]) {
2233
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2234
+ }
2235
+ for (const pending of [...pendingApprovals.values()]) {
2236
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2237
+ }
2238
+ }
2239
+
2038
2240
  const respondToQuestion = async (msg, cancel = false) => {
2039
2241
  const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
2040
2242
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
@@ -2042,46 +2244,30 @@ const plugin = {
2042
2244
  return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
2043
2245
  }
2044
2246
 
2045
- if (cancel) {
2046
- const pending = pendingQuestions.get(rpcId)
2047
- if (pending && pending.sessionId !== sessionId) {
2048
- return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
2049
- }
2050
- } else if (!Array.isArray(msg.answers)) {
2247
+ const pending = pendingQuestions.get(rpcId)
2248
+ if (!pending) {
2249
+ return { kind: 'question-response', rpcId, sessionId, action: cancel ? 'cancel' : 'answer', accepted: false, reason: 'not-pending' }
2250
+ }
2251
+ if (pending.sessionId !== sessionId) {
2252
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
2253
+ }
2254
+ if (!cancel && !Array.isArray(msg.answers)) {
2051
2255
  return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
2052
2256
  }
2053
-
2054
- const result = cancel
2055
- ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
2056
- : {
2057
- ok: true,
2058
- value: {
2059
- sessionId,
2060
- answer: {
2061
- answers: msg.answers.map((answer) => ({
2062
- id: answer && answer.id,
2063
- selected: answer && answer.selected,
2064
- ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
2065
- })),
2066
- },
2067
- },
2068
- }
2069
-
2070
- try {
2071
- const receipt = await api.respond({ type: 'client-response', rpcId, result })
2072
- log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
2073
- return {
2074
- kind: 'question-response',
2075
- rpcId,
2076
- sessionId,
2077
- action: cancel ? 'cancel' : 'answer',
2078
- accepted: receipt.accepted,
2079
- ...(!receipt.accepted ? { reason: receipt.reason } : {}),
2080
- }
2081
- } catch (error) {
2082
- const message = error && error.message ? error.message : String(error)
2083
- log(`question response failed: rpcId=${rpcId} ${message}`)
2084
- return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
2257
+ const normalized = cancel ? null : normalizeQuestionAnswers(pending, msg.answers)
2258
+ if (normalized?.error) {
2259
+ return { kind: 'error', code: 'bad-response', message: normalized.error, requestType: msg.type, sessionId }
2260
+ }
2261
+ const answer = normalized?.value
2262
+ const accepted = completeQuestion(pending, cancel ? 'cancel' : 'answer', answer)
2263
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${accepted}`)
2264
+ return {
2265
+ kind: 'question-response',
2266
+ rpcId,
2267
+ sessionId,
2268
+ action: cancel ? 'cancel' : 'answer',
2269
+ accepted,
2270
+ ...(!accepted ? { reason: 'not-pending' } : {}),
2085
2271
  }
2086
2272
  }
2087
2273
 
@@ -2098,30 +2284,22 @@ const plugin = {
2098
2284
  }
2099
2285
 
2100
2286
  const pending = pendingApprovals.get(rpcId)
2101
- if (pending && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
2287
+ if (!pending) {
2288
+ return { kind: 'approval-response', rpcId, sessionId, approvalId, outcome, accepted: false, reason: 'not-pending' }
2289
+ }
2290
+ if (pending.sessionId !== sessionId || pending.approvalId !== approvalId) {
2102
2291
  return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
2103
2292
  }
2104
-
2105
- try {
2106
- const receipt = await api.respond({
2107
- type: 'client-response',
2108
- rpcId,
2109
- result: { ok: true, value: { sessionId, approvalId, outcome } },
2110
- })
2111
- log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
2112
- return {
2113
- kind: 'approval-response',
2114
- rpcId,
2115
- sessionId,
2116
- approvalId,
2117
- outcome,
2118
- accepted: receipt.accepted,
2119
- ...(!receipt.accepted ? { reason: receipt.reason } : {}),
2120
- }
2121
- } catch (error) {
2122
- const message = error && error.message ? error.message : String(error)
2123
- log(`approval response failed: rpcId=${rpcId} ${message}`)
2124
- return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
2293
+ const accepted = completeApproval(pending, outcome)
2294
+ log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${accepted}`)
2295
+ return {
2296
+ kind: 'approval-response',
2297
+ rpcId,
2298
+ sessionId,
2299
+ approvalId,
2300
+ outcome,
2301
+ accepted,
2302
+ ...(!accepted ? { reason: 'not-pending' } : {}),
2125
2303
  }
2126
2304
  }
2127
2305
 
@@ -2300,6 +2478,8 @@ const plugin = {
2300
2478
  wss.handleUpgrade(req, socket, head, (ws) => {
2301
2479
  const id = ++counter
2302
2480
  ws.filterSessionId = undefined
2481
+ const requestedChannel = req.headers['x-dsh-channel']
2482
+ ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2303
2483
  ws.deviceId = device && device.id
2304
2484
  clients.add(ws)
2305
2485
  if (device) registry.connected(device.id)
@@ -2320,6 +2500,12 @@ const plugin = {
2320
2500
  return
2321
2501
  }
2322
2502
  if (!msg || typeof msg.type !== 'string') return
2503
+ const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2504
+ if ((ws.mobileChannel === 'control' && conversationRequest) ||
2505
+ (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2506
+ ws.send(JSON.stringify({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2507
+ return
2508
+ }
2323
2509
 
2324
2510
  if (msg.type === 'ping') {
2325
2511
  ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
@@ -2349,15 +2535,16 @@ const plugin = {
2349
2535
  })
2350
2536
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
2351
2537
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
2352
- msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2538
+ msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2353
2539
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
2354
2540
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2355
2541
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2356
2542
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2357
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2543
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2544
+ msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2358
2545
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2359
2546
  msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2360
- handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
2547
+ handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2361
2548
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2362
2549
  })
2363
2550
  } else {
@@ -2368,6 +2555,7 @@ const plugin = {
2368
2555
  ws.on('close', () => {
2369
2556
  clients.delete(ws)
2370
2557
  if (ws.deviceId) registry.disconnected(ws.deviceId)
2558
+ releaseUnclaimedInteractions()
2371
2559
  fileTransfers.closeClient(ws).catch((error) => {
2372
2560
  log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
2373
2561
  })
@@ -2385,12 +2573,30 @@ const plugin = {
2385
2573
  ws.send(JSON.stringify({
2386
2574
  kind: 'hello',
2387
2575
  protocol: 3,
2388
- capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2576
+ capabilities: [
2577
+ 'split-channels',
2578
+ 'images',
2579
+ 'session-create',
2580
+ 'commands',
2581
+ 'tasks',
2582
+ 'goals',
2583
+ 'session-cancel',
2584
+ 'queue-control',
2585
+ 'session-archive',
2586
+ 'session-rename',
2587
+ ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
2588
+ ],
2389
2589
  port: transport.port || webServer.port,
2390
2590
  clients: clients.size,
2391
2591
  authenticated: !!device,
2392
2592
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
2393
2593
  }))
2594
+ if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
2595
+ ws.send(JSON.stringify({ kind: 'session-archives', archivedSessionIds }))
2596
+ }
2597
+ if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2598
+ ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2599
+ }
2394
2600
  replayPendingInteractions(ws, 'connect')
2395
2601
  })
2396
2602
  }
@@ -2448,112 +2654,221 @@ const plugin = {
2448
2654
  if (clients.size === 0) return
2449
2655
  const wire = buildWireEvent(session, event)
2450
2656
  if (!wire) return
2657
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
2658
+ broadcastSessionMetadataFrame({
2659
+ kind: 'session-title-changed',
2660
+ sessionId: String(session.id),
2661
+ title: event.data.title,
2662
+ seq: event.seq,
2663
+ time: event.time,
2664
+ ...(event.data.source ? { source: event.data.source } : {}),
2665
+ })
2666
+ }
2451
2667
  const payload = JSON.stringify(wire)
2452
2668
  for (const client of clients) {
2669
+ if (client.mobileChannel === 'control') continue
2453
2670
  if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
2454
2671
  if (client.readyState === 1) client.send(payload)
2455
2672
  }
2456
2673
  })
2457
2674
  log('session/event listener attached')
2458
2675
 
2459
- const interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
2460
- ? (async () => {
2461
- try {
2462
- for await (const envelope of api.events.mux(
2463
- { rpcId: crypto.randomUUID(), payload: {} },
2464
- interactionMuxAbort.signal,
2465
- )) {
2466
- const payload = envelope && envelope.payload
2467
- if (!payload || typeof payload.type !== 'string') continue
2468
- log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
2469
- if (payload.type === 'question/requested') {
2470
- const rpcId = String(envelope.rpcId)
2471
- const pending = {
2472
- rpcId,
2473
- sessionId: String(payload.sessionId),
2474
- questions: payload.questions,
2475
- }
2476
- pendingQuestions.set(rpcId, pending)
2477
- broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2478
- log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
2479
- } else if (payload.type === 'approval/requested') {
2480
- const rpcId = String(envelope.rpcId)
2481
- const pending = {
2482
- rpcId,
2483
- sessionId: String(payload.sessionId),
2484
- approvalId: String(payload.approvalId),
2485
- toolName: payload.toolName,
2486
- ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2487
- ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2488
- }
2489
- pendingApprovals.set(rpcId, pending)
2490
- broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
2491
- log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
2492
- } else if (payload.type === 'question/resolved') {
2493
- const questionRpcId = String(payload.questionRpcId)
2494
- pendingQuestions.delete(questionRpcId)
2495
- broadcastInteractionFrame({
2496
- kind: 'question-resolved',
2497
- rpcId: questionRpcId,
2498
- sessionId: String(payload.sessionId),
2499
- outcome: payload.outcome,
2500
- })
2501
- log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
2502
- } else if (payload.type === 'approval/resolved') {
2503
- const approvalId = String(payload.approvalId)
2504
- const sessionId = String(payload.sessionId)
2505
- const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
2506
- if (!entry) {
2507
- log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2508
- continue
2509
- }
2510
- const [rpcId, pending] = entry
2511
- pendingApprovals.delete(rpcId)
2512
- broadcastInteractionFrame({
2513
- kind: 'approval-resolved',
2514
- rpcId,
2515
- sessionId,
2516
- approvalId,
2517
- outcome: payload.outcome,
2518
- })
2519
- log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2520
- } else if (payload.type === 'session/projection' && (payload.key === 'todos' || payload.key === 'goal')) {
2521
- const sessionId = String(payload.sessionId)
2522
- const kind = payload.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2523
- const valueKey = payload.key === 'todos' ? 'todos' : 'goal'
2524
- broadcastInteractionFrame({
2525
- kind,
2526
- sessionId,
2527
- asOfSeq: payload.seq,
2528
- [valueKey]: payload.value,
2529
- })
2530
- log(`projection forwarded: key=${payload.key} session=${sessionId} seq=${payload.seq}`)
2531
- } else {
2532
- log(`interaction ignored: type=${payload.type}`)
2533
- }
2676
+ const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2677
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2678
+ if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
2679
+ const rpcId = crypto.randomUUID()
2680
+ return new Promise((resolve, reject) => {
2681
+ const onAbort = () => {
2682
+ const pending = pendingQuestions.get(rpcId)
2683
+ if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
2684
+ }
2685
+ request.signal?.addEventListener('abort', onAbort, { once: true })
2686
+ const pending = {
2687
+ rpcId,
2688
+ sessionId,
2689
+ questions: request.questions,
2690
+ next,
2691
+ resolve,
2692
+ reject,
2693
+ cleanup: () => request.signal?.removeEventListener('abort', onAbort),
2694
+ }
2695
+ pendingQuestions.set(rpcId, pending)
2696
+ broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2697
+ log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
2698
+ })
2699
+ }, { prepend: true })
2700
+
2701
+ const disposeApprovals = ctx.on('approval/request', (request, next) => {
2702
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2703
+ if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
2704
+ const rpcId = crypto.randomUUID()
2705
+ const approvalId = crypto.randomUUID()
2706
+ return new Promise((resolve, reject) => {
2707
+ const onAbort = () => {
2708
+ const pending = pendingApprovals.get(rpcId)
2709
+ if (pending) completeApproval(pending, 'cancelled')
2710
+ }
2711
+ request.signal?.addEventListener('abort', onAbort, { once: true })
2712
+ const pending = {
2713
+ rpcId,
2714
+ sessionId,
2715
+ approvalId,
2716
+ toolName: request.toolName,
2717
+ ...(request.callId !== undefined ? { callId: request.callId } : {}),
2718
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
2719
+ next,
2720
+ resolve,
2721
+ reject,
2722
+ cleanup: () => request.signal?.removeEventListener('abort', onAbort),
2723
+ }
2724
+ pendingApprovals.set(rpcId, pending)
2725
+ broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
2726
+ log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
2727
+ })
2728
+ }, { prepend: true })
2729
+
2730
+ const waitForBackgroundRetry = () => new Promise((resolve) => {
2731
+ if (backgroundStreamAbort.signal.aborted) {
2732
+ resolve()
2733
+ return
2734
+ }
2735
+ const finish = () => {
2736
+ clearTimeout(timer)
2737
+ backgroundStreamAbort.signal.removeEventListener('abort', finish)
2738
+ resolve()
2739
+ }
2740
+ const timer = setTimeout(finish, 1_000)
2741
+ backgroundStreamAbort.signal.addEventListener('abort', finish, { once: true })
2742
+ })
2743
+
2744
+ const installArchivedSessionIds = (value) => {
2745
+ if (!Array.isArray(value)) throw new Error('workspace/follow returned invalid archivedSessionIds')
2746
+ const next = value.map((sessionId) => String(sessionId))
2747
+ if (archivedSessionIds !== null
2748
+ && archivedSessionIds.length === next.length
2749
+ && archivedSessionIds.every((sessionId, index) => sessionId === next[index])) return
2750
+ archivedSessionIds = next
2751
+ broadcastSessionMetadataFrame({ kind: 'session-archives', archivedSessionIds: next })
2752
+ log(`session archive state forwarded: count=${next.length}`)
2753
+ }
2754
+
2755
+ const normalizeQueueItems = (value, endpoint) => {
2756
+ if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
2757
+ return value.map((candidate) => {
2758
+ const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
2759
+ ? candidate.message
2760
+ : null
2761
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
2762
+ || typeof candidate.id !== 'string' || !candidate.id
2763
+ || !['queued', 'steering', 'context'].includes(candidate.placement)
2764
+ || !message || typeof message !== 'object' || Array.isArray(message)
2765
+ || typeof message.id !== 'string' || !message.id
2766
+ || !Array.isArray(message.content)
2767
+ || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
2768
+ throw new Error(`${endpoint} returned an invalid queue item`)
2769
+ }
2770
+ return {
2771
+ id: candidate.id,
2772
+ placement: candidate.placement,
2773
+ ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
2774
+ message: { id: message.id, content: message.content },
2775
+ }
2776
+ })
2777
+ }
2778
+
2779
+ const installSessionQueueBaseline = (value) => {
2780
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2781
+ throw new Error('session/control baseline returned invalid queues')
2782
+ }
2783
+ const next = new Map()
2784
+ for (const [sessionId, items] of Object.entries(value)) {
2785
+ next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
2786
+ }
2787
+ sessionQueues = next
2788
+ broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
2789
+ log(`session queue baseline forwarded: sessions=${next.size}`)
2790
+ }
2791
+
2792
+ const installSessionQueue = (sessionIdValue, itemsValue) => {
2793
+ const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
2794
+ if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
2795
+ const items = normalizeQueueItems(itemsValue, 'session/control queue')
2796
+ if (sessionQueues === null) sessionQueues = new Map()
2797
+ sessionQueues.set(sessionId, items)
2798
+ broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
2799
+ log(`session queue forwarded: session=${sessionId} items=${items.length}`)
2800
+ }
2801
+
2802
+ const workspaceTask = (async () => {
2803
+ while (!backgroundStreamAbort.signal.aborted) {
2804
+ try {
2805
+ const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
2806
+ for await (const frame of stream) {
2807
+ if (frame?.type === 'baseline') {
2808
+ installArchivedSessionIds(frame.value?.archivedSessionIds)
2809
+ } else if (frame?.type === 'archived') {
2810
+ installArchivedSessionIds(frame.archivedSessionIds)
2534
2811
  }
2535
- } catch (error) {
2536
- if (!interactionMuxAbort.signal.aborted) {
2537
- log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
2812
+ }
2813
+ if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
2814
+ } catch (error) {
2815
+ if (!backgroundStreamAbort.signal.aborted) {
2816
+ log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2817
+ }
2818
+ }
2819
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2820
+ }
2821
+ })()
2822
+ void workspaceTask
2823
+
2824
+ const controlTask = (async () => {
2825
+ while (!backgroundStreamAbort.signal.aborted) {
2826
+ try {
2827
+ const stream = await api.openControlStream(backgroundStreamAbort.signal)
2828
+ for await (const frame of stream) {
2829
+ if (frame?.type === 'baseline') {
2830
+ installSessionQueueBaseline(frame.value?.queues)
2831
+ } else if (frame?.type === 'queue') {
2832
+ installSessionQueue(frame.sessionId, frame.items)
2833
+ } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
2834
+ 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
+ })
2843
+ log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2538
2844
  }
2539
2845
  }
2540
- })()
2541
- : null
2542
- if (interactionMuxTask) log('api gateway interaction mux attached')
2543
- else log('api gateway interaction mux unavailable; human-in-the-loop disabled')
2846
+ if (!backgroundStreamAbort.signal.aborted) log('session control stream ended; retrying')
2847
+ } catch (error) {
2848
+ if (!backgroundStreamAbort.signal.aborted) {
2849
+ log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2850
+ }
2851
+ }
2852
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2853
+ }
2854
+ })()
2855
+ void controlTask
2856
+ log('Host waterfall interaction listeners, workspace stream, and session control stream attached')
2544
2857
 
2545
2858
  ctx.effect(() => () => {
2546
2859
  if (waitTimer) clearTimeout(waitTimer)
2547
2860
  clearInterval(fileTransferExpiryTimer)
2548
- interactionMuxAbort.abort()
2549
- pendingQuestions.clear()
2550
- pendingApprovals.clear()
2861
+ backgroundStreamAbort.abort()
2862
+ for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
2863
+ for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
2551
2864
  fileTransfers.dispose().catch((error) => {
2552
2865
  log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
2553
2866
  })
2554
2867
  disposeUpgrade()
2555
2868
  disposeMgmt()
2556
2869
  disposeEvents()
2870
+ disposeQuestions()
2871
+ disposeApprovals()
2557
2872
  if (lanServer) lanServer.close()
2558
2873
  for (const client of clients) client.terminate()
2559
2874
  clients.clear()