dsh-plugin-mobile-gateway 0.7.7 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -132,6 +132,8 @@ import devicesModule from './devices.js'
132
132
  import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
133
133
  import { createSessionFollower } from './session-follower.mjs'
134
134
  import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
135
+ import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
136
+ import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
135
137
  import QRCode from 'qrcode'
136
138
 
137
139
  const { createRegistry } = devicesModule
@@ -225,6 +227,9 @@ const Config = Schema.object({
225
227
  lanHost: Schema.string().default('0.0.0.0'),
226
228
  lanPort: Schema.natural().min(1).max(65535).default(3081),
227
229
  lanAdvertiseHost: Schema.string().default(''),
230
+ cloudflaredPath: Schema.string().default('cloudflared'),
231
+ cloudflarePort: Schema.natural().min(1).max(65535).default(3082),
232
+ cloudflareStateFile: Schema.string().default(''),
228
233
  })
229
234
 
230
235
  function log(line) {
@@ -323,7 +328,8 @@ function buildWireEvent(session, event) {
323
328
  case 'tool/result': {
324
329
  let preview = ''
325
330
  for (const block of (d.message && d.message.content) || []) {
326
- for (const inner of (block && block.content) || []) {
331
+ const content = block?.type === 'tool-result' ? block.content || [] : [block]
332
+ for (const inner of content) {
327
333
  if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
328
334
  }
329
335
  }
@@ -333,9 +339,10 @@ function buildWireEvent(session, event) {
333
339
  type: 'tool/result',
334
340
  turn: d.turn,
335
341
  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),
342
+ callId: d.message?.toolCallId ?? d.message?.source?.callId,
343
+ isError: d.message?.isError === true || !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
338
344
  preview,
345
+ ...(d.error ? { error: d.error } : {}),
339
346
  },
340
347
  })
341
348
  }
@@ -1122,7 +1129,7 @@ function validateHistoryCursor(msg, sessionId, field) {
1122
1129
  if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
1123
1130
  return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
1124
1131
  historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
1125
- message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
1132
+ message: `Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be ${SESSION_FORMAT_VERSION}` }
1126
1133
  }
1127
1134
  if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
1128
1135
  return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
