dsh-plugin-mobile-gateway 0.7.6 → 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
 
@@ -2245,20 +2334,43 @@ const plugin = {
2245
2334
  }
2246
2335
 
2247
2336
  const replayPendingInteractions = (ws, trigger) => {
2248
- if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
2337
+ // In split-channel mode `subscribe` belongs to the conversation socket,
2338
+ // while interaction frames belong to the control socket. Bridge a
2339
+ // conversation subscription to every live control/legacy socket so
2340
+ // reopening a Session restores its still-pending cards. Reducers dedupe
2341
+ // by rpcId, so replaying to another trusted device is harmless and keeps
2342
+ // all active clients in sync.
2343
+ // The companion control socket already receives a full replay when it
2344
+ // connects, so a bare conversation connect must not duplicate it again.
2345
+ if (ws.mobileChannel === 'conversation' && trigger !== 'subscribe') {
2346
+ return { questionCount: 0, approvalCount: 0 }
2347
+ }
2348
+ const recipients = ws.mobileChannel === 'conversation'
2349
+ ? [...clients].filter((client) => client.readyState === 1 && client.mobileChannel !== 'conversation')
2350
+ : [ws]
2351
+ if (recipients.length === 0) return { questionCount: 0, approvalCount: 0 }
2352
+ const sessionId = ws.filterSessionId
2249
2353
  let questionCount = 0
2250
2354
  let approvalCount = 0
2251
2355
  for (const pending of pendingQuestions.values()) {
2252
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2253
- ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
2356
+ if (sessionId && sessionId !== pending.sessionId) continue
2357
+ const wire = stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true))
2358
+ for (const recipient of recipients) {
2359
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2360
+ recipient.send(wire)
2361
+ }
2254
2362
  questionCount += 1
2255
2363
  }
2256
2364
  for (const pending of pendingApprovals.values()) {
2257
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2258
- ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
2365
+ if (sessionId && sessionId !== pending.sessionId) continue
2366
+ const wire = stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true))
2367
+ for (const recipient of recipients) {
2368
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2369
+ recipient.send(wire)
2370
+ }
2259
2371
  approvalCount += 1
2260
2372
  }
2261
- log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
2373
+ log(`interaction replay: trigger=${trigger} channel=${ws.mobileChannel} filtered=${Boolean(sessionId)} recipients=${recipients.length} questions=${questionCount} approvals=${approvalCount}`)
2262
2374
  return { questionCount, approvalCount }
2263
2375
  }
2264
2376
 
@@ -2268,15 +2380,24 @@ const plugin = {
2268
2380
  && (!client.filterSessionId || client.filterSessionId === sessionId)
2269
2381
  ))
2270
2382
 
2271
- const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
2383
+ const abortDownstreamInteraction = (pending, kind) => {
2384
+ if (pending.downstreamAbort.signal.aborted) return
2385
+ // The Host API gateway fans this abort out to every WebUI delivery of
2386
+ // the same Remote event. Its existing cancel handling removes each
2387
+ // pending BottomSheet; Mobile peers receive the resolved frame below.
2388
+ pending.downstreamAbort.abort(new Error(`${kind} completed outside WebUI`))
2389
+ }
2390
+
2391
+ const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED', cancellationError, source = 'mobile') => {
2272
2392
  if (pendingQuestions.get(pending.rpcId) !== pending) return false
2273
2393
  pendingQuestions.delete(pending.rpcId)
2274
2394
  pending.cleanup()
2395
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'question')
2275
2396
  if (action === 'answer') pending.resolve(answer)
2276
2397
  else {
2277
- const error = new Error('question cancelled by mobile user')
2278
- error.name = 'UserQuestionError'
2279
- error.code = cancellationCode
2398
+ const error = cancellationError ?? new Error('question cancelled by mobile user')
2399
+ if (!error.name || error.name === 'Error') error.name = 'UserQuestionError'
2400
+ if (!error.code) error.code = cancellationCode
2280
2401
  pending.reject(error)
2281
2402
  }
