dsh-plugin-mobile-gateway 0.10.4 → 0.11.1

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
@@ -21,7 +21,7 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
21
21
  // { "type": "subscribe", "sessionId": "..." } (optional filter)
22
22
  // { "type": "unsubscribe" }
23
23
  // { "type": "message", "sessionId"?, "text"?, "images"?:
24
- // [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
24
+ // [{ "mediaType", "data", "name"? }], "files"?: [{ "receiptId" }], "mode"?: "queue"|"steer",
25
25
  // "workspaceId"?, "cwd"?, "clientTimeZone"? }
26
26
  // sessionId omitted -> a new session is created first; the
27
27
  // new session can be placed in a workspace via workspaceId
@@ -100,6 +100,10 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
100
100
  // "path", "name", "mediaType", "size", "chunkBytes" }
101
101
  // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
102
102
  // { "kind": "file-download-cancelled", "transferId" }
103
+ // { "kind": "file-upload-opened", "requestId", "uploadId", "sessionId", "name", "size", "chunkBytes", "maxBytes", "idleTimeoutMs" }
104
+ // { "kind": "file-upload-chunk-accepted", "requestId", "uploadId", "sessionId", "nextOffset" }
105
+ // { "kind": "file-upload-completed", "requestId", "uploadId", "sessionId", "receiptId", "file", "sha256" }
106
+ // { "kind": "file-upload-cancelled", "requestId", "uploadId", "sessionId" }
103
107
  // { "kind": "session-cancelled", "sessionId", "accepted": true }
104
108
  // { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
105
109
  // { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
@@ -137,8 +141,10 @@ import { WebSocketServer } from 'ws'
137
141
  import devicesModule from './devices.js'
138
142
  import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
139
143
  import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
144
+ import { createFileUploadManager, FILE_UPLOAD_REQUEST_TYPES, FILE_UPLOAD_DEFAULTS } from './file-upload.mjs'
140
145
  import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
141
146
  import { createSessionFollower } from './session-follower.mjs'
147
+ import { parseSessionTarget, TEAM_PROJECTION_KINDS } from './session-target.mjs'
142
148
  import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
143
149
  import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
144
150
  import { gatewayProbeHeader } from './gateway-probe.mjs'
