@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- package/src/web-route.js +116 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createPairing } from './pairing.js'
|
|
3
|
+
import { createDeviceStore } from './devices.js'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { WebSocketServer } from 'ws'
|
|
6
|
+
import { createHostUi, uiRequestOrigin } from './host-ui.js'
|
|
7
|
+
import { CompletionTracker } from './completion-tracker.js'
|
|
8
|
+
|
|
9
|
+
export const PROTO = 1
|
|
10
|
+
const PRE_AUTH_MAX = 64 * 1024
|
|
11
|
+
const MAX_PAYLOAD = 32 * 1024 * 1024
|
|
12
|
+
/** Hello / pair must complete within this window (slow links: ~120s). */
|
|
13
|
+
export const HELLO_TIMEOUT_MS = 120_000
|
|
14
|
+
/** Default application ping interval for slow links (ref dsh-mobile SLOW_CONNECTIONS). */
|
|
15
|
+
export const DEFAULT_HEARTBEAT_MS = 30_000
|
|
16
|
+
/** Drop idle authed clients after this silence (covers ~4 missed heartbeats). */
|
|
17
|
+
export const DEFAULT_IDLE_MS = 120_000
|
|
18
|
+
/**
|
|
19
|
+
* Adapted from saya-ch/dsh-mobile (Apache-2.0) websocket-compression defaults.
|
|
20
|
+
* Compress outbound frames ≥ 1 KiB at zlib level 3; no context takeover.
|
|
21
|
+
*/
|
|
22
|
+
export const WS_DEFLATE = Object.freeze({
|
|
23
|
+
clientNoContextTakeover: true,
|
|
24
|
+
serverNoContextTakeover: true,
|
|
25
|
+
threshold: 1024,
|
|
26
|
+
zlibDeflateOptions: { level: 3 },
|
|
27
|
+
})
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
29
|
+
const eventSeq = (rec) => rec?.event?.seq
|
|
30
|
+
|
|
31
|
+
class OpError extends Error {
|
|
32
|
+
constructor(code, message) { super(message); this.code = code }
|
|
33
|
+
}
|
|
34
|
+
const need = (cond, msg) => { if (!cond) throw new OpError('bad-request', msg) }
|
|
35
|
+
const str = (v) => typeof v === 'string' && v.length > 0
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Keep lightweight session-header metadata without the large usage/outline/
|
|
39
|
+
* context projections carried by full dsh list items.
|
|
40
|
+
*/
|
|
41
|
+
export function slimSessionSummary(item) {
|
|
42
|
+
if (!item || typeof item !== 'object') return item
|
|
43
|
+
const title = item.projections?.values?.title
|
|
44
|
+
const agentPreset = item.projections?.values?.agentPreset
|
|
45
|
+
const out = {
|
|
46
|
+
sessionId: item.sessionId,
|
|
47
|
+
updatedAt: item.updatedAt,
|
|
48
|
+
running: item.running,
|
|
49
|
+
blank: item.blank,
|
|
50
|
+
cwd: item.cwd,
|
|
51
|
+
parentSessionId: item.parentSessionId,
|
|
52
|
+
origin: item.origin,
|
|
53
|
+
}
|
|
54
|
+
if (title != null || agentPreset !== undefined) {
|
|
55
|
+
const values = {}
|
|
56
|
+
if (title != null) values.title = title
|
|
57
|
+
if (agentPreset !== undefined) values.agentPreset = agentPreset
|
|
58
|
+
out.projections = { values }
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function slimSessionsList(result) {
|
|
64
|
+
if (!result || typeof result !== 'object') return result
|
|
65
|
+
if (!Array.isArray(result.items)) return result
|
|
66
|
+
return { ...result, items: result.items.map(slimSessionSummary) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Client-supplied follow window, or none — do not invent an iroh-era default. */
|
|
70
|
+
export function followMaxMessages(args = {}) {
|
|
71
|
+
if (args.maxMessages === undefined) return undefined
|
|
72
|
+
if (!Number.isSafeInteger(args.maxMessages) || args.maxMessages <= 0) {
|
|
73
|
+
throw new OpError('bad-request', 'maxMessages must be a positive integer')
|
|
74
|
+
}
|
|
75
|
+
return args.maxMessages
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Preserve ordinary-session routing; direct children require their durable address. */
|
|
79
|
+
export function sessionAddress(args) {
|
|
80
|
+
need(str(args.sessionId), 'sessionId required')
|
|
81
|
+
if (args.parentSessionId === undefined && args.subagentMode === undefined) {
|
|
82
|
+
return { kind: 'session', sessionId: args.sessionId }
|
|
83
|
+
}
|
|
84
|
+
need(str(args.parentSessionId) && args.parentSessionId !== args.sessionId, 'parentSessionId must name a different session')
|
|
85
|
+
need(['one-shot', 'continuable', 'unknown'].includes(args.subagentMode), 'subagentMode must be one-shot, continuable, or unknown')
|
|
86
|
+
return { kind: 'subagent', parentSessionId: args.parentSessionId, childSessionId: args.sessionId, mode: args.subagentMode }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function childController(address, getSubagents, method) {
|
|
90
|
+
need(address.mode === 'continuable', 'only continuable subagents accept prompt or cancel')
|
|
91
|
+
const controller = getSubagents()
|
|
92
|
+
if (typeof controller?.[method] !== 'function') {
|
|
93
|
+
throw new OpError('subagent/control-unavailable', `Host subagents.${method} is unavailable`)
|
|
94
|
+
}
|
|
95
|
+
return controller
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build the op table over an in-process sessionController. */
|
|
99
|
+
export function createOps(sc, { timeZone, getSubagents = () => undefined } = {}) {
|
|
100
|
+
const tz = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
101
|
+
return {
|
|
102
|
+
'sessions.list': async (a, signal) => slimSessionsList(
|
|
103
|
+
await sc.list({ ...(str(a.cursor) ? { cursor: a.cursor } : {}) }, signal),
|
|
104
|
+
),
|
|
105
|
+
...(typeof sc.projections === 'function' ? {
|
|
106
|
+
'sessions.projections': async (a, signal) => {
|
|
107
|
+
const address = sessionAddress(a)
|
|
108
|
+
if (address.kind === 'subagent') {
|
|
109
|
+
// An empty-prefix read validates durable parent ownership and identity
|
|
110
|
+
// without activating an Agent or loading a transcript into the reply.
|
|
111
|
+
await sc.page({ address, throughSeq: -1, maxMessages: 1 }, signal)
|
|
112
|
+
}
|
|
113
|
+
return sc.projections({ sessionId: a.sessionId }, signal)
|
|
114
|
+
},
|
|
115
|
+
} : {}),
|
|
116
|
+
'sessions.create': (a) => sc.create({
|
|
117
|
+
...(str(a.cwd) ? { cwd: a.cwd } : {}),
|
|
118
|
+
...(str(a.workspaceId) ? { workspaceId: a.workspaceId } : {}),
|
|
119
|
+
...(str(a.agentPreset) ? { agentPreset: a.agentPreset } : {}),
|
|
120
|
+
}),
|
|
121
|
+
'sessions.rename': (a) => {
|
|
122
|
+
need(str(a.sessionId) && str(a.title), 'sessionId and title required')
|
|
123
|
+
return sc.rename({ sessionId: a.sessionId, title: a.title })
|
|
124
|
+
},
|
|
125
|
+
/** Fork at an exact event boundary (omit atSeq → latest completed-turn prefix). */
|
|
126
|
+
'sessions.fork': (a) => {
|
|
127
|
+
need(str(a.sessionId), 'sessionId required')
|
|
128
|
+
const atSeq = a.atSeq
|
|
129
|
+
need(
|
|
130
|
+
atSeq === undefined || atSeq === null || Number.isSafeInteger(atSeq),
|
|
131
|
+
'atSeq must be a safe integer when provided',
|
|
132
|
+
)
|
|
133
|
+
return sc.fork({
|
|
134
|
+
sessionId: a.sessionId,
|
|
135
|
+
...(Number.isSafeInteger(atSeq) ? { atSeq } : {}),
|
|
136
|
+
...(a.allowMigration === false ? { allowMigration: false } : {}),
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
prompt: (a, signal) => {
|
|
140
|
+
const address = sessionAddress(a)
|
|
141
|
+
need(a.mode === 'queue' || a.mode === 'steer', 'mode must be queue or steer')
|
|
142
|
+
const content = []
|
|
143
|
+
if (str(a.text)) content.push({ type: 'text', text: a.text })
|
|
144
|
+
for (const img of Array.isArray(a.images) ? a.images : []) {
|
|
145
|
+
need(str(img?.mediaType) && str(img?.data), 'image needs mediaType and data')
|
|
146
|
+
content.push({ type: 'image', mediaType: img.mediaType, data: img.data, ...(str(img.name) ? { name: img.name } : {}) })
|
|
147
|
+
}
|
|
148
|
+
// Staged non-image files from `attachments.upload` (`{type:'file', receiptId}`).
|
|
149
|
+
for (const file of Array.isArray(a.files) ? a.files : []) {
|
|
150
|
+
need(str(file?.receiptId), 'file needs receiptId')
|
|
151
|
+
content.push({ type: 'file', receiptId: file.receiptId })
|
|
152
|
+
}
|
|
153
|
+
need(content.length > 0, 'text, images, or files required')
|
|
154
|
+
const requestId = str(a.requestId) ? a.requestId : randomUUID()
|
|
155
|
+
if (address.kind === 'subagent') {
|
|
156
|
+
const subagents = childController(address, getSubagents, 'prompt')
|
|
157
|
+
if (content.some(part => part.type === 'file')) {
|
|
158
|
+
const error = new OpError('subagent/attachment-invalid', 'subagent continuation does not accept files')
|
|
159
|
+
error.details = { reason: 'SUBAGENT_FILE_UNSUPPORTED' }
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
162
|
+
return subagents.prompt({
|
|
163
|
+
requestId, parentSessionId: address.parentSessionId, childSessionId: address.childSessionId,
|
|
164
|
+
mode: 'continuable', delivery: a.mode, content, clientTimeZone: tz,
|
|
165
|
+
}, signal)
|
|
166
|
+
}
|
|
167
|
+
return sc.prompt({
|
|
168
|
+
requestId, sessionId: a.sessionId, mode: a.mode, content, clientTimeZone: tz,
|
|
169
|
+
}, signal)
|
|
170
|
+
},
|
|
171
|
+
'queue.update': (a) => {
|
|
172
|
+
need(str(a.sessionId) && str(a.itemId) && a.action !== undefined, 'sessionId, itemId, action required')
|
|
173
|
+
return sc.updateQueue({ sessionId: a.sessionId, itemId: a.itemId, action: a.action })
|
|
174
|
+
},
|
|
175
|
+
cancel: (a) => {
|
|
176
|
+
const address = sessionAddress(a)
|
|
177
|
+
if (address.kind === 'subagent') {
|
|
178
|
+
return childController(address, getSubagents, 'interruptByParent')
|
|
179
|
+
.interruptByParent(address.childSessionId, address.parentSessionId, 'continuable')
|
|
180
|
+
}
|
|
181
|
+
return sc.cancel({ sessionId: a.sessionId })
|
|
182
|
+
},
|
|
183
|
+
'history.page': (a, signal) => {
|
|
184
|
+
need(str(a.sessionId) && Number.isSafeInteger(a.throughSeq), 'sessionId and throughSeq required')
|
|
185
|
+
return sc.page({
|
|
186
|
+
address: sessionAddress(a), throughSeq: a.throughSeq,
|
|
187
|
+
...(Number.isSafeInteger(a.beforeSeq) ? { beforeSeq: a.beforeSeq } : {}),
|
|
188
|
+
...(Number.isSafeInteger(a.maxMessages) ? { maxMessages: a.maxMessages } : {}),
|
|
189
|
+
}, signal)
|
|
190
|
+
},
|
|
191
|
+
'models.catalog': () => sc.modelCatalog(),
|
|
192
|
+
'models.select': (a) => {
|
|
193
|
+
need(str(a.sessionId) && str(a.provider) && str(a.model), 'sessionId, provider, model required')
|
|
194
|
+
return sc.selectModel({
|
|
195
|
+
sessionId: a.sessionId, provider: a.provider, model: a.model,
|
|
196
|
+
...(str(a.reasoningEffort) ? { reasoningEffort: a.reasoningEffort } : {}),
|
|
197
|
+
})
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {{sessionController: any, token: string, host?: string, port?: number,
|
|
204
|
+
* devices?: ReturnType<typeof createDeviceStore>, pairing?: ReturnType<typeof createPairing>,
|
|
205
|
+
* getSubagents?: () => any,
|
|
206
|
+
* onSessionEvent?: (fn: (kind: string, args: unknown[]) => void) => () => void,
|
|
207
|
+
* heartbeatMs?: number, idleMs?: number, pairFailDelayMs?: number, info?: object}} opts
|
|
208
|
+
*/
|
|
209
|
+
export function createPocketServer(opts) {
|
|
210
|
+
const sc = opts.sessionController
|
|
211
|
+
const devices = opts.devices ?? createDeviceStore({ ownerToken: opts.token, persist: false })
|
|
212
|
+
const pairing = opts.pairing ?? createPairing()
|
|
213
|
+
const heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
|
|
214
|
+
const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS
|
|
215
|
+
const pairFailDelayMs = opts.pairFailDelayMs ?? 200
|
|
216
|
+
const ops = createOps(sc, { getSubagents: opts.getSubagents })
|
|
217
|
+
const completion = new CompletionTracker({ initializing: true })
|
|
218
|
+
const completionSeed = new AbortController()
|
|
219
|
+
let hostUi
|
|
220
|
+
const http = createServer(async (req, res) => {
|
|
221
|
+
if (req.method === 'GET' && req.url === '/discovery') {
|
|
222
|
+
const info = opts.info ?? {}
|
|
223
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
224
|
+
res.end(JSON.stringify({
|
|
225
|
+
protocol: 1,
|
|
226
|
+
instanceId: info.instanceId,
|
|
227
|
+
deviceName: info.deviceName,
|
|
228
|
+
plugin: info.plugin,
|
|
229
|
+
version: info.version,
|
|
230
|
+
port: opts.port ?? 7690,
|
|
231
|
+
}))
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (hostUi) { await hostUi.http(req, res); return }
|
|
235
|
+
res.writeHead(426, { 'content-type': 'text/plain' }).end('websocket required')
|
|
236
|
+
})
|
|
237
|
+
// Adapted from saya-ch/dsh-mobile (Apache-2.0): negotiate permessage-deflate with phones.
|
|
238
|
+
// Frame-size caps (PRE_AUTH_MAX / MAX_PAYLOAD / FRAME_*_LIMIT) still apply to decoded JSON.
|
|
239
|
+
const wss = new WebSocketServer({
|
|
240
|
+
noServer: true,
|
|
241
|
+
maxPayload: MAX_PAYLOAD,
|
|
242
|
+
perMessageDeflate: opts.perMessageDeflate === false ? false : (opts.perMessageDeflate ?? WS_DEFLATE),
|
|
243
|
+
})
|
|
244
|
+
http.on('upgrade', (req, socket, head) => {
|
|
245
|
+
if (req.url?.split('?')[0] === '/api/remote.mux') {
|
|
246
|
+
if (hostUi) void hostUi.upgrade(req, socket, head)
|
|
247
|
+
else socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (req.url !== '/') {
|
|
251
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req))
|
|
255
|
+
})
|
|
256
|
+
const clients = new Set()
|
|
257
|
+
/** approvalId -> { payload, settle(outcome) } */
|
|
258
|
+
const approvals = new Map()
|
|
259
|
+
/** [sessionId, callId (or synthetic id)] -> live mobile offer. */
|
|
260
|
+
const userQuestions = new Map()
|
|
261
|
+
const devicePresenceListeners = new Set()
|
|
262
|
+
const notifyDevicePresence = () => { for (const listener of devicePresenceListeners) listener() }
|
|
263
|
+
const questionKey = (sessionId, callId) => JSON.stringify([sessionId, callId])
|
|
264
|
+
/** Session ids explicitly marked as delegated subagents; a fork is still a root. */
|
|
265
|
+
const childSessions = new Set()
|
|
266
|
+
const authedClients = () => [...clients].filter((c) => c.device)
|
|
267
|
+
const broadcast = (m) => { for (const c of authedClients()) c.push(m) }
|
|
268
|
+
const unsubscribeCompletion = completion.subscribe((snapshot) => broadcast({ ev: 'completion', ...snapshot }))
|
|
269
|
+
const projectionKeys = new Set(['sessionStats', 'tokenUsage', 'contextPressure', 'contextBreakdown', 'permissions', 'modelSelection', 'agentPreset', 'goal', 'plan', 'todos', 'turnOutline', 'userQuestions', 'inbox', 'agentTeam', 'subagentCatalog'])
|
|
270
|
+
// Follow's snapshot is the baseline. Hold newer projections until it is sent,
|
|
271
|
+
// including while a reconnect snapshot is paged or trimmed for the wire.
|
|
272
|
+
const followProjections = new WeakMap()
|
|
273
|
+
const pushTaskEvent = (event) => {
|
|
274
|
+
need(str(event?.sessionId), 'sessionId required')
|
|
275
|
+
const kind = event.kind === 'question' ? 'question' : 'done'
|
|
276
|
+
const turn = typeof event.turn === 'number' && Number.isSafeInteger(event.turn) && event.turn >= 0
|
|
277
|
+
? event.turn
|
|
278
|
+
: 0
|
|
279
|
+
const payload = { ev: 'task', kind, sessionId: event.sessionId, turn }
|
|
280
|
+
if (kind === 'question' && str(event.questionId)) payload.questionId = event.questionId
|
|
281
|
+
broadcast(payload)
|
|
282
|
+
}
|
|
283
|
+
const unsub = opts.onSessionEvent?.((kind, args) => {
|
|
284
|
+
if (kind === 'status') completion.status(args[0], args[1] === true)
|
|
285
|
+
else if (kind === 'added') completion.added(args[0]?.sessionId, args[0]?.running === true)
|
|
286
|
+
else if (kind === 'removed') completion.remove(args[0])
|
|
287
|
+
if (kind === 'added') {
|
|
288
|
+
const summary = args[0]
|
|
289
|
+
if (summary?.origin === 'subagent') childSessions.add(summary.sessionId)
|
|
290
|
+
else if (summary?.sessionId) childSessions.delete(summary.sessionId)
|
|
291
|
+
} else if (kind === 'removed') {
|
|
292
|
+
childSessions.delete(args[0])
|
|
293
|
+
}
|
|
294
|
+
broadcast({ ev: 'session', ...sessionEventPayload(kind, args) })
|
|
295
|
+
opts.onSessionLifecycle?.(kind, args)
|
|
296
|
+
})
|
|
297
|
+
// Subscribe before seeding, so no transition can fall into the async list gap.
|
|
298
|
+
// Client list refreshes never seed or replace the Host's unread state.
|
|
299
|
+
void (async () => {
|
|
300
|
+
let items = []
|
|
301
|
+
try {
|
|
302
|
+
const result = await sc.list({}, AbortSignal.any([completionSeed.signal, AbortSignal.timeout(120_000)]))
|
|
303
|
+
items = result?.items ?? []
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!completionSeed.signal.aborted) console.error('[pocket] completion seed failed:', error)
|
|
306
|
+
}
|
|
307
|
+
if (!completionSeed.signal.aborted) completion.seed(items)
|
|
308
|
+
})()
|
|
309
|
+
|
|
310
|
+
const adminOps = {
|
|
311
|
+
'pairing.open': () => pairing.open(),
|
|
312
|
+
'pairing.close': () => { pairing.close(); return { open: false } },
|
|
313
|
+
'pairing.status': () => pairing.status(),
|
|
314
|
+
}
|
|
315
|
+
const deviceOps = {
|
|
316
|
+
'completion.state': () => completion.snapshot(),
|
|
317
|
+
'completion.view': (a, _s, client) => {
|
|
318
|
+
need(a.sessionId === null || str(a.sessionId), 'sessionId must be a nonempty string or null')
|
|
319
|
+
return completion.view(client, a.sessionId)
|
|
320
|
+
},
|
|
321
|
+
'devices.list': () => ({ devices: devices.list() }),
|
|
322
|
+
'devices.revoke': (a) => {
|
|
323
|
+
need(str(a.deviceId), 'deviceId required')
|
|
324
|
+
const removed = devices.revoke(a.deviceId)
|
|
325
|
+
for (const c of clients) if (c.device?.id === a.deviceId) c.close(4403, 'revoked')
|
|
326
|
+
return { removed }
|
|
327
|
+
},
|
|
328
|
+
'approval.answer': (a, _s, client) => {
|
|
329
|
+
need(str(a.approvalId), 'approvalId required')
|
|
330
|
+
need(a.outcome === 'allowed-once' || a.outcome === 'rejected', 'outcome must be allowed-once or rejected')
|
|
331
|
+
const p = approvals.get(a.approvalId)
|
|
332
|
+
if (!p) throw new OpError('approval/not-pending', 'approval already settled or unknown')
|
|
333
|
+
p.settle(a.outcome, client.device.id)
|
|
334
|
+
return { accepted: true }
|
|
335
|
+
},
|
|
336
|
+
'approval.pending': () => ({ approvals: [...approvals.values()].map((p) => p.payload) }),
|
|
337
|
+
}
|
|
338
|
+
/** Ops contributed by optional in-process services (see index.js). */
|
|
339
|
+
const extraOps = {}
|
|
340
|
+
const allOps = () => [...Object.keys(ops), ...Object.keys(deviceOps), ...Object.keys(extraOps), 'follow', 'unfollow']
|
|
341
|
+
/** peer endpoint id → last reported iroh path (`direct`|`relay`) */
|
|
342
|
+
const irohPaths = new Map()
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Bind a transport-agnostic client (WebSocket or iroh pipe).
|
|
346
|
+
* @param {{ push(m: object): void, close(code?: number, reason?: string): void, terminate?(): void, meta?: object }} transport
|
|
347
|
+
*/
|
|
348
|
+
function bindClient(transport) {
|
|
349
|
+
let disposed = false
|
|
350
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
351
|
+
let helloTimer
|
|
352
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
353
|
+
let beat
|
|
354
|
+
const dispose = () => {
|
|
355
|
+
if (disposed) return
|
|
356
|
+
disposed = true
|
|
357
|
+
clearTimeout(helloTimer); clearInterval(beat); clients.delete(client)
|
|
358
|
+
hostUi?.revoke(client)
|
|
359
|
+
completion.disconnect(client)
|
|
360
|
+
for (const ac of client.subs.values()) ac.abort()
|
|
361
|
+
notifyDevicePresence()
|
|
362
|
+
}
|
|
363
|
+
const client = {
|
|
364
|
+
device: undefined, subs: new Map(), lastSeen: Date.now(), nextSub: 1,
|
|
365
|
+
meta: transport.meta ?? { transport: 'ws' },
|
|
366
|
+
push: (m) => { if (!disposed) transport.push(m) },
|
|
367
|
+
close: (code, reason) => { transport.close(code, reason); dispose() },
|
|
368
|
+
}
|
|
369
|
+
clients.add(client)
|
|
370
|
+
helloTimer = setTimeout(() => { if (!client.device) client.close(4401, 'hello timeout') }, HELLO_TIMEOUT_MS)
|
|
371
|
+
beat = setInterval(() => {
|
|
372
|
+
if (!client.device) return
|
|
373
|
+
if (Date.now() - client.lastSeen > idleMs) {
|
|
374
|
+
if (typeof transport.terminate === 'function') { transport.terminate(); dispose() }
|
|
375
|
+
else client.close(1001, 'idle')
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
client.push({ ev: 'ping', t: Date.now() })
|
|
379
|
+
}, heartbeatMs)
|
|
380
|
+
let busy = false
|
|
381
|
+
const onMessage = async (msg, rawLen = 0) => {
|
|
382
|
+
client.lastSeen = Date.now()
|
|
383
|
+
if (!client.device && rawLen > PRE_AUTH_MAX) { client.close(1009, 'too large'); return }
|
|
384
|
+
if (msg === null || typeof msg !== 'object') { client.close(1007, 'bad frame'); return }
|
|
385
|
+
if (msg.op === 'pong') return
|
|
386
|
+
const reply = (body) => client.push({ id: msg.id, ...body })
|
|
387
|
+
const fail = (code, message, details) => reply({ ok: false, error: { code, message, ...(details === undefined ? {} : { details }) } })
|
|
388
|
+
if (!client.device) {
|
|
389
|
+
if (busy) return
|
|
390
|
+
if (msg.op !== 'hello' && msg.op !== 'pair') { fail('auth/denied', 'hello with a valid token required'); client.close(4401, 'unauthorized'); return }
|
|
391
|
+
if (msg.args?.proto !== PROTO) { fail('proto/unsupported', `proto ${PROTO} required`); client.close(4400, 'proto'); return }
|
|
392
|
+
if (msg.op === 'pair') {
|
|
393
|
+
busy = true
|
|
394
|
+
const reason = pairing.attempt(msg.args?.code)
|
|
395
|
+
if (reason !== null) {
|
|
396
|
+
await sleep(pairFailDelayMs)
|
|
397
|
+
fail(reason, 'pairing failed'); client.close(4401, 'pair failed'); return
|
|
398
|
+
}
|
|
399
|
+
const issued = devices.add(msg.args?.name)
|
|
400
|
+
client.device = devices.authenticate(issued.token)
|
|
401
|
+
busy = false; clearTimeout(helloTimer)
|
|
402
|
+
reply({ ok: true, result: { proto: PROTO, ...issued, ...(opts.info ?? {}), ops: allOps() } })
|
|
403
|
+
sendPending(client)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
const device = msg.op === 'hello' ? devices.authenticate(msg.args?.token) : undefined
|
|
407
|
+
if (!device) { fail('auth/denied', 'hello with a valid token required'); client.close(4401, 'unauthorized'); return }
|
|
408
|
+
client.device = device; clearTimeout(helloTimer)
|
|
409
|
+
reply({ ok: true, result: { proto: PROTO, device: { id: device.id, name: device.name }, ...(opts.info ?? {}), ops: allOps() } })
|
|
410
|
+
sendPending(client)
|
|
411
|
+
notifyDevicePresence()
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
void handle(client, msg, reply, fail)
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
client,
|
|
418
|
+
onMessage,
|
|
419
|
+
onRemoteClose: dispose,
|
|
420
|
+
close: (code, reason) => { client.close(code, reason) },
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
wss.on('connection', (ws, req) => {
|
|
425
|
+
const bound = bindClient({
|
|
426
|
+
push: (m) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m)) },
|
|
427
|
+
close: (code, reason) => { try { ws.close(code ?? 1000, reason) } catch { /* ignore */ } },
|
|
428
|
+
terminate: () => ws.terminate(),
|
|
429
|
+
meta: { transport: 'ws', uiOrigin: uiRequestOrigin(req) },
|
|
430
|
+
})
|
|
431
|
+
ws.on('error', (error) => {
|
|
432
|
+
console.warn('[pocket] WebSocket connection failed:', error.message)
|
|
433
|
+
bound.onRemoteClose()
|
|
434
|
+
})
|
|
435
|
+
ws.on('close', () => bound.onRemoteClose())
|
|
436
|
+
ws.on('message', (raw, isBinary) => {
|
|
437
|
+
if (isBinary) { bound.client.close(1003, 'text only'); return }
|
|
438
|
+
let msg
|
|
439
|
+
try { msg = JSON.parse(raw.toString('utf8')) } catch { bound.client.close(1007, 'bad json'); return }
|
|
440
|
+
void bound.onMessage(msg, raw.length)
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
function sendPending(client) {
|
|
445
|
+
for (const p of approvals.values()) client.push({ ev: 'approval', ...p.payload })
|
|
446
|
+
for (const p of userQuestions.values()) client.push({ ev: 'user-question', ...p.payload })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function handle(client, msg, reply, fail) {
|
|
450
|
+
const args = msg.args && typeof msg.args === 'object' ? msg.args : {}
|
|
451
|
+
try {
|
|
452
|
+
if (msg.op === 'follow') {
|
|
453
|
+
sessionAddress(args)
|
|
454
|
+
need(args.afterSeq === undefined || Number.isSafeInteger(args.afterSeq), 'afterSeq must be an integer')
|
|
455
|
+
need(args.maxMessages === undefined || (Number.isSafeInteger(args.maxMessages) && args.maxMessages > 0), 'maxMessages must be a positive integer')
|
|
456
|
+
const subId = `s${client.nextSub++}`
|
|
457
|
+
const ac = new AbortController()
|
|
458
|
+
client.subs.set(subId, ac)
|
|
459
|
+
followProjections.set(ac, { sessionId: args.sessionId, ready: false, pending: new Map() })
|
|
460
|
+
reply({ ok: true, result: { subId } })
|
|
461
|
+
void pump(client, subId, ac, args)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
if (msg.op === 'unfollow') {
|
|
465
|
+
const ac = client.subs.get(args.subId)
|
|
466
|
+
ac?.abort(); client.subs.delete(args.subId)
|
|
467
|
+
reply({ ok: true, result: { stopped: ac !== undefined } })
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
let op
|
|
471
|
+
if (Object.hasOwn(ops, msg.op)) op = ops[msg.op]
|
|
472
|
+
else if (Object.hasOwn(deviceOps, msg.op)) op = deviceOps[msg.op]
|
|
473
|
+
else if (Object.hasOwn(extraOps, msg.op)) op = extraOps[msg.op]
|
|
474
|
+
else if (Object.hasOwn(adminOps, msg.op)) {
|
|
475
|
+
if (!client.device.owner) { fail('auth/forbidden', 'owner token required'); return }
|
|
476
|
+
op = adminOps[msg.op]
|
|
477
|
+
}
|
|
478
|
+
if (op === undefined) { fail('unknown-op', `unknown op ${JSON.stringify(msg.op)}`); return }
|
|
479
|
+
const result = await op(args, AbortSignal.timeout(msg.op === 'office.render' ? 600_000 : 120_000), client)
|
|
480
|
+
reply({ ok: true, result: result ?? null })
|
|
481
|
+
} catch (error) {
|
|
482
|
+
fail(error?.code ?? 'internal', String(error?.message ?? error), error?.details)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function resumeFrame(snapshot, args, signal) {
|
|
487
|
+
const after = args.afterSeq
|
|
488
|
+
const records = snapshot.records ?? []
|
|
489
|
+
const firstSeq = eventSeq(records[0])
|
|
490
|
+
let tail = records.filter((r) => eventSeq(r) > after)
|
|
491
|
+
let complete = records.length === 0 ? snapshot.cursor <= after || !snapshot.hasMore : firstSeq <= after + 1
|
|
492
|
+
// Fill a gap between afterSeq and the snapshot window with history pages (bounded).
|
|
493
|
+
let before = firstSeq
|
|
494
|
+
for (let i = 0; !complete && before !== undefined && i < 10; i++) {
|
|
495
|
+
const page = await sc.page({ address: sessionAddress(args), throughSeq: snapshot.cursor, beforeSeq: before, maxMessages: 200 }, signal)
|
|
496
|
+
const recs = page.records ?? []
|
|
497
|
+
if (recs.length === 0) break
|
|
498
|
+
tail = [...recs.filter((r) => eventSeq(r) > after), ...tail]
|
|
499
|
+
before = eventSeq(recs[0])
|
|
500
|
+
complete = before <= after + 1 || !page.hasMore
|
|
501
|
+
}
|
|
502
|
+
if (!complete) return snapshot
|
|
503
|
+
const { records: _r, hasMore: _h, ...rest } = snapshot
|
|
504
|
+
return { ...rest, type: 'resume', afterSeq: after, records: tail }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function pump(client, subId, ac, args) {
|
|
508
|
+
try {
|
|
509
|
+
const maxMessages = followMaxMessages(args)
|
|
510
|
+
const it = sc.follow({
|
|
511
|
+
address: sessionAddress(args), assistantStream: true,
|
|
512
|
+
...(maxMessages === undefined ? {} : { maxMessages }),
|
|
513
|
+
}, ac.signal)
|
|
514
|
+
let first = true
|
|
515
|
+
for await (let frame of it) {
|
|
516
|
+
if (ac.signal.aborted) break
|
|
517
|
+
if (first && frame?.type === 'snapshot' && args.afterSeq !== undefined) {
|
|
518
|
+
frame = await resumeFrame(frame, args, ac.signal)
|
|
519
|
+
}
|
|
520
|
+
first = false
|
|
521
|
+
const wire = { ev: 'follow', subId, frame }
|
|
522
|
+
client.push(wire)
|
|
523
|
+
const projectionState = followProjections.get(ac)
|
|
524
|
+
if (projectionState && !projectionState.ready) {
|
|
525
|
+
projectionState.ready = true
|
|
526
|
+
const baselineSeq = frame.projections?.asOfSeq
|
|
527
|
+
for (const update of projectionState.pending.values()) {
|
|
528
|
+
if (Number.isSafeInteger(baselineSeq) && update.seq <= baselineSeq) continue
|
|
529
|
+
client.push({ ev: 'follow', subId, frame: update })
|
|
530
|
+
}
|
|
531
|
+
projectionState.pending.clear()
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (!ac.signal.aborted) client.push({ ev: 'follow.end', subId })
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (!ac.signal.aborted) client.push({ ev: 'follow.error', subId, error: { code: error?.code ?? 'internal', message: String(error?.message ?? error) } })
|
|
537
|
+
} finally {
|
|
538
|
+
client.subs.delete(subId)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
completion,
|
|
544
|
+
devices, pairing,
|
|
545
|
+
/** A service-scoped, fixed-local Host backend. Disposing removes ops and all grants. */
|
|
546
|
+
installHostUi(backend) {
|
|
547
|
+
const installed = createHostUi({ ...backend, devices, isClientLive: (client) => clients.has(client) })
|
|
548
|
+
hostUi?.dispose()
|
|
549
|
+
hostUi = installed
|
|
550
|
+
const unregister = this.registerOps({
|
|
551
|
+
'ui.open': (args, _signal, client) => installed.open(args, client),
|
|
552
|
+
'ui.close': (args, _signal, client) => {
|
|
553
|
+
need(args.ticket === undefined || typeof args.ticket === 'string', 'ticket must be a string')
|
|
554
|
+
installed.revoke(client, args.ticket)
|
|
555
|
+
return { closed: true }
|
|
556
|
+
},
|
|
557
|
+
})
|
|
558
|
+
return () => {
|
|
559
|
+
installed.dispose()
|
|
560
|
+
if (hostUi === installed) hostUi = undefined
|
|
561
|
+
unregister()
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
/** Register extra ops; returns a disposer. */
|
|
565
|
+
registerOps(table) {
|
|
566
|
+
Object.assign(extraOps, table)
|
|
567
|
+
broadcast({ ev: 'ops.changed', ops: allOps() })
|
|
568
|
+
return () => {
|
|
569
|
+
for (const k of Object.keys(table)) if (extraOps[k] === table[k]) delete extraOps[k]
|
|
570
|
+
broadcast({ ev: 'ops.changed', ops: allOps() })
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
broadcastEvent(ev, payload = {}) { broadcast({ ...payload, ev }) },
|
|
574
|
+
hasDevices: () => authedClients().length > 0,
|
|
575
|
+
onDevicePresence(listener) {
|
|
576
|
+
devicePresenceListeners.add(listener)
|
|
577
|
+
return () => devicePresenceListeners.delete(listener)
|
|
578
|
+
},
|
|
579
|
+
/** Device ids currently authenticated on a live connection. */
|
|
580
|
+
onlineDeviceIds: () => new Set(authedClients().filter((c) => !c.device.owner).map((c) => c.device.id)),
|
|
581
|
+
/** Revoke a paired device and kick any live connection. */
|
|
582
|
+
revokeDevice(id) {
|
|
583
|
+
const removed = devices.revoke(id)
|
|
584
|
+
for (const c of clients) if (c.device?.id === id) c.close(4403, 'revoked')
|
|
585
|
+
return removed
|
|
586
|
+
},
|
|
587
|
+
/**
|
|
588
|
+
* Attach an iroh (or other) pipe that speaks the same JSON frames.
|
|
589
|
+
* @param {{ send(frame: object): void, close(): void, meta?: object }} pipe
|
|
590
|
+
*/
|
|
591
|
+
attachPipe(pipe) {
|
|
592
|
+
let closed = false
|
|
593
|
+
const bound = bindClient({
|
|
594
|
+
push: (m) => { if (!closed) pipe.send(m) },
|
|
595
|
+
close: () => {
|
|
596
|
+
if (closed) return
|
|
597
|
+
closed = true
|
|
598
|
+
pipe.close()
|
|
599
|
+
},
|
|
600
|
+
meta: pipe.meta ?? { transport: 'iroh' },
|
|
601
|
+
})
|
|
602
|
+
return {
|
|
603
|
+
onMessage: (msg, rawLen) => bound.onMessage(msg, rawLen),
|
|
604
|
+
onRemoteClose: () => { closed = true; bound.onRemoteClose() },
|
|
605
|
+
close: (code, reason) => { bound.close(code, reason) },
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
noteIrohPath(peer, kind) { if (peer && kind) irohPaths.set(peer, kind) },
|
|
609
|
+
clearIrohPath(peer) { if (peer) irohPaths.delete(peer) },
|
|
610
|
+
irohPath: (peer) => irohPaths.get(peer),
|
|
611
|
+
/** Active iroh peer paths: `{ peer, kind: 'direct'|'relay' }[]`. */
|
|
612
|
+
irohConnections: () => [...irohPaths.entries()].map(([peer, kind]) => ({ peer, kind })),
|
|
613
|
+
/**
|
|
614
|
+
* Aggregate connection type for /pocket/status: prefer direct if any peer
|
|
615
|
+
* is direct, else relay, else null when no path reported yet.
|
|
616
|
+
*/
|
|
617
|
+
connectionType() {
|
|
618
|
+
const kinds = [...irohPaths.values()]
|
|
619
|
+
if (kinds.includes('direct')) return 'direct'
|
|
620
|
+
if (kinds.includes('relay')) return 'relay'
|
|
621
|
+
return null
|
|
622
|
+
},
|
|
623
|
+
/**
|
|
624
|
+
* Offer one approval to connected devices.
|
|
625
|
+
* @returns {{ answer: Promise<{outcome: string, by: string}>, close(outcome: string, by: string): void }}
|
|
626
|
+
*/
|
|
627
|
+
offerApproval(payload) {
|
|
628
|
+
const approvalId = randomUUID()
|
|
629
|
+
const full = { approvalId, ...payload }
|
|
630
|
+
let resolve
|
|
631
|
+
const answer = new Promise((r) => { resolve = r })
|
|
632
|
+
const close = (outcome, by) => {
|
|
633
|
+
if (!approvals.has(approvalId)) return
|
|
634
|
+
approvals.delete(approvalId)
|
|
635
|
+
broadcast({ ev: 'approval.closed', approvalId, outcome, by })
|
|
636
|
+
}
|
|
637
|
+
approvals.set(approvalId, {
|
|
638
|
+
payload: full,
|
|
639
|
+
settle: (outcome, deviceId) => { resolve({ outcome, by: `device:${deviceId}` }) },
|
|
640
|
+
})
|
|
641
|
+
broadcast({ ev: 'approval', ...full })
|
|
642
|
+
return { approvalId, answer, close }
|
|
643
|
+
},
|
|
644
|
+
/**
|
|
645
|
+
* Offer one user-question batch to connected devices.
|
|
646
|
+
* @returns {{ questionId: string, answer: Promise<{answer: object, by: string}>, close(status: string, by: string): void }}
|
|
647
|
+
*/
|
|
648
|
+
offerUserQuestion(payload, { isActive = () => true } = {}) {
|
|
649
|
+
const questionId = str(payload?.callId) ? payload.callId : randomUUID()
|
|
650
|
+
const full = { questionId, ...payload }
|
|
651
|
+
const key = questionKey(full.sessionId, questionId)
|
|
652
|
+
let resolve
|
|
653
|
+
const answer = new Promise((r) => { resolve = r })
|
|
654
|
+
const close = (status, by) => {
|
|
655
|
+
if (userQuestions.get(key) !== entry) return
|
|
656
|
+
userQuestions.delete(key)
|
|
657
|
+
broadcast({ ev: 'user-question.closed', questionId, sessionId: full.sessionId, status, by })
|
|
658
|
+
}
|
|
659
|
+
const entry = {
|
|
660
|
+
payload: full,
|
|
661
|
+
settle: (ans, deviceId) => {
|
|
662
|
+
if (userQuestions.get(key) !== entry || !isActive()) {
|
|
663
|
+
close('aborted', 'host')
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
const ids = new Set(ans.answers.map((item) => item.id))
|
|
667
|
+
if (ids.size !== ans.answers.length || full.questions.length !== ids.size ||
|
|
668
|
+
!full.questions.every((item) => ids.has(item.id))) {
|
|
669
|
+
throw Object.assign(new Error('answer must name each question exactly once'), { code: 'BAD_ANSWER' })
|
|
670
|
+
}
|
|
671
|
+
close('answered', `device:${deviceId}`)
|
|
672
|
+
resolve({ answer: ans, by: `device:${deviceId}` })
|
|
673
|
+
return true
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
userQuestions.set(key, entry)
|
|
677
|
+
broadcast({ ev: 'user-question', ...full })
|
|
678
|
+
if (str(full.sessionId) && !childSessions.has(full.sessionId)) {
|
|
679
|
+
pushTaskEvent({ sessionId: full.sessionId, kind: 'question', questionId })
|
|
680
|
+
}
|
|
681
|
+
return { questionId, answer, close }
|
|
682
|
+
},
|
|
683
|
+
/**
|
|
684
|
+
* Push a minimal task-completion / question event (sessionId + turn).
|
|
685
|
+
* Phones render notification text locally from session title + kind.
|
|
686
|
+
* @param {{ sessionId: string, turn?: number, kind?: 'done'|'question', questionId?: string }} event
|
|
687
|
+
*/
|
|
688
|
+
broadcastTaskEvent(event) {
|
|
689
|
+
pushTaskEvent(event)
|
|
690
|
+
},
|
|
691
|
+
/** Mirror the official session control carrier, only to matching live follows. */
|
|
692
|
+
broadcastProjection(sessionId, key, value, seq) {
|
|
693
|
+
if (!str(sessionId) || !projectionKeys.has(key) || !Number.isSafeInteger(seq)) return
|
|
694
|
+
const frame = { type: 'projection', sessionId, key, value: value ?? null, seq }
|
|
695
|
+
for (const client of authedClients()) {
|
|
696
|
+
for (const [subId, ac] of client.subs) {
|
|
697
|
+
const state = followProjections.get(ac)
|
|
698
|
+
if (!state || state.sessionId !== sessionId || ac.signal.aborted) continue
|
|
699
|
+
if (state.ready) client.push({ ev: 'follow', subId, frame })
|
|
700
|
+
else {
|
|
701
|
+
const previous = state.pending.get(key)
|
|
702
|
+
if (!previous || seq >= previous.seq) state.pending.set(key, frame)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
/** Invalidate the process catalog; authenticated clients re-read its complete value. */
|
|
708
|
+
broadcastPermissionCatalogChanged() {
|
|
709
|
+
broadcast({ ev: 'permission-presets/catalog-changed' })
|
|
710
|
+
},
|
|
711
|
+
isRootSession(sessionId) {
|
|
712
|
+
return str(sessionId) && !childSessions.has(sessionId)
|
|
713
|
+
},
|
|
714
|
+
hasPendingUserQuestionForSession(sessionId) {
|
|
715
|
+
if (!str(sessionId)) return false
|
|
716
|
+
for (const p of userQuestions.values()) {
|
|
717
|
+
if (p.payload?.sessionId === sessionId) return true
|
|
718
|
+
}
|
|
719
|
+
return false
|
|
720
|
+
},
|
|
721
|
+
/** Settle a pending waterfall offer from `userQuestions.answer` (returns false if none). */
|
|
722
|
+
settleUserQuestion(sessionId, callId, answer, deviceId = 'phone') {
|
|
723
|
+
return userQuestions.get(questionKey(sessionId, callId))?.settle(answer, deviceId) ?? false
|
|
724
|
+
},
|
|
725
|
+
hasPendingUserQuestion(sessionId, callId) {
|
|
726
|
+
return userQuestions.has(questionKey(sessionId, callId))
|
|
727
|
+
},
|
|
728
|
+
listen: () => new Promise((resolve, reject) => {
|
|
729
|
+
http.once('error', reject)
|
|
730
|
+
http.listen(opts.port ?? 7690, opts.host ?? '127.0.0.1', () => {
|
|
731
|
+
http.off('error', reject)
|
|
732
|
+
const a = http.address()
|
|
733
|
+
resolve(typeof a === 'string' ? a : `${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}`)
|
|
734
|
+
})
|
|
735
|
+
}),
|
|
736
|
+
address: () => http.address(),
|
|
737
|
+
close: () => new Promise((resolve) => {
|
|
738
|
+
unsub?.()
|
|
739
|
+
completionSeed.abort()
|
|
740
|
+
unsubscribeCompletion()
|
|
741
|
+
hostUi?.dispose()
|
|
742
|
+
hostUi = undefined
|
|
743
|
+
for (const c of [...clients]) c.close(1001, 'shutdown')
|
|
744
|
+
for (const ws of wss.clients) ws.terminate()
|
|
745
|
+
wss.close(() => {
|
|
746
|
+
http.close(() => resolve())
|
|
747
|
+
})
|
|
748
|
+
}),
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function sessionEventPayload(kind, args) {
|
|
753
|
+
switch (kind) {
|
|
754
|
+
case 'added': return { kind, summary: slimSessionSummary(args[0]) }
|
|
755
|
+
case 'removed': return { kind, sessionId: args[0] }
|
|
756
|
+
case 'status': return { kind, sessionId: args[0], running: args[1] }
|
|
757
|
+
case 'activity': return { kind, sessionId: args[0], updatedAt: args[1] }
|
|
758
|
+
case 'error': return { kind, sessionId: args[0], message: args[1] }
|
|
759
|
+
default: return { kind, args }
|
|
760
|
+
}
|
|
761
|
+
}
|