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.
@@ -1,6 +1,6 @@
1
- # DSH 0.1.5-rc.2 移动端接入
1
+ # DSH 0.2.1-rc.2 移动端接入
2
2
 
3
- 本次源码以 DSH 0.1.5-rc.2 为唯一 Host 基线,不提供旧 Host 分支。配对、设备 token、双连接和 `dsh-mobile-v1` 保持原样;以下是新的实时订阅和历史坐标约定。App 需要同步实现本说明,不能把独立流帧交给旧的 `SessionEvent.seq` reducer。
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.5-rc.2",
14
- "historyFormatVersion": 3,
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 格式值 3 是独立概念。
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": 3,
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: 3`、`cursor`。conversation 视图隐藏 `system/message`、`request/header`、`request/context`,移除 Assistant 事件中的 `data.stream`,保留真实 seq、最终 message、usage 和 interrupted。默认原始视图保留完整内嵌 stream,用于按需读取轨迹。全部为隐藏事件的页仍返回可前进的 nextBeforeSeq。
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": 3 }
133
- { "type": "fork", "sessionId": "s1", "atSeq": 42, "historyFormatVersion": 3 }
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。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个 3 后重试。游标必须是非负安全整数。省略 atSeq 的 fork 仍表示从最近完成轮次分叉。
136
+ 缺失版本、旧版本或版本不匹配,返回 `code: "history-format-mismatch"`、`resetRequired: true` 和当前 historyFormatVersion,且不会调用上游分页/fork。客户端必须重建缓存后再取新坐标,不能给旧坐标补一个 4 后重试。游标必须是非负安全整数。省略 atSeq 的 fork 仍表示从最近完成轮次分叉。
137
137
 
138
138
  收到 session-snapshot 后,不要用晚到的普通 history 响应覆盖正在进行的订阅基线;更早历史页只合并到历史部分。这样可以避免独立查询与实时流交错造成回滚。
139
139
 
@@ -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.sessionId)
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: { kind: 'session', sessionId: payload.sessionId },
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) => invoke('session', 'cancel', requestArgs(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: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
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', requestType: 'message',
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, ...(sessionId ? { sessionId } : {}) }
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), { sessionId })
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
- sessionId,
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 sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1452
- if (!sessionId) {
1453
- return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
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 = { sessionId }
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 sessionId = requireSessionId(msg)
1531
- if (sessionId.error) return sessionId.error
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
- { sessionId: sessionId.value },
1579
+ target.value,
1537
1580
  )
1538
- if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1539
- return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
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 sessionId = requireSessionId(msg)
1844
- if (sessionId.error) return sessionId.error
1845
- return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1846
- }
1847
- if (msg.type === 'goal') {
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 === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
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' ? broadcastSessionMetadataFrame : broadcastInteractionFrame
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()
@@ -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'].includes(error.code)
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.10.4",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全文件预览、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.4 精简会话列表投影,仅保留标题与投影切点,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改。",
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",