2282
2403
  broadcastInteractionFrame({
@@ -2315,10 +2436,11 @@ const plugin = {
2315
2436
  return { value: { answers: normalized } }
2316
2437
  }
2317
2438
 
2318
- const completeApproval = (pending, outcome) => {
2439
+ const completeApproval = (pending, outcome, source = 'mobile') => {
2319
2440
  if (pendingApprovals.get(pending.rpcId) !== pending) return false
2320
2441
  pendingApprovals.delete(pending.rpcId)
2321
2442
  pending.cleanup()
2443
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'approval')
2322
2444
  pending.resolve(outcome)
2323
2445
  broadcastInteractionFrame({
2324
2446
  kind: 'approval-resolved',
@@ -2330,18 +2452,80 @@ const plugin = {
2330
2452
  return true
2331
2453
  }
2332
2454
 
2455
+ const startDownstreamQuestion = (pending) => {
2456
+ let downstream
2457
+ try {
2458
+ downstream = pending.next()
2459
+ } catch (error) {
2460
+ pending.downstream = { kind: 'rejected', error }
2461
+ return
2462
+ }
2463
+ pending.downstream = { kind: 'pending' }
2464
+ Promise.resolve(downstream).then((answer) => {
2465
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2466
+ if (answer && Array.isArray(answer.answers)) {
2467
+ completeQuestion(pending, 'answer', answer, undefined, undefined, 'downstream')
2468
+ log(`question resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2469
+ } else {
2470
+ // A downstream answerer may report that no WebUI currently owns the
2471
+ // request. Keep Mobile active; the value is used only if every Mobile
2472
+ // client later disconnects.
2473
+ pending.downstream = { kind: 'unavailable', value: answer }
2474
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2475
+ }
2476
+ }, (error) => {
2477
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2478
+ if (error?.code === 'ASK_CANCELLED' || error?.code === 'ASK_ABORTED') {
2479
+ completeQuestion(pending, 'cancel', undefined, error.code, error, 'downstream')
2480
+ log(`question cancelled by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2481
+ } else {
2482
+ pending.downstream = { kind: 'rejected', error }
2483
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2484
+ }
2485
+ })
2486
+ }
2487
+
2488
+ const startDownstreamApproval = (pending) => {
2489
+ let downstream
2490
+ try {
2491
+ downstream = pending.next()
2492
+ } catch (error) {
2493
+ pending.downstream = { kind: 'rejected', error }
2494
+ return
2495
+ }
2496
+ pending.downstream = { kind: 'pending' }
2497
+ Promise.resolve(downstream).then((outcome) => {
2498
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2499
+ if (outcome === 'allowed-once' || outcome === 'rejected') {
2500
+ completeApproval(pending, outcome, 'downstream')
2501
+ log(`approval resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId} outcome=${outcome}`)
2502
+ } else {
2503
+ pending.downstream = { kind: 'unavailable', value: outcome }
2504
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2505
+ }
2506
+ }, (error) => {
2507
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2508
+ pending.downstream = { kind: 'rejected', error }
2509
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2510
+ })
2511
+ }
2512
+
2333
2513
  const fallbackQuestion = (pending) => {
2334
2514
  if (pendingQuestions.get(pending.rpcId) !== pending) return
2515
+ if (pending.downstream?.kind === 'pending') return
2335
2516
  pendingQuestions.delete(pending.rpcId)
2336
2517
  pending.cleanup()
2337
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2518
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2519
+ else pending.resolve(pending.downstream?.value)
2338
2520
  }
2339
2521
 
2340
2522
  const fallbackApproval = (pending) => {
2341
2523
  if (pendingApprovals.get(pending.rpcId) !== pending) return
2524
+ if (pending.downstream?.kind === 'pending') return
2342
2525
  pendingApprovals.delete(pending.rpcId)
2343
2526
  pending.cleanup()
2344
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2527
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2528
+ else pending.resolve(pending.downstream?.value)
2345
2529
  }
2346
2530
 
2347
2531
  const releaseUnclaimedInteractions = () => {
@@ -2425,6 +2609,7 @@ const plugin = {
2425
2609
  log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
2426
2610
 
2427
2611
  // ---- device management routes (loopback-gated admin surface) ----
2612
+ let restartPending = false
2428
2613
  const disposeMgmt = webServer.register({
2429
2614
  kind: 'prefix',
2430
2615
  path: '/mgw',
@@ -2441,6 +2626,7 @@ const plugin = {
2441
2626
  const url = new URL(req.url || '/', 'http://x')
2442
2627
  const p = url.pathname
2443
2628
  if (req.method === 'GET' && p === '/mgw/status') {
2629
+ const profile = ctx.get?.('profileContext')
2444
2630
  sendJson(res, 200, {
2445
2631
  version: PLUGIN_VERSION,
2446
2632
  requireAuth,
@@ -2451,8 +2637,13 @@ const plugin = {
2451
2637
  waitExpiresAt,
2452
2638
  connectedClients: clients.size,
2453
2639
  webPort: webServer.port,
2640
+ webPid: process.pid,
2454
2641
  wsPath,
2455
- 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,
2456
2647
  lan: {
2457
2648
  enabled: options.lanEnabled === true,
2458
2649
  listening: lanListening,
@@ -2464,7 +2655,49 @@ const plugin = {
2464
2655
  },
2465
2656
  pairingTtlMs: options.pairingTtlMs,
2466
2657
  queryTokenAllowed: !!options.allowQueryToken,
2658
+ tools: {
2659
+ restartWeb: webRestartAvailable(profile),
2660
+ stopWeb: webRestartAvailable(profile),
2661
+ },
2467
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 })
2468
2701
  } else if (req.method === 'GET' && p === '/mgw/public-setup') {
2469
2702
  sendJson(res, 200, await systemHelperStatus())
2470
2703
  } else if (req.method === 'POST' && p === '/mgw/public-setup') {
@@ -2483,6 +2716,12 @@ const plugin = {
2483
2716
  if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
2484
2717
  setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
2485
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 })
2486
2725
  } else if (req.method === 'POST' && p === '/mgw/auth') {
2487
2726
  const body = await readBody(req)
2488
2727
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -2703,7 +2942,9 @@ const plugin = {
2703
2942
  msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2704
2943
  msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2705
2944
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2706
- 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') {
2707
2948
  handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2708
2949
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2709
2950
  })
@@ -2749,6 +2990,7 @@ const plugin = {
2749
2990
  'commands',
2750
2991
  'tasks',
2751
2992
  'goals',
2993
+ 'schedule-management',
2752
2994
  'session-cancel',
2753
2995
  'queue-control',
2754
2996
  'session-archive',
@@ -2822,6 +3064,18 @@ const plugin = {
2822
3064
  })
2823
3065
  }
2824
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
+
2825
3079
  const disposeEvents = ctx.on('session/event', (session, event) => {
2826
3080
  api.observeSessionEvent(String(session.id), event)
2827
3081
  if (clients.size === 0) return
@@ -2852,27 +3106,39 @@ const plugin = {
2852
3106
  })
2853
3107
  log('session/event listener attached')
2854
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
+
2855
3114
  const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
2856
3115
  const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
2857
3116
  if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
2858
3117
  const rpcId = crypto.randomUUID()
2859
3118
  return new Promise((resolve, reject) => {
3119
+ const requestSignal = request.signal
3120
+ const downstreamAbort = new AbortController()
3121
+ request.signal = requestSignal
3122
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
3123
+ : downstreamAbort.signal
2860
3124
  const onAbort = () => {
2861
3125
  const pending = pendingQuestions.get(rpcId)
2862
3126
  if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
2863
3127
  }
2864
- request.signal?.addEventListener('abort', onAbort, { once: true })
3128
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2865
3129
  const pending = {
2866
3130
  rpcId,
2867
3131
  sessionId,
2868
3132
  questions: request.questions,
2869
3133
  next,
3134
+ downstreamAbort,
2870
3135
  resolve,
2871
3136
  reject,
2872
- cleanup: () => request.signal?.removeEventListener('abort', onAbort),
3137
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
2873
3138
  }
2874
3139
  pendingQuestions.set(rpcId, pending)
2875
3140
  broadcastInteractionFrame(questionFrameFor(rpcId, pending))
3141
+ startDownstreamQuestion(pending)
2876
3142
  log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
2877
3143
  })
2878
3144
  }, { prepend: true })
@@ -2883,11 +3149,16 @@ const plugin = {
2883
3149
  const rpcId = crypto.randomUUID()
2884
3150
  const approvalId = crypto.randomUUID()
2885
3151
  return new Promise((resolve, reject) => {
3152
+ const requestSignal = request.signal
3153
+ const downstreamAbort = new AbortController()
3154
+ request.signal = requestSignal
3155
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
3156
+ : downstreamAbort.signal
2886
3157
  const onAbort = () => {
2887
3158
  const pending = pendingApprovals.get(rpcId)
2888
3159
  if (pending) completeApproval(pending, 'cancelled')
2889
3160
  }
2890
- request.signal?.addEventListener('abort', onAbort, { once: true })
3161
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2891
3162
  const pending = {
2892
3163
  rpcId,
2893
3164
  sessionId,
@@ -2895,13 +3166,16 @@ const plugin = {
2895
3166
  toolName: request.toolName,
2896
3167
  ...(request.callId !== undefined ? { callId: request.callId } : {}),
2897
3168
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
3169
+ ...(request.displayReason !== undefined ? { displayReason: request.displayReason } : {}),
2898
3170
  next,
3171
+ downstreamAbort,
2899
3172
  resolve,
2900
3173
  reject,
2901
- cleanup: () => request.signal?.removeEventListener('abort', onAbort),
3174
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
2902
3175
  }
2903
3176
  pendingApprovals.set(rpcId, pending)
2904
3177
  broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
3178
+ startDownstreamApproval(pending)
2905
3179
  log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
2906
3180
  })
2907
3181
  }, { prepend: true })
@@ -2931,49 +3205,53 @@ const plugin = {
2931
3205
  log(`session archive state forwarded: count=${next.length}`)
2932
3206
  }
2933
3207
 
2934
- const normalizeQueueItems = (value, endpoint) => {
2935
- if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
2936
- return value.map((candidate) => {
2937
- const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
2938
- ? candidate.message
2939
- : null
2940
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
2941
- || typeof candidate.id !== 'string' || !candidate.id
2942
- || !['queued', 'steering', 'context'].includes(candidate.placement)
2943
- || !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)
2944
3218
  || typeof message.id !== 'string' || !message.id
2945
3219
  || !Array.isArray(message.content)
2946
- || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
2947
- 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`)
2948
3223
  }
2949
3224
  return {
2950
- id: candidate.id,
2951
- placement: candidate.placement,
2952
- ...(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 }),
2953
3228
  message: { id: message.id, content: message.content },
2954
3229
  }
2955
3230
  })
3231
+ return [...convert(value['next-turn'], 'next-turn'), ...convert(value['next-step'], 'next-step')]
2956
3232
  }
2957
3233
 
2958
- const installSessionQueueBaseline = (value) => {
2959
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
2960
- 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')
2961
3237
  }
2962
3238
  const next = new Map()
2963
- for (const [sessionId, items] of Object.entries(value)) {
2964
- 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)
2965
3242
  }
2966
3243
  sessionQueues = next
2967
3244
  broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
2968
3245
  log(`session queue baseline forwarded: sessions=${next.size}`)
2969
3246
  }
2970
3247
 
2971
- const installSessionQueue = (sessionIdValue, itemsValue) => {
3248
+ const installSessionQueue = (sessionIdValue, inboxValue) => {
2972
3249
  const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
2973
3250
  if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
2974
- const items = normalizeQueueItems(itemsValue, 'session/control queue')
3251
+ const items = queueItemsFromInbox(inboxValue, 'session/control inbox')
2975
3252
  if (sessionQueues === null) sessionQueues = new Map()
2976
- sessionQueues.set(sessionId, items)
3253
+ if (items.length > 0) sessionQueues.set(sessionId, items)
3254
+ else sessionQueues.delete(sessionId)
2977
3255
  broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
2978
3256
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
2979
3257
  }
@@ -3034,9 +3312,9 @@ const plugin = {
3034
3312
  for await (const frame of stream) {
3035
3313
  if (frame?.type === 'baseline') {
3036
3314
  installProjectionBaseline(frame.value?.projections)
3037
- installSessionQueueBaseline(frame.value?.queues)
3038
- } else if (frame?.type === 'queue') {
3039
- 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)
3040
3318
  } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3041
3319
  const sessionId = String(frame.sessionId)
3042
3320
  const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
@@ -3070,9 +3348,11 @@ const plugin = {
3070
3348
  disposeUpgrade()
3071
3349
  disposeMgmt()
3072
3350
  disposeEvents()
3351
+ disposeScheduleChanged()
3073
3352
  disposeQuestions()
3074
3353
  disposeApprovals()
3075
3354
  if (lanServer) lanServer.close()
3355
+ tunnel.dispose()
3076
3356
  for (const client of clients) {
3077
3357
  client.sessionFollower?.stop()
3078
3358
  client.terminate()