dsh-plugin-mobile-gateway 0.7.3 → 0.7.5
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 +15 -13
- package/README.md +9 -4
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-rc2-mobile-integration.md +155 -0
- package/docs/typert-remote-gateway-feature-checklist.md +19 -21
- package/lib/dsh-host-adapter.mjs +58 -82
- package/lib/index.mjs +188 -73
- package/lib/session-follower.mjs +136 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +3 -3
package/lib/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringifyWireFrame } from './wire-json.mjs'
|
|
1
2
|
// dsh-plugin-mobile-gateway — persistent Host plugin.
|
|
2
3
|
//
|
|
3
4
|
// Two directions:
|
|
@@ -128,7 +129,8 @@ import crypto from 'node:crypto'
|
|
|
128
129
|
import Schema from '@deepseek-ai/schemastery'
|
|
129
130
|
import { WebSocketServer } from 'ws'
|
|
130
131
|
import devicesModule from './devices.js'
|
|
131
|
-
import { createDshHostAdapter } from './dsh-host-adapter.mjs'
|
|
132
|
+
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
133
|
+
import { createSessionFollower } from './session-follower.mjs'
|
|
132
134
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
133
135
|
import QRCode from 'qrcode'
|
|
134
136
|
|
|
@@ -264,7 +266,10 @@ function imagesOf(blocks) {
|
|
|
264
266
|
// Build the small, owned JSON wire record for one session event. Reads only
|
|
265
267
|
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
266
268
|
function buildWireEvent(session, event) {
|
|
267
|
-
const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time
|
|
269
|
+
const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time,
|
|
270
|
+
...(event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }),
|
|
271
|
+
...(event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }),
|
|
272
|
+
}
|
|
268
273
|
const d = event.data || {}
|
|
269
274
|
switch (event.type) {
|
|
270
275
|
case 'user/message': {
|
|
@@ -274,19 +279,11 @@ function buildWireEvent(session, event) {
|
|
|
274
279
|
type: 'user/message',
|
|
275
280
|
text: textOf(d.content || []),
|
|
276
281
|
source: d.source && d.source.kind,
|
|
282
|
+
...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
|
|
277
283
|
...(images.length ? { images } : {}),
|
|
278
284
|
},
|
|
279
285
|
})
|
|
280
286
|
}
|
|
281
|
-
case 'assistant/chunk': {
|
|
282
|
-
const chunk = d.chunk || {}
|
|
283
|
-
const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
|
|
284
|
-
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') ev.text = chunk.text
|
|
285
|
-
if (chunk.type === 'tool-call-delta') ev.tool = { id: chunk.id, name: chunk.name, argumentsDelta: chunk.argumentsDelta }
|
|
286
|
-
if (chunk.type === 'usage') ev.usage = chunk.usage
|
|
287
|
-
if (chunk.type === 'finish') ev.finish = { kind: chunk.reason && chunk.reason.kind }
|
|
288
|
-
return Object.assign(base, { event: ev })
|
|
289
|
-
}
|
|
290
287
|
case 'assistant/message': {
|
|
291
288
|
const blocks = (d.message && d.message.content) || []
|
|
292
289
|
let text = ''
|
|
@@ -300,9 +297,15 @@ function buildWireEvent(session, event) {
|
|
|
300
297
|
else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
|
|
301
298
|
}
|
|
302
299
|
return Object.assign(base, {
|
|
303
|
-
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
|
|
300
|
+
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
|
|
301
|
+
...(d.interrupted === true ? { interrupted: true } : {}),
|
|
302
|
+
...(d.usage === undefined ? {} : { usage: d.usage }),
|
|
303
|
+
...(images.length ? { images } : {}),
|
|
304
|
+
},
|
|
304
305
|
})
|
|
305
306
|
}
|
|
307
|
+
case 'assistant/attempt':
|
|
308
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
|
|
306
309
|
case 'session/title':
|
|
307
310
|
return Object.assign(base, {
|
|
308
311
|
event: {
|
|
@@ -329,7 +332,7 @@ function buildWireEvent(session, event) {
|
|
|
329
332
|
turn: d.turn,
|
|
330
333
|
step: d.step,
|
|
331
334
|
callId: d.message && d.message.source && d.message.source.callId,
|
|
332
|
-
isError: !!d.error,
|
|
335
|
+
isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
333
336
|
preview,
|
|
334
337
|
},
|
|
335
338
|
})
|
|
@@ -479,7 +482,7 @@ async function admitCommand(host, msg) {
|
|
|
479
482
|
if (!descriptor) {
|
|
480
483
|
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
481
484
|
}
|
|
482
|
-
if (parsedImages.value.length > 0 && descriptor
|
|
485
|
+
if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
|
|
483
486
|
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
484
487
|
}
|
|
485
488
|
return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
@@ -561,6 +564,13 @@ async function proxyQuery(api, type, method, payload, signal) {
|
|
|
561
564
|
}
|
|
562
565
|
}
|
|
563
566
|
|
|
567
|
+
// Projection queries must keep the caller's request identity on failure.
|
|
568
|
+
// They need the opening cut and projections, not the full conversation body.
|
|
569
|
+
async function querySessionProjection(api, requestType, sessionId) {
|
|
570
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
|
|
571
|
+
return history.kind === 'error' ? { ...history, requestType, sessionId } : history
|
|
572
|
+
}
|
|
573
|
+
|
|
564
574
|
function fileTransferError(code, message, requestType, sessionId) {
|
|
565
575
|
return {
|
|
566
576
|
kind: 'error',
|
|
@@ -1024,6 +1034,9 @@ async function mutateGoal(api, type, method, payload) {
|
|
|
1024
1034
|
// and truncates oversized tool-result text.
|
|
1025
1035
|
// ---------------------------------------------------------------------------
|
|
1026
1036
|
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
|
|
1037
|
+
// Opening snapshots should be readable before older history is requested.
|
|
1038
|
+
// Keep the newest indivisible message even when it alone exceeds this budget.
|
|
1039
|
+
const HISTORY_OPENING_MAX_BYTES = 256 * 1024
|
|
1027
1040
|
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
|
|
1028
1041
|
|
|
1029
1042
|
function eventBytes(event) {
|
|
@@ -1037,8 +1050,15 @@ function trimConversationEvent(event) {
|
|
|
1037
1050
|
switch (event.type) {
|
|
1038
1051
|
case 'assistant/chunk':
|
|
1039
1052
|
case 'request/header':
|
|
1053
|
+
case 'request/context':
|
|
1054
|
+
case 'system/message':
|
|
1040
1055
|
// token-level replay / system-prompt header: not rendered on the chat page
|
|
1041
1056
|
return null
|
|
1057
|
+
case 'assistant/message':
|
|
1058
|
+
case 'assistant/attempt': {
|
|
1059
|
+
const { stream, ...data } = event.data || {}
|
|
1060
|
+
return { ...event, data }
|
|
1061
|
+
}
|
|
1042
1062
|
case 'tool/result': {
|
|
1043
1063
|
const d = event.data || {}
|
|
1044
1064
|
const message = d.message
|
|
@@ -1088,12 +1108,46 @@ function capHistoryEvents(events, maxBytes, trim) {
|
|
|
1088
1108
|
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
1089
1109
|
}
|
|
1090
1110
|
|
|
1111
|
+
function validateHistoryCursor(msg, sessionId, field) {
|
|
1112
|
+
if (msg[field] === undefined && msg.historyFormatVersion === undefined) return null
|
|
1113
|
+
if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
|
|
1114
|
+
return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
|
|
1115
|
+
historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
|
|
1116
|
+
message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
|
|
1117
|
+
}
|
|
1118
|
+
if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
|
|
1119
|
+
return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
|
|
1120
|
+
message: `${field} must be a non-negative safe integer` }
|
|
1121
|
+
}
|
|
1122
|
+
return null
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function historyPage(value, msg) {
|
|
1126
|
+
const rawEvents = value.events.map(entry => entry.event)
|
|
1127
|
+
const maxBytes = Number.isSafeInteger(msg.maxBytes) && msg.maxBytes > 0 ? msg.maxBytes : HISTORY_DEFAULT_MAX_BYTES
|
|
1128
|
+
const trim = msg.view === 'conversation'
|
|
1129
|
+
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
1130
|
+
const hasMore = value.hasMore === true || capped.dropped > 0
|
|
1131
|
+
// An all-hidden page still needs a cursor so callers can reach older text.
|
|
1132
|
+
const oldest = capped.events[0]?.seq ?? rawEvents[0]?.seq
|
|
1133
|
+
return { ...value, kind: 'history', sessionId: msg.sessionId,
|
|
1134
|
+
events: capped.events, bytes: capped.bytes, hasMore,
|
|
1135
|
+
...(trim ? { view: 'conversation' } : {}),
|
|
1136
|
+
...(hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1091
1140
|
function resolveCommandCatalogCopy(locale) {
|
|
1092
1141
|
return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
|
|
1093
1142
|
? COMMAND_CATALOG_COPY.en
|
|
1094
1143
|
: COMMAND_CATALOG_COPY.zh
|
|
1095
1144
|
}
|
|
1096
1145
|
|
|
1146
|
+
// DSH 0.1.5-rc.2 command descriptors use input.attachments.
|
|
1147
|
+
function commandAcceptsAttachments(command) {
|
|
1148
|
+
return command?.input?.attachments === true
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1097
1151
|
function commandUiDescriptor(command, copy) {
|
|
1098
1152
|
const override = COMMAND_UI_OVERRIDES[command.name]
|
|
1099
1153
|
if (override) return { ...override, insertText: `/${command.name}` }
|
|
@@ -1104,7 +1158,7 @@ function commandUiDescriptor(command, copy) {
|
|
|
1104
1158
|
insertText: `/${command.name} `,
|
|
1105
1159
|
hint: command.input.hint,
|
|
1106
1160
|
...(displayHint ? { displayHint } : {}),
|
|
1107
|
-
images: command
|
|
1161
|
+
images: commandAcceptsAttachments(command),
|
|
1108
1162
|
submitRequest: 'command-execute',
|
|
1109
1163
|
}
|
|
1110
1164
|
}
|
|
@@ -1279,33 +1333,13 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1279
1333
|
if (!sessionId) {
|
|
1280
1334
|
return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
|
|
1281
1335
|
}
|
|
1336
|
+
const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
|
|
1337
|
+
if (cursorError) return cursorError
|
|
1282
1338
|
const payload = { sessionId }
|
|
1283
1339
|
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
1284
1340
|
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
1285
1341
|
const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
|
|
1286
|
-
if (frame.kind === 'history') {
|
|
1287
|
-
// Scheme A base: pass the raw SessionEvent list through (drop the host
|
|
1288
|
-
// render intent); keep the projections block and echo the sessionId.
|
|
1289
|
-
const rawEvents = Array.isArray(frame.events) ? frame.events.map((entry) => entry.event) : []
|
|
1290
|
-
// Byte budget + optional conversation trim — keeps the frame under the
|
|
1291
|
-
// client's WebSocket limit and pages the rest via nextBeforeSeq.
|
|
1292
|
-
const maxBytes = typeof msg.maxBytes === 'number' && Number.isFinite(msg.maxBytes) && msg.maxBytes > 0
|
|
1293
|
-
? Math.floor(msg.maxBytes)
|
|
1294
|
-
: HISTORY_DEFAULT_MAX_BYTES
|
|
1295
|
-
const trim = msg.view === 'conversation'
|
|
1296
|
-
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
1297
|
-
frame.events = capped.events
|
|
1298
|
-
frame.sessionId = sessionId
|
|
1299
|
-
frame.bytes = capped.bytes
|
|
1300
|
-
if (trim) frame.view = 'conversation'
|
|
1301
|
-
// hasMore combines the host's message-count pagination with byte-drop.
|
|
1302
|
-
const apiHasMore = frame.hasMore === true
|
|
1303
|
-
const byteDropped = capped.dropped > 0
|
|
1304
|
-
frame.hasMore = apiHasMore || byteDropped > 0
|
|
1305
|
-
if (frame.hasMore && capped.events.length > 0) {
|
|
1306
|
-
frame.nextBeforeSeq = capped.events[0].seq // oldest kept event: page back from here
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1342
|
+
if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
|
|
1309
1343
|
return frame
|
|
1310
1344
|
}
|
|
1311
1345
|
if (msg.type === 'attachment') {
|
|
@@ -1363,6 +1397,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1363
1397
|
if (msg.type === 'fork') {
|
|
1364
1398
|
const sessionId = requireSessionId(msg)
|
|
1365
1399
|
if (sessionId.error) return sessionId.error
|
|
1400
|
+
const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
|
|
1401
|
+
if (cursorError) return cursorError
|
|
1366
1402
|
const payload = { sessionId: sessionId.value }
|
|
1367
1403
|
if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
|
|
1368
1404
|
const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
|
|
@@ -1515,7 +1551,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1515
1551
|
action: 'execute',
|
|
1516
1552
|
ui: commandUiDescriptor(command, copy),
|
|
1517
1553
|
...(command.input && typeof command.input.hint === 'string'
|
|
1518
|
-
? { input: { hint: command.input.hint, ...(command
|
|
1554
|
+
? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
|
|
1519
1555
|
: {}),
|
|
1520
1556
|
}))
|
|
1521
1557
|
if (!commands.some((command) => command.name === 'model')) {
|
|
@@ -1600,7 +1636,9 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1600
1636
|
}
|
|
1601
1637
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1602
1638
|
if (sessionId) {
|
|
1603
|
-
|
|
1639
|
+
out.sessionId = sessionId
|
|
1640
|
+
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1641
|
+
if (hist.kind === 'error') return hist
|
|
1604
1642
|
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1605
1643
|
out.sessionPermissions = hist.projections.values.permissions || null
|
|
1606
1644
|
}
|
|
@@ -1648,7 +1686,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1648
1686
|
if (msg.type === 'context-usage') {
|
|
1649
1687
|
const sessionId = requireSessionId(msg)
|
|
1650
1688
|
if (sessionId.error) return sessionId.error
|
|
1651
|
-
const hist = await
|
|
1689
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1652
1690
|
if (hist.kind !== 'history') return hist
|
|
1653
1691
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1654
1692
|
return {
|
|
@@ -1662,7 +1700,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1662
1700
|
if (msg.type === 'session-stats') {
|
|
1663
1701
|
const sessionId = requireSessionId(msg)
|
|
1664
1702
|
if (sessionId.error) return sessionId.error
|
|
1665
|
-
const hist = await
|
|
1703
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1666
1704
|
if (hist.kind !== 'history') return hist
|
|
1667
1705
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1668
1706
|
return {
|
|
@@ -2053,6 +2091,7 @@ const plugin = {
|
|
|
2053
2091
|
const backgroundStreamAbort = new AbortController()
|
|
2054
2092
|
let archivedSessionIds = null
|
|
2055
2093
|
let sessionQueues = null
|
|
2094
|
+
let sessionProjectionBaselines = {}
|
|
2056
2095
|
let counter = 0
|
|
2057
2096
|
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2058
2097
|
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
@@ -2154,7 +2193,7 @@ const plugin = {
|
|
|
2154
2193
|
})
|
|
2155
2194
|
|
|
2156
2195
|
const broadcastInteractionFrame = (frame) => {
|
|
2157
|
-
const wire =
|
|
2196
|
+
const wire = stringifyWireFrame(frame)
|
|
2158
2197
|
for (const client of clients) {
|
|
2159
2198
|
if (client.mobileChannel === 'conversation') continue
|
|
2160
2199
|
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
@@ -2166,7 +2205,7 @@ const plugin = {
|
|
|
2166
2205
|
// subscribed to one conversation. Subscriptions only scope the heavier
|
|
2167
2206
|
// conversation and interaction streams.
|
|
2168
2207
|
const broadcastSessionMetadataFrame = (frame) => {
|
|
2169
|
-
const wire =
|
|
2208
|
+
const wire = stringifyWireFrame(frame)
|
|
2170
2209
|
for (const client of clients) {
|
|
2171
2210
|
if (client.mobileChannel === 'conversation') continue
|
|
2172
2211
|
if (client.readyState === 1) client.send(wire)
|
|
@@ -2179,12 +2218,12 @@ const plugin = {
|
|
|
2179
2218
|
let approvalCount = 0
|
|
2180
2219
|
for (const pending of pendingQuestions.values()) {
|
|
2181
2220
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2182
|
-
ws.send(
|
|
2221
|
+
ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
|
|
2183
2222
|
questionCount += 1
|
|
2184
2223
|
}
|
|
2185
2224
|
for (const pending of pendingApprovals.values()) {
|
|
2186
2225
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2187
|
-
ws.send(
|
|
2226
|
+
ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
|
|
2188
2227
|
approvalCount += 1
|
|
2189
2228
|
}
|
|
2190
2229
|
log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
|
|
@@ -2539,47 +2578,87 @@ const plugin = {
|
|
|
2539
2578
|
log('mobile gateway wait completed: device connected')
|
|
2540
2579
|
}
|
|
2541
2580
|
|
|
2581
|
+
const sendFrame = frame => {
|
|
2582
|
+
if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2583
|
+
}
|
|
2584
|
+
ws.sessionFollower = createSessionFollower(api, {
|
|
2585
|
+
onFrame(frame, context) {
|
|
2586
|
+
if (frame.type === 'snapshot') {
|
|
2587
|
+
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
|
|
2588
|
+
kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
|
|
2589
|
+
} else if (frame.type === 'event') {
|
|
2590
|
+
sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
|
|
2591
|
+
} else {
|
|
2592
|
+
sendFrame({ kind: 'assistant-stream', ...context, frame: frame.frame })
|
|
2593
|
+
}
|
|
2594
|
+
},
|
|
2595
|
+
onError(error, context) {
|
|
2596
|
+
sendFrame({ kind: 'session-stream-reset', ...context,
|
|
2597
|
+
code: error.code || 'stream-interrupted', message: error.message })
|
|
2598
|
+
},
|
|
2599
|
+
})
|
|
2600
|
+
|
|
2542
2601
|
ws.on('message', (data) => {
|
|
2543
2602
|
let msg
|
|
2544
2603
|
try {
|
|
2545
2604
|
msg = JSON.parse(data.toString())
|
|
2546
2605
|
} catch (error) {
|
|
2547
|
-
ws.send(
|
|
2606
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
|
|
2548
2607
|
return
|
|
2549
2608
|
}
|
|
2550
2609
|
if (!msg || typeof msg.type !== 'string') return
|
|
2551
2610
|
const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
|
|
2552
2611
|
if ((ws.mobileChannel === 'control' && conversationRequest) ||
|
|
2553
2612
|
(ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
|
|
2554
|
-
ws.send(
|
|
2613
|
+
ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
|
|
2555
2614
|
return
|
|
2556
2615
|
}
|
|
2557
2616
|
|
|
2558
2617
|
if (msg.type === 'ping') {
|
|
2559
|
-
ws.send(
|
|
2618
|
+
ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
|
|
2560
2619
|
} else if (msg.type === 'subscribe') {
|
|
2561
|
-
|
|
2562
|
-
|
|
2620
|
+
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
2621
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
2622
|
+
return
|
|
2623
|
+
}
|
|
2624
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : undefined
|
|
2625
|
+
if (msg.assistantStream === true && !sessionId) {
|
|
2626
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream requires a sessionId' })
|
|
2627
|
+
return
|
|
2628
|
+
}
|
|
2629
|
+
ws.sessionFollower.stop()
|
|
2630
|
+
ws.filterSessionId = sessionId
|
|
2631
|
+
ws.sessionFollowerActive = msg.assistantStream === true
|
|
2632
|
+
const subscriptionId = crypto.randomUUID()
|
|
2633
|
+
sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
|
|
2634
|
+
if (ws.sessionFollowerActive) {
|
|
2635
|
+
void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
|
|
2636
|
+
log(`session follower failed: ${error.message}`)
|
|
2637
|
+
if (ws.readyState === 1) ws.close(1011, 'session follower failed')
|
|
2638
|
+
})
|
|
2639
|
+
}
|
|
2563
2640
|
replayPendingInteractions(ws, 'subscribe')
|
|
2564
2641
|
} else if (msg.type === 'unsubscribe') {
|
|
2642
|
+
ws.sessionFollower.stop()
|
|
2643
|
+
ws.sessionFollowerActive = false
|
|
2565
2644
|
ws.filterSessionId = undefined
|
|
2566
|
-
|
|
2645
|
+
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2567
2646
|
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2568
2647
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2569
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2648
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2570
2649
|
})
|
|
2571
2650
|
} else if (msg.type === 'approval-response') {
|
|
2572
2651
|
respondToApproval(msg).then((frame) => {
|
|
2573
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2652
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2574
2653
|
})
|
|
2575
2654
|
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
2576
2655
|
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
2577
2656
|
fileTransfers.handle(ws, msg).then((frame) => {
|
|
2578
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2657
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2579
2658
|
})
|
|
2580
2659
|
} else if (msg.type === 'message') {
|
|
2581
2660
|
admitMessage(api, msg).then((frame) => {
|
|
2582
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2661
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2583
2662
|
})
|
|
2584
2663
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
2585
2664
|
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
@@ -2593,14 +2672,15 @@ const plugin = {
|
|
|
2593
2672
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2594
2673
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
2595
2674
|
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2596
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2675
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2597
2676
|
})
|
|
2598
2677
|
} else {
|
|
2599
|
-
ws.send(
|
|
2678
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
|
|
2600
2679
|
}
|
|
2601
2680
|
})
|
|
2602
2681
|
|
|
2603
2682
|
ws.on('close', () => {
|
|
2683
|
+
ws.sessionFollower.stop()
|
|
2604
2684
|
clients.delete(ws)
|
|
2605
2685
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2606
2686
|
releaseUnclaimedInteractions()
|
|
@@ -2612,19 +2692,24 @@ const plugin = {
|
|
|
2612
2692
|
|
|
2613
2693
|
log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
|
|
2614
2694
|
if (paired) {
|
|
2615
|
-
ws.send(
|
|
2695
|
+
ws.send(stringifyWireFrame({
|
|
2616
2696
|
kind: 'paired',
|
|
2617
2697
|
token: paired.token,
|
|
2618
2698
|
device: paired.device,
|
|
2619
2699
|
...gatewayIdentity,
|
|
2620
2700
|
}))
|
|
2621
2701
|
}
|
|
2622
|
-
ws.send(
|
|
2702
|
+
ws.send(stringifyWireFrame({
|
|
2623
2703
|
kind: 'hello',
|
|
2624
2704
|
...gatewayIdentity,
|
|
2625
2705
|
protocol: 3,
|
|
2706
|
+
dshVersion: DSH_VERSION,
|
|
2707
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
2626
2708
|
capabilities: [
|
|
2627
2709
|
'split-channels',
|
|
2710
|
+
'assistant-stream-v1',
|
|
2711
|
+
'history-format-version',
|
|
2712
|
+
'projection-baseline',
|
|
2628
2713
|
'images',
|
|
2629
2714
|
'session-create',
|
|
2630
2715
|
'commands',
|
|
@@ -2642,10 +2727,13 @@ const plugin = {
|
|
|
2642
2727
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
2643
2728
|
}))
|
|
2644
2729
|
if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
|
|
2645
|
-
ws.send(
|
|
2730
|
+
ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
|
|
2646
2731
|
}
|
|
2647
2732
|
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2648
|
-
ws.send(
|
|
2733
|
+
ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2734
|
+
}
|
|
2735
|
+
if (ws.mobileChannel !== 'conversation') {
|
|
2736
|
+
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
2649
2737
|
}
|
|
2650
2738
|
replayPendingInteractions(ws, 'connect')
|
|
2651
2739
|
})
|
|
@@ -2714,9 +2802,9 @@ const plugin = {
|
|
|
2714
2802
|
...(event.data.source ? { source: event.data.source } : {}),
|
|
2715
2803
|
})
|
|
2716
2804
|
}
|
|
2717
|
-
const payload =
|
|
2805
|
+
const payload = stringifyWireFrame(wire)
|
|
2718
2806
|
for (const client of clients) {
|
|
2719
|
-
if (client.mobileChannel === 'control') continue
|
|
2807
|
+
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2720
2808
|
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
|
2721
2809
|
if (client.readyState === 1) client.send(payload)
|
|
2722
2810
|
}
|
|
@@ -2849,6 +2937,33 @@ const plugin = {
|
|
|
2849
2937
|
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
2850
2938
|
}
|
|
2851
2939
|
|
|
2940
|
+
const projectionFrame = (sessionId, key, value, seq) => ({
|
|
2941
|
+
kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
|
|
2942
|
+
[key]: value,
|
|
2943
|
+
})
|
|
2944
|
+
|
|
2945
|
+
const installProjectionBaseline = value => {
|
|
2946
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
|
|
2947
|
+
const next = {}
|
|
2948
|
+
for (const [sessionId, baseline] of Object.entries(value)) {
|
|
2949
|
+
if (!Number.isSafeInteger(baseline?.asOfSeq) || !baseline.values || typeof baseline.values !== 'object' || Array.isArray(baseline.values)) {
|
|
2950
|
+
throw new Error('invalid session projection baseline')
|
|
2951
|
+
}
|
|
2952
|
+
Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
2953
|
+
value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
|
|
2954
|
+
}
|
|
2955
|
+
const previous = sessionProjectionBaselines
|
|
2956
|
+
sessionProjectionBaselines = next
|
|
2957
|
+
broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
|
|
2958
|
+
for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
|
|
2959
|
+
const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
|
|
2960
|
+
for (const key of ['todos', 'goal']) {
|
|
2961
|
+
broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
|
|
2962
|
+
baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2852
2967
|
const workspaceTask = (async () => {
|
|
2853
2968
|
while (!backgroundStreamAbort.signal.aborted) {
|
|
2854
2969
|
try {
|
|
@@ -2877,19 +2992,16 @@ const plugin = {
|
|
|
2877
2992
|
const stream = await api.openControlStream(backgroundStreamAbort.signal)
|
|
2878
2993
|
for await (const frame of stream) {
|
|
2879
2994
|
if (frame?.type === 'baseline') {
|
|
2995
|
+
installProjectionBaseline(frame.value?.projections)
|
|
2880
2996
|
installSessionQueueBaseline(frame.value?.queues)
|
|
2881
2997
|
} else if (frame?.type === 'queue') {
|
|
2882
2998
|
installSessionQueue(frame.sessionId, frame.items)
|
|
2883
2999
|
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
2884
3000
|
const sessionId = String(frame.sessionId)
|
|
2885
|
-
const
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
sessionId,
|
|
2890
|
-
asOfSeq: frame.seq,
|
|
2891
|
-
[valueKey]: frame.value,
|
|
2892
|
-
})
|
|
3001
|
+
const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
|
|
3002
|
+
Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
3003
|
+
value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
|
|
3004
|
+
broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
|
|
2893
3005
|
log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
|
|
2894
3006
|
}
|
|
2895
3007
|
}
|
|
@@ -2920,7 +3032,10 @@ const plugin = {
|
|
|
2920
3032
|
disposeQuestions()
|
|
2921
3033
|
disposeApprovals()
|
|
2922
3034
|
if (lanServer) lanServer.close()
|
|
2923
|
-
for (const client of clients)
|
|
3035
|
+
for (const client of clients) {
|
|
3036
|
+
client.sessionFollower?.stop()
|
|
3037
|
+
client.terminate()
|
|
3038
|
+
}
|
|
2924
3039
|
clients.clear()
|
|
2925
3040
|
wss.close()
|
|
2926
3041
|
log('plugin stopped, all sockets closed')
|