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/PROTOCOL.md +71 -1
- package/README.md +9 -1
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/dsh-rc2-mobile-integration.md +10 -10
- package/docs/file-upload-app-integration.md +250 -0
- package/lib/dsh-host-adapter.mjs +59 -6
- package/lib/file-upload.mjs +205 -0
- package/lib/index.mjs +154 -42
- package/lib/session-follower.mjs +5 -4
- package/lib/session-target.mjs +29 -0
- package/package.json +3 -3
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',
|
|
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
|
-
|
|
549
|
-
|
|
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, ...(
|
|
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,
|
|
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), {
|
|
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
|
-
|
|
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
|
|
1452
|
-
if (
|
|
1453
|
-
|
|
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 = {
|
|
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
|
|
1531
|
-
if (
|
|
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
|
-
|
|
1612
|
+
target.value,
|
|
1537
1613
|
)
|
|
1538
|
-
if (frame.kind === 'error') return { ...frame,
|
|
1539
|
-
return { ...frame, kind: 'session-cancelled',
|
|
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
|
|
1844
|
-
if (
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
|
|
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,
|
|
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 === '
|
|
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'
|
|
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()
|
package/lib/session-follower.mjs
CHANGED
|
@@ -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'
|
|
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.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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",
|