dsh-plugin-mobile-gateway 0.10.1 → 0.10.3

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,7 +13,7 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- export const DSH_VERSION = '0.2.0-rc.2'
16
+ export const DSH_VERSION = '0.2.1-rc.2'
17
17
  export const SESSION_FORMAT_VERSION = 4
18
18
 
19
19
  export function readHistoryRecords(records) {
@@ -44,12 +44,12 @@ export function readSessionSnapshot(frame, sessionId) {
44
44
  }
45
45
  }
46
46
 
47
- async function firstStreamFrame(gateway, namespace, method, args, signal) {
47
+ async function firstStreamFrame(stream, namespace, method, args, signal) {
48
48
  const ownedAbort = new AbortController()
49
49
  const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
50
50
  let iterator
51
51
  try {
52
- const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
52
+ const iterable = await stream(namespace, method, args, streamSignal)
53
53
  const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
54
54
  if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
55
55
  iterator = iteratorFactory.call(iterable)
@@ -78,21 +78,28 @@ function sessionListArgs(request) {
78
78
  * descriptors. Its public methods intentionally match mobile-gateway domain
79
79
  * operations, not the upstream transport envelope.
80
80
  */
81
- export function createDshHostAdapter(typertGateway) {
81
+ export function createDshHostAdapter(typertGateway, peer) {
82
82
  if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
83
83
  throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
84
84
  }
85
+ if (!peer || !(peer.signal instanceof AbortSignal)) {
86
+ throw new Error('mobile-gateway requires a DSH Connection Peer (DSH 0.2.1-rc.2 or later)')
87
+ }
85
88
 
86
89
  const invoke = (namespace, method, args, signal) => typertGateway.invoke({
87
90
  namespace,
88
91
  method,
89
92
  args,
93
+ peer,
90
94
  ...(signal === undefined ? {} : { signal }),
91
95
  })
96
+ const stream = (namespace, method, args, signal) => typertGateway.stream({
97
+ namespace, method, args, peer, signal,
98
+ })
92
99
 
93
100
  const sessionSnapshot = async (request, signal) => {
94
101
  const frame = requireRecord(await firstStreamFrame(
95
- typertGateway,
102
+ stream,
96
103
  'session',
97
104
  'follow',
98
105
  requestArgs(request),
@@ -200,7 +207,7 @@ export function createDshHostAdapter(typertGateway) {
200
207
  return {
201
208
  speech: {
202
209
  catalog: (signal) => invoke('speech', 'catalog', {}, signal),
203
- follow: (signal) => typertGateway.stream({ namespace: 'speech', method: 'follow', args: {}, signal }),
210
+ follow: (signal) => stream('speech', 'follow', {}, signal),
204
211
  configure: (patch, signal) => invoke('speech', 'configure', { patch }, signal),
205
212
  prepare: (providerId, options, signal) => invoke('speech', 'prepare', {
206
213
  providerId, ...(options === undefined ? {} : { options }),
@@ -248,7 +255,7 @@ export function createDshHostAdapter(typertGateway) {
248
255
  },
249
256
  workspace: {
250
257
  async list(_payload = {}, signal) {
251
- const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
258
+ const frame = requireRecord(await firstStreamFrame(stream, 'workspace', 'follow', {}, signal), 'workspace/follow')
252
259
  if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
253
260
  return requireRecord(frame.value, 'workspace/follow baseline')
254
261
  },
@@ -371,18 +378,17 @@ export function createDshHostAdapter(typertGateway) {
371
378
  if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
372
379
  },
373
380
  openSessionStream(sessionId, signal) {
374
- return typertGateway.stream({
375
- namespace: 'session', method: 'follow',
381
+ return stream('session', 'follow',
376
382
  // Bound the opening window at the Host; older records remain available via session.page.
377
- args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
383
+ requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
378
384
  signal,
379
- })
385
+ )
380
386
  },
381
387
  openControlStream(signal) {
382
- return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
388
+ return stream('session', 'control', {}, signal)
383
389
  },
384
390
  openWorkspaceStream(signal) {
385
- return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
391
+ return stream('workspace', 'follow', {}, signal)
386
392
  },
387
393
  }
388
394
  }
package/lib/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { stringifyWireFrame } from './wire-json.mjs'
2
+ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails } from './session-event-details.mjs'
2
3
  // dsh-plugin-mobile-gateway — persistent Host plugin.
3
4
  //
4
5
  // Two directions:
@@ -35,6 +36,7 @@ import { stringifyWireFrame } from './wire-json.mjs'
35
36
  // { "type": "attachment", "sessionId", "attachmentId" }
36
37
  // -> verified image metadata + canonical base64 bytes
37
38
  // { "type": "file-list", "requestId"?, "sessionId", "path"? }
39
+ // { "type": "file-resolve", "requestId", "sessionId", "path" }
38
40
  // { "type": "file-download-open", "requestId", "sessionId", "path" }
39
41
  // { "type": "file-download-read", "transferId", "offset" }
40
42
  // { "type": "file-download-cancel", "transferId" }
@@ -93,6 +95,7 @@ import { stringifyWireFrame } from './wire-json.mjs'
93
95
  // { "kind": "command-selected", "sessionId", "command", "selected" }
94
96
  // { "kind": "attachment", "sessionId", "attachment", "data" }
95
97
  // { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
98
+ // { "kind": "file-resolved", "requestId", "sessionId", "path", "name", "mediaType", "size", "modifiedAt" }
96
99
  // { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
97
100
  // "path", "name", "mediaType", "size", "chunkBytes" }
98
101
  // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
@@ -308,6 +311,7 @@ function buildWireEvent(session, event) {
308
311
  }
309
312
  return Object.assign(base, {
310
313
  event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
314
+ ...(assistantFinish(d) ? { finish: assistantFinish(d) } : {}),
311
315
  ...(d.interrupted === true ? { interrupted: true } : {}),
312
316
  ...(d.usage === undefined ? {} : { usage: d.usage }),
313
317
  ...(images.length ? { images } : {}),
@@ -315,7 +319,15 @@ function buildWireEvent(session, event) {
315
319
  })
316
320
  }
317
321
  case 'assistant/attempt':
318
- return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
322
+ return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream,
323
+ ...(assistantFinish(d) ? { finish: assistantFinish(d) } : {}) } })
324
+ case 'llm/retry':
325
+ case 'llm/retry-started':
326
+ return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step,
327
+ retryId: d.retryId, retry: d.retry,
328
+ ...(event.type === 'llm/retry' ? { provider: d.provider, mode: d.mode,
329
+ maxRetries: d.maxRetries, delayMs: d.delayMs, failure: failureDetails(d.failure) } : {}),
330
+ } })
319
331
  case 'session/title':
320
332
  return Object.assign(base, {
321
333
  event: {
@@ -326,6 +338,16 @@ function buildWireEvent(session, event) {
326
338
  })
327
339
  case 'agent-preset/selected':
328
340
  return Object.assign(base, { event: { type: event.type, agentPreset: d.agentPreset } })
341
+ case 'deliverables/presented':
342
+ return Object.assign(base, {
343
+ event: { type: event.type, turn: d.turn, callId: d.callId,
344
+ files: (Array.isArray(d.files) ? d.files : [])
345
+ .filter(file => file && typeof file.path === 'string' && file.path.trim())
346
+ .map(file => ({ path: file.path,
347
+ ...(typeof file.description === 'string' ? { description: file.description } : {}),
348
+ })),
349
+ },
350
+ })
329
351
  case 'tool/call':
330
352
  return Object.assign(base, {
331
353
  event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
@@ -347,6 +369,7 @@ function buildWireEvent(session, event) {
347
369
  callId: d.message?.toolCallId ?? d.message?.source?.callId,
348
370
  isError: d.message?.isError === true || !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
349
371
  preview,
372
+ ...toolResultDetails(d),
350
373
  ...(d.error ? { error: d.error } : {}),
351
374
  },
352
375
  })
@@ -405,7 +428,8 @@ function buildWireEvent(session, event) {
405
428
  case 'step/start':
406
429
  case 'step/end':
407
430
  return Object.assign(base, {
408
- event: { type: event.type, turn: d.turn, step: d.step, reason: d.reason && d.reason.kind },
431
+ event: { type: event.type, turn: d.turn, step: d.step, reason: d.reason && d.reason.kind,
432
+ ...(turnReasonDetails(d.reason) ? { reasonDetails: turnReasonDetails(d.reason) } : {}) },
409
433
  })
410
434
  default:
411
435
  return Object.assign(base, { event: { type: event.type } })
@@ -674,20 +698,31 @@ function createFileTransferManager(api, options) {
674
698
  if (!stat.isDirectory()) {
675
699
  return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
676
700
  }
677
- return { root }
701
+ return { root, inputRoot: path.resolve(item.cwd) }
678
702
  } catch {
679
703
  return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
680
704
  }
681
705
  }
682
706
 
683
707
  const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
684
- const requested = normalizedWorkspaceInput(rawPath, inputOptions)
708
+ // Only the resolver accepts native absolute paths from recorded deliveries
709
+ // or Markdown links. Download requests remain workspace-relative.
710
+ const absoluteInput = inputOptions?.allowAbsolute === true
711
+ && typeof rawPath === 'string' && path.isAbsolute(rawPath.trim())
712
+ const requested = absoluteInput
713
+ ? (rawPath.includes('\0') || rawPath.trim().split(/[\\/]+/).includes('..') ? null : rawPath.trim())
714
+ : normalizedWorkspaceInput(rawPath, inputOptions)
685
715
  if (!requested) {
686
- return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
716
+ return fileTransferError('bad-request', `${requestType} requires a valid ${inputOptions?.allowAbsolute ? '' : 'relative '}workspace path`, requestType, sessionId)
687
717
  }
688
718
  const rootResult = await resolveSessionRoot(sessionId, requestType)
689
719
  if (rootResult.kind === 'error') return rootResult
690
- const candidate = path.resolve(rootResult.root, requested)
720
+ // macOS commonly exposes one workspace as both /var/... and /private/var/...
721
+ // (and cwd itself may be a symlink). Map only the known cwd alias before
722
+ // enforcing the canonical root, then still verify the target's realpath.
723
+ const candidate = absoluteInput && isInsideWorkspace(rootResult.inputRoot, requested)
724
+ ? path.resolve(rootResult.root, path.relative(rootResult.inputRoot, requested))
725
+ : path.resolve(rootResult.root, requested)
691
726
  if (!isInsideWorkspace(rootResult.root, candidate)) {
692
727
  return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
693
728
  }
@@ -706,6 +741,28 @@ function createFileTransferManager(api, options) {
706
741
  }
707
742
  }
708
743
 
744
+ const resolveFile = async (msg) => {
745
+ const session = requireSessionId(msg)
746
+ if (session.error) return session.error
747
+ if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
748
+ return fileTransferError('bad-request', 'file-resolve requires a requestId', 'file-resolve', session.value)
749
+ }
750
+ const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-resolve', { allowAbsolute: true })
751
+ if (resolved.kind === 'error') return resolved
752
+ if (!resolved.stat.isFile()) {
753
+ return fileTransferError('file-not-regular', 'only regular files can be previewed', 'file-resolve', session.value)
754
+ }
755
+ if (resolved.stat.size > options.fileDownloadMaxBytes) {
756
+ return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-resolve', session.value)
757
+ }
758
+ return {
759
+ kind: 'file-resolved', requestId: msg.requestId, sessionId: session.value,
760
+ path: workspaceRelativePath(resolved.root, resolved.target),
761
+ name: path.basename(resolved.target), mediaType: fileMediaType(resolved.target),
762
+ size: resolved.stat.size, modifiedAt: resolved.stat.mtimeMs,
763
+ }
764
+ }
765
+
709
766
  const fileList = async (msg) => {
710
767
  const session = requireSessionId(msg)
711
768
  if (session.error) return session.error
@@ -885,9 +942,11 @@ function createFileTransferManager(api, options) {
885
942
 
886
943
  return {
887
944
  async handle(client, msg) {
945
+ const correlation = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
888
946
  if (!options.fileDownloadsEnabled) {
889
- return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
947
+ return { ...fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type), ...correlation }
890
948
  }
949
+ if (msg.type === 'file-resolve') return { ...await resolveFile(msg), ...correlation }
891
950
  if (msg.type === 'file-list') return fileList(msg)
892
951
  if (msg.type === 'file-download-open') return open(client, msg)
893
952
  if (msg.type === 'file-download-read') return read(client, msg)
@@ -1078,12 +1137,14 @@ function trimConversationEvent(event) {
1078
1137
  case 'assistant/message':
1079
1138
  case 'assistant/attempt': {
1080
1139
  const { stream, ...data } = event.data || {}
1081
- return { ...event, data }
1140
+ const finish = assistantFinish(event.data || {})
1141
+ return { ...event, data: { ...data, ...(finish ? { finish } : {}) } }
1082
1142
  }
1083
1143
  case 'tool/result': {
1084
1144
  const d = event.data || {}
1085
1145
  const message = d.message
1086
- if (!message || !Array.isArray(message.content)) return event
1146
+ const details = toolResultDetails(d)
1147
+ if (!message || !Array.isArray(message.content)) return { ...event, data: { ...d, ...details } }
1087
1148
  let changed = false
1088
1149
  const truncateBlock = (block) => {
1089
1150
  if (!block || typeof block !== 'object') return block
@@ -1097,8 +1158,7 @@ function trimConversationEvent(event) {
1097
1158
  return block
1098
1159
  }
1099
1160
  const content = message.content.map(truncateBlock)
1100
- if (!changed) return event
1101
- return { ...event, data: { ...d, message: { ...message, content } } }
1161
+ return { ...event, data: { ...d, ...details, ...(changed ? { message: { ...message, content } } : {}) } }
1102
1162
  }
1103
1163
  default:
1104
1164
  return event
@@ -2133,13 +2193,19 @@ const plugin = {
2133
2193
  // Hard dependencies: the gateway registers on the host web server and
2134
2194
  // admits mobile messages through the Host Remote Gateway. Cordis must not
2135
2195
  // activate this row before either service exists.
2136
- inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
2196
+ inject: ['webServer', 'typertGateway', 'connection', 'agentDefaultModel'],
2137
2197
  // exported for local tests; Cordis ignores unknown plugin fields
2138
2198
  admitMessage,
2139
2199
  handleQuery,
2140
2200
  apply(ctx, config) {
2141
2201
  const webServer = ctx.webServer
2142
- const api = createDshHostAdapter(ctx.typertGateway)
2202
+ if (typeof ctx.connection?.createPeer !== 'function') {
2203
+ throw new Error('mobile-gateway requires DSH 0.2.1-rc.2 or later with Connection.createPeer')
2204
+ }
2205
+ // 已配对设备沿用本机用户权限;身份只由 Host 创建,不能由移动端参数指定。
2206
+ // 问答与审批仍由本插件的 Host 监听器处理,无需另开 Remote 交互流。
2207
+ const peer = ctx.connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' })
2208
+ const api = createDshHostAdapter(ctx.typertGateway, peer)
2143
2209
  const host = api
2144
2210
  const agentDefaultModel = ctx.agentDefaultModel
2145
2211
  const options = {
@@ -2952,7 +3018,7 @@ const plugin = {
2952
3018
  respondToApproval(msg).then((frame) => {
2953
3019
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2954
3020
  })
2955
- } else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
3021
+ } else if (msg.type === 'file-list' || msg.type === 'file-resolve' || msg.type === 'file-download-open' ||
2956
3022
  msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
2957
3023
  fileTransfers.handle(ws, msg).then((frame) => {
2958
3024
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
@@ -3043,7 +3109,7 @@ const plugin = {
3043
3109
  'queue-control',
3044
3110
  'session-archive',
3045
3111
  'session-rename',
3046
- ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
3112
+ ...(options.fileDownloadsEnabled ? ['file-downloads', 'file-references-v1'] : []),
3047
3113
  ],
3048
3114
  port: transport.port || webServer.port,
3049
3115
  clients: clients.size,
@@ -3213,7 +3279,7 @@ const plugin = {
3213
3279
 
3214
3280
  const disposeApprovals = ctx.on('approval/request', (request, next) => {
3215
3281
  const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
3216
- if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
3282
+ if (!sessionId || typeof request.toolName !== 'string' || !gatewayEnabled) return next()
3217
3283
  const rpcId = crypto.randomUUID()
3218
3284
  const approvalId = crypto.randomUUID()
3219
3285
  return new Promise((resolve, reject) => {
@@ -3243,7 +3309,10 @@ const plugin = {
3243
3309
  }
3244
3310
  pendingApprovals.set(rpcId, pending)
3245
3311
  broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
3312
+ // 控制连接重连期间仍登记待审批,桌面端继续处理同一个请求。
3313
+ // 若桌面端仍在等待,后续连接可以重放;无可用答复时保持原有回退行为。
3246
3314
  startDownstreamApproval(pending)
3315
+ if (!hasInteractionClient(sessionId)) fallbackApproval(pending)
3247
3316
  log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
3248
3317
  })
3249
3318
  }, { prepend: true })
@@ -3408,6 +3477,9 @@ const plugin = {
3408
3477
  if (waitTimer) clearTimeout(waitTimer)
3409
3478
  clearInterval(fileTransferExpiryTimer)
3410
3479
  backgroundStreamAbort.abort()
3480
+ peer.dispose().catch((error) => {
3481
+ log(`Host peer disposal failed: ${error && error.message ? error.message : String(error)}`)
3482
+ })
3411
3483
  speechInput.dispose()
3412
3484
  for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
3413
3485
  for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
@@ -0,0 +1,53 @@
1
+ // Keep diagnostic facts when token replay and tool bodies are trimmed for mobile.
2
+ const TOOL_OUTPUT_LIMIT = 16_000
3
+
4
+ export function failureDetails(value) {
5
+ if (!value || typeof value !== 'object') return undefined
6
+ const result = {}
7
+ for (const key of ['message', 'code', 'name', 'reason', 'requestId']) {
8
+ if (typeof value[key] === 'string') result[key] = value[key]
9
+ }
10
+ for (const key of ['status', 'providerRetryAfterMs']) {
11
+ if (Number.isFinite(value[key])) result[key] = value[key]
12
+ }
13
+ return Object.keys(result).length ? result : undefined
14
+ }
15
+
16
+ export function assistantFinish(data) {
17
+ for (let i = (data.stream?.length || 0) - 1; i >= 0; i--) {
18
+ const record = data.stream[i]
19
+ if (record?.type !== 'chunk' || record.chunk?.type !== 'finish') continue
20
+ const reason = record.chunk.reason
21
+ if (typeof reason?.kind !== 'string') return undefined
22
+ const failure = failureDetails(reason.failure ?? reason.error)
23
+ return { kind: reason.kind, ...(failure ? { failure } : {}) }
24
+ }
25
+ return data.finish
26
+ }
27
+
28
+ export function toolResultDetails(data) {
29
+ const texts = []
30
+ const visit = block => {
31
+ if (block?.type === 'text' && typeof block.text === 'string') texts.push(block.text)
32
+ else if (block?.type === 'tool-result' && Array.isArray(block.content)) block.content.forEach(visit)
33
+ }
34
+ for (const block of data.message?.content || []) visit(block)
35
+ const full = texts.join('')
36
+ const outputTruncated = full.length > TOOL_OUTPUT_LIMIT
37
+ // Retain the terminal status and sandbox hints at the end as well as the start.
38
+ const output = outputTruncated
39
+ ? `${full.slice(0, TOOL_OUTPUT_LIMIT / 2)}\n[…]\n${full.slice(-TOOL_OUTPUT_LIMIT / 2)}`
40
+ : full
41
+ return { output, outputTruncated }
42
+ }
43
+
44
+ export function turnReasonDetails(reason) {
45
+ if (!reason || typeof reason.kind !== 'string') return undefined
46
+ const error = failureDetails(reason.error ?? reason.failure)
47
+ return { kind: reason.kind, ...(error ? { error } : {}),
48
+ ...(typeof reason.reason?.kind === 'string' ? { reason: {
49
+ kind: reason.reason.kind,
50
+ ...(typeof reason.reason.reason === 'string' ? { reason: reason.reason.reason } : {}),
51
+ } } : {}),
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.10.1",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.1 新增官方语音输入接入,支持模型准备与状态同步、音频转文字及取消转写。",
3
+ "version": "0.10.3",
4
+ "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全文件预览、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.3 适配 DSH 0.2.1-rc.2,为 Remote 请求及实时订阅补齐 Connection Peer,修复读取 signal 时的空值报错,并在网关停止时释放 Peer。",
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/gateway.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/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"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",