dsh-plugin-mobile-gateway 0.7.7 → 0.9.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
@@ -68,6 +68,8 @@ import { stringifyWireFrame } from './wire-json.mjs'
68
68
  // { "type": "goal", "sessionId" } -> current goal projection
69
69
  // { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
70
70
  // { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
71
+ // { "type": "plugin-catalog"|"plugin-inspect"|"plugin-install"|"plugin-settings"|..., "requestId", ... }
72
+ // -> paired control connection; see PROTOCOL.md section 18
71
73
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
72
74
  // { "type": "defaults" } -> default agent preset + default permission
73
75
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
@@ -75,7 +77,7 @@ import { stringifyWireFrame } from './wire-json.mjs'
75
77
  // { "type": "question-cancel", "rpcId", "sessionId" }
76
78
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
77
79
  // "outcome": "allowed-once"|"rejected" }
78
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
80
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "plugin-management-v1", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
79
81
  // "authenticated", "device"?, "port", "clients" }
80
82
  // { "kind": "pong", "at" }
81
83
  // { "kind": "subscribed", "sessionId" }
@@ -130,8 +132,11 @@ import Schema from '@deepseek-ai/schemastery'
130
132
  import { WebSocketServer } from 'ws'
131
133
  import devicesModule from './devices.js'
132
134
  import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
135
+ import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
133
136
  import { createSessionFollower } from './session-follower.mjs'
134
137
  import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
138
+ import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
139
+ import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
135
140
  import QRCode from 'qrcode'
136
141
 
137
142
  const { createRegistry } = devicesModule
@@ -225,6 +230,9 @@ const Config = Schema.object({
225
230
  lanHost: Schema.string().default('0.0.0.0'),
226
231
  lanPort: Schema.natural().min(1).max(65535).default(3081),
227
232
  lanAdvertiseHost: Schema.string().default(''),
233
+ cloudflaredPath: Schema.string().default('cloudflared'),
234
+ cloudflarePort: Schema.natural().min(1).max(65535).default(3082),
235
+ cloudflareStateFile: Schema.string().default(''),
228
236
  })
229
237
 
230
238
  function log(line) {
@@ -323,7 +331,8 @@ function buildWireEvent(session, event) {
323
331
  case 'tool/result': {
324
332
  let preview = ''
325
333
  for (const block of (d.message && d.message.content) || []) {
326
- for (const inner of (block && block.content) || []) {
334
+ const content = block?.type === 'tool-result' ? block.content || [] : [block]
335
+ for (const inner of content) {
327
336
  if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
328
337
  }
329
338
  }
@@ -333,9 +342,10 @@ function buildWireEvent(session, event) {
333
342
  type: 'tool/result',
334
343
  turn: d.turn,
335
344
  step: d.step,
336
- callId: d.message && d.message.source && d.message.source.callId,
337
- isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
345
+ callId: d.message?.toolCallId ?? d.message?.source?.callId,
346
+ isError: d.message?.isError === true || !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
338
347
  preview,
348
+ ...(d.error ? { error: d.error } : {}),
339
349
  },
340
350
  })
341
351
  }
@@ -1122,7 +1132,7 @@ function validateHistoryCursor(msg, sessionId, field) {
1122
1132
  if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
1123
1133
  return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
1124
1134
  historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
1125
- message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
1135
+ message: `Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be ${SESSION_FORMAT_VERSION}` }
1126
1136
  }
1127
1137
  if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
1128
1138
  return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
