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/PROTOCOL.md +52 -6
- package/README.md +3 -1
- package/docs/dsh-rc2-mobile-integration.md +9 -1
- package/docs/file-cards-mobile-integration.md +111 -0
- package/docs/voice-input-implementation.md +56 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/README.md +33 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-objc.txt +222957 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-preconfigure-annotated.asm +90 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-preconfigure.asm +90 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-session-configure-annotated.asm +131 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-session-configure.asm +131 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-string-reference-candidates.json +20 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/deepseek-touch-began.asm +133 -0
- package/docs/voice-input-ipa-evidence/startup-20261014/rea-macho-summary.json +2435 -0
- package/lib/index.mjs +77 -14
- package/lib/session-event-details.mjs +53 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' || !
|
|
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.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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",
|