@@ -226,6 +232,12 @@ const Config = Schema.object({
226
232
  fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
227
233
  fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
228
234
  fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
235
+ fileUploadsEnabled: Schema.boolean().default(FILE_UPLOAD_DEFAULTS.fileUploadsEnabled),
236
+ fileUploadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(FILE_UPLOAD_DEFAULTS.fileUploadMaxBytes),
237
+ fileUploadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(FILE_UPLOAD_DEFAULTS.fileUploadChunkBytes),
238
+ fileUploadMaxTransfers: Schema.natural().min(1).max(16).default(FILE_UPLOAD_DEFAULTS.fileUploadMaxTransfers),
239
+ fileUploadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(FILE_UPLOAD_DEFAULTS.fileUploadIdleMs),
240
+ fileUploadCommitTimeoutMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(FILE_UPLOAD_DEFAULTS.fileUploadCommitTimeoutMs),
229
241
  adminLoopbackOnly: Schema.boolean().default(true),
230
242
  publicUrl: Schema.string().default(''),
231
243
  deviceFile: Schema.string().default(''),
@@ -277,6 +289,12 @@ function imagesOf(blocks) {
277
289
  return images
278
290
  }
279
291
 
292
+ function filesOf(blocks) {
293
+ return blocks.filter(block => block?.type === 'file' && block.attachment).map(({ attachment }) => ({
294
+ attachmentId: attachment.attachmentId, name: attachment.name, bytes: attachment.bytes,
295
+ }))
296
+ }
297
+
280
298
  // Build the small, owned JSON wire record for one session event. Reads only
281
299
  // leaf fields of the live SessionEvent — never serializes live objects.
282
300
  function buildWireEvent(session, event) {
@@ -288,6 +306,7 @@ function buildWireEvent(session, event) {
288
306
  switch (event.type) {
289
307
  case 'user/message': {
290
308
  const images = imagesOf(d.content || [])
309
+ const files = filesOf(d.content || [])
291
310
  return Object.assign(base, {
292
311
  event: {
293
312
  type: 'user/message',
@@ -295,6 +314,7 @@ function buildWireEvent(session, event) {
295
314
  source: d.source && d.source.kind,
296
315
  ...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
297
316
  ...(images.length ? { images } : {}),
317
+ ...(files.length ? { files } : {}),
298
318
  },
299
319
  })
300
320
  }
@@ -535,18 +555,39 @@ async function admitCommand(host, msg) {
535
555
  // Handle one mobile -> agent message through the official host API. Returns
536
556
  // the wire frame to send back, or null when nothing should be sent.
537
557
  async function admitMessage(api, msg) {
558
+ const target = parseSessionTarget(msg, { optional: true })
559
+ if (target.error) return target.error
560
+ const requestIdentity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
561
+ const rejectMessage = frame => ({ ...frame, requestType: 'message', ...target.value, ...requestIdentity })
562
+ if (target.value.parentSessionId !== undefined && target.value.subagentMode !== 'continuable') {
563
+ return { kind: 'error', code: 'subagent/not-resumable', requestType: 'message', ...target.value,
564
+ ...requestIdentity, message: 'only continuable subagents accept messages' }
565
+ }
538
566
  const preset = parseAgentPreset(msg, 'message')
539
- if (preset.error) return preset.error
567
+ if (preset.error) return rejectMessage(preset.error)
540
568
  if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
541
- return { kind: 'error', code: 'bad-request', requestType: 'message',
542
- message: 'use select-agent-preset before sending a message to an existing session' }
569
+ return rejectMessage({ kind: 'error', code: 'bad-request',
570
+ message: 'use select-agent-preset before sending a message to an existing session' })
543
571
  }
544
572
  const text = typeof msg.text === 'string' ? msg.text.trim() : ''
545
573
  const parsedImages = parseWireImages(msg.images, 'message')
546
- if (parsedImages.error) return parsedImages.error
574
+ if (parsedImages.error) return rejectMessage(parsedImages.error)
547
575
  const imageParts = parsedImages.value
548
- if (!text && imageParts.length === 0) {
549
- return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
576
+ const files = msg.files === undefined ? [] : msg.files
577
+ if (!Array.isArray(files) || files.length > 20 || files.some(file => !file || typeof file.receiptId !== 'string'
578
+ || !file.receiptId.trim() || file.receiptId.length > 256)
579
+ || new Set(files.map(file => file.receiptId)).size !== files.length) {
580
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'files must contain at most 20 distinct receiptId entries' })
581
+ }
582
+ if (files.length && (target.value.parentSessionId !== undefined || typeof msg.sessionId !== 'string' || !msg.sessionId.trim())) {
583
+ return rejectMessage({ kind: 'error', code: target.value.parentSessionId ? 'subagent/attachment-invalid' : 'bad-request',
584
+ message: 'file receipts require an existing ordinary session' })
585
+ }
586
+ if (files.length && (typeof msg.requestId !== 'string' || !msg.requestId.trim() || msg.requestId.length > 128)) {
587
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'file messages require a requestId of 1..128 characters' })
588
+ }
589
+ if (!text && imageParts.length === 0 && files.length === 0) {
590
+ return rejectMessage({ kind: 'error', code: 'bad-request', message: 'message requires text, images or uploaded file receipts' })
550
591
  }
551
592
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
552
593
 
@@ -570,10 +611,13 @@ async function admitMessage(api, msg) {
570
611
  }
571
612
 
572
613
  const resp = await api.sessions.prompt({
614
+ ...target.value,
573
615
  sessionId,
574
616
  mode,
617
+ ...(target.value.parentSessionId === undefined && files.length === 0 ? {} : requestIdentity),
575
618
  // Match the official WebUI ordering: images first, optional text last.
576
- content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
619
+ content: [...imageParts, ...files.map(file => ({ type: 'file', receiptId: file.receiptId })),
620
+ ...(text ? [{ type: 'text', text }] : [])],
577
621
  ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
578
622
  })
579
623
  log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
@@ -581,13 +625,19 @@ async function admitMessage(api, msg) {
581
625
  kind: 'sent',
582
626
  sessionId,
583
627
  mode,
628
+ ...requestIdentity,
629
+ ...(target.value.parentSessionId === undefined ? {} : {
630
+ parentSessionId: target.value.parentSessionId, subagentMode: target.value.subagentMode,
631
+ messageId: resp.messageId,
632
+ }),
584
633
  ...(resp.command ? { command: resp.command } : {}),
585
634
  }
586
635
  } catch (error) {
587
636
  const code = error && error.code ? error.code : 'internal'
588
637
  const message = error && error.message ? error.message : String(error)
589
638
  log(`mobile message failed: ${message}`)
590
- return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
639
+ return { kind: 'error', code, message, requestType: 'message', ...target.value,
640
+ ...requestIdentity, ...(sessionId ? { sessionId } : {}) }
591
641
  }
592
642
  }
593
643
 
@@ -1080,13 +1130,13 @@ function requireGoalRef(msg, sessionId) {
1080
1130
  return { value: { id, revision } }
1081
1131
  }
1082
1132
 
1083
- async function readSessionProjection(api, type, sessionId, key) {
1084
- const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1085
- if (history.kind !== 'history') return history
1133
+ async function readSessionProjection(api, type, sessionId, key, target = { sessionId }) {
1134
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { ...target, maxMessages: 1 })
1135
+ if (history.kind !== 'history') return { ...history, ...target, requestType: type }
1086
1136
  const values = (history.projections && history.projections.values) || {}
1087
1137
  return {
1088
1138
  kind: type,
1089
- sessionId,
1139
+ ...target,
1090
1140
  asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
1091
1141
  [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
1092
1142
  }
@@ -1403,6 +1453,31 @@ function parseAgentPreset(msg, requestType, required = false) {
1403
1453
  // Dispatch one mobile query frame; returns the wire frame to send back.
1404
1454
  async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1405
1455
  if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
1456
+ if (msg.type === 'agent-team' || msg.type === 'subagents') {
1457
+ const session = requireSessionId(msg)
1458
+ const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
1459
+ if (session.error) return { ...session.error, ...identity }
1460
+ const sessionId = session.value
1461
+ const key = msg.type === 'agent-team' ? 'agentTeam' : 'subagentCatalog'
1462
+ try {
1463
+ const snapshot = await api.sessions.projections({ sessionId })
1464
+ if (snapshot === null) throw Object.assign(new Error('session not found'), { code: 'session/not-found' })
1465
+ if (!['sequenced', 'migration-required'].includes(snapshot?.kind)
1466
+ || !snapshot.values || (snapshot.kind === 'sequenced' && !Number.isSafeInteger(snapshot.asOfSeq))) {
1467
+ throw new Error('invalid session projections')
1468
+ }
1469
+ const value = snapshot.values[key] ?? null
1470
+ if (value !== null && (key === 'subagentCatalog' ? !Array.isArray(value)
1471
+ : !Array.isArray(value.members) || !Array.isArray(value.tasks))) throw new Error('invalid team projection')
1472
+ return { kind: msg.type, sessionId, ...identity,
1473
+ projectionState: snapshot.kind === 'migration-required' ? 'migration-required'
1474
+ : value === null ? 'unavailable' : 'ready',
1475
+ ...(snapshot.kind === 'sequenced' ? { asOfSeq: snapshot.asOfSeq } : {}), [key]: value }
1476
+ } catch (error) {
1477
+ return { kind: 'error', code: error.code || 'internal', message: error.message,
1478
+ requestType: msg.type, sessionId, ...identity }
1479
+ }
1480
+ }
1406
1481
  if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1407
1482
  const session = requireSessionId(msg)
1408
1483
  const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
@@ -1448,18 +1523,18 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1448
1523
  }) }
1449
1524
  }
1450
1525
  if (msg.type === 'history') {
1451
- const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1452
- if (!sessionId) {
1453
- return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
1454
- }
1526
+ const target = parseSessionTarget(msg)
1527
+ if (target.error) return target.error
1528
+ const { sessionId } = target.value
1529
+ const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
1455
1530
  const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
1456
- if (cursorError) return cursorError
1457
- const payload = { sessionId }
1531
+ if (cursorError) return { ...cursorError, ...target.value, ...identity }
1532
+ const payload = { ...target.value }
1458
1533
  if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
1459
1534
  if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
1460
1535
  const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
1461
- if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
1462
- return frame
1536
+ if (frame.kind === 'history') return { ...historyPage(frame, { ...msg, sessionId }), ...target.value, ...identity }
1537
+ return { ...frame, ...target.value, ...identity }
1463
1538
  }
1464
1539
  if (msg.type === 'attachment') {
1465
1540
  const sessionId = requireSessionId(msg)
@@ -1527,16 +1602,17 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1527
1602
  return frame
1528
1603
  }
1529
1604
  if (msg.type === 'session-cancel') {
1530
- const sessionId = requireSessionId(msg)
1531
- if (sessionId.error) return sessionId.error
1605
+ const target = parseSessionTarget(msg)
1606
+ if (target.error) return target.error
1607
+ const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
1532
1608
  const frame = await proxyQuery(
1533
1609
  api,
1534
1610
  'session-cancel',
1535
1611
  api.sessions.cancel.bind(api.sessions),
1536
- { sessionId: sessionId.value },
1612
+ target.value,
1537
1613
  )
1538
- if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1539
- return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
1614
+ if (frame.kind === 'error') return { ...frame, ...target.value, ...identity }
1615
+ return { ...frame, kind: 'session-cancelled', ...target.value, ...identity }
1540
1616
  }
1541
1617
  if (msg.type === 'queue-update') {
1542
1618
  const sessionId = requireSessionId(msg)
@@ -1839,15 +1915,12 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1839
1915
  contextPressure: values.contextPressure || null,
1840
1916
  }
1841
1917
  }
