dsh-plugin-mobile-gateway 0.7.2 → 0.7.4
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 +38 -14
- package/README.md +43 -5
- package/cordis.patch.yml +8 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-rc2-mobile-integration.md +151 -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 +19 -21
- package/lib/client.js +30 -21
- package/lib/dsh-host-adapter.mjs +54 -81
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +223 -72
- package/lib/session-follower.mjs +134 -0
- package/package.json +3 -3
package/lib/index.mjs
CHANGED
|
@@ -128,7 +128,9 @@ import crypto from 'node:crypto'
|
|
|
128
128
|
import Schema from '@deepseek-ai/schemastery'
|
|
129
129
|
import { WebSocketServer } from 'ws'
|
|
130
130
|
import devicesModule from './devices.js'
|
|
131
|
-
import { createDshHostAdapter } from './dsh-host-adapter.mjs'
|
|
131
|
+
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
132
|
+
import { createSessionFollower } from './session-follower.mjs'
|
|
133
|
+
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
132
134
|
import QRCode from 'qrcode'
|
|
133
135
|
|
|
134
136
|
const { createRegistry } = devicesModule
|
|
@@ -201,6 +203,10 @@ const Config = Schema.object({
|
|
|
201
203
|
path: Schema.string().default(DEFAULT_WS_PATH),
|
|
202
204
|
requireAuth: Schema.boolean().default(true),
|
|
203
205
|
gatewayEnabled: Schema.boolean().default(false),
|
|
206
|
+
gatewayMode: Schema.union(GATEWAY_MODES),
|
|
207
|
+
gatewayStateFile: Schema.string().default(''),
|
|
208
|
+
gatewayName: Schema.string().default(''),
|
|
209
|
+
endpoints: Schema.array(Schema.string()).default([]),
|
|
204
210
|
gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
|
|
205
211
|
maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
|
|
206
212
|
fileDownloadsEnabled: Schema.boolean().default(true),
|
|
@@ -259,7 +265,10 @@ function imagesOf(blocks) {
|
|
|
259
265
|
// Build the small, owned JSON wire record for one session event. Reads only
|
|
260
266
|
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
261
267
|
function buildWireEvent(session, event) {
|
|
262
|
-
const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time
|
|
268
|
+
const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time,
|
|
269
|
+
...(event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }),
|
|
270
|
+
...(event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }),
|
|
271
|
+
}
|
|
263
272
|
const d = event.data || {}
|
|
264
273
|
switch (event.type) {
|
|
265
274
|
case 'user/message': {
|
|
@@ -273,15 +282,6 @@ function buildWireEvent(session, event) {
|
|
|
273
282
|
},
|
|
274
283
|
})
|
|
275
284
|
}
|
|
276
|
-
case 'assistant/chunk': {
|
|
277
|
-
const chunk = d.chunk || {}
|
|
278
|
-
const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
|
|
279
|
-
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') ev.text = chunk.text
|
|
280
|
-
if (chunk.type === 'tool-call-delta') ev.tool = { id: chunk.id, name: chunk.name, argumentsDelta: chunk.argumentsDelta }
|
|
281
|
-
if (chunk.type === 'usage') ev.usage = chunk.usage
|
|
282
|
-
if (chunk.type === 'finish') ev.finish = { kind: chunk.reason && chunk.reason.kind }
|
|
283
|
-
return Object.assign(base, { event: ev })
|
|
284
|
-
}
|
|
285
285
|
case 'assistant/message': {
|
|
286
286
|
const blocks = (d.message && d.message.content) || []
|
|
287
287
|
let text = ''
|
|
@@ -295,9 +295,15 @@ function buildWireEvent(session, event) {
|
|
|
295
295
|
else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
|
|
296
296
|
}
|
|
297
297
|
return Object.assign(base, {
|
|
298
|
-
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
|
|
298
|
+
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
|
|
299
|
+
...(d.interrupted === true ? { interrupted: true } : {}),
|
|
300
|
+
...(d.usage === undefined ? {} : { usage: d.usage }),
|
|
301
|
+
...(images.length ? { images } : {}),
|
|
302
|
+
},
|
|
299
303
|
})
|
|
300
304
|
}
|
|
305
|
+
case 'assistant/attempt':
|
|
306
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
|
|
301
307
|
case 'session/title':
|
|
302
308
|
return Object.assign(base, {
|
|
303
309
|
event: {
|
|
@@ -324,7 +330,7 @@ function buildWireEvent(session, event) {
|
|
|
324
330
|
turn: d.turn,
|
|
325
331
|
step: d.step,
|
|
326
332
|
callId: d.message && d.message.source && d.message.source.callId,
|
|
327
|
-
isError: !!d.error,
|
|
333
|
+
isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
328
334
|
preview,
|
|
329
335
|
},
|
|
330
336
|
})
|
|
@@ -474,7 +480,7 @@ async function admitCommand(host, msg) {
|
|
|
474
480
|
if (!descriptor) {
|
|
475
481
|
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
476
482
|
}
|
|
477
|
-
if (parsedImages.value.length > 0 && descriptor
|
|
483
|
+
if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
|
|
478
484
|
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
479
485
|
}
|
|
480
486
|
return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
@@ -1032,8 +1038,15 @@ function trimConversationEvent(event) {
|
|
|
1032
1038
|
switch (event.type) {
|
|
1033
1039
|
case 'assistant/chunk':
|
|
1034
1040
|
case 'request/header':
|
|
1041
|
+
case 'request/context':
|
|
1042
|
+
case 'system/message':
|
|
1035
1043
|
// token-level replay / system-prompt header: not rendered on the chat page
|
|
1036
1044
|
return null
|
|
1045
|
+
case 'assistant/message':
|
|
1046
|
+
case 'assistant/attempt': {
|
|
1047
|
+
const { stream, ...data } = event.data || {}
|
|
1048
|
+
return { ...event, data }
|
|
1049
|
+
}
|
|
1037
1050
|
case 'tool/result': {
|
|
1038
1051
|
const d = event.data || {}
|
|
1039
1052
|
const message = d.message
|
|
@@ -1083,12 +1096,46 @@ function capHistoryEvents(events, maxBytes, trim) {
|
|
|
1083
1096
|
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
1084
1097
|
}
|
|
1085
1098
|
|
|
1099
|
+
function validateHistoryCursor(msg, sessionId, field) {
|
|
1100
|
+
if (msg[field] === undefined && msg.historyFormatVersion === undefined) return null
|
|
1101
|
+
if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
|
|
1102
|
+
return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
|
|
1103
|
+
historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
|
|
1104
|
+
message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
|
|
1105
|
+
}
|
|
1106
|
+
if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
|
|
1107
|
+
return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
|
|
1108
|
+
message: `${field} must be a non-negative safe integer` }
|
|
1109
|
+
}
|
|
1110
|
+
return null
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function historyPage(value, msg) {
|
|
1114
|
+
const rawEvents = value.events.map(entry => entry.event)
|
|
1115
|
+
const maxBytes = Number.isSafeInteger(msg.maxBytes) && msg.maxBytes > 0 ? msg.maxBytes : HISTORY_DEFAULT_MAX_BYTES
|
|
1116
|
+
const trim = msg.view === 'conversation'
|
|
1117
|
+
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
1118
|
+
const hasMore = value.hasMore === true || capped.dropped > 0
|
|
1119
|
+
// An all-hidden page still needs a cursor so callers can reach older text.
|
|
1120
|
+
const oldest = capped.events[0]?.seq ?? rawEvents[0]?.seq
|
|
1121
|
+
return { ...value, kind: 'history', sessionId: msg.sessionId,
|
|
1122
|
+
events: capped.events, bytes: capped.bytes, hasMore,
|
|
1123
|
+
...(trim ? { view: 'conversation' } : {}),
|
|
1124
|
+
...(hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1086
1128
|
function resolveCommandCatalogCopy(locale) {
|
|
1087
1129
|
return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
|
|
1088
1130
|
? COMMAND_CATALOG_COPY.en
|
|
1089
1131
|
: COMMAND_CATALOG_COPY.zh
|
|
1090
1132
|
}
|
|
1091
1133
|
|
|
1134
|
+
// DSH 0.1.5-rc.2 command descriptors use input.attachments.
|
|
1135
|
+
function commandAcceptsAttachments(command) {
|
|
1136
|
+
return command?.input?.attachments === true
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1092
1139
|
function commandUiDescriptor(command, copy) {
|
|
1093
1140
|
const override = COMMAND_UI_OVERRIDES[command.name]
|
|
1094
1141
|
if (override) return { ...override, insertText: `/${command.name}` }
|
|
@@ -1099,7 +1146,7 @@ function commandUiDescriptor(command, copy) {
|
|
|
1099
1146
|
insertText: `/${command.name} `,
|
|
1100
1147
|
hint: command.input.hint,
|
|
1101
1148
|
...(displayHint ? { displayHint } : {}),
|
|
1102
|
-
images: command
|
|
1149
|
+
images: commandAcceptsAttachments(command),
|
|
1103
1150
|
submitRequest: 'command-execute',
|
|
1104
1151
|
}
|
|
1105
1152
|
}
|
|
@@ -1274,33 +1321,13 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1274
1321
|
if (!sessionId) {
|
|
1275
1322
|
return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
|
|
1276
1323
|
}
|
|
1324
|
+
const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
|
|
1325
|
+
if (cursorError) return cursorError
|
|
1277
1326
|
const payload = { sessionId }
|
|
1278
1327
|
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
1279
1328
|
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
1280
1329
|
const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
|
|
1281
|
-
if (frame.kind === 'history') {
|
|
1282
|
-
// Scheme A base: pass the raw SessionEvent list through (drop the host
|
|
1283
|
-
// render intent); keep the projections block and echo the sessionId.
|
|
1284
|
-
const rawEvents = Array.isArray(frame.events) ? frame.events.map((entry) => entry.event) : []
|
|
1285
|
-
// Byte budget + optional conversation trim — keeps the frame under the
|
|
1286
|
-
// client's WebSocket limit and pages the rest via nextBeforeSeq.
|
|
1287
|
-
const maxBytes = typeof msg.maxBytes === 'number' && Number.isFinite(msg.maxBytes) && msg.maxBytes > 0
|
|
1288
|
-
? Math.floor(msg.maxBytes)
|
|
1289
|
-
: HISTORY_DEFAULT_MAX_BYTES
|
|
1290
|
-
const trim = msg.view === 'conversation'
|
|
1291
|
-
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
1292
|
-
frame.events = capped.events
|
|
1293
|
-
frame.sessionId = sessionId
|
|
1294
|
-
frame.bytes = capped.bytes
|
|
1295
|
-
if (trim) frame.view = 'conversation'
|
|
1296
|
-
// hasMore combines the host's message-count pagination with byte-drop.
|
|
1297
|
-
const apiHasMore = frame.hasMore === true
|
|
1298
|
-
const byteDropped = capped.dropped > 0
|
|
1299
|
-
frame.hasMore = apiHasMore || byteDropped > 0
|
|
1300
|
-
if (frame.hasMore && capped.events.length > 0) {
|
|
1301
|
-
frame.nextBeforeSeq = capped.events[0].seq // oldest kept event: page back from here
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1330
|
+
if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
|
|
1304
1331
|
return frame
|
|
1305
1332
|
}
|
|
1306
1333
|
if (msg.type === 'attachment') {
|
|
@@ -1358,6 +1385,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1358
1385
|
if (msg.type === 'fork') {
|
|
1359
1386
|
const sessionId = requireSessionId(msg)
|
|
1360
1387
|
if (sessionId.error) return sessionId.error
|
|
1388
|
+
const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
|
|
1389
|
+
if (cursorError) return cursorError
|
|
1361
1390
|
const payload = { sessionId: sessionId.value }
|
|
1362
1391
|
if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
|
|
1363
1392
|
const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
|
|
@@ -1510,7 +1539,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1510
1539
|
action: 'execute',
|
|
1511
1540
|
ui: commandUiDescriptor(command, copy),
|
|
1512
1541
|
...(command.input && typeof command.input.hint === 'string'
|
|
1513
|
-
? { input: { hint: command.input.hint, ...(command
|
|
1542
|
+
? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
|
|
1514
1543
|
: {}),
|
|
1515
1544
|
}))
|
|
1516
1545
|
if (!commands.some((command) => command.name === 'model')) {
|
|
@@ -1845,11 +1874,13 @@ function normalizePublicUrl(value, req, wsPath) {
|
|
|
1845
1874
|
const host = req.headers.host || `127.0.0.1`
|
|
1846
1875
|
raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
|
|
1847
1876
|
}
|
|
1848
|
-
|
|
1877
|
+
let url
|
|
1878
|
+
try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
|
|
1849
1879
|
if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
1850
1880
|
if (url.protocol === 'http:') url.protocol = 'ws:'
|
|
1851
1881
|
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
|
|
1852
1882
|
if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
|
|
1883
|
+
if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
|
|
1853
1884
|
if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
|
|
1854
1885
|
return url.toString()
|
|
1855
1886
|
}
|
|
@@ -1882,8 +1913,12 @@ function readBody(req) {
|
|
|
1882
1913
|
reject(error)
|
|
1883
1914
|
return
|
|
1884
1915
|
}
|
|
1885
|
-
try {
|
|
1886
|
-
const
|
|
1916
|
+
try {
|
|
1917
|
+
const body = data === '' ? {} : JSON.parse(data)
|
|
1918
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
|
|
1919
|
+
resolve(body)
|
|
1920
|
+
} catch (cause) {
|
|
1921
|
+
const error = new Error('request body must be a JSON object', { cause })
|
|
1887
1922
|
error.status = 400
|
|
1888
1923
|
reject(error)
|
|
1889
1924
|
}
|
|
@@ -1974,6 +2009,9 @@ const plugin = {
|
|
|
1974
2009
|
path: DEFAULT_WS_PATH,
|
|
1975
2010
|
requireAuth: true,
|
|
1976
2011
|
gatewayEnabled: false,
|
|
2012
|
+
gatewayStateFile: '',
|
|
2013
|
+
gatewayName: '',
|
|
2014
|
+
endpoints: [],
|
|
1977
2015
|
gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
|
|
1978
2016
|
maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
|
|
1979
2017
|
fileDownloadsEnabled: true,
|
|
@@ -2006,6 +2044,17 @@ const plugin = {
|
|
|
2006
2044
|
let requireAuth = options.requireAuth !== false
|
|
2007
2045
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2008
2046
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
2047
|
+
if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
|
|
2048
|
+
if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
|
|
2049
|
+
const normalizeEndpoints = (values) => {
|
|
2050
|
+
if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
|
|
2051
|
+
throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
|
|
2052
|
+
}
|
|
2053
|
+
return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
|
|
2054
|
+
}
|
|
2055
|
+
const configuredEndpoints = normalizeEndpoints(options.endpoints)
|
|
2056
|
+
const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
|
|
2057
|
+
const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
|
|
2009
2058
|
const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
|
|
2010
2059
|
const wss = new WebSocketServer({
|
|
2011
2060
|
noServer: true,
|
|
@@ -2028,8 +2077,10 @@ const plugin = {
|
|
|
2028
2077
|
const backgroundStreamAbort = new AbortController()
|
|
2029
2078
|
let archivedSessionIds = null
|
|
2030
2079
|
let sessionQueues = null
|
|
2080
|
+
let sessionProjectionBaselines = {}
|
|
2031
2081
|
let counter = 0
|
|
2032
|
-
let
|
|
2082
|
+
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2083
|
+
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
2033
2084
|
let waitExpiresAt = null
|
|
2034
2085
|
let waitTimer = null
|
|
2035
2086
|
let connectedSinceEnabled = false
|
|
@@ -2053,25 +2104,44 @@ const plugin = {
|
|
|
2053
2104
|
}
|
|
2054
2105
|
}
|
|
2055
2106
|
|
|
2056
|
-
const
|
|
2107
|
+
const advertisedEndpoints = (primary, extra = []) => {
|
|
2108
|
+
const publicUrl = configuredPublicUrl()
|
|
2109
|
+
return normalizeEndpoints([...new Set([
|
|
2110
|
+
...(primary ? [primary] : []),
|
|
2111
|
+
...extra,
|
|
2112
|
+
...configuredEndpoints,
|
|
2113
|
+
...(publicUrl ? [publicUrl] : []),
|
|
2114
|
+
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2115
|
+
])])
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const setGatewayMode = (mode, reason, persist = true) => {
|
|
2119
|
+
if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
|
|
2120
|
+
// Commit before changing the live service, so a failed save never reports success.
|
|
2121
|
+
if (persist) gatewayState.setMode(mode)
|
|
2057
2122
|
if (waitTimer) clearTimeout(waitTimer)
|
|
2058
2123
|
waitTimer = null
|
|
2059
|
-
|
|
2124
|
+
gatewayMode = mode
|
|
2125
|
+
gatewayEnabled = mode !== 'disabled'
|
|
2060
2126
|
waitExpiresAt = null
|
|
2061
|
-
connectedSinceEnabled =
|
|
2062
|
-
if (
|
|
2127
|
+
connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
|
|
2128
|
+
if (mode === 'temporary' && !connectedSinceEnabled) {
|
|
2063
2129
|
waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
|
|
2064
2130
|
waitTimer = setTimeout(() => {
|
|
2065
2131
|
waitTimer = null
|
|
2066
|
-
if (!gatewayEnabled || connectedSinceEnabled || clients.
|
|
2132
|
+
if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
|
|
2067
2133
|
gatewayEnabled = false
|
|
2134
|
+
gatewayMode = 'disabled'
|
|
2068
2135
|
waitExpiresAt = null
|
|
2136
|
+
try { gatewayState.setMode('disabled') } catch (error) {
|
|
2137
|
+
log(`failed to persist automatic gateway disable: ${error.message}`)
|
|
2138
|
+
}
|
|
2069
2139
|
log('mobile gateway automatically disabled: no device connected before timeout')
|
|
2070
2140
|
}, options.gatewayWaitTimeoutMs)
|
|
2071
|
-
} else {
|
|
2141
|
+
} else if (mode === 'disabled') {
|
|
2072
2142
|
for (const client of clients) client.close(4004, 'mobile gateway disabled')
|
|
2073
2143
|
}
|
|
2074
|
-
log(`mobile gateway
|
|
2144
|
+
log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
|
|
2075
2145
|
}
|
|
2076
2146
|
|
|
2077
2147
|
const logAuthRejected = (req) => {
|
|
@@ -2303,12 +2373,8 @@ const plugin = {
|
|
|
2303
2373
|
}
|
|
2304
2374
|
}
|
|
2305
2375
|
|
|
2306
|
-
//
|
|
2307
|
-
|
|
2308
|
-
// auto-close. That safety net stays attached to the ephemeral panel toggle,
|
|
2309
|
-
// where it belongs. gatewayEnabled is already true from options at this point,
|
|
2310
|
-
// so nothing else is needed here beyond the log line.
|
|
2311
|
-
if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
|
|
2376
|
+
// Saved user choice wins; a temporary startup gets a fresh first-connection window.
|
|
2377
|
+
setGatewayMode(gatewayMode, 'startup', false)
|
|
2312
2378
|
|
|
2313
2379
|
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2314
2380
|
|
|
@@ -2333,6 +2399,9 @@ const plugin = {
|
|
|
2333
2399
|
version: PLUGIN_VERSION,
|
|
2334
2400
|
requireAuth,
|
|
2335
2401
|
gatewayEnabled,
|
|
2402
|
+
gatewayMode,
|
|
2403
|
+
...gatewayIdentity,
|
|
2404
|
+
endpoints: advertisedEndpoints(),
|
|
2336
2405
|
waitExpiresAt,
|
|
2337
2406
|
connectedClients: clients.size,
|
|
2338
2407
|
webPort: webServer.port,
|
|
@@ -2364,9 +2433,10 @@ const plugin = {
|
|
|
2364
2433
|
sendJson(res, 200, result)
|
|
2365
2434
|
} else if (req.method === 'POST' && p === '/mgw/gateway') {
|
|
2366
2435
|
const body = await readBody(req)
|
|
2367
|
-
if (
|
|
2368
|
-
|
|
2369
|
-
|
|
2436
|
+
if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
|
|
2437
|
+
if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
|
|
2438
|
+
setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
|
|
2439
|
+
sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
|
|
2370
2440
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2371
2441
|
const body = await readBody(req)
|
|
2372
2442
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2393,12 +2463,15 @@ const plugin = {
|
|
|
2393
2463
|
const body = await readBody(req)
|
|
2394
2464
|
const name = typeof body.name === 'string' ? body.name : undefined
|
|
2395
2465
|
const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
|
|
2466
|
+
const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
|
|
2396
2467
|
const pairing = registry.createPairing(name)
|
|
2397
2468
|
const payload = {
|
|
2398
2469
|
version: 2,
|
|
2399
2470
|
publicUrl,
|
|
2400
2471
|
pairingCode: pairing.code,
|
|
2401
2472
|
expiresAt: pairing.expiresAt,
|
|
2473
|
+
...gatewayIdentity,
|
|
2474
|
+
endpoints,
|
|
2402
2475
|
}
|
|
2403
2476
|
// QR/manual pairing has one canonical wire representation: the
|
|
2404
2477
|
// UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
|
|
@@ -2491,6 +2564,26 @@ const plugin = {
|
|
|
2491
2564
|
log('mobile gateway wait completed: device connected')
|
|
2492
2565
|
}
|
|
2493
2566
|
|
|
2567
|
+
const sendFrame = frame => {
|
|
2568
|
+
if (ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
2569
|
+
}
|
|
2570
|
+
ws.sessionFollower = createSessionFollower(api, {
|
|
2571
|
+
onFrame(frame, context) {
|
|
2572
|
+
if (frame.type === 'snapshot') {
|
|
2573
|
+
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation' }),
|
|
2574
|
+
kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
|
|
2575
|
+
} else if (frame.type === 'event') {
|
|
2576
|
+
sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
|
|
2577
|
+
} else {
|
|
2578
|
+
sendFrame({ kind: 'assistant-stream', ...context, frame: frame.frame })
|
|
2579
|
+
}
|
|
2580
|
+
},
|
|
2581
|
+
onError(error, context) {
|
|
2582
|
+
sendFrame({ kind: 'session-stream-reset', ...context,
|
|
2583
|
+
code: error.code || 'stream-interrupted', message: error.message })
|
|
2584
|
+
},
|
|
2585
|
+
})
|
|
2586
|
+
|
|
2494
2587
|
ws.on('message', (data) => {
|
|
2495
2588
|
let msg
|
|
2496
2589
|
try {
|
|
@@ -2510,12 +2603,32 @@ const plugin = {
|
|
|
2510
2603
|
if (msg.type === 'ping') {
|
|
2511
2604
|
ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
|
|
2512
2605
|
} else if (msg.type === 'subscribe') {
|
|
2513
|
-
|
|
2514
|
-
|
|
2606
|
+
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
2607
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2610
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : undefined
|
|
2611
|
+
if (msg.assistantStream === true && !sessionId) {
|
|
2612
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream requires a sessionId' })
|
|
2613
|
+
return
|
|
2614
|
+
}
|
|
2615
|
+
ws.sessionFollower.stop()
|
|
2616
|
+
ws.filterSessionId = sessionId
|
|
2617
|
+
ws.sessionFollowerActive = msg.assistantStream === true
|
|
2618
|
+
const subscriptionId = crypto.randomUUID()
|
|
2619
|
+
sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
|
|
2620
|
+
if (ws.sessionFollowerActive) {
|
|
2621
|
+
void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
|
|
2622
|
+
log(`session follower failed: ${error.message}`)
|
|
2623
|
+
if (ws.readyState === 1) ws.close(1011, 'session follower failed')
|
|
2624
|
+
})
|
|
2625
|
+
}
|
|
2515
2626
|
replayPendingInteractions(ws, 'subscribe')
|
|
2516
2627
|
} else if (msg.type === 'unsubscribe') {
|
|
2628
|
+
ws.sessionFollower.stop()
|
|
2629
|
+
ws.sessionFollowerActive = false
|
|
2517
2630
|
ws.filterSessionId = undefined
|
|
2518
|
-
|
|
2631
|
+
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2519
2632
|
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2520
2633
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2521
2634
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
@@ -2553,6 +2666,7 @@ const plugin = {
|
|
|
2553
2666
|
})
|
|
2554
2667
|
|
|
2555
2668
|
ws.on('close', () => {
|
|
2669
|
+
ws.sessionFollower.stop()
|
|
2556
2670
|
clients.delete(ws)
|
|
2557
2671
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2558
2672
|
releaseUnclaimedInteractions()
|
|
@@ -2568,13 +2682,20 @@ const plugin = {
|
|
|
2568
2682
|
kind: 'paired',
|
|
2569
2683
|
token: paired.token,
|
|
2570
2684
|
device: paired.device,
|
|
2685
|
+
...gatewayIdentity,
|
|
2571
2686
|
}))
|
|
2572
2687
|
}
|
|
2573
2688
|
ws.send(JSON.stringify({
|
|
2574
2689
|
kind: 'hello',
|
|
2690
|
+
...gatewayIdentity,
|
|
2575
2691
|
protocol: 3,
|
|
2692
|
+
dshVersion: DSH_VERSION,
|
|
2693
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
2576
2694
|
capabilities: [
|
|
2577
2695
|
'split-channels',
|
|
2696
|
+
'assistant-stream-v1',
|
|
2697
|
+
'history-format-version',
|
|
2698
|
+
'projection-baseline',
|
|
2578
2699
|
'images',
|
|
2579
2700
|
'session-create',
|
|
2580
2701
|
'commands',
|
|
@@ -2597,6 +2718,9 @@ const plugin = {
|
|
|
2597
2718
|
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2598
2719
|
ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2599
2720
|
}
|
|
2721
|
+
if (ws.mobileChannel !== 'conversation') {
|
|
2722
|
+
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
2723
|
+
}
|
|
2600
2724
|
replayPendingInteractions(ws, 'connect')
|
|
2601
2725
|
})
|
|
2602
2726
|
}
|
|
@@ -2666,7 +2790,7 @@ const plugin = {
|
|
|
2666
2790
|
}
|
|
2667
2791
|
const payload = JSON.stringify(wire)
|
|
2668
2792
|
for (const client of clients) {
|
|
2669
|
-
if (client.mobileChannel === 'control') continue
|
|
2793
|
+
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2670
2794
|
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
|
2671
2795
|
if (client.readyState === 1) client.send(payload)
|
|
2672
2796
|
}
|
|
@@ -2799,6 +2923,33 @@ const plugin = {
|
|
|
2799
2923
|
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
2800
2924
|
}
|
|
2801
2925
|
|
|
2926
|
+
const projectionFrame = (sessionId, key, value, seq) => ({
|
|
2927
|
+
kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
|
|
2928
|
+
[key]: value,
|
|
2929
|
+
})
|
|
2930
|
+
|
|
2931
|
+
const installProjectionBaseline = value => {
|
|
2932
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
|
|
2933
|
+
const next = {}
|
|
2934
|
+
for (const [sessionId, baseline] of Object.entries(value)) {
|
|
2935
|
+
if (!Number.isSafeInteger(baseline?.asOfSeq) || !baseline.values || typeof baseline.values !== 'object' || Array.isArray(baseline.values)) {
|
|
2936
|
+
throw new Error('invalid session projection baseline')
|
|
2937
|
+
}
|
|
2938
|
+
Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
2939
|
+
value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
|
|
2940
|
+
}
|
|
2941
|
+
const previous = sessionProjectionBaselines
|
|
2942
|
+
sessionProjectionBaselines = next
|
|
2943
|
+
broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
|
|
2944
|
+
for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
|
|
2945
|
+
const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
|
|
2946
|
+
for (const key of ['todos', 'goal']) {
|
|
2947
|
+
broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
|
|
2948
|
+
baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2802
2953
|
const workspaceTask = (async () => {
|
|
2803
2954
|
while (!backgroundStreamAbort.signal.aborted) {
|
|
2804
2955
|
try {
|
|
@@ -2827,19 +2978,16 @@ const plugin = {
|
|
|
2827
2978
|
const stream = await api.openControlStream(backgroundStreamAbort.signal)
|
|
2828
2979
|
for await (const frame of stream) {
|
|
2829
2980
|
if (frame?.type === 'baseline') {
|
|
2981
|
+
installProjectionBaseline(frame.value?.projections)
|
|
2830
2982
|
installSessionQueueBaseline(frame.value?.queues)
|
|
2831
2983
|
} else if (frame?.type === 'queue') {
|
|
2832
2984
|
installSessionQueue(frame.sessionId, frame.items)
|
|
2833
2985
|
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
2834
2986
|
const sessionId = String(frame.sessionId)
|
|
2835
|
-
const
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
sessionId,
|
|
2840
|
-
asOfSeq: frame.seq,
|
|
2841
|
-
[valueKey]: frame.value,
|
|
2842
|
-
})
|
|
2987
|
+
const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
|
|
2988
|
+
Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
2989
|
+
value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
|
|
2990
|
+
broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
|
|
2843
2991
|
log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
|
|
2844
2992
|
}
|
|
2845
2993
|
}
|
|
@@ -2870,7 +3018,10 @@ const plugin = {
|
|
|
2870
3018
|
disposeQuestions()
|
|
2871
3019
|
disposeApprovals()
|
|
2872
3020
|
if (lanServer) lanServer.close()
|
|
2873
|
-
for (const client of clients)
|
|
3021
|
+
for (const client of clients) {
|
|
3022
|
+
client.sessionFollower?.stop()
|
|
3023
|
+
client.terminate()
|
|
3024
|
+
}
|
|
2874
3025
|
clients.clear()
|
|
2875
3026
|
wss.close()
|
|
2876
3027
|
log('plugin stopped, all sockets closed')
|