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/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, ...(images.length ? { images } : {}) },
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.input?.images !== true) {
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.input.images === true,
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.input.images === true ? { images: true } : {}) } }
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
- const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
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 proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
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 proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
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 = JSON.stringify(frame)
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 = JSON.stringify(frame)
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(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
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(JSON.stringify(approvalFrameFor(pending.rpcId, pending, true)))
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(JSON.stringify({ kind: 'error', message: 'invalid json' }))
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(JSON.stringify({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
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(JSON.stringify({ kind: 'pong', at: Date.now() }))
2618
+ ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
2560
2619
  } 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 }))
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
- ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
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(JSON.stringify(frame))
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(JSON.stringify(frame))
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(JSON.stringify(frame))
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(JSON.stringify(frame))
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(JSON.stringify(frame))
2675
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2597
2676
  })
2598
2677
  } else {
2599
- ws.send(JSON.stringify({ kind: 'error', message: 'unknown message type: ' + msg.type }))
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(JSON.stringify({
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(JSON.stringify({
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(JSON.stringify({ kind: 'session-archives', archivedSessionIds }))
2730
+ ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
2646
2731
  }
2647
2732
  if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2648
- ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
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 = JSON.stringify(wire)
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 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
- })
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) client.terminate()
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')