@@ -1220,7 +1227,9 @@ async function loadCommandOptions(api, command, sessionId) {
1220
1227
  const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1221
1228
  if (history.kind !== 'history') return history
1222
1229
  const permissions = history.projections && history.projections.values && history.projections.values.permissions
1223
- if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
1230
+ const catalog = await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
1231
+ if (catalog.kind === 'error') return catalog
1232
+ if (!permissions || typeof permissions.currentValue !== 'string' || !Array.isArray(catalog.options)) {
1224
1233
  return {
1225
1234
  kind: 'error',
1226
1235
  code: 'command-options-unavailable',
@@ -1233,7 +1242,7 @@ async function loadCommandOptions(api, command, sessionId) {
1233
1242
  kind: 'command-options',
1234
1243
  sessionId,
1235
1244
  command,
1236
- options: permissions.options.map((option) => ({
1245
+ options: catalog.options.map((option) => ({
1237
1246
  id: String(option.value),
1238
1247
  label: String(option.name || option.value),
1239
1248
  ...(option.description ? { description: String(option.description) } : {}),
@@ -1660,11 +1669,18 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1660
1669
  return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1661
1670
  }
1662
1671
  if (msg.type === 'permission-options') {
1663
- const frame = await proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {})
1672
+ const [frame, catalog] = await Promise.all([
1673
+ proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {}),
1674
+ proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
1675
+ ])
1664
1676
  if (frame.kind !== 'permission-options') return frame
1677
+ if (catalog.kind === 'error') return catalog
1665
1678
  const out = {
1666
1679
  kind: 'permission-options',
1667
1680
  namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
1681
+ options: catalog.options,
1682
+ defaultOptions: catalog.defaultOptions,
1683
+ defaultPreset: catalog.defaultPreset,
1668
1684
  }
1669
1685
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1670
1686
  if (sessionId) {
@@ -1672,7 +1688,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1672
1688
  const hist = await querySessionProjection(api, msg.type, sessionId)
1673
1689
  if (hist.kind === 'error') return hist
1674
1690
  if (hist.kind === 'history' && hist.projections && hist.projections.values) {
1675
- out.sessionPermissions = hist.projections.values.permissions || null
1691
+ const current = hist.projections.values.permissions
1692
+ out.sessionPermissions = current ? { ...current, options: catalog.options } : null
1676
1693
  }
1677
1694
  }
1678
1695
  return out
@@ -1777,18 +1794,66 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1777
1794
  const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1778
1795
  return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1779
1796
  }
1797
+ if (msg.type === 'schedule-catalog') {
1798
+ return proxyQuery(api, msg.type, api.schedule.catalog.bind(api.schedule), {})
1799
+ }
1800
+ if (msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
1801
+ msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
1802
+ const sessionId = requireSessionId(msg)
1803
+ if (sessionId.error) return sessionId.error
1804
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? { requestId: msg.requestId } : {}
1805
+ const payload = { sessionId: sessionId.value }
1806
+ if (msg.type !== 'schedule-list') {
1807
+ if (typeof msg.id !== 'string' || !msg.id.trim()) {
1808
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires an id`, requestType: msg.type, sessionId: sessionId.value, ...requestId }
1809
+ }
1810
+ payload.id = msg.id.trim()
1811
+ }
1812
+ if (msg.type === 'schedule-history') {
1813
+ if (!Number.isSafeInteger(msg.limit) || msg.limit < 1 || msg.limit > 100 ||
1814
+ (msg.before !== undefined && (typeof msg.before !== 'string' || !msg.before.trim()))) {
1815
+ 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 }
1816
+ }
1817
+ payload.limit = msg.limit
1818
+ if (msg.before !== undefined) payload.before = msg.before
1819
+ }
1820
+ if (msg.type === 'schedule-update') {
1821
+ if (!msg.expected || typeof msg.expected !== 'object' || Array.isArray(msg.expected) || msg.expected.id !== payload.id) {
1822
+ 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 }
1823
+ }
1824
+ // Catalog rows add Host management metadata. The Remote update contract
1825
+ // requires the original ScheduleRecord only.
1826
+ payload.expected = Object.fromEntries(
1827
+ ['id', 'kind', 'title', 'prompt', 'scheduledAt', 'afterSeconds', 'everySeconds', 'time', 'timeZone', 'weekdays', 'expression']
1828
+ .filter((field) => Object.hasOwn(msg.expected, field))
1829
+ .map((field) => [field, msg.expected[field]]),
1830
+ )
1831
+ for (const field of ['title', 'prompt']) {
1832
+ if (msg[field] !== undefined) payload[field] = msg[field]
1833
+ }
1834
+ if (msg.change !== undefined) payload.change = msg.change
1835
+ }
1836
+ const method = msg.type.slice('schedule-'.length)
1837
+ const frame = await proxyQuery(api, msg.type, api.schedule[method].bind(api.schedule), payload)
1838
+ return { ...frame, sessionId: sessionId.value, ...requestId }
1839
+ }
1780
1840
  if (msg.type === 'agent-presets') {
1781
1841
  return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1782
1842
  }
1783
1843
  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')
1844
+ const [presets, catalog] = await Promise.all([
1845
+ proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {}),
1846
+ proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
1847
+ ])
1848
+ if (presets.kind === 'error') return presets
1849
+ if (catalog.kind === 'error') return catalog
1788
1850
  return {
1789
1851
  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,
1852
+ agentPresetDefault: presets.presets?.find((preset) => preset.isDefault)?.id ?? null,
1853
+ permissionDefault: catalog.defaultPreset ?? null,
1854
+ permissionDefaultOptions: catalog.defaultOptions ?? [],
1855
+ // rc.2 removed this roster flag; only an explicit older-host false disables selection.
1856
+ modeSelectionEnabled: presets.modeSelectionEnabled !== false,
1792
1857
  }
1793
1858
  }
1794
1859
  if (msg.type === 'set-default') {
@@ -1800,8 +1865,21 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1800
1865
  if (!value) {
1801
1866
  return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
1802
1867
  }
1803
- const ns = target === 'agent-preset' ? 'agent-presets' : 'permission'
1804
- const patch = target === 'agent-preset' ? { default: value } : { defaultPreset: value }
1868
+ const catalog = target === 'agent-preset'
1869
+ ? await proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1870
+ : await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
1871
+ if (catalog.kind === 'error') return catalog
1872
+ if (target === 'agent-preset' && catalog.modeSelectionEnabled === false) {
1873
+ return { kind: 'error', code: 'mode-selection-disabled', message: 'Agent mode selection is disabled', requestType: 'set-default' }
1874
+ }
1875
+ const choices = target === 'agent-preset'
1876
+ ? catalog.presets?.filter((preset) => !preset.broken).map((preset) => preset.id)
1877
+ : catalog.defaultOptions?.map((option) => option.value)
1878
+ if (!choices?.includes(value)) {
1879
+ return { kind: 'error', code: 'bad-request', message: 'default value is not available', requestType: 'set-default' }
1880
+ }
1881
+ const ns = target === 'agent-preset' ? 'agent-preset-registry' : 'permission'
1882
+ const patch = target === 'agent-preset' ? { selectedDefault: value } : { defaultPreset: value }
1805
1883
  const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
1806
1884
  if (updated.kind !== 'set-default') return updated
1807
1885
  log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
@@ -2075,6 +2153,9 @@ const plugin = {
2075
2153
  lanHost: '0.0.0.0',
2076
2154
  lanPort: 3081,
2077
2155
  lanAdvertiseHost: '',
2156
+ cloudflaredPath: 'cloudflared',
2157
+ cloudflarePort: 3082,
2158
+ cloudflareStateFile: '',
2078
2159
  ...(config || {}),
2079
2160
  }
2080
2161
  const wsPath = options.path
@@ -2087,6 +2168,9 @@ const plugin = {
2087
2168
  if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
2088
2169
  throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
2089
2170
  }
2171
+ if (typeof options.cloudflaredPath !== 'string' || !options.cloudflaredPath.trim()) {
2172
+ throw new Error('mobile-gateway: cloudflaredPath must be a nonempty executable path')
2173
+ }
2090
2174
  let requireAuth = options.requireAuth !== false
2091
2175
  const adminLoopbackOnly = options.adminLoopbackOnly !== false
2092
2176
  const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
@@ -2136,6 +2220,7 @@ const plugin = {
2136
2220
  let lanListening = false
2137
2221
  let lanListenError = null
2138
2222
  let lanBoundPort = options.lanPort
2223
+ let tunnel = null
2139
2224
  const configuredPublicUrl = () => {
2140
2225
  if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
2141
2226
  if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
@@ -2157,6 +2242,7 @@ const plugin = {
2157
2242
  ...extra,
2158
2243
  ...configuredEndpoints,
2159
2244
  ...(publicUrl ? [publicUrl] : []),
2245
+ ...(tunnel?.snapshot().publicUrl ? [tunnel.snapshot().publicUrl] : []),
2160
2246
  ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2161
2247
  ])])
2162
2248
  }
@@ -2183,11 +2269,13 @@ const plugin = {
2183
2269
  log(`failed to persist automatic gateway disable: ${error.message}`)
2184
2270
  }
2185
2271
  log('mobile gateway automatically disabled: no device connected before timeout')
2272
+ void tunnel?.reconcile()
2186
2273
  }, options.gatewayWaitTimeoutMs)
2187
2274
  } else if (mode === 'disabled') {
2188
2275
  for (const client of clients) client.close(4004, 'mobile gateway disabled')
2189
2276
  }
2190
2277
  log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
2278
+ void tunnel?.reconcile()
2191
2279
  }
2192
2280
 
2193
2281
  const logAuthRejected = (req) => {
@@ -2221,6 +2309,7 @@ const plugin = {
2221
2309
  toolName: payload.toolName,
2222
2310
  ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2223
2311
  ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2312
+ ...(payload.displayReason !== undefined ? { displayReason: payload.displayReason } : {}),
2224
2313
  ...(replay ? { replay: true } : {}),
2225
2314
  })
2226
2315
 
@@ -2520,6 +2609,7 @@ const plugin = {
2520
2609
  log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2521
2610
 
2522
2611
  // ---- device management routes (loopback-gated admin surface) ----
2612
+ let restartPending = false
2523
2613
  const disposeMgmt = webServer.register({
2524
2614
  kind: 'prefix',
2525
2615
  path: '/mgw',
@@ -2536,6 +2626,7 @@ const plugin = {
2536
2626
  const url = new URL(req.url || '/', 'http://x')
2537
2627
  const p = url.pathname
2538
2628
  if (req.method === 'GET' && p === '/mgw/status') {
2629
+ const profile = ctx.get?.('profileContext')
2539
2630
  sendJson(res, 200, {
2540
2631
  version: PLUGIN_VERSION,
2541
2632
  requireAuth,
@@ -2546,8 +2637,13 @@ const plugin = {
2546
2637
  waitExpiresAt,
2547
2638
  connectedClients: clients.size,
2548
2639
  webPort: webServer.port,
2640
+ webPid: process.pid,
2549
2641
  wsPath,
2550
- publicUrl: configuredPublicUrl() || null,
2642
+ publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
2643
+ ? tunnel.snapshot().publicUrl
2644
+ : tunnel?.snapshot().publicUrl || configuredPublicUrl() || null,
2645
+ platform: process.platform,
2646
+ cloudflare: tunnel?.snapshot() || null,
2551
2647
  lan: {
2552
2648
  enabled: options.lanEnabled === true,
2553
2649
  listening: lanListening,
@@ -2559,7 +2655,49 @@ const plugin = {
2559
2655
  },
2560
2656
  pairingTtlMs: options.pairingTtlMs,
2561
2657
  queryTokenAllowed: !!options.allowQueryToken,
2658
+ tools: {
2659
+ restartWeb: webRestartAvailable(profile),
2660
+ stopWeb: webRestartAvailable(profile),
2661
+ },
2562
2662
  })
2663
+ } else if (req.method === 'POST' && p === '/mgw/tools/restart-web') {
2664
+ if (!isLoopback(req)) {
2665
+ sendJson(res, 403, { error: 'forbidden', message: 'Web restart is loopback-only' })
2666
+ return
2667
+ }
2668
+ if (!webRestartAvailable(ctx.get?.('profileContext'))) {
2669
+ sendJson(res, 409, { error: 'unavailable', message: '只能在通过 dsh web 启动的 Web profile 中重启' })
2670
+ return
2671
+ }
2672
+ if (restartPending) {
2673
+ sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启' })
2674
+ return
2675
+ }
2676
+ restartPending = true
2677
+ try {
2678
+ await prepareWebRestart()
2679
+ } catch (error) {
2680
+ restartPending = false
2681
+ throw error
2682
+ }
2683
+ res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
2684
+ sendJson(res, 202, { restarting: true })
2685
+ } else if (req.method === 'POST' && p === '/mgw/tools/stop-web') {
2686
+ if (!isLoopback(req)) {
2687
+ sendJson(res, 403, { error: 'forbidden', message: 'Web shutdown is loopback-only' })
2688
+ return
2689
+ }
2690
+ if (!webRestartAvailable(ctx.get?.('profileContext'))) {
2691
+ sendJson(res, 409, { error: 'unavailable', message: '只能停止通过 dsh web 启动的 Web profile' })
2692
+ return
2693
+ }
2694
+ if (restartPending) {
2695
+ sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启或停止' })
2696
+ return
2697
+ }
2698
+ restartPending = true
2699
+ res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
2700
+ sendJson(res, 202, { stopping: true })
2563
2701
  } else if (req.method === 'GET' && p === '/mgw/public-setup') {
2564
2702
  sendJson(res, 200, await systemHelperStatus())
2565
2703
  } else if (req.method === 'POST' && p === '/mgw/public-setup') {
@@ -2578,6 +2716,12 @@ const plugin = {
2578
2716
  if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2579
2717
  setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2580
2718
  sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
2719
+ } else if (req.method === 'POST' && p === '/mgw/cloudflare') {
2720
+ const body = await readBody(req)
2721
+ await tunnel.configure(body)
2722
+ if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
2723
+ await tunnel.reconcile()
2724
+ sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
2581
2725
  } else if (req.method === 'POST' && p === '/mgw/auth') {
2582
2726
  const body = await readBody(req)
2583
2727
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -2798,7 +2942,9 @@ const plugin = {
2798
2942
  msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2799
2943
  msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2800
2944
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2801
- msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2945
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
2946
+ msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
2947
+ msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
2802
2948
  handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2803
2949
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2804
2950
  })
@@ -2844,6 +2990,7 @@ const plugin = {
2844
2990
  'commands',
2845
2991
  'tasks',
2846
2992
  'goals',
2993
+ 'schedule-management',
2847
2994
  'session-cancel',
2848
2995
  'queue-control',
2849
2996
  'session-archive',
@@ -2917,6 +3064,18 @@ const plugin = {
2917
3064
  })
2918
3065
  }
2919
3066
 
3067
+ tunnel = createCloudflareTunnel({
3068
+ file: options.cloudflareStateFile || `${deviceFile}.cloudflare.json`,
3069
+ port: options.cloudflarePort,
3070
+ wsPath,
3071
+ executable: options.cloudflaredPath,
3072
+ supported: process.platform === 'darwin' || process.platform === 'win32',
3073
+ isGatewayEnabled: () => gatewayEnabled,
3074
+ onUpgrade: handleMobileUpgrade,
3075
+ log,
3076
+ })
3077
+ void tunnel.reconcile()
3078
+
2920
3079
  const disposeEvents = ctx.on('session/event', (session, event) => {
2921
3080
  api.observeSessionEvent(String(session.id), event)
2922
3081
  if (clients.size === 0) return
@@ -2947,6 +3106,11 @@ const plugin = {
2947
3106
  })
2948
3107
  log('session/event listener attached')
2949
3108
 
3109
+ // The Host notification has no payload: readers refetch the task catalog.
3110
+ const disposeScheduleChanged = ctx.on('schedule/changed', () => {
3111
+ if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
3112
+ })
3113
+
2950
3114
  const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2951
3115
  const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2952
3116
  if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
@@ -3002,6 +3166,7 @@ const plugin = {
3002
3166
  toolName: request.toolName,
3003
3167
  ...(request.callId !== undefined ? { callId: request.callId } : {}),
3004
3168
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
3169
+ ...(request.displayReason !== undefined ? { displayReason: request.displayReason } : {}),
3005
3170
  next,
3006
3171
  downstreamAbort,
3007
3172
  resolve,
@@ -3040,49 +3205,53 @@ const plugin = {
3040
3205
  log(`session archive state forwarded: count=${next.length}`)
3041
3206
  }
3042
3207
 
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)
3208
+ // DSH rc.1 carries pending messages in the durable inbox projection.
3209
+ // session/control no longer emits a separate queues baseline or queue frames.
3210
+ const queueItemsFromInbox = (value, endpoint) => {
3211
+ if (value == null) return []
3212
+ if (typeof value !== 'object' || Array.isArray(value)
3213
+ || !Array.isArray(value['next-turn']) || !Array.isArray(value['next-step'])) {
3214
+ throw new Error(`${endpoint} returned an invalid inbox projection`)
3215
+ }
3216
+ const convert = (messages, target) => messages.map((message) => {
3217
+ if (!message || typeof message !== 'object' || Array.isArray(message)
3053
3218
  || typeof message.id !== 'string' || !message.id
3054
3219
  || !Array.isArray(message.content)
3055
- || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
3056
- throw new Error(`${endpoint} returned an invalid queue item`)
3220
+ || !message.source || typeof message.source.kind !== 'string'
3221
+ || (message.source.rpcId !== undefined && typeof message.source.rpcId !== 'string')) {
3222
+ throw new Error(`${endpoint} returned an invalid inbox message`)
3057
3223
  }
3058
3224
  return {
3059
- id: candidate.id,
3060
- placement: candidate.placement,
3061
- ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
3225
+ id: message.id,
3226
+ placement: target === 'next-turn' ? 'queued' : message.source.kind === 'user' ? 'steering' : 'context',
3227
+ ...(message.source.rpcId === undefined ? {} : { rpcId: message.source.rpcId }),
3062
3228
  message: { id: message.id, content: message.content },
3063
3229
  }
3064
3230
  })
3231
+ return [...convert(value['next-turn'], 'next-turn'), ...convert(value['next-step'], 'next-step')]
3065
3232
  }
3066
3233
 
3067
- const installSessionQueueBaseline = (value) => {
3068
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
3069
- throw new Error('session/control baseline returned invalid queues')
3234
+ const installSessionQueueBaseline = (projections) => {
3235
+ if (!projections || typeof projections !== 'object' || Array.isArray(projections)) {
3236
+ throw new Error('session/control baseline returned invalid projections')
3070
3237
  }
3071
3238
  const next = new Map()
3072
- for (const [sessionId, items] of Object.entries(value)) {
3073
- next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
3239
+ for (const [sessionId, baseline] of Object.entries(projections)) {
3240
+ const items = queueItemsFromInbox(baseline?.values?.inbox, 'session/control baseline')
3241
+ if (items.length > 0) next.set(sessionId, items)
3074
3242
  }
3075
3243
  sessionQueues = next
3076
3244
  broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
3077
3245
  log(`session queue baseline forwarded: sessions=${next.size}`)
3078
3246
  }
3079
3247
 
3080
- const installSessionQueue = (sessionIdValue, itemsValue) => {
3248
+ const installSessionQueue = (sessionIdValue, inboxValue) => {
3081
3249
  const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
3082
3250
  if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
3083
- const items = normalizeQueueItems(itemsValue, 'session/control queue')
3251
+ const items = queueItemsFromInbox(inboxValue, 'session/control inbox')
3084
3252
  if (sessionQueues === null) sessionQueues = new Map()
3085
- sessionQueues.set(sessionId, items)
3253
+ if (items.length > 0) sessionQueues.set(sessionId, items)
3254
+ else sessionQueues.delete(sessionId)
3086
3255
  broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
3087
3256
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
3088
3257
  }
@@ -3143,9 +3312,9 @@ const plugin = {
3143
3312
  for await (const frame of stream) {
3144
3313
  if (frame?.type === 'baseline') {
3145
3314
  installProjectionBaseline(frame.value?.projections)
3146
- installSessionQueueBaseline(frame.value?.queues)
3147
- } else if (frame?.type === 'queue') {
3148
- installSessionQueue(frame.sessionId, frame.items)
3315
+ installSessionQueueBaseline(frame.value?.projections)
3316
+ } else if (frame?.type === 'projection' && frame.key === 'inbox') {
3317
+ installSessionQueue(frame.sessionId, frame.value)
3149
3318
  } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3150
3319
  const sessionId = String(frame.sessionId)
3151
3320
  const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
@@ -3179,9 +3348,11 @@ const plugin = {
3179
3348
  disposeUpgrade()
3180
3349
  disposeMgmt()
3181
3350
  disposeEvents()
3351
+ disposeScheduleChanged()
3182
3352
  disposeQuestions()
3183
3353
  disposeApprovals()
3184
3354
  if (lanServer) lanServer.close()
3355
+ tunnel.dispose()
3185
3356
  for (const client of clients) {
3186
3357
  client.sessionFollower?.stop()
3187
3358
  client.terminate()
@@ -0,0 +1,47 @@
1
+ // Detached handoff process: only launched by the loopback-only management API.
2
+ import { spawn } from 'node:child_process'
3
+ import { openSync, closeSync, appendFileSync } from 'node:fs'
4
+
5
+ const logFile = '/tmp/mobile-gateway.log'
6
+ const note = (message) => {
7
+ try { appendFileSync(logFile, `[${new Date().toISOString()}] web restart: ${message}\n`) } catch {}
8
+ }
9
+
10
+ try {
11
+ const spec = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString())
12
+ if (!Number.isSafeInteger(spec.pid) || spec.pid <= 0 || typeof spec.execPath !== 'string'
13
+ || !Array.isArray(spec.execArgv) || !Array.isArray(spec.argv) || typeof spec.cwd !== 'string') {
14
+ throw new Error('invalid restart handoff')
15
+ }
16
+ const deadline = Date.now() + 30_000
17
+ while (Date.now() < deadline) {
18
+ try { process.kill(spec.pid, 0) } catch (error) {
19
+ if (error.code === 'ESRCH') break
20
+ throw error
21
+ }
22
+ await new Promise((resolve) => setTimeout(resolve, 200))
23
+ }
24
+ try {
25
+ process.kill(spec.pid, 0)
26
+ throw new Error('old DSH Web process did not exit within 30 seconds')
27
+ } catch (error) {
28
+ if (error.code !== 'ESRCH') throw error
29
+ }
30
+ const fd = openSync(logFile, 'a')
31
+ try {
32
+ const child = spawn(spec.execPath, [...spec.execArgv, ...spec.argv], {
33
+ cwd: spec.cwd, env: process.env, detached: true, stdio: ['ignore', fd, fd],
34
+ })
35
+ await new Promise((resolve, reject) => {
36
+ child.once('spawn', resolve)
37
+ child.once('error', reject)
38
+ })
39
+ child.unref()
40
+ note(`started DSH Web pid=${child.pid}`)
41
+ } finally {
42
+ closeSync(fd)
43
+ }
44
+ } catch (error) {
45
+ note(error?.message || String(error))
46
+ process.exitCode = 1
47
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.7.7",
4
- "description": "Mobile gateway for dsh 0.1.5-rc.1/2 with per-session Agent preset selection before the first conversation, automatic preset locking after conversation starts, and live preset updates",
3
+ "version": "0.8.1",
4
+ "description": "Authenticated mobile gateway for DeepSeek Harness with LAN and Cloudflare Tunnel access, device pairing, and session sync",
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/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.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/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.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/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",