dsh-plugin-mobile-gateway 0.7.3 → 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 +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 +151 -0
- package/docs/typert-remote-gateway-feature-checklist.md +19 -21
- package/lib/dsh-host-adapter.mjs +54 -81
- package/lib/index.mjs +153 -52
- package/lib/session-follower.mjs +134 -0
- package/package.json +3 -3
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,88 +13,52 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const DSH_VERSION = '0.1.5-rc.2'
|
|
17
|
+
export const SESSION_FORMAT_VERSION = 3
|
|
18
|
+
|
|
19
|
+
export function readHistoryRecords(records) {
|
|
20
|
+
return requireArray(records, 'session history').map(record => {
|
|
21
|
+
const value = requireRecord(record, 'session history record')
|
|
22
|
+
if (value.type !== 'event') throw new Error('DSH 0.1.5-rc.2 history requires event records')
|
|
23
|
+
const event = requireRecord(value.event, 'session history event')
|
|
24
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
|
|
25
|
+
return event
|
|
26
|
+
})
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
? requireArray(data.args, event.type)
|
|
29
|
-
: requireArray(data.texts, event.type)
|
|
30
|
-
const gaps = requireArray(data.dt, event.type)
|
|
31
|
-
if (gaps.length !== Math.max(0, values.length - 1)) {
|
|
32
|
-
throw new Error(`${event.type} returned invalid timing data`)
|
|
33
|
-
}
|
|
34
|
-
const events = []
|
|
35
|
-
let time = event.time
|
|
36
|
-
for (let index = 0; index < values.length; index += 1) {
|
|
37
|
-
if (index > 0) time += gaps[index - 1]
|
|
38
|
-
let chunk
|
|
39
|
-
if (event.type === 'chunkrow/text-chunks') {
|
|
40
|
-
chunk = { type: 'text-delta', index: data.index, text: values[index] }
|
|
41
|
-
} else if (event.type === 'chunkrow/reasoning-chunks') {
|
|
42
|
-
chunk = { type: 'reasoning-delta', index: data.index, text: values[index] }
|
|
43
|
-
} else {
|
|
44
|
-
chunk = {
|
|
45
|
-
type: 'tool-call-delta',
|
|
46
|
-
index: data.index,
|
|
47
|
-
id: data.id,
|
|
48
|
-
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
|
|
49
|
-
argumentsDelta: values[index],
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
events.push({
|
|
53
|
-
type: 'assistant/chunk',
|
|
54
|
-
seq: event.seq + index,
|
|
55
|
-
time,
|
|
56
|
-
data: { turn: data.turn, step: data.step, chunk },
|
|
29
|
+
export function readSessionSnapshot(frame, sessionId) {
|
|
30
|
+
requireRecord(frame, 'session/follow')
|
|
31
|
+
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
|
|
32
|
+
|| frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
|
|
33
|
+
if (frame.header.version !== SESSION_FORMAT_VERSION) {
|
|
34
|
+
throw Object.assign(new Error('mobile-gateway requires DSH 0.1.5-rc.2 Session format 3'), {
|
|
35
|
+
code: 'unsupported-session-format',
|
|
57
36
|
})
|
|
58
37
|
}
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const value = requireRecord(record, 'session history record')
|
|
66
|
-
const event = requireRecord(value.event, 'session history event')
|
|
67
|
-
if (value.type === 'event') {
|
|
68
|
-
events.push(event)
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
if (value.type !== 'chunks' || ![
|
|
72
|
-
'chunkrow/text-chunks',
|
|
73
|
-
'chunkrow/reasoning-chunks',
|
|
74
|
-
'chunkrow/tool-call-chunks',
|
|
75
|
-
].includes(event.type)) {
|
|
76
|
-
throw new Error('session history returned an unsupported record')
|
|
77
|
-
}
|
|
78
|
-
if (chunkRowLength(event) === 0) throw new Error(`${event.type} returned an empty run`)
|
|
79
|
-
events.push(...expandChunkRow(event))
|
|
38
|
+
return {
|
|
39
|
+
events: readHistoryRecords(frame.records).map(event => ({ event })),
|
|
40
|
+
hasMore: frame.hasMore === true,
|
|
41
|
+
projections: requireRecord(frame.projections, 'session/follow projections'),
|
|
42
|
+
historyFormatVersion: frame.header.version,
|
|
43
|
+
cursor: frame.cursor,
|
|
80
44
|
}
|
|
81
|
-
return events
|
|
82
45
|
}
|
|
83
46
|
|
|
84
47
|
async function firstStreamFrame(gateway, namespace, method, args, signal) {
|
|
85
|
-
const ownedAbort =
|
|
86
|
-
const streamSignal = signal
|
|
87
|
-
|
|
88
|
-
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
89
|
-
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
90
|
-
const iterator = iteratorFactory.call(iterable)
|
|
48
|
+
const ownedAbort = new AbortController()
|
|
49
|
+
const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
|
|
50
|
+
let iterator
|
|
91
51
|
try {
|
|
52
|
+
const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
|
|
53
|
+
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
54
|
+
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
55
|
+
iterator = iteratorFactory.call(iterable)
|
|
92
56
|
const first = await iterator.next()
|
|
93
57
|
if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
|
|
94
58
|
return first.value
|
|
95
59
|
} finally {
|
|
96
|
-
ownedAbort
|
|
97
|
-
if (typeof iterator
|
|
60
|
+
ownedAbort.abort()
|
|
61
|
+
if (typeof iterator?.return === 'function') await iterator.return()
|
|
98
62
|
}
|
|
99
63
|
}
|
|
100
64
|
|
|
@@ -102,7 +66,7 @@ function requestArgs(request) {
|
|
|
102
66
|
return { request }
|
|
103
67
|
}
|
|
104
68
|
|
|
105
|
-
// The
|
|
69
|
+
// The 0.1.5-rc.2 SessionController names its reserved list argument
|
|
106
70
|
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
107
71
|
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
108
72
|
function sessionListArgs(request) {
|
|
@@ -134,10 +98,7 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
134
98
|
requestArgs(request),
|
|
135
99
|
signal,
|
|
136
100
|
), 'session/follow')
|
|
137
|
-
|
|
138
|
-
throw new Error('session/follow returned an invalid opening snapshot')
|
|
139
|
-
}
|
|
140
|
-
return frame
|
|
101
|
+
return readSessionSnapshot(frame, request.address.sessionId)
|
|
141
102
|
}
|
|
142
103
|
|
|
143
104
|
const history = async (payload, signal) => {
|
|
@@ -146,8 +107,8 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
146
107
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
147
108
|
}
|
|
148
109
|
const snapshot = await sessionSnapshot(request, signal)
|
|
149
|
-
let
|
|
150
|
-
let hasMore = snapshot.hasMore
|
|
110
|
+
let events = snapshot.events
|
|
111
|
+
let hasMore = snapshot.hasMore
|
|
151
112
|
if (payload.beforeSeq !== undefined) {
|
|
152
113
|
const page = requireRecord(await invoke('session', 'page', requestArgs({
|
|
153
114
|
address: request.address,
|
|
@@ -155,13 +116,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
155
116
|
beforeSeq: payload.beforeSeq,
|
|
156
117
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
157
118
|
}), signal), 'session/page')
|
|
158
|
-
|
|
119
|
+
events = readHistoryRecords(page.records).map(event => ({ event }))
|
|
159
120
|
hasMore = page.hasMore === true
|
|
160
121
|
}
|
|
161
122
|
return {
|
|
162
|
-
|
|
123
|
+
...snapshot,
|
|
124
|
+
events,
|
|
163
125
|
hasMore,
|
|
164
|
-
projections: requireRecord(snapshot.projections, 'session/follow projections'),
|
|
165
126
|
}
|
|
166
127
|
}
|
|
167
128
|
|
|
@@ -172,10 +133,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
172
133
|
|
|
173
134
|
const commands = {
|
|
174
135
|
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
175
|
-
|
|
136
|
+
// DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
|
|
137
|
+
// wire images already carry the { type: 'image', ... } shape it expects, so
|
|
138
|
+
// only the field name changes.
|
|
139
|
+
execute: (sessionId, line, attachments, signal) => invoke(
|
|
176
140
|
'commands',
|
|
177
141
|
'execute',
|
|
178
|
-
{ agentId: sessionId, line,
|
|
142
|
+
{ agentId: sessionId, line, submittedAttachments: attachments },
|
|
179
143
|
signal,
|
|
180
144
|
),
|
|
181
145
|
}
|
|
@@ -196,6 +160,8 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
196
160
|
])
|
|
197
161
|
return {
|
|
198
162
|
version: 'remote-gateway',
|
|
163
|
+
dshVersion: DSH_VERSION,
|
|
164
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
199
165
|
cwd: os.homedir(),
|
|
200
166
|
attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
|
|
201
167
|
canOpenPath: canOpenPath === true,
|
|
@@ -301,6 +267,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
301
267
|
describe: (_payload = {}, signal) => describeHost(signal),
|
|
302
268
|
},
|
|
303
269
|
describeHost,
|
|
270
|
+
openSessionStream(sessionId, signal) {
|
|
271
|
+
return typertGateway.stream({
|
|
272
|
+
namespace: 'session', method: 'follow',
|
|
273
|
+
args: requestArgs({ address: { kind: 'session', sessionId }, assistantStream: true }),
|
|
274
|
+
signal,
|
|
275
|
+
})
|
|
276
|
+
},
|
|
304
277
|
openControlStream(signal) {
|
|
305
278
|
return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
|
|
306
279
|
},
|
package/lib/index.mjs
CHANGED
|
@@ -128,7 +128,8 @@ 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'
|
|
132
133
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
133
134
|
import QRCode from 'qrcode'
|
|
134
135
|
|
|
@@ -264,7 +265,10 @@ function imagesOf(blocks) {
|
|
|
264
265
|
// Build the small, owned JSON wire record for one session event. Reads only
|
|
265
266
|
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
266
267
|
function buildWireEvent(session, event) {
|
|
267
|
-
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
|
+
}
|
|
268
272
|
const d = event.data || {}
|
|
269
273
|
switch (event.type) {
|
|
270
274
|
case 'user/message': {
|
|
@@ -278,15 +282,6 @@ function buildWireEvent(session, event) {
|
|
|
278
282
|
},
|
|
279
283
|
})
|
|
280
284
|
}
|
|
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
285
|
case 'assistant/message': {
|
|
291
286
|
const blocks = (d.message && d.message.content) || []
|
|
292
287
|
let text = ''
|
|
@@ -300,9 +295,15 @@ function buildWireEvent(session, event) {
|
|
|
300
295
|
else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
|
|
301
296
|
}
|
|
302
297
|
return Object.assign(base, {
|
|
303
|
-
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
|
+
},
|
|
304
303
|
})
|
|
305
304
|
}
|
|
305
|
+
case 'assistant/attempt':
|
|
306
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
|
|
306
307
|
case 'session/title':
|
|
307
308
|
return Object.assign(base, {
|
|
308
309
|
event: {
|
|
@@ -329,7 +330,7 @@ function buildWireEvent(session, event) {
|
|
|
329
330
|
turn: d.turn,
|
|
330
331
|
step: d.step,
|
|
331
332
|
callId: d.message && d.message.source && d.message.source.callId,
|
|
332
|
-
isError: !!d.error,
|
|
333
|
+
isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
333
334
|
preview,
|
|
334
335
|
},
|
|
335
336
|
})
|
|
@@ -479,7 +480,7 @@ async function admitCommand(host, msg) {
|
|
|
479
480
|
if (!descriptor) {
|
|
480
481
|
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
481
482
|
}
|
|
482
|
-
if (parsedImages.value.length > 0 && descriptor
|
|
483
|
+
if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
|
|
483
484
|
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
484
485
|
}
|
|
485
486
|
return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
@@ -1037,8 +1038,15 @@ function trimConversationEvent(event) {
|
|
|
1037
1038
|
switch (event.type) {
|
|
1038
1039
|
case 'assistant/chunk':
|
|
1039
1040
|
case 'request/header':
|
|
1041
|
+
case 'request/context':
|
|
1042
|
+
case 'system/message':
|
|
1040
1043
|
// token-level replay / system-prompt header: not rendered on the chat page
|
|
1041
1044
|
return null
|
|
1045
|
+
case 'assistant/message':
|
|
1046
|
+
case 'assistant/attempt': {
|
|
1047
|
+
const { stream, ...data } = event.data || {}
|
|
1048
|
+
return { ...event, data }
|
|
1049
|
+
}
|
|
1042
1050
|
case 'tool/result': {
|
|
1043
1051
|
const d = event.data || {}
|
|
1044
1052
|
const message = d.message
|
|
@@ -1088,12 +1096,46 @@ function capHistoryEvents(events, maxBytes, trim) {
|
|
|
1088
1096
|
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
1089
1097
|
}
|
|
1090
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
|
+
|
|
1091
1128
|
function resolveCommandCatalogCopy(locale) {
|
|
1092
1129
|
return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
|
|
1093
1130
|
? COMMAND_CATALOG_COPY.en
|
|
1094
1131
|
: COMMAND_CATALOG_COPY.zh
|
|
1095
1132
|
}
|
|
1096
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
|
+
|
|
1097
1139
|
function commandUiDescriptor(command, copy) {
|
|
1098
1140
|
const override = COMMAND_UI_OVERRIDES[command.name]
|
|
1099
1141
|
if (override) return { ...override, insertText: `/${command.name}` }
|
|
@@ -1104,7 +1146,7 @@ function commandUiDescriptor(command, copy) {
|
|
|
1104
1146
|
insertText: `/${command.name} `,
|
|
1105
1147
|
hint: command.input.hint,
|
|
1106
1148
|
...(displayHint ? { displayHint } : {}),
|
|
1107
|
-
images: command
|
|
1149
|
+
images: commandAcceptsAttachments(command),
|
|
1108
1150
|
submitRequest: 'command-execute',
|
|
1109
1151
|
}
|
|
1110
1152
|
}
|
|
@@ -1279,33 +1321,13 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1279
1321
|
if (!sessionId) {
|
|
1280
1322
|
return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
|
|
1281
1323
|
}
|
|
1324
|
+
const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
|
|
1325
|
+
if (cursorError) return cursorError
|
|
1282
1326
|
const payload = { sessionId }
|
|
1283
1327
|
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
1284
1328
|
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
1285
1329
|
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
|
-
}
|
|
1330
|
+
if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
|
|
1309
1331
|
return frame
|
|
1310
1332
|
}
|
|
1311
1333
|
if (msg.type === 'attachment') {
|
|
@@ -1363,6 +1385,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1363
1385
|
if (msg.type === 'fork') {
|
|
1364
1386
|
const sessionId = requireSessionId(msg)
|
|
1365
1387
|
if (sessionId.error) return sessionId.error
|
|
1388
|
+
const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
|
|
1389
|
+
if (cursorError) return cursorError
|
|
1366
1390
|
const payload = { sessionId: sessionId.value }
|
|
1367
1391
|
if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
|
|
1368
1392
|
const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
|
|
@@ -1515,7 +1539,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1515
1539
|
action: 'execute',
|
|
1516
1540
|
ui: commandUiDescriptor(command, copy),
|
|
1517
1541
|
...(command.input && typeof command.input.hint === 'string'
|
|
1518
|
-
? { input: { hint: command.input.hint, ...(command
|
|
1542
|
+
? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
|
|
1519
1543
|
: {}),
|
|
1520
1544
|
}))
|
|
1521
1545
|
if (!commands.some((command) => command.name === 'model')) {
|
|
@@ -2053,6 +2077,7 @@ const plugin = {
|
|
|
2053
2077
|
const backgroundStreamAbort = new AbortController()
|
|
2054
2078
|
let archivedSessionIds = null
|
|
2055
2079
|
let sessionQueues = null
|
|
2080
|
+
let sessionProjectionBaselines = {}
|
|
2056
2081
|
let counter = 0
|
|
2057
2082
|
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2058
2083
|
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
@@ -2539,6 +2564,26 @@ const plugin = {
|
|
|
2539
2564
|
log('mobile gateway wait completed: device connected')
|
|
2540
2565
|
}
|
|
2541
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
|
+
|
|
2542
2587
|
ws.on('message', (data) => {
|
|
2543
2588
|
let msg
|
|
2544
2589
|
try {
|
|
@@ -2558,12 +2603,32 @@ const plugin = {
|
|
|
2558
2603
|
if (msg.type === 'ping') {
|
|
2559
2604
|
ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
|
|
2560
2605
|
} else if (msg.type === 'subscribe') {
|
|
2561
|
-
|
|
2562
|
-
|
|
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
|
+
}
|
|
2563
2626
|
replayPendingInteractions(ws, 'subscribe')
|
|
2564
2627
|
} else if (msg.type === 'unsubscribe') {
|
|
2628
|
+
ws.sessionFollower.stop()
|
|
2629
|
+
ws.sessionFollowerActive = false
|
|
2565
2630
|
ws.filterSessionId = undefined
|
|
2566
|
-
|
|
2631
|
+
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2567
2632
|
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2568
2633
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2569
2634
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
@@ -2601,6 +2666,7 @@ const plugin = {
|
|
|
2601
2666
|
})
|
|
2602
2667
|
|
|
2603
2668
|
ws.on('close', () => {
|
|
2669
|
+
ws.sessionFollower.stop()
|
|
2604
2670
|
clients.delete(ws)
|
|
2605
2671
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2606
2672
|
releaseUnclaimedInteractions()
|
|
@@ -2623,8 +2689,13 @@ const plugin = {
|
|
|
2623
2689
|
kind: 'hello',
|
|
2624
2690
|
...gatewayIdentity,
|
|
2625
2691
|
protocol: 3,
|
|
2692
|
+
dshVersion: DSH_VERSION,
|
|
2693
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
2626
2694
|
capabilities: [
|
|
2627
2695
|
'split-channels',
|
|
2696
|
+
'assistant-stream-v1',
|
|
2697
|
+
'history-format-version',
|
|
2698
|
+
'projection-baseline',
|
|
2628
2699
|
'images',
|
|
2629
2700
|
'session-create',
|
|
2630
2701
|
'commands',
|
|
@@ -2647,6 +2718,9 @@ const plugin = {
|
|
|
2647
2718
|
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2648
2719
|
ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2649
2720
|
}
|
|
2721
|
+
if (ws.mobileChannel !== 'conversation') {
|
|
2722
|
+
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
2723
|
+
}
|
|
2650
2724
|
replayPendingInteractions(ws, 'connect')
|
|
2651
2725
|
})
|
|
2652
2726
|
}
|
|
@@ -2716,7 +2790,7 @@ const plugin = {
|
|
|
2716
2790
|
}
|
|
2717
2791
|
const payload = JSON.stringify(wire)
|
|
2718
2792
|
for (const client of clients) {
|
|
2719
|
-
if (client.mobileChannel === 'control') continue
|
|
2793
|
+
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2720
2794
|
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
|
2721
2795
|
if (client.readyState === 1) client.send(payload)
|
|
2722
2796
|
}
|
|
@@ -2849,6 +2923,33 @@ const plugin = {
|
|
|
2849
2923
|
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
2850
2924
|
}
|
|
2851
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
|
+
|
|
2852
2953
|
const workspaceTask = (async () => {
|
|
2853
2954
|
while (!backgroundStreamAbort.signal.aborted) {
|
|
2854
2955
|
try {
|
|
@@ -2877,19 +2978,16 @@ const plugin = {
|
|
|
2877
2978
|
const stream = await api.openControlStream(backgroundStreamAbort.signal)
|
|
2878
2979
|
for await (const frame of stream) {
|
|
2879
2980
|
if (frame?.type === 'baseline') {
|
|
2981
|
+
installProjectionBaseline(frame.value?.projections)
|
|
2880
2982
|
installSessionQueueBaseline(frame.value?.queues)
|
|
2881
2983
|
} else if (frame?.type === 'queue') {
|
|
2882
2984
|
installSessionQueue(frame.sessionId, frame.items)
|
|
2883
2985
|
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
2884
2986
|
const sessionId = String(frame.sessionId)
|
|
2885
|
-
const
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
sessionId,
|
|
2890
|
-
asOfSeq: frame.seq,
|
|
2891
|
-
[valueKey]: frame.value,
|
|
2892
|
-
})
|
|
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))
|
|
2893
2991
|
log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
|
|
2894
2992
|
}
|
|
2895
2993
|
}
|
|
@@ -2920,7 +3018,10 @@ const plugin = {
|
|
|
2920
3018
|
disposeQuestions()
|
|
2921
3019
|
disposeApprovals()
|
|
2922
3020
|
if (lanServer) lanServer.close()
|
|
2923
|
-
for (const client of clients)
|
|
3021
|
+
for (const client of clients) {
|
|
3022
|
+
client.sessionFollower?.stop()
|
|
3023
|
+
client.terminate()
|
|
3024
|
+
}
|
|
2924
3025
|
clients.clear()
|
|
2925
3026
|
wss.close()
|
|
2926
3027
|
log('plugin stopped, all sockets closed')
|