dsh-plugin-mobile-gateway 0.10.3 → 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/lib/index.mjs CHANGED
@@ -116,6 +116,7 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
116
116
  // { "kind": "goal-clear", "sessionId", "cleared": true }
117
117
  // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
118
118
  // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
119
+ // { "kind": "turn-outline-updated", "sessionId", "asOfSeq", "turnOutline": [...] | null }
119
120
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
120
121
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
121
122
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
@@ -138,6 +139,7 @@ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh
138
139
  import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
139
140
  import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
140
141
  import { createSessionFollower } from './session-follower.mjs'
142
+ import { parseSessionTarget, TEAM_PROJECTION_KINDS } from './session-target.mjs'
141
143
  import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
142
144
  import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
143
145
  import { gatewayProbeHeader } from './gateway-probe.mjs'
@@ -534,18 +536,26 @@ async function admitCommand(host, msg) {
534
536
  // Handle one mobile -> agent message through the official host API. Returns
535
537
  // the wire frame to send back, or null when nothing should be sent.
536
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
+ }
537
547
  const preset = parseAgentPreset(msg, 'message')
538
- if (preset.error) return preset.error
548
+ if (preset.error) return rejectMessage(preset.error)
539
549
  if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
540
- return { kind: 'error', code: 'bad-request', requestType: 'message',
541
- 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' })
542
552
  }
543
553
  const text = typeof msg.text === 'string' ? msg.text.trim() : ''
544
554
  const parsedImages = parseWireImages(msg.images, 'message')
545
- if (parsedImages.error) return parsedImages.error
555
+ if (parsedImages.error) return rejectMessage(parsedImages.error)
546
556
  const imageParts = parsedImages.value
547
557
  if (!text && imageParts.length === 0) {
548
- 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' })
549
559
  }
550
560
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
551
561
 
@@ -569,8 +579,10 @@ async function admitMessage(api, msg) {
569
579
  }
570
580
 
571
581
  const resp = await api.sessions.prompt({
582
+ ...target.value,
572
583
  sessionId,
573
584
  mode,
585
+ ...(target.value.parentSessionId === undefined ? {} : requestIdentity),
574
586
  // Match the official WebUI ordering: images first, optional text last.
575
587
  content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
576
588
  ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
@@ -580,13 +592,19 @@ async function admitMessage(api, msg) {
580
592
  kind: 'sent',
581
593
  sessionId,
582
594
  mode,
595
+ ...requestIdentity,
596
+ ...(target.value.parentSessionId === undefined ? {} : {
597
+ parentSessionId: target.value.parentSessionId, subagentMode: target.value.subagentMode,
598
+ messageId: resp.messageId,
599
+ }),
583
600
  ...(resp.command ? { command: resp.command } : {}),
584
601
  }
585
602
  } catch (error) {
586
603
  const code = error && error.code ? error.code : 'internal'
587
604
  const message = error && error.message ? error.message : String(error)
588
605
  log(`mobile message failed: ${message}`)
589
- return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
606
+ return { kind: 'error', code, message, requestType: 'message', ...target.value,
607
+ ...requestIdentity, ...(sessionId ? { sessionId } : {}) }
590
608
  }
591
609
  }
592
610
 
@@ -1079,13 +1097,13 @@ function requireGoalRef(msg, sessionId) {
1079
1097
  return { value: { id, revision } }
1080
1098
  }
1081
1099
 
1082
- async function readSessionProjection(api, type, sessionId, key) {
1083
- const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1084
- 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 }
1085
1103
  const values = (history.projections && history.projections.values) || {}
1086
1104
  return {
1087
1105
  kind: type,
1088
- sessionId,
1106
+ ...target,
1089
1107
  asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
1090
1108
  [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
1091
1109
  }
@@ -1402,6 +1420,31 @@ function parseAgentPreset(msg, requestType, required = false) {
1402
1420
  // Dispatch one mobile query frame; returns the wire frame to send back.
1403
1421
  async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1404
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
+ }
1405
1448
  if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1406
1449
  const session = requireSessionId(msg)
1407
1450
  const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
@@ -1433,21 +1476,32 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1433
1476
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1434
1477
  }
