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.
@@ -13,88 +13,52 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- function chunkRowLength(event) {
17
- if (event.type === 'chunkrow/tool-call-chunks') return requireArray(event.data?.args, event.type).length
18
- return requireArray(event.data?.texts, event.type).length
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
- // DSH 0.1.2 compresses consecutive assistant deltas inside history pages.
22
- // dsh-mobile-v1 predates that storage shape, so restore the exact logical
23
- // SessionEvent sequence at this boundary instead of teaching every client a
24
- // Host-specific encoding detail.
25
- function expandChunkRow(event) {
26
- const data = requireRecord(event.data, event.type)
27
- const values = event.type === 'chunkrow/tool-call-chunks'
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 events
60
- }
61
-
62
- export function expandHistoryRecords(records) {
63
- const events = []
64
- for (const record of requireArray(records, 'session history')) {
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 = signal === undefined ? new AbortController() : null
86
- const streamSignal = signal ?? ownedAbort.signal
87
- const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
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?.abort()
97
- if (typeof iterator.return === 'function') await iterator.return()
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 RC.1 SessionController intentionally names its reserved list argument
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
- if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
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 records = snapshot.records
150
- let hasMore = snapshot.hasMore === true
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
- records = page.records
119
+ events = readHistoryRecords(page.records).map(event => ({ event }))
159
120
  hasMore = page.hasMore === true
160
121
  }
161
122
  return {
162
- events: expandHistoryRecords(records).map(event => ({ event })),
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
- execute: (sessionId, line, images, signal) => invoke(
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, images },
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, ...(images.length ? { images } : {}) },
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.input?.images !== true) {
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.input.images === true,
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.input.images === true ? { images: true } : {}) } }
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
- ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
2562
- ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
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
- ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
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 kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2886
- const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2887
- broadcastInteractionFrame({
2888
- kind,
2889
- sessionId,
2890
- asOfSeq: frame.seq,
2891
- [valueKey]: frame.value,
2892
- })
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) client.terminate()
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')