1842
- if (msg.type === 'tasks') {
1843
- const sessionId = requireSessionId(msg)
1844
- if (sessionId.error) return sessionId.error
1845
- return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1846
- }
1847
- if (msg.type === 'goal') {
1848
- const sessionId = requireSessionId(msg)
1849
- if (sessionId.error) return sessionId.error
1850
- return readSessionProjection(api, 'goal', sessionId.value, 'goal')
1918
+ if (msg.type === 'tasks' || msg.type === 'goal') {
1919
+ const target = parseSessionTarget(msg)
1920
+ if (target.error) return target.error
1921
+ const frame = await readSessionProjection(api, msg.type, target.value.sessionId,
1922
+ msg.type === 'tasks' ? 'todos' : 'goal', target.value)
1923
+ return { ...frame, ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}) }
1851
1924
  }
1852
1925
  if (msg.type === 'goal-edit') {
1853
1926
  const sessionId = requireSessionId(msg)
@@ -2217,7 +2290,7 @@ const plugin = {
2217
2290
  // 已配对设备沿用本机用户权限;身份只由 Host 创建,不能由移动端参数指定。
2218
2291
  // 问答与审批仍由本插件的 Host 监听器处理,无需另开 Remote 交互流。
2219
2292
  const peer = ctx.connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' })
2220
- const api = createDshHostAdapter(ctx.typertGateway, peer)
2293
+ const api = createDshHostAdapter(ctx.typertGateway, peer, ctx.connection)
2221
2294
  const host = api
2222
2295
  const agentDefaultModel = ctx.agentDefaultModel
2223
2296
  const options = {
@@ -2234,6 +2307,7 @@ const plugin = {
2234
2307
  fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
2235
2308
  fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
2236
2309
  fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
2310
+ ...FILE_UPLOAD_DEFAULTS,
2237
2311
  adminLoopbackOnly: true,
2238
2312
  publicUrl: '',
2239
2313
  deviceFile: '',
@@ -2291,12 +2365,14 @@ const plugin = {
2291
2365
  const pendingQuestions = new Map()
2292
2366
  const pendingApprovals = new Map()
2293
2367
  const fileTransfers = createFileTransferManager(api, options)
2368
+ const fileUploads = createFileUploadManager(api, options)
2294
2369
  const speechInput = createSpeechInputManager(api, options)
2295
2370
  const fileTransferExpiryTimer = setInterval(() => {
2296
2371
  fileTransfers.expire().catch((error) => {
2297
2372
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
2298
2373
  })
2299
- }, Math.min(options.fileDownloadIdleMs, 30_000))
2374
+ fileUploads.expire().catch(error => log(`file upload expiry failed: ${error.message}`))
2375
+ }, Math.min(options.fileDownloadIdleMs, options.fileUploadIdleMs, 30_000))
2300
2376
  const backgroundStreamAbort = new AbortController()
2301
2377
  let archivedSessionIds = null
2302
2378
  let sessionQueues = null
@@ -2981,12 +3057,20 @@ const plugin = {
2981
3057
  const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2982
3058
  if ((ws.mobileChannel === 'control' && conversationRequest) ||
2983
3059
  (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2984
- ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
3060
+ ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type,
3061
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}), message: 'Request belongs to the other mobile channel' }))
2985
3062
  return
2986
3063
  }
