@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.
Files changed (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. 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
+ }