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/PROTOCOL.md +156 -12
- package/README.md +10 -2
- package/cordis.patch.yml +2 -2
- package/docs/blog-mobile-gateway.md +1 -1
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/typert-remote-gateway-feature-checklist.md +296 -0
- package/lib/dsh-host-adapter.mjs +311 -0
- package/lib/index.mjs +549 -234
- 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):
|
|
@@ -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(
|
|
411
|
-
const listed = await
|
|
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(
|
|
439
|
+
async function executeHostCommand(host, sessionId, line, images, requestType) {
|
|
422
440
|
try {
|
|
423
|
-
const execution = await
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
501
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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: '
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
...(
|
|
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
|
|
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
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
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,
|
|
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
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
1962
|
+
// admits mobile messages through the Host Remote Gateway. Cordis must not
|
|
1867
1963
|
// activate this row before either service exists.
|
|
1868
|
-
inject: ['webServer', '
|
|
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.
|
|
1875
|
-
const
|
|
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
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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 === '
|
|
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,
|
|
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: [
|
|
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
|
|
2460
|
-
?
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
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
|
-
}
|
|
2536
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
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
|
-
|
|
2549
|
-
pendingQuestions.
|
|
2550
|
-
pendingApprovals.
|
|
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()
|