2987
3064
 
2988
3065
  if (msg.type === 'ping') {
2989
3066
  ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
3067
+ } else if (FILE_UPLOAD_REQUEST_TYPES.has(msg.type)) {
3068
+ if (!ws.deviceId) {
3069
+ sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
3070
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}), message: 'File uploads require a paired device' })
3071
+ return
3072
+ }
3073
+ void fileUploads.handle(ws, msg).then(sendFrame)
2990
3074
  } else if (SPEECH_REQUEST_TYPES.has(msg.type)) {
2991
3075
  if (!ws.deviceId) {
2992
3076
  sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
@@ -2996,6 +3080,8 @@ const plugin = {
2996
3080
  }
2997
3081
  void speechInput.handle(ws, msg, sendFrame).then(sendFrame)
2998
3082
  } else if (msg.type === 'subscribe') {
3083
+ const target = parseSessionTarget(msg, { optional: true })
3084
+ if (target.error) { sendFrame(target.error); return }
2999
3085
  if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
3000
3086
  sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
3001
3087
  return
@@ -3009,9 +3095,9 @@ const plugin = {
3009
3095
  ws.filterSessionId = sessionId
3010
3096
  ws.sessionFollowerActive = msg.assistantStream === true
3011
3097
  const subscriptionId = crypto.randomUUID()
3012
- sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
3098
+ sendFrame({ kind: 'subscribed', ...target.value, sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
3013
3099
  if (ws.sessionFollowerActive) {
3014
- void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
3100
+ void ws.sessionFollower.start(sessionId, subscriptionId, target.value).catch(error => {
3015
3101
  log(`session follower failed: ${error.message}`)
3016
3102
  if (ws.readyState === 1) ws.close(1011, 'session follower failed')
3017
3103
  })
@@ -3036,10 +3122,17 @@ const plugin = {
3036
3122
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3037
3123
  })
3038
3124
  } else if (msg.type === 'message') {
3125
+ if (Array.isArray(msg.files) && msg.files.length > 0 && (!ws.deviceId || !options.fileUploadsEnabled)) {
3126
+ sendFrame({ kind: 'error', code: !ws.deviceId ? 'authentication-required' : 'file-upload-disabled',
3127
+ requestType: 'message', ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
3128
+ message: 'File attachments require paired-device file upload access' })
3129
+ return
3130
+ }
3039
3131
  admitMessage(api, msg).then((frame) => {
3040
3132
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3041
3133
  })
3042
- } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
3134
+ } else if (msg.type === 'agent-team' || msg.type === 'subagents' ||
3135
+ msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
3043
3136
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
3044
3137
  msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
3045
3138
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
@@ -3085,6 +3178,7 @@ const plugin = {
3085
3178
  fileTransfers.closeClient(ws).catch((error) => {
3086
3179
  log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
3087
3180
  })
3181
+ fileUploads.closeClient(ws).catch(error => log(`file upload cleanup failed: ${error.message}`))
3088
3182
  log(`client disconnected (id=${id}, remaining=${clients.size})`)
3089
3183
  })
3090
3184
 
@@ -3112,12 +3206,15 @@ const plugin = {
3112
3206
  'images',
3113
3207
  'session-create',
3114
3208
  'session-agent-preset',
3209
+ 'agent-team-v1',
3210
+ 'subagent-conversations-v1',
3115
3211
  'commands',
3116
3212
  'tasks',
3117
3213
  'goals',
3118
3214
  'schedule-management',
3119
3215
  'plugin-management-v1',
3120
3216
  ...(device ? ['speech-input-v1'] : []),
3217
+ ...(device && options.fileUploadsEnabled ? ['file-upload-v1'] : []),
3121
3218
  'session-cancel',
3122
3219
  'queue-control',
3123
3220
  'session-archive',
@@ -3237,6 +3334,15 @@ const plugin = {
3237
3334
  const disposeScheduleChanged = ctx.on('schedule/changed', () => {
3238
3335
  if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
3239
3336
  })
3337
+ const disposeSessionStatus = ctx.on('api-session/status', (sessionId, running) => {
3338
+ broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(sessionId), running })
3339
+ })
3340
+ const disposeSessionAdded = ctx.on('api-session/added', summary => {
3341
+ broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(summary.sessionId), running: summary.running })
3342
+ })
3343
+ const disposeSessionRemoved = ctx.on('api-session/removed', sessionId => {
3344
+ broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(sessionId), running: false })
3345
+ })
3240
3346
 
