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/PROTOCOL.md +54 -14
- package/README.md +30 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +254 -18
- package/lib/cloudflare-tunnel.mjs +268 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +20 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +341 -61
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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:
|
|
1791
|
-
permissionDefault:
|
|
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
|
|
1804
|
-
|
|
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
|
-
|
|
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 (
|
|
2253
|
-
|
|
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 (
|
|
2258
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
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
|
-
||
|
|
2947
|
-
|
|
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:
|
|
2951
|
-
placement:
|
|
2952
|
-
...(
|
|
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 = (
|
|
2959
|
-
if (!
|
|
2960
|
-
throw new Error('session/control baseline returned invalid
|
|
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,
|
|
2964
|
-
|
|
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,
|
|
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 =
|
|
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?.
|
|
3038
|
-
} else if (frame?.type === '
|
|
3039
|
-
installSessionQueue(frame.sessionId, frame.
|
|
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()
|