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