3241
3347
  const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
3242
3348
  if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
@@ -3410,6 +3516,7 @@ const plugin = {
3410
3516
  todos: 'tasks-updated',
3411
3517
  goal: 'goal-updated',
3412
3518
  turnOutline: 'turn-outline-updated',
3519
+ ...TEAM_PROJECTION_KINDS,
3413
3520
  }
3414
3521
  const projectionValues = (values = {}) => Object.fromEntries(
3415
3522
  Object.keys(projectionKinds).map(key => [key, values[key] ?? null]),
@@ -3419,7 +3526,8 @@ const plugin = {
3419
3526
  [key]: value,
3420
3527
  })
3421
3528
  const broadcastProjection = (sessionId, key, value, seq) => {
3422
- const broadcast = key === 'turnOutline' ? broadcastSessionMetadataFrame : broadcastInteractionFrame
3529
+ const broadcast = key === 'turnOutline' || Object.hasOwn(TEAM_PROJECTION_KINDS, key)
3530
+ ? broadcastSessionMetadataFrame : broadcastInteractionFrame
3423
3531
  broadcast(projectionFrame(sessionId, key, value, seq))
3424
3532
  }
3425
3533
 
@@ -3513,10 +3621,14 @@ const plugin = {
3513
3621
  fileTransfers.dispose().catch((error) => {
3514
3622
  log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
3515
3623
  })
3624
+ fileUploads.dispose().catch(error => log(`file upload disposal failed: ${error.message}`))
3516
3625
  disposeUpgrade()
3517
3626
  disposeMgmt()
3518
3627
  disposeEvents()
3519
3628
  disposeScheduleChanged()
3629
+ disposeSessionStatus()
3630
+ disposeSessionAdded()
3631
+ disposeSessionRemoved()
3520
3632
  disposePluginsChanged()
3521
3633
  disposePluginSettingsChanged()
3522
3634
  disposePluginInstallState()
@@ -91,7 +91,7 @@ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 })
91
91
  current = undefined