1435
1478
  if (msg.type === 'sessions') {
1436
- return proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
1479
+ const frame = await proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
1480
+ if (frame.kind !== 'sessions' || !Array.isArray(frame.items)) return frame
1481
+ // Lists need only titles; full projections can make tunnel frames many MiB.
1482
+ // Copy at the mobile boundary so Host data and history projections stay intact.
1483
+ return { ...frame, items: frame.items.map(item => {
1484
+ if (!item?.projections) return item
1485
+ const { asOfSeq, values } = item.projections
1486
+ return { ...item, projections: {
1487
+ ...(asOfSeq === undefined ? {} : { asOfSeq }),
1488
+ values: values && Object.hasOwn(values, 'title') ? { title: values.title } : {},
1489
+ } }
1490
+ }) }
1437
1491
  }
1438
1492
  if (msg.type === 'history') {
1439
- const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1440
- if (!sessionId) {
1441
- return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
1442
- }
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 } : {}
1443
1497
  const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
1444
- if (cursorError) return cursorError
1445
- const payload = { sessionId }
1498
+ if (cursorError) return { ...cursorError, ...target.value, ...identity }
1499
+ const payload = { ...target.value }
1446
1500
  if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
1447
1501
  if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
1448
1502
  const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
1449
- if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
1450
- return frame
1503
+ if (frame.kind === 'history') return { ...historyPage(frame, { ...msg, sessionId }), ...target.value, ...identity }
1504
+ return { ...frame, ...target.value, ...identity }
1451
1505
  }
1452
1506
  if (msg.type === 'attachment') {
1453
1507
  const sessionId = requireSessionId(msg)
@@ -1515,16 +1569,17 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1515
1569
  return frame
1516
1570
  }
1517
1571
  if (msg.type === 'session-cancel') {
1518
- const sessionId = requireSessionId(msg)
1519
- 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 } : {}
1520
1575
  const frame = await proxyQuery(
1521
1576
  api,
1522
1577
  'session-cancel',
1523
1578
  api.sessions.cancel.bind(api.sessions),
1524
- { sessionId: sessionId.value },
1579
+ target.value,
1525
1580
  )
1526
- if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1527
- 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 }
1528
1583
  }
1529
1584
  if (msg.type === 'queue-update') {
1530
1585
  const sessionId = requireSessionId(msg)
@@ -1827,15 +1882,12 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1827
1882
  contextPressure: values.contextPressure || null,
1828
1883
  }
1829
1884
  }
1830
- if (msg.type === 'tasks') {
1831
- const sessionId = requireSessionId(msg)
1832
- if (sessionId.error) return sessionId.error
1833
- return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1834
- }
1835
- if (msg.type === 'goal') {
1836
- const sessionId = requireSessionId(msg)
1837
- if (sessionId.error) return sessionId.error
1838
- 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 } : {}) }
1839
1891
  }