@@ -1220,7 +1230,9 @@ async function loadCommandOptions(api, command, sessionId) {
1220
1230
  const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1221
1231
  if (history.kind !== 'history') return history
1222
1232
  const permissions = history.projections && history.projections.values && history.projections.values.permissions
1223
- if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
1233
+ const catalog = await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
1234
+ if (catalog.kind === 'error') return catalog
1235
+ if (!permissions || typeof permissions.currentValue !== 'string' || !Array.isArray(catalog.options)) {
1224
1236
  return {
1225
1237
  kind: 'error',
1226
1238
  code: 'command-options-unavailable',
@@ -1233,7 +1245,7 @@ async function loadCommandOptions(api, command, sessionId) {
1233
1245
  kind: 'command-options',
1234
1246
  sessionId,
1235
1247
  command,
1236
- options: permissions.options.map((option) => ({
1248
+ options: catalog.options.map((option) => ({
1237
1249
  id: String(option.value),
1238
1250
  label: String(option.name || option.value),
1239
1251
  ...(option.description ? { description: String(option.description) } : {}),
@@ -1326,7 +1338,8 @@ function parseAgentPreset(msg, requestType, required = false) {
1326
1338
  }
1327
1339
 
1328
1340
  // Dispatch one mobile query frame; returns the wire frame to send back.
1329
- async function handleQuery(api, host, agentDefaultModel, msg) {
1341
+ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1342
+ if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
1330
1343
  if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1331
1344
  const session = requireSessionId(msg)
1332
1345
  const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
@@ -1660,11 +1673,18 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1660
1673
  return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1661
1674
  }
1662
1675
  if (msg.type === 'permission-options') {
1663
- const frame = await proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {})
1676
+ const [frame, catalog] = await Promise.all([
1677
+ proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {}),
1678
+ proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
1679
+ ])
1664
1680
  if (frame.kind !== 'permission-options') return frame
1681
+ if (catalog.kind === 'error') return catalog
1665
1682
  const out = {
1666
1683
  kind: 'permission-options',
1667
1684
  namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
1685
+ options: catalog.options,
1686
+ defaultOptions: catalog.defaultOptions,
1687
+ defaultPreset: catalog.defaultPreset,
1668
1688
  }
1669
1689
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1670
1690
  if (sessionId) {
@@ -1672,7 +1692,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1672
1692
  const hist = await querySessionProjection(api, msg.type, sessionId)
1673
1693
  if (hist.kind === 'error') return hist
1674
1694
  if (hist.kind === 'history' && hist.projections && hist.projections.values) {
1675
- out.sessionPermissions = hist.projections.values.permissions || null
1695
+ const current = hist.projections.values.permissions
1696
+ out.sessionPermissions = current ? { ...current, options: catalog.options } : null
1676
1697
  }
1677
1698
  }
1678
1699
  return out
@@ -1777,18 +1798,66 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1777
1798
  const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1778
1799
  return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1779
1800
  }
1801
+ if (msg.type === 'schedule-catalog') {
1802
+ return proxyQuery(api, msg.type, api.schedule.catalog.bind(api.schedule), {})
1803
+ }
1804
+ if (msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
1805
+ msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
1806
+ const sessionId = requireSessionId(msg)
1807
+ if (sessionId.error) return sessionId.error
1808
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? { requestId: msg.requestId } : {}
1809
+ const payload = { sessionId: sessionId.value }
1810
+ if (msg.type !== 'schedule-list') {
1811
+ if (typeof msg.id !== 'string' || !msg.id.trim()) {
1812
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires an id`, requestType: msg.type, sessionId: sessionId.value, ...requestId }
1813
+ }
1814
+ payload.id = msg.id.trim()
1815
+ }
1816
+ if (msg.type === 'schedule-history') {
1817
+ if (!Number.isSafeInteger(msg.limit) || msg.limit < 1 || msg.limit > 100 ||
1818
+ (msg.before !== undefined && (typeof msg.before !== 'string' || !msg.before.trim()))) {
1819
+ return { kind: 'error', code: 'bad-request', message: 'schedule-history requires limit 1..100 and an optional non-empty before cursor', requestType: msg.type, sessionId: sessionId.value, ...requestId }
1820
+ }
1821
+ payload.limit = msg.limit
1822
+ if (msg.before !== undefined) payload.before = msg.before
1823
+ }
1824
+ if (msg.type === 'schedule-update') {
1825
+ if (!msg.expected || typeof msg.expected !== 'object' || Array.isArray(msg.expected) || msg.expected.id !== payload.id) {
1826
+ return { kind: 'error', code: 'bad-request', message: 'schedule-update requires the complete expected record with a matching id', requestType: msg.type, sessionId: sessionId.value, ...requestId }
1827
+ }
1828
+ // Catalog rows add Host management metadata. The Remote update contract
1829
+ // requires the original ScheduleRecord only.
1830
+ payload.expected = Object.fromEntries(
1831
+ ['id', 'kind', 'title', 'prompt', 'scheduledAt', 'afterSeconds', 'everySeconds', 'time', 'timeZone', 'weekdays', 'expression']
1832
+ .filter((field) => Object.hasOwn(msg.expected, field))
1833
+ .map((field) => [field, msg.expected[field]]),
1834
+ )
1835
+ for (const field of ['title', 'prompt']) {
1836
+ if (msg[field] !== undefined) payload[field] = msg[field]
1837
+ }
1838
+ if (msg.change !== undefined) payload.change = msg.change
1839
+ }
1840
+ const method = msg.type.slice('schedule-'.length)
1841
+ const frame = await proxyQuery(api, msg.type, api.schedule[method].bind(api.schedule), payload)
1842
+ return { ...frame, sessionId: sessionId.value, ...requestId }
1843
+ }
1780
1844
  if (msg.type === 'agent-presets') {
1781
1845
  return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1782
1846
  }
1783
1847
  if (msg.type === 'defaults') {
1784
- const frame = await proxyQuery(api, 'defaults', api.settings.describe.bind(api.settings), {})
1785
- if (frame.kind !== 'defaults') return frame
1786
- const agentPresetNs = (frame.namespaces || []).find((n) => n.ns === 'agent-presets')
1787
- const permissionNs = (frame.namespaces || []).find((n) => n.ns === 'permission')
1848
+ const [presets, catalog] = await Promise.all([
1849
+ proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {}),
1850
+ proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
1851
+ ])
1852
+ if (presets.kind === 'error') return presets
1853
+ if (catalog.kind === 'error') return catalog
1788
1854
  return {
1789
1855
  kind: 'defaults',
1790
- agentPresetDefault: agentPresetNs && agentPresetNs.value && agentPresetNs.value.default !== undefined ? agentPresetNs.value.default : null,
1791
- permissionDefault: permissionNs && permissionNs.value && permissionNs.value.defaultPreset !== undefined ? permissionNs.value.defaultPreset : null,
1856
+ agentPresetDefault: presets.presets?.find((preset) => preset.isDefault)?.id ?? null,
1857
+ permissionDefault: catalog.defaultPreset ?? null,
1858
+ permissionDefaultOptions: catalog.defaultOptions ?? [],
1859
+ // rc.2 removed this roster flag; only an explicit older-host false disables selection.
1860
+ modeSelectionEnabled: presets.modeSelectionEnabled !== false,
1792
1861
  }
1793
1862
  }
1794
1863
  if (msg.type === 'set-default') {
@@ -1800,8 +1869,21 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1800
1869
  if (!value) {
1801
1870
  return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
1802
1871
  }
1803
- const ns = target === 'agent-preset' ? 'agent-presets' : 'permission'
1804
- const patch = target === 'agent-preset' ? { default: value } : { defaultPreset: value }
1872
+ const catalog = target === 'agent-preset'
1873
+ ? await proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1874
+ : await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
1875
+ if (catalog.kind === 'error') return catalog
1876
+ if (target === 'agent-preset' && catalog.modeSelectionEnabled === false) {
1877
+ return { kind: 'error', code: 'mode-selection-disabled', message: 'Agent mode selection is disabled', requestType: 'set-default' }
1878
+ }
1879
+ const choices = target === 'agent-preset'
1880
+ ? catalog.presets?.filter((preset) => !preset.broken).map((preset) => preset.id)
1881
+ : catalog.defaultOptions?.map((option) => option.value)
1882
+ if (!choices?.includes(value)) {
1883
+ return { kind: 'error', code: 'bad-request', message: 'default value is not available', requestType: 'set-default' }
1884
+ }
1885
+ const ns = target === 'agent-preset' ? 'agent-preset-registry' : 'permission'
1886
+ const patch = target === 'agent-preset' ? { selectedDefault: value } : { defaultPreset: value }
1805
1887
  const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
1806
1888
  if (updated.kind !== 'set-default') return updated
1807
1889
  log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
@@ -2075,6 +2157,9 @@ const plugin = {
2075
2157
  lanHost: '0.0.0.0',
2076
2158
  lanPort: 3081,
2077
2159
  lanAdvertiseHost: '',
2160
+ cloudflaredPath: 'cloudflared',
2161
+ cloudflarePort: 3082,
2162
+ cloudflareStateFile: '',
2078
2163
  ...(config || {}),
2079
2164
  }
2080
2165
  const wsPath = options.path
@@ -2087,6 +2172,9 @@ const plugin = {
2087
2172
  if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
2088
2173
  throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
2089
2174
  }
2175
+ if (typeof options.cloudflaredPath !== 'string' || !options.cloudflaredPath.trim()) {
2176
+ throw new Error('mobile-gateway: cloudflaredPath must be a nonempty executable path')
2177
+ }
2090
2178
  let requireAuth = options.requireAuth !== false
2091
2179
  const adminLoopbackOnly = options.adminLoopbackOnly !== false
2092
2180
  const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
@@ -2112,6 +2200,7 @@ const plugin = {
2112
2200
  },
2113
2201
  })
2114
2202
  const clients = new Set()
2203
+ const pluginInstallOwners = new Map()
2115
2204
  const pendingQuestions = new Map()
2116
2205
  const pendingApprovals = new Map()
2117
2206
  const fileTransfers = createFileTransferManager(api, options)
@@ -2136,6 +2225,7 @@ const plugin = {
2136
2225
  let lanListening = false
2137
2226
  let lanListenError = null
2138
2227
  let lanBoundPort = options.lanPort
2228
+ let tunnel = null
2139
2229
  const configuredPublicUrl = () => {
2140
2230
  if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
2141
2231
  if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
@@ -2157,6 +2247,7 @@ const plugin = {
2157
2247
  ...extra,
2158
2248
  ...configuredEndpoints,
2159
2249
  ...(publicUrl ? [publicUrl] : []),
2250
+ ...(tunnel?.snapshot().publicUrl ? [tunnel.snapshot().publicUrl] : []),
2160
2251
  ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2161
2252
  ])])
2162
2253
  }
@@ -2183,11 +2274,13 @@ const plugin = {
2183
2274
  log(`failed to persist automatic gateway disable: ${error.message}`)
2184
2275
  }
2185
2276
  log('mobile gateway automatically disabled: no device connected before timeout')
2277
+ void tunnel?.reconcile()
2186
2278
  }, options.gatewayWaitTimeoutMs)
2187
2279
  } else if (mode === 'disabled') {
2188
2280
  for (const client of clients) client.close(4004, 'mobile gateway disabled')
2189
2281
  }
2190
2282
  log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
2283
+ void tunnel?.reconcile()
2191
2284
  }
2192
2285
 
2193
2286
  const logAuthRejected = (req) => {
@@ -2221,6 +2314,7 @@ const plugin = {
2221
2314
  toolName: payload.toolName,
2222
2315
  ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2223
2316
  ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2317
+ ...(payload.displayReason !== undefined ? { displayReason: payload.displayReason } : {}),
2224
2318
  ...(replay ? { replay: true } : {}),
2225
2319
  })
2226
2320
 
@@ -2520,6 +2614,7 @@ const plugin = {
2520
2614
  log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2521
2615
 
2522
2616
  // ---- device management routes (loopback-gated admin surface) ----
2617
+ let restartPending = false
2523
2618
  const disposeMgmt = webServer.register({
2524
2619
  kind: 'prefix',
2525
2620
  path: '/mgw',
@@ -2536,6 +2631,7 @@ const plugin = {
2536
2631
  const url = new URL(req.url || '/', 'http://x')
2537
2632
  const p = url.pathname
2538
2633
  if (req.method === 'GET' && p === '/mgw/status') {
2634
+ const profile = ctx.get?.('profileContext')
2539
2635
  sendJson(res, 200, {
2540
2636
  version: PLUGIN_VERSION,
2541
2637
  requireAuth,
@@ -2546,8 +2642,13 @@ const plugin = {
2546
2642
  waitExpiresAt,
2547
2643
  connectedClients: clients.size,
2548
2644
  webPort: webServer.port,
2645
+ webPid: process.pid,
2549
2646
  wsPath,
2550
- publicUrl: configuredPublicUrl() || null,
2647
+ publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
2648
+ ? tunnel.snapshot().publicUrl
2649
+ : tunnel?.snapshot().publicUrl || configuredPublicUrl() || null,
2650
+ platform: process.platform,
2651
+ cloudflare: tunnel?.snapshot() || null,
2551
2652
  lan: {
2552
2653
  enabled: options.lanEnabled === true,
2553
2654
  listening: lanListening,
@@ -2559,7 +2660,49 @@ const plugin = {
2559
2660
  },
2560
2661
  pairingTtlMs: options.pairingTtlMs,
2561
2662
  queryTokenAllowed: !!options.allowQueryToken,
2663
+ tools: {
2664
+ restartWeb: webRestartAvailable(profile),
2665
+ stopWeb: webRestartAvailable(profile),
2666
+ },
2562
2667
  })
2668
+ } else if (req.method === 'POST' && p === '/mgw/tools/restart-web') {
2669
+ if (!isLoopback(req)) {
2670
+ sendJson(res, 403, { error: 'forbidden', message: 'Web restart is loopback-only' })
2671
+ return
2672
+ }
2673
+ if (!webRestartAvailable(ctx.get?.('profileContext'))) {
2674
+ sendJson(res, 409, { error: 'unavailable', message: '只能在通过 dsh web 启动的 Web profile 中重启' })
2675
+ return
2676
+ }
2677
+ if (restartPending) {
2678
+ sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启' })
2679
+ return
2680
+ }
2681
+ restartPending = true
2682
+ try {
2683
+ await prepareWebRestart()
2684
+ } catch (error) {
2685
+ restartPending = false
2686
+ throw error
2687
+ }
2688
+ res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
2689
+ sendJson(res, 202, { restarting: true })
2690
+ } else if (req.method === 'POST' && p === '/mgw/tools/stop-web') {
2691
+ if (!isLoopback(req)) {
2692
+ sendJson(res, 403, { error: 'forbidden', message: 'Web shutdown is loopback-only' })
2693
+ return
2694
+ }
2695
+ if (!webRestartAvailable(ctx.get?.('profileContext'))) {
2696
+ sendJson(res, 409, { error: 'unavailable', message: '只能停止通过 dsh web 启动的 Web profile' })
2697
+ return
2698
+ }
2699
+ if (restartPending) {
2700
+ sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启或停止' })
2701
+ return
2702
+ }
2703
+ restartPending = true
2704
+ res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
2705
+ sendJson(res, 202, { stopping: true })
2563
2706
  } else if (req.method === 'GET' && p === '/mgw/public-setup') {
2564
2707
  sendJson(res, 200, await systemHelperStatus())
2565
2708
  } else if (req.method === 'POST' && p === '/mgw/public-setup') {
@@ -2578,6 +2721,15 @@ const plugin = {
2578
2721
  if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2579
2722
  setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2580
2723
  sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
2724
+ } else if (req.method === 'POST' && p === '/mgw/cloudflare') {
2725
+ const body = await readBody(req)
2726
+ await tunnel.configure(body)
2727
+ if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
2728
+ await tunnel.reconcile()
2729
+ sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
2730
+ } else if (req.method === 'POST' && p === '/mgw/cloudflare/restart') {
2731
+ await tunnel.restart()
2732
+ sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
2581
2733
  } else if (req.method === 'POST' && p === '/mgw/auth') {
2582
2734
  const body = await readBody(req)
2583
2735
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -2798,8 +2950,25 @@ const plugin = {
2798
2950
  msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2799
2951
  msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2800
2952
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2801
- msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2802
- handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2953
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
2954
+ msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
2955
+ msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
2956
+ PLUGIN_REQUEST_TYPES.has(msg.type)) {
2957
+ if (PLUGIN_REQUEST_TYPES.has(msg.type) && !ws.deviceId) {
2958
+ sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
2959
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
2960
+ message: 'Plugin management requires a paired device' })
2961
+ return
2962
+ }
2963
+ if (msg.type === 'plugin-install' && pluginInstallOwners.has(msg.requestId)) {
2964
+ sendFrame({ kind: 'error', code: 'plugin-install/in-progress', requestType: msg.type,
2965
+ requestId: msg.requestId, message: 'An installation already uses this requestId' })
2966
+ return
2967
+ }
2968
+ handleQuery(api, host, agentDefaultModel, msg, {
2969
+ onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
2970
+ onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
2971
+ }).then((frame) => {
2803
2972
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2804
2973
  })
2805
2974
  } else {
@@ -2844,6 +3013,8 @@ const plugin = {
2844
3013
  'commands',
2845
3014
  'tasks',
2846
3015
  'goals',
3016
+ 'schedule-management',
3017
+ 'plugin-management-v1',
2847
3018
  'session-cancel',
2848
3019
  'queue-control',
2849
3020
  'session-archive',
@@ -2917,6 +3088,18 @@ const plugin = {
2917
3088
  })
2918
3089
  }
2919
3090
 
3091
+ tunnel = createCloudflareTunnel({
3092
+ file: options.cloudflareStateFile || `${deviceFile}.cloudflare.json`,
3093
+ port: options.cloudflarePort,
3094
+ wsPath,
3095
+ executable: options.cloudflaredPath,
3096
+ supported: process.platform === 'darwin' || process.platform === 'win32',
3097
+ isGatewayEnabled: () => gatewayEnabled,
3098
+ onUpgrade: handleMobileUpgrade,
3099
+ log,
3100
+ })
3101
+ void tunnel.reconcile()
3102
+
2920
3103
  const disposeEvents = ctx.on('session/event', (session, event) => {
2921
3104
  api.observeSessionEvent(String(session.id), event)
2922
3105
  if (clients.size === 0) return
@@ -2947,6 +3130,31 @@ const plugin = {
2947
3130
  })
2948
3131
  log('session/event listener attached')
2949
3132
 
3133
+ // The Host notification has no payload: readers refetch the task catalog.
3134
+ const disposeScheduleChanged = ctx.on('schedule/changed', () => {
3135
+ if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
3136
+ })
3137
+
3138
+ const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
3139
+ if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
3140
+ })
3141
+ const disposePluginSettingsChanged = ctx.on('settings/document-updated', (ns, revision) => {
3142
+ if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugin-settings-changed', ns, revision })
3143
+ })
3144
+ const sendPluginInstallEvent = (kind, event) => {
3145
+ const owner = pluginInstallOwners.get(event?.requestId)
3146
+ if (owner?.readyState === 1) owner.send(stringifyWireFrame({ kind, ...event }))
3147
+ }
3148
+ const disposePluginInstallState = ctx.on('plugin-manager/install-state', (progress) => {
3149
+ sendPluginInstallEvent('plugin-install-state', progress)
3150
+ })
3151
+ const disposePluginInstallLog = ctx.on('plugin-manager/install-log', (chunk) => {
3152
+ if (!chunk?.requestId) return
3153
+ const { requestId, jobId, stream, text, exitCode } = chunk
3154
+ sendPluginInstallEvent('plugin-install-log', { requestId, jobId, stream, text,
3155
+ ...(exitCode === undefined ? {} : { exitCode }) })
3156
+ })
3157
+
2950
3158
  const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2951
3159
  const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2952
3160
  if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
@@ -3002,6 +3210,7 @@ const plugin = {
3002
3210
  toolName: request.toolName,
3003
3211
  ...(request.callId !== undefined ? { callId: request.callId } : {}),
3004
3212
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
3213
+ ...(request.displayReason !== undefined ? { displayReason: request.displayReason } : {}),
3005
3214
  next,
3006
3215
  downstreamAbort,
3007
3216
  resolve,
@@ -3040,49 +3249,53 @@ const plugin = {
3040
3249
  log(`session archive state forwarded: count=${next.length}`)
3041
3250
  }
3042
3251
 
3043
- const normalizeQueueItems = (value, endpoint) => {
3044
- if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
3045
- return value.map((candidate) => {
3046
- const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
3047
- ? candidate.message
3048
- : null
3049
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
3050
- || typeof candidate.id !== 'string' || !candidate.id
3051
- || !['queued', 'steering', 'context'].includes(candidate.placement)
3052
- || !message || typeof message !== 'object' || Array.isArray(message)
3252
+ // DSH rc.1 carries pending messages in the durable inbox projection.
3253
+ // session/control no longer emits a separate queues baseline or queue frames.
3254
+ const queueItemsFromInbox = (value, endpoint) => {
3255
+ if (value == null) return []
3256
+ if (typeof value !== 'object' || Array.isArray(value)
3257
+ || !Array.isArray(value['next-turn']) || !Array.isArray(value['next-step'])) {
3258
+ throw new Error(`${endpoint} returned an invalid inbox projection`)
3259
+ }
3260
+ const convert = (messages, target) => messages.map((message) => {
3261
+ if (!message || typeof message !== 'object' || Array.isArray(message)
3053
3262
  || typeof message.id !== 'string' || !message.id
3054
3263
  || !Array.isArray(message.content)
3055
- || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
3056
- throw new Error(`${endpoint} returned an invalid queue item`)
3264
+ || !message.source || typeof message.source.kind !== 'string'
3265
+ || (message.source.rpcId !== undefined && typeof message.source.rpcId !== 'string')) {
3266
+ throw new Error(`${endpoint} returned an invalid inbox message`)
3057
3267
  }
3058
3268
  return {
3059
- id: candidate.id,
3060
- placement: candidate.placement,
3061
- ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
3269
+ id: message.id,
3270
+ placement: target === 'next-turn' ? 'queued' : message.source.kind === 'user' ? 'steering' : 'context',
3271
+ ...(message.source.rpcId === undefined ? {} : { rpcId: message.source.rpcId }),
3062
3272
  message: { id: message.id, content: message.content },
3063
3273
  }
3064
3274
  })
3275
+ return [...convert(value['next-turn'], 'next-turn'), ...convert(value['next-step'], 'next-step')]
3065
3276
  }
3066
3277
 
3067
- const installSessionQueueBaseline = (value) => {
3068
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
3069
- throw new Error('session/control baseline returned invalid queues')
3278
+ const installSessionQueueBaseline = (projections) => {
3279
+ if (!projections || typeof projections !== 'object' || Array.isArray(projections)) {
3280
+ throw new Error('session/control baseline returned invalid projections')
3070
3281
  }
3071
3282
  const next = new Map()
3072
- for (const [sessionId, items] of Object.entries(value)) {
3073
- next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
3283
+ for (const [sessionId, baseline] of Object.entries(projections)) {
3284
+ const items = queueItemsFromInbox(baseline?.values?.inbox, 'session/control baseline')
3285
+ if (items.length > 0) next.set(sessionId, items)
3074
3286
  }
3075
3287
  sessionQueues = next
3076
3288
  broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
3077
3289
  log(`session queue baseline forwarded: sessions=${next.size}`)
3078
3290
  }
3079
3291
 
3080
- const installSessionQueue = (sessionIdValue, itemsValue) => {
3292
+ const installSessionQueue = (sessionIdValue, inboxValue) => {
3081
3293
  const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
3082
3294
  if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
3083
- const items = normalizeQueueItems(itemsValue, 'session/control queue')
3295
+ const items = queueItemsFromInbox(inboxValue, 'session/control inbox')
3084
3296
  if (sessionQueues === null) sessionQueues = new Map()
3085
- sessionQueues.set(sessionId, items)
3297
+ if (items.length > 0) sessionQueues.set(sessionId, items)
3298
+ else sessionQueues.delete(sessionId)
3086
3299
  broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
3087
3300
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
3088
3301
  }
@@ -3143,9 +3356,9 @@ const plugin = {
3143
3356
  for await (const frame of stream) {
3144
3357
  if (frame?.type === 'baseline') {
3145
3358
  installProjectionBaseline(frame.value?.projections)
3146
- installSessionQueueBaseline(frame.value?.queues)
3147
- } else if (frame?.type === 'queue') {
3148
- installSessionQueue(frame.sessionId, frame.items)
3359
+ installSessionQueueBaseline(frame.value?.projections)
3360
+ } else if (frame?.type === 'projection' && frame.key === 'inbox') {
3361
+ installSessionQueue(frame.sessionId, frame.value)
3149
3362
  } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3150
3363
  const sessionId = String(frame.sessionId)
3151
3364
  const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
@@ -3179,14 +3392,21 @@ const plugin = {
3179
3392
  disposeUpgrade()
3180
3393
  disposeMgmt()
3181
3394
  disposeEvents()
3395
+ disposeScheduleChanged()
3396
+ disposePluginsChanged()
3397
+ disposePluginSettingsChanged()
3398
+ disposePluginInstallState()
3399
+ disposePluginInstallLog()
3182
3400
  disposeQuestions()
3183
3401
  disposeApprovals()
3184
3402
  if (lanServer) lanServer.close()
3403
+ tunnel.dispose()
3185
3404
  for (const client of clients) {
3186
3405
  client.sessionFollower?.stop()
3187
3406
  client.terminate()
3188
3407
  }
3189
3408
  clients.clear()
3409
+ pluginInstallOwners.clear()
3190
3410
  wss.close()
3191
3411
  log('plugin stopped, all sockets closed')
3192
3412
  })