92
92
  previous?.abort.abort()
93
93
  }
94
- const start = (sessionId, subscriptionId) => {
94
+ const start = (sessionId, subscriptionId, target = { sessionId }) => {
95
95
  stop()
96
96
  const state = { abort: new AbortController() }
97
97
  current = state
@@ -101,12 +101,12 @@ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 })
101
101
  const attemptAbort = new AbortController()
102
102
  const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
103
103
  const streamId = crypto.randomUUID()
104
- const context = { sessionId, subscriptionId, streamId }
104
+ const context = { ...target, sessionId, subscriptionId, streamId }
105
105
  const decode = createFollowDecoder(sessionId)
106
106
  let iterator
107
107
  let permanent = false
108
108
  try {
109
- const stream = await api.openSessionStream(sessionId, signal)
109
+ const stream = await api.openSessionStream(sessionId, signal, target)
110
110
  iterator = stream[Symbol.asyncIterator]()
111
111
  while (current === state && !signal.aborted) {
112
112
  const item = await iterator.next()
@@ -117,7 +117,8 @@ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 })
117
117
  }
118
118
  } catch (error) {
119
119
  if (current !== state || signal.aborted) break
120
- permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
120
+ permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request',
121
+ 'subagent/unauthorized', 'subagent/not-found', 'subagent/catalog-diagnostic'].includes(error.code)
121
122
  || (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
122
123
  && /refuses this format .*Session/.test(error.message || ''))
