dsh-plugin-mobile-gateway 0.10.1 → 0.10.2

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,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
@@ -2952,7 +3012,7 @@ const plugin = {
2952
3012
  respondToApproval(msg).then((frame) => {
2953
3013
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2954
3014
  })
2955
- } else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
3015
+ } else if (msg.type === 'file-list' || msg.type === 'file-resolve' || msg.type === 'file-download-open' ||
2956
3016
  msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
2957
3017
  fileTransfers.handle(ws, msg).then((frame) => {
2958
3018
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
@@ -3043,7 +3103,7 @@ const plugin = {
3043
3103
  'queue-control',
3044
3104
  'session-archive',
3045
3105
  'session-rename',
3046
- ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
3106
+ ...(options.fileDownloadsEnabled ? ['file-downloads', 'file-references-v1'] : []),
3047
3107
  ],
3048
3108
  port: transport.port || webServer.port,
3049
3109
  clients: clients.size,
@@ -3213,7 +3273,7 @@ const plugin = {
3213
3273
 
3214
3274
  const disposeApprovals = ctx.on('approval/request', (request, next) => {
3215
3275
  const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
3216
- if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
3276
+ if (!sessionId || typeof request.toolName !== 'string' || !gatewayEnabled) return next()
3217
3277
  const rpcId = crypto.randomUUID()
3218
3278
  const approvalId = crypto.randomUUID()
3219
3279
  return new Promise((resolve, reject) => {
@@ -3243,7 +3303,10 @@ const plugin = {
3243
3303
  }
3244
3304
  pendingApprovals.set(rpcId, pending)
3245
3305
  broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
3306
+ // 控制连接重连期间仍登记待审批,桌面端继续处理同一个请求。
3307
+ // 若桌面端仍在等待,后续连接可以重放;无可用答复时保持原有回退行为。
3246
3308
  startDownstreamApproval(pending)
3309
+ if (!hasInteractionClient(sessionId)) fallbackApproval(pending)
3247
3310
  log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
3248
3311
  })
3249
3312
  }, { prepend: true })
@@ -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.2",
4
+ "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.2 新增文件卡片事件透传与安全文件引用解析,复用分块下载为移动端文件卡片和正文文件链接预览提供协议支持。",
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",