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/PROTOCOL.md +113 -22
- package/README.md +11 -1
- package/cordis.patch.yml +2 -2
- package/docs/blog-mobile-gateway.md +1 -1
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/lib/dsh-host-adapter.mjs +304 -0
- package/lib/index.mjs +376 -224
- package/package.json +3 -3
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
|
|
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(
|
|
401
|
-
const listed = await
|
|
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(
|
|
417
|
+
async function executeHostCommand(host, sessionId, line, images, requestType) {
|
|
412
418
|
try {
|
|
413
|
-
const execution = await
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
491
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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: '
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
...(
|
|
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
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
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,
|
|
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
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
1837
|
+
// admits mobile messages through the Host Remote Gateway. Cordis must not
|
|
1776
1838
|
// activate this row before either service exists.
|
|
1777
|
-
inject: ['webServer', '
|
|
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.
|
|
1784
|
-
const
|
|
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
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
|
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
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
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
|
|
2367
|
-
?
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
-
|
|
2445
|
-
pendingQuestions.
|
|
2446
|
-
pendingApprovals.
|
|
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()
|