123
124
  onError(error, { ...context, retrying: !permanent })
@@ -0,0 +1,29 @@
1
+ // 移动端保留 sessionId 为当前视图 ID,父子关系只作为显式路由地址。
2
+ export function parseSessionTarget(msg, { optional = false } = {}) {
3
+ const fail = message => ({ error: { kind: 'error', code: 'bad-request', requestType: msg.type, message,
4
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}) } })
5
+ const sessionId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''
6
+ const hasChildAddress = msg.parentSessionId !== undefined || msg.subagentMode !== undefined
7
+ if (!sessionId && (!optional || hasChildAddress)) return fail('sessionId must be a non-empty string')
8
+ if (!hasChildAddress) return { value: sessionId ? { sessionId } : {} }
9
+ const parentSessionId = typeof msg.parentSessionId === 'string' ? msg.parentSessionId.trim() : ''
10
+ if (!parentSessionId || parentSessionId === sessionId) return fail('parentSessionId must identify a different direct parent')
11
+ if (!['continuable', 'one-shot', 'unknown'].includes(msg.subagentMode)) {
12
+ return fail('subagentMode must be continuable, one-shot, or unknown')
13
+ }
14
+ return { value: { sessionId, parentSessionId, subagentMode: msg.subagentMode } }
15
+ }
16
+
17
+ export function sessionAddress(target) {
18
+ return target.parentSessionId === undefined
19
+ ? { kind: 'session', sessionId: target.sessionId }
20
+ : { kind: 'subagent', parentSessionId: target.parentSessionId,
21
+ childSessionId: target.sessionId, mode: target.subagentMode }
22
+ }
23
+
24
+ export const TEAM_PROJECTION_KINDS = {
25
+ agentTeam: 'agent-team-updated',
26
+ subagentCatalog: 'subagents-updated',
27
+ subagent: 'subagent-identity-updated',
28
+ subagentTiming: 'subagent-timing-updated',
29
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.10.4",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全文件预览、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.4 精简会话列表投影,仅保留标题与投影切点,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改。",
3
+ "version": "0.11.1",
4
+ "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、智能体团队与子会话、插件管理、官方语音输入,以及局域网和 Cloudflare Tunnel。0.11.1 新增普通文件分块上传,支持 SHA-256 校验、进度确认、取消与断线清理,复用 DSH 官方附件存储和会话专属凭证,支持文件随消息发送及实时和历史文件引用;提供详细 App 对接指南。",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/speech-input.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/session-event-details.test.mjs && node test/gateway.test.mjs && node --test test/approval-replay.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node --test test/file-upload.test.mjs && node test/speech-input.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node --test test/agent-team.test.mjs && node test/session-event-details.test.mjs && node test/gateway.test.mjs && node --test test/approval-replay.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",