1840
1892
  if (msg.type === 'goal-edit') {
1841
1893
  const sessionId = requireSessionId(msg)
@@ -2984,6 +3036,8 @@ const plugin = {
2984
3036
  }
2985
3037
  void speechInput.handle(ws, msg, sendFrame).then(sendFrame)
2986
3038
  } else if (msg.type === 'subscribe') {
3039
+ const target = parseSessionTarget(msg, { optional: true })
3040
+ if (target.error) { sendFrame(target.error); return }
2987
3041
  if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
2988
3042
  sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
2989
3043
  return
@@ -2997,9 +3051,9 @@ const plugin = {
2997
3051
  ws.filterSessionId = sessionId
2998
3052
  ws.sessionFollowerActive = msg.assistantStream === true
2999
3053
  const subscriptionId = crypto.randomUUID()
3000
- sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
3054
+ sendFrame({ kind: 'subscribed', ...target.value, sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
3001
3055
  if (ws.sessionFollowerActive) {
3002
- void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
3056
+ void ws.sessionFollower.start(sessionId, subscriptionId, target.value).catch(error => {
3003
3057
  log(`session follower failed: ${error.message}`)
3004
3058
  if (ws.readyState === 1) ws.close(1011, 'session follower failed')
3005
3059
  })
@@ -3027,7 +3081,8 @@ const plugin = {
3027
3081
  admitMessage(api, msg).then((frame) => {
3028
3082
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3029
3083
  })
3030
- } 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' ||
3031
3086
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
3032
3087
  msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
3033
3088
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
@@ -3096,9 +3151,12 @@ const plugin = {
3096
3151
  'assistant-stream-v1',
3097
3152
  'history-format-version',
3098
3153
  'projection-baseline',
3154
+ 'turn-outline-v1',
3099
3155
  'images',
3100
3156
  'session-create',
3101
3157
  'session-agent-preset',
3158
+ 'agent-team-v1',
3159
+ 'subagent-conversations-v1',
3102
3160
  'commands',
3103
3161
  'tasks',
3104
3162
  'goals',
@@ -3224,6 +3282,15 @@ const plugin = {
3224
3282
  const disposeScheduleChanged = ctx.on('schedule/changed', () => {
3225
3283
  if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
3226
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
+ })
3227
3294
 
3228
3295
  const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
3229
3296
  if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
@@ -3393,10 +3460,24 @@ const plugin = {
3393
3460
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
3394
3461
  }
3395
3462
 
3463
+ const projectionKinds = {
3464
+ todos: 'tasks-updated',
3465
+ goal: 'goal-updated',
3466
+ turnOutline: 'turn-outline-updated',
3467
+ ...TEAM_PROJECTION_KINDS,
3468
+ }
3469
+ const projectionValues = (values = {}) => Object.fromEntries(
3470
+ Object.keys(projectionKinds).map(key => [key, values[key] ?? null]),
3471
+ )
3396
3472
  const projectionFrame = (sessionId, key, value, seq) => ({
3397
- kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
3473
+ kind: projectionKinds[key], sessionId, asOfSeq: seq,
3398
3474
  [key]: value,
3399
3475
  })
3476
+ const broadcastProjection = (sessionId, key, value, seq) => {
3477
+ const broadcast = key === 'turnOutline' || Object.hasOwn(TEAM_PROJECTION_KINDS, key)
3478
+ ? broadcastSessionMetadataFrame : broadcastInteractionFrame
3479
+ broadcast(projectionFrame(sessionId, key, value, seq))
3480
+ }
3400
3481
 
3401
3482
  const installProjectionBaseline = value => {
3402
3483
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
@@ -3406,16 +3487,17 @@ const plugin = {
3406
3487
  throw new Error('invalid session projection baseline')
3407
3488
  }
3408
3489
  Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
3409
- value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
3490
+ value: { asOfSeq: baseline.asOfSeq,
3491
+ values: projectionValues(baseline.values) } })
3410
3492
  }
3411
3493
  const previous = sessionProjectionBaselines
3412
3494
  sessionProjectionBaselines = next
3413
3495
  broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
3414
3496
  for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
3415
3497
  const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
3416
- for (const key of ['todos', 'goal']) {
3417
- broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
3418
- baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
3498
+ for (const key of Object.keys(projectionKinds)) {
3499
+ broadcastProjection(sessionId, key, baseline?.values[key] ?? null,
3500
+ baseline?.asOfSeq ?? previous[sessionId].asOfSeq)
3419
3501
  }
3420
3502
  }
3421
3503
  }
@@ -3452,12 +3534,13 @@ const plugin = {
3452
3534
  installSessionQueueBaseline(frame.value?.projections)
3453
3535
  } else if (frame?.type === 'projection' && frame.key === 'inbox') {
3454
3536
  installSessionQueue(frame.sessionId, frame.value)
3455
- } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3537
+ } else if (frame?.type === 'projection' && Object.hasOwn(projectionKinds, frame.key)) {
3456
3538
  const sessionId = String(frame.sessionId)
3457
3539
  const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
3540
+ const value = frame.value ?? null
3458
3541
  Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
3459
- value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
3460
- broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
3542
+ value: { asOfSeq: frame.seq, values: { ...projectionValues(previous?.values), [frame.key]: value } } })
3543
+ broadcastProjection(sessionId, frame.key, value, frame.seq)
3461
3544
  log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
3462
3545
  }
3463
3546
  }
@@ -3490,6 +3573,9 @@ const plugin = {
3490
3573
  disposeMgmt()
3491
3574
  disposeEvents()
3492
3575
  disposeScheduleChanged()
3576
+ disposeSessionStatus()
3577
+ disposeSessionAdded()
3578
+ disposeSessionRemoved()
3493
3579
  disposePluginsChanged()
3494
3580
  disposePluginSettingsChanged()
3495
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.3",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全文件预览、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.3 适配 DSH 0.2.1-rc.2,为 Remote 请求及实时订阅补齐 Connection Peer,修复读取 signal 时的空值报错,并在网关停止时释放 Peer。",
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",