dsh-plugin-mobile-gateway 0.6.9 → 0.7.1

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):
@@ -57,6 +57,10 @@
57
57
  // { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
58
58
  // { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
59
59
  // (the input-box stats strip source)
60
+ // { "type": "tasks", "sessionId" } -> current todo-list projection
61
+ // { "type": "goal", "sessionId" } -> current goal projection
62
+ // { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
63
+ // { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
60
64
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
61
65
  // { "type": "defaults" } -> default agent preset + default permission
62
66
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
@@ -64,7 +68,7 @@
64
68
  // { "type": "question-cancel", "rpcId", "sessionId" }
65
69
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
66
70
  // "outcome": "allowed-once"|"rejected" }
67
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "file-downloads"],
71
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"],
68
72
  // "authenticated", "device"?, "port", "clients" }
69
73
  // { "kind": "pong", "at" }
70
74
  // { "kind": "subscribed", "sessionId" }
@@ -86,6 +90,12 @@
86
90
  // { "kind": "file-download-cancelled", "transferId" }
87
91
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
88
92
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
93
+ // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
94
+ // { "kind": "goal", "sessionId", "asOfSeq", "goal" }
95
+ // { "kind": "goal-edit"|"goal-pause"|"goal-resume", "sessionId", "ref" }
96
+ // { "kind": "goal-clear", "sessionId", "cleared": true }
97
+ // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
98
+ // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
89
99
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
90
100
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
91
101
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
@@ -104,6 +114,7 @@ import crypto from 'node:crypto'
104
114
  import Schema from '@deepseek-ai/schemastery'
105
115
  import { WebSocketServer } from 'ws'
106
116
  import devicesModule from './devices.js'
117
+ import { createDshHostAdapter } from './dsh-host-adapter.mjs'
107
118
  import QRCode from 'qrcode'
108
119
 
109
120
  const { createRegistry } = devicesModule
@@ -397,25 +408,15 @@ function commandNameOf(line) {
397
408
  return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
398
409
  }
399
410
 
