dsh-plugin-mobile-gateway 0.10.4 → 0.11.0
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 +38 -0
- package/README.md +5 -1
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/dsh-rc2-mobile-integration.md +10 -10
- package/lib/dsh-host-adapter.mjs +25 -5
- package/lib/index.mjs +95 -36
- package/lib/session-follower.mjs +5 -4
- package/lib/session-target.mjs +29 -0
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# DSH 0.1
|
|
1
|
+
# DSH 0.2.1-rc.2 移动端接入
|
|
2
2
|
|
|
3
|
-
本次源码以 DSH 0.1
|
|
3
|
+
本次源码以 DSH 0.2.1-rc.2 为唯一 Host 基线,不提供旧 Host 分支。配对、设备 token、双连接和 `dsh-mobile-v1` 保持原样;以下是新的实时订阅和历史坐标约定。App 需要同步实现本说明,不能把独立流帧交给旧的 `SessionEvent.seq` reducer。
|
|
4
4
|
|
|
5
5
|
## 1. 能力与历史缓存
|
|
6
6
|
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
{
|
|
11
11
|
"kind": "hello",
|
|
12
12
|
"protocol": 3,
|
|
13
|
-
"dshVersion": "0.1
|
|
14
|
-
"historyFormatVersion":
|
|
13
|
+
"dshVersion": "0.2.1-rc.2",
|
|
14
|
+
"historyFormatVersion": 4,
|
|
15
15
|
"capabilities": ["assistant-stream-v1", "history-format-version", "projection-baseline"]
|
|
16
16
|
}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
示例只列出新增字段和能力;原有能力仍在。`dshVersion` 是本适配器的目标版本声明,非运行时 CLI 版本探测。握手协议值 3 与 Session 格式值
|
|
19
|
+
示例只列出新增字段和能力;原有能力仍在。`dshVersion` 是本适配器的目标版本声明,非运行时 CLI 版本探测。握手协议值 3 与 Session 格式值 4 是独立概念。
|
|
20
20
|
|
|
21
21
|
缓存键至少包含 `(gatewayId, sessionId, historyFormatVersion)`。缓存没有格式版本或与服务端不同,必须丢弃该 Session 的旧历史、分页游标、fork 锚点及临时输出,重新安装基线。DSH 自己迁移磁盘日志,客户端不得迁移旧 seq。
|
|
22
22
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"sessionId": "s1",
|
|
43
43
|
"subscriptionId": "<UUID>",
|
|
44
44
|
"streamId": "<本次上游 follow 的 UUID>",
|
|
45
|
-
"historyFormatVersion":
|
|
45
|
+
"historyFormatVersion": 4,
|
|
46
46
|
"cursor": 41,
|
|
47
47
|
"replace": true,
|
|
48
48
|
"view": "conversation",
|
|
@@ -124,16 +124,16 @@
|
|
|
124
124
|
{ "type": "history", "sessionId": "s1", "view": "conversation" }
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
返回值新增 `historyFormatVersion:
|
|
127
|
+
返回值新增 `historyFormatVersion: 4`、`cursor`。conversation 视图隐藏 `system/message`、`request/header`、`request/context`,移除 Assistant 事件中的 `data.stream`,保留真实 seq、最终 message、usage 和 interrupted。默认原始视图保留完整内嵌 stream,用于按需读取轨迹。全部为隐藏事件的页仍返回可前进的 nextBeforeSeq。
|
|
128
128
|
|
|
129
129
|
携带游标必须同时携带读取该游标时的格式版本:
|
|
130
130
|
|
|
131
131
|
```json
|
|
132
|
-
{ "type": "history", "sessionId": "s1", "beforeSeq": 20, "historyFormatVersion":
|
|
133
|
-
{ "type": "fork", "sessionId": "s1", "atSeq": 42, "historyFormatVersion":
|
|
132
|
+
{ "type": "history", "sessionId": "s1", "beforeSeq": 20, "historyFormatVersion": 4 }
|
|
133
|
+
{ "type": "fork", "sessionId": "s1", "atSeq": 42, "historyFormatVersion": 4 }
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
缺失版本、旧版本或版本不匹配,返回 `code: "history-format-mismatch"`、`resetRequired: true` 和当前 historyFormatVersion,且不会调用上游分页/fork。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个
|
|
136
|
+
缺失版本、旧版本或版本不匹配,返回 `code: "history-format-mismatch"`、`resetRequired: true` 和当前 historyFormatVersion,且不会调用上游分页/fork。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个 4 后重试。游标必须是非负安全整数。省略 atSeq 的 fork 仍表示从最近完成轮次分叉。
|
|
137
137
|
|
|
138
138
|
收到 session-snapshot 后,不要用晚到的普通 history 响应覆盖正在进行的订阅基线;更早历史页只合并到历史部分。这样可以避免独立查询与实时流交错造成回滚。
|
|
139
139
|
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import os from 'node:os'
|
|
3
|
+
import { sessionAddress } from './session-target.mjs'
|
|
3
4
|
|
|
4
5
|
function requireRecord(value, endpoint) {
|
|
5
6
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -105,12 +106,13 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
105
106
|
requestArgs(request),
|
|
106
107
|
signal,
|
|
107
108
|
), 'session/follow')
|
|
108
|
-
return readSessionSnapshot(frame, request.address.
|
|
109
|
+
return readSessionSnapshot(frame, request.address.kind === 'subagent'
|
|
110
|
+
? request.address.childSessionId : request.address.sessionId)
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
const history = async (payload, signal) => {
|
|
112
114
|
const request = {
|
|
113
|
-
address:
|
|
115
|
+
address: sessionAddress(payload),
|
|
114
116
|
// Older-page requests need only the opening cursor/projections, not another large latest page.
|
|
115
117
|
...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
|
|
116
118
|
: payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
@@ -216,10 +218,22 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
216
218
|
transcribe: (request, signal) => invoke('speech', 'transcribe', { request }, signal),
|
|
217
219
|
},
|
|
218
220
|
sessions: {
|
|
221
|
+
projections: (payload, signal) => invoke('session', 'projections', requestArgs(payload), signal),
|
|
219
222
|
list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
|
|
220
223
|
search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
|
|
221
224
|
create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
|
|
222
225
|
prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
|
|
226
|
+
if (payload.parentSessionId !== undefined) {
|
|
227
|
+
if (payload.subagentMode !== 'continuable') {
|
|
228
|
+
throw Object.assign(new Error('only continuable subagents accept messages'), { code: 'subagent/not-resumable' })
|
|
229
|
+
}
|
|
230
|
+
return invoke('subagents', 'prompt', requestArgs({
|
|
231
|
+
requestId: payload.requestId ?? crypto.randomUUID(),
|
|
232
|
+
parentSessionId: payload.parentSessionId, childSessionId: payload.sessionId,
|
|
233
|
+
mode: 'continuable', delivery: payload.mode, content: payload.content,
|
|
234
|
+
...(payload.clientTimeZone === undefined ? {} : { clientTimeZone: payload.clientTimeZone }),
|
|
235
|
+
}), signal)
|
|
236
|
+
}
|
|
223
237
|
// Accepted input may not have reached turn/start yet. Keep that gap
|
|
224
238
|
// locked; the persistent event or a subsequent read retires the marker.
|
|
225
239
|
const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
|
|
@@ -233,7 +247,13 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
233
247
|
}),
|
|
234
248
|
attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
|
|
235
249
|
fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
|
|
236
|
-
cancel: (payload, signal) =>
|
|
250
|
+
cancel: (payload, signal) => payload.parentSessionId === undefined
|
|
251
|
+
? invoke('session', 'cancel', requestArgs(payload), signal)
|
|
252
|
+
: payload.subagentMode !== 'continuable'
|
|
253
|
+
? Promise.reject(Object.assign(new Error('only continuable subagents accept interrupts'), { code: 'subagent/not-resumable' }))
|
|
254
|
+
: invoke('subagents', 'interruptByParent', {
|
|
255
|
+
parentSessionId: payload.parentSessionId, childSessionId: payload.sessionId, mode: 'continuable',
|
|
256
|
+
}, signal),
|
|
237
257
|
updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
|
|
238
258
|
rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
|
|
239
259
|
selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
|
|
@@ -377,10 +397,10 @@ export function createDshHostAdapter(typertGateway, peer) {
|
|
|
377
397
|
observeSessionEvent(sessionId, event) {
|
|
378
398
|
if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
|
|
379
399
|
},
|
|
380
|
-
openSessionStream(sessionId, signal) {
|
|
400
|
+
openSessionStream(sessionId, signal, target = { sessionId }) {
|
|
381
401
|
return stream('session', 'follow',
|
|
382
402
|
// Bound the opening window at the Host; older records remain available via session.page.
|
|
383
|
-
requestArgs({ address:
|
|
403
|
+
requestArgs({ address: sessionAddress(target), maxMessages: 12, assistantStream: true }),
|
|
384
404
|
signal,
|
|
385
405
|
)
|
|
386
406
|
},
|
package/lib/index.mjs
CHANGED
|
@@ -139,6 +139,7 @@ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh
|
|
|
139
139
|
import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
|
|
140
140
|
import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
|
|
141
141
|
import { createSessionFollower } from './session-follower.mjs'
|
|
142
|
+
import { parseSessionTarget, TEAM_PROJECTION_KINDS } from './session-target.mjs'
|
|
142
143
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
143
144
|
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
144
145
|
import { gatewayProbeHeader } from './gateway-probe.mjs'
|
|
@@ -535,18 +536,26 @@ async function admitCommand(host, msg) {
|
|
|
535
536
|
// Handle one mobile -> agent message through the official host API. Returns
|
|
536
537
|
// the wire frame to send back, or null when nothing should be sent.
|
|
537
538
|
async function admitMessage(api, msg) {
|
|
539
|
+
const target = parseSessionTarget(msg, { optional: true })
|
|
540
|
+
if (target.error) return target.error
|
|
541
|
+
const requestIdentity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
542
|
+
const rejectMessage = frame => ({ ...frame, requestType: 'message', ...target.value, ...requestIdentity })
|
|
543
|
+
if (target.value.parentSessionId !== undefined && target.value.subagentMode !== 'continuable') {
|
|
544
|
+
return { kind: 'error', code: 'subagent/not-resumable', requestType: 'message', ...target.value,
|
|
545
|
+
...requestIdentity, message: 'only continuable subagents accept messages' }
|
|
546
|
+
}
|
|
538
547
|
const preset = parseAgentPreset(msg, 'message')
|
|
539
|
-
if (preset.error) return preset.error
|
|
548
|
+
if (preset.error) return rejectMessage(preset.error)
|
|
540
549
|
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' }
|
|
550
|
+
return rejectMessage({ kind: 'error', code: 'bad-request',
|
|
551
|
+
message: 'use select-agent-preset before sending a message to an existing session' })
|
|
543
552
|
}
|
|
544
553
|
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
545
554
|
const parsedImages = parseWireImages(msg.images, 'message')
|
|
546
|
-
if (parsedImages.error) return parsedImages.error
|
|
555
|
+
if (parsedImages.error) return rejectMessage(parsedImages.error)
|
|
547
556
|
const imageParts = parsedImages.value
|
|
548
557
|
if (!text && imageParts.length === 0) {
|
|
549
|
-
return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
|
|
558
|
+
return rejectMessage({ kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' })
|
|
550
559
|
}
|
|
551
560
|
const mode = msg.mode === 'steer' ? 'steer' : 'queue'
|
|
552
561
|
|
|
@@ -570,8 +579,10 @@ async function admitMessage(api, msg) {
|
|
|
570
579
|
}
|
|
571
580
|
|
|
572
581
|
const resp = await api.sessions.prompt({
|
|
582
|
+
...target.value,
|
|
573
583
|
sessionId,
|
|
574
584
|
mode,
|
|
585
|
+
...(target.value.parentSessionId === undefined ? {} : requestIdentity),
|
|
575
586
|
// Match the official WebUI ordering: images first, optional text last.
|
|
576
587
|
content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
|
|
577
588
|
...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
|
|
@@ -581,13 +592,19 @@ async function admitMessage(api, msg) {
|
|
|
581
592
|
kind: 'sent',
|
|
582
593
|
sessionId,
|
|
583
594
|
mode,
|
|
595
|
+
...requestIdentity,
|
|
596
|
+
...(target.value.parentSessionId === undefined ? {} : {
|
|
597
|
+
parentSessionId: target.value.parentSessionId, subagentMode: target.value.subagentMode,
|
|
598
|
+
messageId: resp.messageId,
|
|
599
|
+
}),
|
|
584
600
|
...(resp.command ? { command: resp.command } : {}),
|
|
585
601
|
}
|
|
586
602
|
} catch (error) {
|
|
587
603
|
const code = error && error.code ? error.code : 'internal'
|
|
588
604
|
const message = error && error.message ? error.message : String(error)
|
|
589
605
|
log(`mobile message failed: ${message}`)
|
|
590
|
-
return { kind: 'error', code, message,
|
|
606
|
+
return { kind: 'error', code, message, requestType: 'message', ...target.value,
|
|
607
|
+
...requestIdentity, ...(sessionId ? { sessionId } : {}) }
|
|
591
608
|
}
|
|
592
609
|
}
|
|
593
610
|
|
|
@@ -1080,13 +1097,13 @@ function requireGoalRef(msg, sessionId) {
|
|
|
1080
1097
|
return { value: { id, revision } }
|
|
1081
1098
|
}
|
|
1082
1099
|
|
|
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
|
|
1100
|
+
async function readSessionProjection(api, type, sessionId, key, target = { sessionId }) {
|
|
1101
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { ...target, maxMessages: 1 })
|
|
1102
|
+
if (history.kind !== 'history') return { ...history, ...target, requestType: type }
|
|
1086
1103
|
const values = (history.projections && history.projections.values) || {}
|
|
1087
1104
|
return {
|
|
1088
1105
|
kind: type,
|
|
1089
|
-
|
|
1106
|
+
...target,
|
|
1090
1107
|
asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
|
|
1091
1108
|
[key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
|
|
1092
1109
|
}
|
|
@@ -1403,6 +1420,31 @@ function parseAgentPreset(msg, requestType, required = false) {
|
|
|
1403
1420
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1404
1421
|
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1405
1422
|
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1423
|
+
if (msg.type === 'agent-team' || msg.type === 'subagents') {
|
|
1424
|
+
const session = requireSessionId(msg)
|
|
1425
|
+
const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
1426
|
+
if (session.error) return { ...session.error, ...identity }
|
|
1427
|
+
const sessionId = session.value
|
|
1428
|
+
const key = msg.type === 'agent-team' ? 'agentTeam' : 'subagentCatalog'
|
|
1429
|
+
try {
|
|
1430
|
+
const snapshot = await api.sessions.projections({ sessionId })
|
|
1431
|
+
if (snapshot === null) throw Object.assign(new Error('session not found'), { code: 'session/not-found' })
|
|
1432
|
+
if (!['sequenced', 'migration-required'].includes(snapshot?.kind)
|
|
1433
|
+
|| !snapshot.values || (snapshot.kind === 'sequenced' && !Number.isSafeInteger(snapshot.asOfSeq))) {
|
|
1434
|
+
throw new Error('invalid session projections')
|
|
1435
|
+
}
|
|
1436
|
+
const value = snapshot.values[key] ?? null
|
|
1437
|
+
if (value !== null && (key === 'subagentCatalog' ? !Array.isArray(value)
|
|
1438
|
+
: !Array.isArray(value.members) || !Array.isArray(value.tasks))) throw new Error('invalid team projection')
|
|
1439
|
+
return { kind: msg.type, sessionId, ...identity,
|
|
1440
|
+
projectionState: snapshot.kind === 'migration-required' ? 'migration-required'
|
|
1441
|
+
: value === null ? 'unavailable' : 'ready',
|
|
1442
|
+
...(snapshot.kind === 'sequenced' ? { asOfSeq: snapshot.asOfSeq } : {}), [key]: value }
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
return { kind: 'error', code: error.code || 'internal', message: error.message,
|
|
1445
|
+
requestType: msg.type, sessionId, ...identity }
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1406
1448
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1407
1449
|
const session = requireSessionId(msg)
|
|
1408
1450
|
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
@@ -1448,18 +1490,18 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
|
1448
1490
|
}) }
|
|
1449
1491
|
}
|
|
1450
1492
|
if (msg.type === 'history') {
|
|
1451
|
-
const
|
|
1452
|
-
if (
|
|
1453
|
-
|
|
1454
|
-
}
|
|
1493
|
+
const target = parseSessionTarget(msg)
|
|
1494
|
+
if (target.error) return target.error
|
|
1495
|
+
const { sessionId } = target.value
|
|
1496
|
+
const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
1455
1497
|
const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
|
|
1456
|
-
if (cursorError) return cursorError
|
|
1457
|
-
const payload = {
|
|
1498
|
+
if (cursorError) return { ...cursorError, ...target.value, ...identity }
|
|
1499
|
+
const payload = { ...target.value }
|
|
1458
1500
|
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
1459
1501
|
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
1460
1502
|
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
|
|
1503
|
+
if (frame.kind === 'history') return { ...historyPage(frame, { ...msg, sessionId }), ...target.value, ...identity }
|
|
1504
|
+
return { ...frame, ...target.value, ...identity }
|
|
1463
1505
|
}
|
|
1464
1506
|
if (msg.type === 'attachment') {
|
|
1465
1507
|
const sessionId = requireSessionId(msg)
|
|
@@ -1527,16 +1569,17 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
|
1527
1569
|
return frame
|
|
1528
1570
|
}
|
|
1529
1571
|
if (msg.type === 'session-cancel') {
|
|
1530
|
-
const
|
|
1531
|
-
if (
|
|
1572
|
+
const target = parseSessionTarget(msg)
|
|
1573
|
+
if (target.error) return target.error
|
|
1574
|
+
const identity = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
1532
1575
|
const frame = await proxyQuery(
|
|
1533
1576
|
api,
|
|
1534
1577
|
'session-cancel',
|
|
1535
1578
|
api.sessions.cancel.bind(api.sessions),
|
|
1536
|
-
|
|
1579
|
+
target.value,
|
|
1537
1580
|
)
|
|
1538
|
-
if (frame.kind === 'error') return { ...frame,
|
|
1539
|
-
return { ...frame, kind: 'session-cancelled',
|
|
1581
|
+
if (frame.kind === 'error') return { ...frame, ...target.value, ...identity }
|
|
1582
|
+
return { ...frame, kind: 'session-cancelled', ...target.value, ...identity }
|
|
1540
1583
|
}
|
|
1541
1584
|
if (msg.type === 'queue-update') {
|
|
1542
1585
|
const sessionId = requireSessionId(msg)
|
|
@@ -1839,15 +1882,12 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
|
1839
1882
|
contextPressure: values.contextPressure || null,
|
|
1840
1883
|
}
|
|
1841
1884
|
}
|
|
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')
|
|
1885
|
+
if (msg.type === 'tasks' || msg.type === 'goal') {
|
|
1886
|
+
const target = parseSessionTarget(msg)
|
|
1887
|
+
if (target.error) return target.error
|
|
1888
|
+
const frame = await readSessionProjection(api, msg.type, target.value.sessionId,
|
|
1889
|
+
msg.type === 'tasks' ? 'todos' : 'goal', target.value)
|
|
1890
|
+
return { ...frame, ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}) }
|
|
1851
1891
|
}
|
|
1852
1892
|
if (msg.type === 'goal-edit') {
|
|
1853
1893
|
const sessionId = requireSessionId(msg)
|
|
@@ -2996,6 +3036,8 @@ const plugin = {
|
|
|
2996
3036
|
}
|
|
2997
3037
|
void speechInput.handle(ws, msg, sendFrame).then(sendFrame)
|
|
2998
3038
|
} else if (msg.type === 'subscribe') {
|
|
3039
|
+
const target = parseSessionTarget(msg, { optional: true })
|
|
3040
|
+
if (target.error) { sendFrame(target.error); return }
|
|
2999
3041
|
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
3000
3042
|
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
3001
3043
|
return
|
|
@@ -3009,9 +3051,9 @@ const plugin = {
|
|
|
3009
3051
|
ws.filterSessionId = sessionId
|
|
3010
3052
|
ws.sessionFollowerActive = msg.assistantStream === true
|
|
3011
3053
|
const subscriptionId = crypto.randomUUID()
|
|
3012
|
-
sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
|
|
3054
|
+
sendFrame({ kind: 'subscribed', ...target.value, sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
|
|
3013
3055
|
if (ws.sessionFollowerActive) {
|
|
3014
|
-
void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
|
|
3056
|
+
void ws.sessionFollower.start(sessionId, subscriptionId, target.value).catch(error => {
|
|
3015
3057
|
log(`session follower failed: ${error.message}`)
|
|
3016
3058
|
if (ws.readyState === 1) ws.close(1011, 'session follower failed')
|
|
3017
3059
|
})
|
|
@@ -3039,7 +3081,8 @@ const plugin = {
|
|
|
3039
3081
|
admitMessage(api, msg).then((frame) => {
|
|
3040
3082
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
3041
3083
|
})
|
|
3042
|
-
} else if (msg.type === '
|
|
3084
|
+
} else if (msg.type === 'agent-team' || msg.type === 'subagents' ||
|
|
3085
|
+
msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
3043
3086
|
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
3044
3087
|
msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
|
|
3045
3088
|
msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
|
|
@@ -3112,6 +3155,8 @@ const plugin = {
|
|
|
3112
3155
|
'images',
|
|
3113
3156
|
'session-create',
|
|
3114
3157
|
'session-agent-preset',
|
|
3158
|
+
'agent-team-v1',
|
|
3159
|
+
'subagent-conversations-v1',
|
|
3115
3160
|
'commands',
|
|
3116
3161
|
'tasks',
|
|
3117
3162
|
'goals',
|
|
@@ -3237,6 +3282,15 @@ const plugin = {
|
|
|
3237
3282
|
const disposeScheduleChanged = ctx.on('schedule/changed', () => {
|
|
3238
3283
|
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
|
|
3239
3284
|
})
|
|
3285
|
+
const disposeSessionStatus = ctx.on('api-session/status', (sessionId, running) => {
|
|
3286
|
+
broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(sessionId), running })
|
|
3287
|
+
})
|
|
3288
|
+
const disposeSessionAdded = ctx.on('api-session/added', summary => {
|
|
3289
|
+
broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(summary.sessionId), running: summary.running })
|
|
3290
|
+
})
|
|
3291
|
+
const disposeSessionRemoved = ctx.on('api-session/removed', sessionId => {
|
|
3292
|
+
broadcastSessionMetadataFrame({ kind: 'session-status', sessionId: String(sessionId), running: false })
|
|
3293
|
+
})
|
|
3240
3294
|
|
|
3241
3295
|
const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
|
|
3242
3296
|
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
|
|
@@ -3410,6 +3464,7 @@ const plugin = {
|
|
|
3410
3464
|
todos: 'tasks-updated',
|
|
3411
3465
|
goal: 'goal-updated',
|
|
3412
3466
|
turnOutline: 'turn-outline-updated',
|
|
3467
|
+
...TEAM_PROJECTION_KINDS,
|
|
3413
3468
|
}
|
|
3414
3469
|
const projectionValues = (values = {}) => Object.fromEntries(
|
|
3415
3470
|
Object.keys(projectionKinds).map(key => [key, values[key] ?? null]),
|
|
@@ -3419,7 +3474,8 @@ const plugin = {
|
|
|
3419
3474
|
[key]: value,
|
|
3420
3475
|
})
|
|
3421
3476
|
const broadcastProjection = (sessionId, key, value, seq) => {
|
|
3422
|
-
const broadcast = key === 'turnOutline'
|
|
3477
|
+
const broadcast = key === 'turnOutline' || Object.hasOwn(TEAM_PROJECTION_KINDS, key)
|
|
3478
|
+
? broadcastSessionMetadataFrame : broadcastInteractionFrame
|
|
3423
3479
|
broadcast(projectionFrame(sessionId, key, value, seq))
|
|
3424
3480
|
}
|
|
3425
3481
|
|
|
@@ -3517,6 +3573,9 @@ const plugin = {
|
|
|
3517
3573
|
disposeMgmt()
|
|
3518
3574
|
disposeEvents()
|
|
3519
3575
|
disposeScheduleChanged()
|
|
3576
|
+
disposeSessionStatus()
|
|
3577
|
+
disposeSessionAdded()
|
|
3578
|
+
disposeSessionRemoved()
|
|
3520
3579
|
disposePluginsChanged()
|
|
3521
3580
|
disposePluginSettingsChanged()
|
|
3522
3581
|
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.0",
|
|
4
|
+
"description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全预览、局域网及 Cloudflare Tunnel。0.11.0 新增智能体团队与子会话协议,支持团队成员、共享任务看板、多层子智能体目录和运行状态实时同步,以及子智能体独立历史、视图切换、继续对话、图片输入与停止执行;提供完整 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/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",
|