400
- async function listHostCommands(typertGateway, sessionId) {
401
- const listed = await typertGateway.invoke({
402
- namespace: 'commands',
403
- method: 'list',
404
- args: { agentId: sessionId },
405
- signal: new AbortController().signal,
406
- })
411
+ async function listHostCommands(host, sessionId) {
412
+ const listed = await host.commands.list(sessionId, new AbortController().signal)
407
413
  if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
408
414
  return listed
409
415
  }
410
416
 
411
- async function executeHostCommand(typertGateway, sessionId, line, images, requestType) {
417
+ async function executeHostCommand(host, sessionId, line, images, requestType) {
412
418
  try {
413
- const execution = await typertGateway.invoke({
414
- namespace: 'commands',
415
- method: 'execute',
416
- args: { agentId: sessionId, line, images },
417
- signal: new AbortController().signal,
418
- })
419
+ const execution = await host.commands.execute(sessionId, line, images, new AbortController().signal)
419
420
  if (execution === undefined || execution === null) {
420
421
  return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
421
422
  }
@@ -435,7 +436,7 @@ async function executeHostCommand(typertGateway, sessionId, line, images, reques
435
436
  }
436
437
  }
437
438
 
438
- async function admitCommand(typertGateway, msg) {
439
+ async function admitCommand(host, msg) {
439
440
  const sessionId = requireSessionId(msg)
440
441
  if (sessionId.error) return sessionId.error
441
442
  const line = typeof msg.line === 'string' ? msg.line.trim() : ''
@@ -446,7 +447,7 @@ async function admitCommand(typertGateway, msg) {
446
447
  const parsedImages = parseWireImages(msg.images, 'command-execute')
447
448
  if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
448
449
  try {
449
- const listed = await listHostCommands(typertGateway, sessionId.value)
450
+ const listed = await listHostCommands(host, sessionId.value)
450
451
  const descriptor = listed.find((command) => command && command.name === name)
451
452
  if (!descriptor) {
452
453
  return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
@@ -454,7 +455,7 @@ async function admitCommand(typertGateway, msg) {
454
455
  if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
455
456
  return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
456
457
  }
457
- return executeHostCommand(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
458
+ return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
458
459
  } catch (error) {
459
460
  const code = error && error.code ? error.code : 'internal'
460
461
  const message = error && error.message ? error.message : String(error)
@@ -487,44 +488,30 @@ async function admitMessage(api, msg) {
487
488
  if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
488
489
  else createPayload.cwd = msg.cwd.trim()
489
490
  }
490
- const created = await api.sessions.create({ rpcId: crypto.randomUUID(), payload: createPayload })
491
- if (!created.result.ok) {
492
- return { kind: 'error', code: created.result.error.code, message: created.result.error.message }
493
- }
494
- sessionId = created.result.value.sessionId
491
+ const created = await api.sessions.create(createPayload)
492
+ sessionId = created.sessionId
495
493
  log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
496
494
  }
497
495
 
498
496
  const resp = await api.sessions.prompt({
499
- rpcId: crypto.randomUUID(),
500
- payload: {
501
- sessionId,
502
- mode,
503
- // Match the official WebUI ordering: images first, optional text last.
504
- content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
505
- ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
506
- },
497
+ sessionId,
498
+ mode,
499
+ // Match the official WebUI ordering: images first, optional text last.
500
+ content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
501
+ ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
507
502
  })
508
- if (resp.result.ok) {
509
- log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
510
- return {
511
- kind: 'sent',
512
- sessionId,
513
- mode,
514
- ...(resp.result.value.command ? { command: resp.result.value.command } : {}),
515
- }
516
- }
517
- log(`mobile message rejected: ${resp.result.error.code}: ${resp.result.error.message}`)
503
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
518
504
  return {
519
- kind: 'error',
520
- code: resp.result.error.code,
521
- message: resp.result.error.message,
522
- ...(sessionId ? { sessionId } : {}),
505
+ kind: 'sent',
506
+ sessionId,
507
+ mode,
508
+ ...(resp.command ? { command: resp.command } : {}),
523
509
  }
524
510
  } catch (error) {
511
+ const code = error && error.code ? error.code : 'internal'
525
512
  const message = error && error.message ? error.message : String(error)
526
513
  log(`mobile message failed: ${message}`)
527
- return { kind: 'error', code: 'internal', message, ...(sessionId ? { sessionId } : {}) }
514
+ return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
528
515
  }
529
516
  }
530
517
 
@@ -533,23 +520,17 @@ async function admitMessage(api, msg) {
533
520
  // the uniform `{ kind: 'error', code, message, requestType }` frame.
534
521
  async function proxyQuery(api, type, method, payload, signal) {
535
522
  try {
536
- const req = { rpcId: crypto.randomUUID(), payload }
537
- const resp = signal ? await method(req, signal) : await method(req)
538
- if (resp.result.ok) {
539
- log(`query ok: ${type}`)
540
- return { kind: type, ...resp.result.value }
541
- }
542
- log(`query rejected: ${type} -> ${resp.result.error.code}: ${resp.result.error.message}`)
543
- return {
544
- kind: 'error',
545
- code: resp.result.error.code,
546
- message: resp.result.error.message,
547
- requestType: type,
523
+ const value = signal ? await method(payload, signal) : await method(payload)
524
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
525
+ throw new Error(`${type} returned an invalid response`)
548
526
  }
527
+ log(`query ok: ${type}`)
528
+ return { kind: type, ...value }
549
529
  } catch (error) {
530
+ const code = error && error.code ? error.code : 'internal'
550
531
  const message = error && error.message ? error.message : String(error)
551
- log(`query failed: ${type} -> ${message}`)
552
- return { kind: 'error', code: 'internal', message, requestType: type }
532
+ log(`query failed: ${type} -> ${code}: ${message}`)
533
+ return { kind: 'error', code, message, requestType: type }
553
534
  }
554
535
  }
555
536
 
@@ -958,6 +939,54 @@ function requireSessionId(msg) {
958
939
  return { value: sessionId }
959
940
  }
960
941
 
942
+ // Goal mutations use DSH's compare-and-set reference. Returning a stale
943
+ // revision is intentionally rejected by DSH instead of overwriting a newer
944
+ // WebUI/mobile update.
945
+ function requireGoalRef(msg, sessionId) {
946
+ const ref = msg.ref
947
+ const id = ref && typeof ref.id === 'string' && ref.id.trim() !== '' ? ref.id.trim() : null
948
+ const revision = ref && typeof ref.revision === 'number' && Number.isSafeInteger(ref.revision) && ref.revision > 0
949
+ ? ref.revision
950
+ : null
951
+ if (!id || revision === null) {
952
+ return {
953
+ error: {
954
+ kind: 'error',
955
+ code: 'bad-request',
956
+ message: `${typeof msg.type === 'string' ? msg.type : 'goal mutation'} requires ref.id and a positive integer ref.revision`,
957
+ requestType: typeof msg.type === 'string' ? msg.type : 'goal',
958
+ sessionId,
959
+ },
960
+ }
961
+ }
962
+ return { value: { id, revision } }
963
+ }
964
+
965
+ async function readSessionProjection(api, type, sessionId, key) {
966
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
967
+ if (history.kind !== 'history') return history
968
+ const values = (history.projections && history.projections.values) || {}
969
+ return {
970
+ kind: type,
971
+ sessionId,
972
+ asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
973
+ [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
974
+ }
975
+ }
976
+
977
+ async function mutateGoal(api, type, method, payload) {
978
+ if (!api.goals || typeof api.goals[method] !== 'function') {
979
+ return {
980
+ kind: 'error',
981
+ code: 'unsupported',
982
+ message: 'goal management is unavailable in this DSH host',
983
+ requestType: type,
984
+ sessionId: payload.sessionId,
985
+ }
986
+ }
987
+ return proxyQuery(api, type, api.goals[method].bind(api.goals), payload)
988
+ }
989
+
961
990
  // ---------------------------------------------------------------------------
962
991
  // History frame sizing: raw session logs can be huge (trajectory, tool output,
963
992
  // context), so a single history response must never blow the client's
@@ -1132,7 +1161,7 @@ async function loadCommandOptions(api, command, sessionId) {
1132
1161
  }
1133
1162
  }
1134
1163
 
1135
- async function selectCommandOption(api, typertGateway, command, sessionId, optionId) {
1164
+ async function selectCommandOption(api, host, command, sessionId, optionId) {
1136
1165
  const catalog = await loadCommandOptions(api, command, sessionId)
1137
1166
  if (catalog.kind !== 'command-options') return catalog
1138
1167
  const option = catalog.options.find((candidate) => candidate.id === optionId)
@@ -1148,12 +1177,12 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
1148
1177
 
1149
1178
  if (command === 'permission') {
1150
1179
  try {
1151
- const execution = await typertGateway.invoke({
1152
- namespace: 'commands',
1153
- method: 'execute',
1154
- args: { agentId: sessionId, line: '/permission ' + optionId, images: [] },
1155
- signal: new AbortController().signal,
1156
- })
1180
+ const execution = await host.commands.execute(
1181
+ sessionId,
1182
+ '/permission ' + optionId,
1183
+ [],
1184
+ new AbortController().signal,
1185
+ )
1157
1186
  if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
1158
1187
  if (execution.result.kind === 'error') {
1159
1188
  return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
@@ -1198,7 +1227,7 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
1198
1227
  }
1199
1228
 
1200
1229
  // Dispatch one mobile query frame; returns the wire frame to send back.
1201
- async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1230
+ async function handleQuery(api, host, agentDefaultModel, msg) {
1202
1231
  if (msg.type === 'workspaces') {
1203
1232
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1204
1233
  }
@@ -1328,7 +1357,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1328
1357
  return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
1329
1358
  }
1330
1359
  if (msg.type === 'command-execute') {
1331
- return admitCommand(typertGateway, msg)
1360
+ return admitCommand(host, msg)
1332
1361
  }
1333
1362
  if (msg.type === 'commands') {
1334
1363
  const sessionId = requireSessionId(msg)
@@ -1340,7 +1369,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1340
1369
  // the mobile wire. The Web UI also contributes /model client-side; expose
1341
1370
  // the equivalent action here so mobile clients can render the same menu.
1342
1371
  const [listed, skillFrame] = await Promise.all([
1343
- listHostCommands(typertGateway, sessionId.value),
1372
+ listHostCommands(host, sessionId.value),
1344
1373
  api.skills && typeof api.skills.list === 'function'
1345
1374
  ? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
1346
1375
  : Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
@@ -1418,7 +1447,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1418
1447
  const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1419
1448
  const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
1420
1449
  if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
1421
- return selectCommandOption(api, typertGateway, command, sessionId.value, optionId)
1450
+ return selectCommandOption(api, host, command, sessionId.value, optionId)
1422
1451
  }
1423
1452
  if (msg.type === 'select-model') {
1424
1453
  const sessionId = requireSessionId(msg)
@@ -1460,14 +1489,14 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1460
1489
  // command registry handler WITHOUT sending anything to the model, and the
1461
1490
  // api-remotes agent lookup resumes cold sessions just like prompt does.
1462
1491
  try {
1463
- const execution = await typertGateway.invoke({
1464
- namespace: 'commands',
1465
- method: 'execute',
1466
- // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1467
- // 权限斜杠命令没有附件,因此显式传空数组。
1468
- args: { agentId: sessionId.value, line: '/permission ' + name, images: [] },
1469
- signal: new AbortController().signal,
1470
- })
1492
+ // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1493
+ // 权限斜杠命令没有附件,因此显式传空数组。
1494
+ const execution = await host.commands.execute(
1495
+ sessionId.value,
1496
+ '/permission ' + name,
1497
+ [],
1498
+ new AbortController().signal,
1499
+ )
1471
1500
  if (execution === undefined || execution === null) {
1472
1501
  return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
1473
1502
  }
@@ -1515,6 +1544,39 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1515
1544
  contextPressure: values.contextPressure || null,
1516
1545
  }
1517
1546
  }
1547
+ if (msg.type === 'tasks') {
1548
+ const sessionId = requireSessionId(msg)
1549
+ if (sessionId.error) return sessionId.error
1550
+ return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1551
+ }
1552
+ if (msg.type === 'goal') {
1553
+ const sessionId = requireSessionId(msg)
1554
+ if (sessionId.error) return sessionId.error
1555
+ return readSessionProjection(api, 'goal', sessionId.value, 'goal')
1556
+ }
1557
+ if (msg.type === 'goal-edit') {
1558
+ const sessionId = requireSessionId(msg)
1559
+ if (sessionId.error) return sessionId.error
1560
+ const ref = requireGoalRef(msg, sessionId.value)
1561
+ if (ref.error) return ref.error
1562
+ const payload = { sessionId: sessionId.value, ref: ref.value }
1563
+ if (typeof msg.objective === 'string' && msg.objective.trim() !== '') payload.objective = msg.objective.trim()
1564
+ if (typeof msg.maxGoalRounds === 'number' && Number.isSafeInteger(msg.maxGoalRounds) && msg.maxGoalRounds > 0) {
1565
+ payload.maxGoalRounds = msg.maxGoalRounds
1566
+ }
1567
+ if (payload.objective === undefined && payload.maxGoalRounds === undefined) {
1568
+ return { kind: 'error', code: 'bad-request', message: 'goal-edit requires a non-empty objective or a positive maxGoalRounds', requestType: 'goal-edit', sessionId: sessionId.value }
1569
+ }
1570
+ return mutateGoal(api, 'goal-edit', 'edit', payload)
1571
+ }
1572
+ if (msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
1573
+ const sessionId = requireSessionId(msg)
1574
+ if (sessionId.error) return sessionId.error
1575
+ const ref = requireGoalRef(msg, sessionId.value)
1576
+ if (ref.error) return ref.error
1577
+ const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1578
+ return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1579
+ }
1518
1580
  if (msg.type === 'agent-presets') {
1519
1581
  return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1520
1582
  }
@@ -1772,16 +1834,16 @@ const plugin = {
1772
1834
  name: 'mobile-gateway',
1773
1835
  Config,
1774
1836
  // Hard dependencies: the gateway registers on the host web server and
1775
- // admits mobile messages through the host API gateway. Cordis must not
1837
+ // admits mobile messages through the Host Remote Gateway. Cordis must not
1776
1838
  // activate this row before either service exists.
1777
- inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
1839
+ inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
1778
1840
  // exported for local tests; Cordis ignores unknown plugin fields
1779
1841
  admitMessage,
1780
1842
  handleQuery,
1781
1843
  apply(ctx, config) {
1782
1844
  const webServer = ctx.webServer
1783
- const api = ctx.apiProxy
1784
- const typertGateway = ctx.typertGateway
1845
+ const api = createDshHostAdapter(ctx.typertGateway)
1846
+ const host = api
1785
1847
  const agentDefaultModel = ctx.agentDefaultModel
1786
1848
  const options = {
1787
1849
  path: DEFAULT_WS_PATH,
@@ -1838,7 +1900,7 @@ const plugin = {
1838
1900
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
1839
1901
  })
1840
1902
  }, Math.min(options.fileDownloadIdleMs, 30_000))
1841
- const interactionMuxAbort = new AbortController()
1903
+ const controlStreamAbort = new AbortController()
1842
1904
  let counter = 0
1843
1905
  let gatewayEnabled = options.gatewayEnabled === true
1844
1906
  let waitExpiresAt = null
@@ -1944,6 +2006,96 @@ const plugin = {
1944
2006
  return { questionCount, approvalCount }
1945
2007
  }
1946
2008
 
2009
+ const hasInteractionClient = (sessionId) => [...clients].some((client) => (
2010
+ client.readyState === 1
2011
+ && (!client.filterSessionId || client.filterSessionId === sessionId)
2012
+ ))
2013
+
2014
+ const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
2015
+ if (pendingQuestions.get(pending.rpcId) !== pending) return false
2016
+ pendingQuestions.delete(pending.rpcId)
2017
+ pending.cleanup()
2018
+ if (action === 'answer') pending.resolve(answer)
2019
+ else {
2020
+ const error = new Error('question cancelled by mobile user')
2021
+ error.name = 'UserQuestionError'
2022
+ error.code = cancellationCode
2023
+ pending.reject(error)
2024
+ }
2025
+ broadcastInteractionFrame({
2026
+ kind: 'question-resolved',
2027
+ rpcId: pending.rpcId,
2028
+ sessionId: pending.sessionId,
2029
+ outcome: action === 'answer' ? 'answered' : 'cancelled',
2030
+ })
2031
+ return true
2032
+ }
2033
+
2034
+ const normalizeQuestionAnswers = (pending, answers) => {
2035
+ if (answers.length !== pending.questions.length) return { error: 'answers must cover every question exactly once' }
2036
+ const normalized = []
2037
+ for (let index = 0; index < pending.questions.length; index += 1) {
2038
+ const question = pending.questions[index]
2039
+ const answer = answers[index]
2040
+ if (!answer || typeof answer !== 'object' || answer.id !== question.id || !Array.isArray(answer.selected)) {
2041
+ return { error: 'answers must preserve question order, ids, and selected arrays' }
2042
+ }
2043
+ if (answer.selected.some((label) => typeof label !== 'string')) return { error: 'selected values must be strings' }
2044
+ if (new Set(answer.selected).size !== answer.selected.length) return { error: 'selected values must not repeat' }
2045
+ const offered = new Set((question.options || []).map((option) => option.label))
2046
+ if (answer.selected.some((label) => !offered.has(label))) return { error: 'selected values must match offered option labels' }
2047
+ if (question.multiSelect !== true && answer.selected.length > 1) return { error: 'single-select questions accept at most one selection' }
2048
+ let custom
2049
+ if (answer.custom !== undefined) {
2050
+ if (typeof answer.custom !== 'string' || !answer.custom.trim()) return { error: 'custom answers must be non-empty strings' }
2051
+ custom = answer.custom.trim()
2052
+ }
2053
+ if (question.multiSelect !== true && custom !== undefined && answer.selected.length > 0) {
2054
+ return { error: 'single-select custom answers cannot accompany a selection' }
2055
+ }
2056
+ normalized.push({ id: answer.id, selected: [...answer.selected], ...(custom === undefined ? {} : { custom }) })
2057
+ }
2058
+ return { value: { answers: normalized } }
2059
+ }
2060
+
2061
+ const completeApproval = (pending, outcome) => {
2062
+ if (pendingApprovals.get(pending.rpcId) !== pending) return false
2063
+ pendingApprovals.delete(pending.rpcId)
2064
+ pending.cleanup()
2065
+ pending.resolve(outcome)
2066
+ broadcastInteractionFrame({
2067
+ kind: 'approval-resolved',
2068
+ rpcId: pending.rpcId,
2069
+ sessionId: pending.sessionId,
2070
+ approvalId: pending.approvalId,
2071
+ outcome,
2072
+ })
2073
+ return true
2074
+ }
2075
+
2076
+ const fallbackQuestion = (pending) => {
2077
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2078
+ pendingQuestions.delete(pending.rpcId)
2079
+ pending.cleanup()
2080
+ Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2081
+ }
2082
+
2083
+ const fallbackApproval = (pending) => {
2084
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2085
+ pendingApprovals.delete(pending.rpcId)
2086
+ pending.cleanup()
2087
+ Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2088
+ }
2089
+
2090
+ const releaseUnclaimedInteractions = () => {
2091
+ for (const pending of [...pendingQuestions.values()]) {
2092
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2093
+ }
2094
+ for (const pending of [...pendingApprovals.values()]) {
2095
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2096
+ }
2097
+ }
2098
+
1947
2099
  const respondToQuestion = async (msg, cancel = false) => {
1948
2100
  const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
1949
2101
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
@@ -1951,46 +2103,30 @@ const plugin = {
1951
2103
  return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
1952
2104
  }
1953
2105
 
1954
- if (cancel) {
1955
- const pending = pendingQuestions.get(rpcId)
1956
- if (pending && pending.sessionId !== sessionId) {
1957
- return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
1958
- }
1959
- } else if (!Array.isArray(msg.answers)) {
2106
+ const pending = pendingQuestions.get(rpcId)
2107
+ if (!pending) {
2108
+ return { kind: 'question-response', rpcId, sessionId, action: cancel ? 'cancel' : 'answer', accepted: false, reason: 'not-pending' }
2109
+ }
2110
+ if (pending.sessionId !== sessionId) {
2111
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
2112
+ }
2113
+ if (!cancel && !Array.isArray(msg.answers)) {
1960
2114
  return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
1961
2115
  }
1962
-
1963
- const result = cancel
1964
- ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
1965
- : {
1966
- ok: true,
1967
- value: {
1968
- sessionId,
1969
- answer: {
1970
- answers: msg.answers.map((answer) => ({
1971
- id: answer && answer.id,
1972
- selected: answer && answer.selected,
1973
- ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
1974
- })),
1975
- },
1976
- },
1977
- }
1978
-
1979
- try {
1980
- const receipt = await api.respond({ type: 'client-response', rpcId, result })
1981
- log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
1982
- return {
1983
- kind: 'question-response',
1984
- rpcId,
1985
- sessionId,
1986
- action: cancel ? 'cancel' : 'answer',
1987
- accepted: receipt.accepted,
1988
- ...(!receipt.accepted ? { reason: receipt.reason } : {}),
1989
- }
1990
- } catch (error) {
1991
- const message = error && error.message ? error.message : String(error)
1992
- log(`question response failed: rpcId=${rpcId} ${message}`)
1993
- return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
2116
+ const normalized = cancel ? null : normalizeQuestionAnswers(pending, msg.answers)
2117
+ if (normalized?.error) {
2118
+ return { kind: 'error', code: 'bad-response', message: normalized.error, requestType: msg.type, sessionId }
2119
+ }
2120
+ const answer = normalized?.value
2121
+ const accepted = completeQuestion(pending, cancel ? 'cancel' : 'answer', answer)
2122
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${accepted}`)
2123
+ return {
2124
+ kind: 'question-response',
2125
+ rpcId,
2126
+ sessionId,
2127
+ action: cancel ? 'cancel' : 'answer',
2128
+ accepted,
2129
+ ...(!accepted ? { reason: 'not-pending' } : {}),
1994
2130
  }
1995
2131
  }
1996
2132
 
@@ -2007,30 +2143,22 @@ const plugin = {
2007
2143
  }
2008
2144
 
2009
2145
  const pending = pendingApprovals.get(rpcId)
2010
- if (pending && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
2146
+ if (!pending) {
2147
+ return { kind: 'approval-response', rpcId, sessionId, approvalId, outcome, accepted: false, reason: 'not-pending' }
2148
+ }
2149
+ if (pending.sessionId !== sessionId || pending.approvalId !== approvalId) {
2011
2150
  return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
2012
2151
  }
2013
-
2014
- try {
2015
- const receipt = await api.respond({
2016
- type: 'client-response',
2017
- rpcId,
2018
- result: { ok: true, value: { sessionId, approvalId, outcome } },
2019
- })
2020
- log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
2021
- return {
2022
- kind: 'approval-response',
2023
- rpcId,
2024
- sessionId,
2025
- approvalId,
2026
- outcome,
2027
- accepted: receipt.accepted,
2028
- ...(!receipt.accepted ? { reason: receipt.reason } : {}),
2029
- }
2030
- } catch (error) {
2031
- const message = error && error.message ? error.message : String(error)
2032
- log(`approval response failed: rpcId=${rpcId} ${message}`)
2033
- return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
2152
+ const accepted = completeApproval(pending, outcome)
2153
+ log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${accepted}`)
2154
+ return {
2155
+ kind: 'approval-response',
2156
+ rpcId,
2157
+ sessionId,
2158
+ approvalId,
2159
+ outcome,
2160
+ accepted,
2161
+ ...(!accepted ? { reason: 'not-pending' } : {}),
2034
2162
  }
2035
2163
  }
2036
2164
 
@@ -2263,8 +2391,10 @@ const plugin = {
2263
2391
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2264
2392
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2265
2393
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2266
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers') {
2267
- handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
2394
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2395
+ msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2396
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2397
+ handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2268
2398
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2269
2399
  })
2270
2400
  } else {
@@ -2275,6 +2405,7 @@ const plugin = {
2275
2405
  ws.on('close', () => {
2276
2406
  clients.delete(ws)
2277
2407
  if (ws.deviceId) registry.disconnected(ws.deviceId)
2408
+ releaseUnclaimedInteractions()
2278
2409
  fileTransfers.closeClient(ws).catch((error) => {
2279
2410
  log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
2280
2411
  })
@@ -2292,7 +2423,7 @@ const plugin = {
2292
2423
  ws.send(JSON.stringify({
2293
2424
  kind: 'hello',
2294
2425
  protocol: 3,
2295
- capabilities: ['images', 'commands', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2426
+ capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2296
2427
  port: transport.port || webServer.port,
2297
2428
  clients: clients.size,
2298
2429
  authenticated: !!device,
@@ -2363,93 +2494,114 @@ const plugin = {
2363
2494
  })
2364
2495
  log('session/event listener attached')
2365
2496
 
2366
- const interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
2367
- ? (async () => {
2368
- try {
2369
- for await (const envelope of api.events.mux(
2370
- { rpcId: crypto.randomUUID(), payload: {} },
2371
- interactionMuxAbort.signal,
2372
- )) {
2373
- const payload = envelope && envelope.payload
2374
- if (!payload || typeof payload.type !== 'string') continue
2375
- log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
2376
- if (payload.type === 'question/requested') {
2377
- const rpcId = String(envelope.rpcId)
2378
- const pending = {
2379
- rpcId,
2380
- sessionId: String(payload.sessionId),
2381
- questions: payload.questions,
2382
- }
2383
- pendingQuestions.set(rpcId, pending)
2384
- broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2385
- log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
2386
- } else if (payload.type === 'approval/requested') {
2387
- const rpcId = String(envelope.rpcId)
2388
- const pending = {
2389
- rpcId,
2390
- sessionId: String(payload.sessionId),
2391
- approvalId: String(payload.approvalId),
2392
- toolName: payload.toolName,
2393
- ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2394
- ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2395
- }
2396
- pendingApprovals.set(rpcId, pending)
2397
- broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
2398
- log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
2399
- } else if (payload.type === 'question/resolved') {
2400
- const questionRpcId = String(payload.questionRpcId)
2401
- pendingQuestions.delete(questionRpcId)
2402
- broadcastInteractionFrame({
2403
- kind: 'question-resolved',
2404
- rpcId: questionRpcId,
2405
- sessionId: String(payload.sessionId),
2406
- outcome: payload.outcome,
2407
- })
2408
- log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
2409
- } else if (payload.type === 'approval/resolved') {
2410
- const approvalId = String(payload.approvalId)
2411
- const sessionId = String(payload.sessionId)
2412
- const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
2413
- if (!entry) {
2414
- log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2415
- continue
2416
- }
2417
- const [rpcId, pending] = entry
2418
- pendingApprovals.delete(rpcId)
2419
- broadcastInteractionFrame({
2420
- kind: 'approval-resolved',
2421
- rpcId,
2422
- sessionId,
2423
- approvalId,
2424
- outcome: payload.outcome,
2425
- })
2426
- log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2427
- } else {
2428
- log(`interaction ignored: type=${payload.type}`)
2429
- }
2430
- }
2431
- } catch (error) {
2432
- if (!interactionMuxAbort.signal.aborted) {
2433
- log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
2434
- }
2497
+ const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2498
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2499
+ if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
2500
+ const rpcId = crypto.randomUUID()
2501
+ return new Promise((resolve, reject) => {
2502
+ const onAbort = () => {
2503
+ const pending = pendingQuestions.get(rpcId)
2504
+ if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
2505
+ }
2506
+ request.signal?.addEventListener('abort', onAbort, { once: true })
2507
+ const pending = {
2508
+ rpcId,
2509
+ sessionId,
2510
+ questions: request.questions,
2511
+ next,
2512
+ resolve,
2513
+ reject,
2514
+ cleanup: () => request.signal?.removeEventListener('abort', onAbort),
2515
+ }
2516
+ pendingQuestions.set(rpcId, pending)
2517
+ broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2518
+ log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
2519
+ })
2520
+ }, { prepend: true })
2521
+
2522
+ const disposeApprovals = ctx.on('approval/request', (request, next) => {
2523
+ const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2524
+ if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
2525
+ const rpcId = crypto.randomUUID()
2526
+ const approvalId = crypto.randomUUID()
2527
+ return new Promise((resolve, reject) => {
2528
+ const onAbort = () => {
2529
+ const pending = pendingApprovals.get(rpcId)
2530
+ if (pending) completeApproval(pending, 'cancelled')
2531
+ }
2532
+ request.signal?.addEventListener('abort', onAbort, { once: true })
2533
+ const pending = {
2534
+ rpcId,
2535
+ sessionId,
2536
+ approvalId,
2537
+ toolName: request.toolName,
2538
+ ...(request.callId !== undefined ? { callId: request.callId } : {}),
2539
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
2540
+ next,
2541
+ resolve,
2542
+ reject,
2543
+ cleanup: () => request.signal?.removeEventListener('abort', onAbort),
2544
+ }
2545
+ pendingApprovals.set(rpcId, pending)
2546
+ broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
2547
+ log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
2548
+ })
2549
+ }, { prepend: true })
2550
+
2551
+ const projectionTask = (async () => {
2552
+ while (!controlStreamAbort.signal.aborted) {
2553
+ try {
2554
+ const stream = await api.openControlStream(controlStreamAbort.signal)
2555
+ 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}`)
2435
2567
  }
2436
- })()
2437
- : null
2438
- if (interactionMuxTask) log('api gateway interaction mux attached')
2439
- else log('api gateway interaction mux unavailable; human-in-the-loop disabled')
2568
+ if (!controlStreamAbort.signal.aborted) log('session control stream ended; retrying')
2569
+ } catch (error) {
2570
+ if (!controlStreamAbort.signal.aborted) {
2571
+ log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2572
+ }
2573
+ }
2574
+ if (controlStreamAbort.signal.aborted) break
2575
+ await new Promise((resolve) => {
2576
+ const onAbort = () => {
2577
+ clearTimeout(timer)
2578
+ resolve()
2579
+ }
2580
+ const timer = setTimeout(() => {
2581
+ controlStreamAbort.signal.removeEventListener('abort', onAbort)
2582
+ resolve()
2583
+ }, 1_000)
2584
+ controlStreamAbort.signal.addEventListener('abort', onAbort, { once: true })
2585
+ })
2586
+ }
2587
+ })()
2588
+ void projectionTask
2589
+ log('Host waterfall interaction listeners and session control stream attached')
2440
2590
 
2441
2591
  ctx.effect(() => () => {
2442
2592
  if (waitTimer) clearTimeout(waitTimer)
2443
2593
  clearInterval(fileTransferExpiryTimer)
2444
- interactionMuxAbort.abort()
2445
- pendingQuestions.clear()
2446
- pendingApprovals.clear()
2594
+ controlStreamAbort.abort()
2595
+ for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
2596
+ for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
2447
2597
  fileTransfers.dispose().catch((error) => {
2448
2598
  log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
2449
2599
  })
2450
2600
  disposeUpgrade()
2451
2601
  disposeMgmt()
2452
2602
  disposeEvents()
2603
+ disposeQuestions()
2604
+ disposeApprovals()
2453
2605
  if (lanServer) lanServer.close()
2454
2606
  for (const client of clients) client.terminate()
2455
2607
  clients.clear()