@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
@@ -0,0 +1,604 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ const str = (v) => typeof v === 'string' && v.length > 0
4
+ const bad = (m) => Object.assign(new Error(m), { code: 'bad-request' })
5
+
6
+ /** Session object of a live (or auto-resumed) Agent. */
7
+ function sessionOf(agents, sessionId) {
8
+ if (!str(sessionId)) throw bad('sessionId required')
9
+ const agent = agents.get(sessionId)
10
+ if (!agent) throw Object.assign(new Error('session has no live agent'), { code: 'session/not-found' })
11
+ return agent.session
12
+ }
13
+
14
+ /** Live Agent for terminal / command ops that need the Gateway Agent handle. */
15
+ function agentOf(agents, sessionId) {
16
+ if (!str(sessionId)) throw bad('sessionId required')
17
+ const agent = agents.get(sessionId)
18
+ if (!agent) throw Object.assign(new Error('session has no live agent'), { code: 'session/not-found' })
19
+ return agent
20
+ }
21
+
22
+ /** permissions.* over the in-process permissionPresets service. */
23
+ export function permissionOps(presets, agents) {
24
+ return {
25
+ 'permissions.catalog': () => presets.catalog(),
26
+ 'permissions.get': (a) => ({ current: presets.current(sessionOf(agents, a.sessionId)) }),
27
+ 'permissions.set': (a) => {
28
+ if (!str(a.name)) throw bad('name required')
29
+ const session = sessionOf(agents, a.sessionId)
30
+ presets.set(session, a.name)
31
+ return { current: presets.current(session) }
32
+ },
33
+ }
34
+ }
35
+
36
+ /** presets.list over the agentPresets registry (same roster the web UI uses). */
37
+ export function presetOps(agentPresets, settingsController) {
38
+ return {
39
+ 'presets.list': () => agentPresets.remoteExportList(),
40
+ ...(typeof agentPresets.readDocument === 'function' ? {
41
+ 'presets.read': (a) => {
42
+ if (!str(a.id)) throw bad('preset id required')
43
+ return agentPresets.readDocument(a.id)
44
+ },
45
+ } : {}),
46
+ ...(settingsController?.describe ? {
47
+ 'presets.settings': async () => {
48
+ const described = await settingsController.describe()
49
+ return {
50
+ ...await agentPresets.remoteExportList(),
51
+ writable: described.writable !== false && typeof settingsController?.mutate === 'function',
52
+ namespace: described.namespaces.find((ns) => ns.ns === 'agent-preset-registry'),
53
+ }
54
+ },
55
+ } : {}),
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Standing scope of the default agent preset — the same key desktop uses to
61
+ * read a new-conversation catalog before a Session exists.
62
+ * Prefer `acquireScope()` (SessionSkillCatalog's Host lease); `standingKeyFor()`
63
+ * is the same preset package's older name for that key.
64
+ */
65
+ async function defaultProfileScope(agentPresets) {
66
+ if (typeof agentPresets?.acquireScope === 'function') return agentPresets.acquireScope()
67
+ if (typeof agentPresets?.standingKeyFor === 'function') {
68
+ return { key: await agentPresets.standingKeyFor() }
69
+ }
70
+ throw Object.assign(new Error('default agent profile is unavailable'), { code: 'unavailable' })
71
+ }
72
+
73
+ async function withDefaultProfileScope(agentPresets, fn) {
74
+ const lease = await defaultProfileScope(agentPresets)
75
+ try {
76
+ return await fn(lease.key)
77
+ } finally {
78
+ await lease?.[Symbol.asyncDispose]?.()
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Map a Host `SkillSummary` to the pocket `skills.list` entry SessionSkillCatalog
84
+ * returns. Invocation policy is required; user-only filtering matches desktop.
85
+ */
86
+ function userInvocableSkillEntry(skill) {
87
+ const invocation = skill?.invocation
88
+ if (!invocation || typeof invocation.userInvocable !== 'boolean'
89
+ || typeof invocation.modelInvocable !== 'boolean') {
90
+ throw Object.assign(new Error('skill catalog entry is missing invocation policy'), { code: 'internal' })
91
+ }
92
+ if (!invocation.userInvocable) return undefined
93
+ if (!str(skill.name)) throw Object.assign(new Error('skill catalog entry is missing name'), { code: 'internal' })
94
+ if (typeof skill.description !== 'string') {
95
+ throw Object.assign(new Error('skill catalog entry is missing description'), { code: 'internal' })
96
+ }
97
+ return {
98
+ name: skill.name,
99
+ ...skill.path === undefined ? {} : { path: skill.path },
100
+ description: skill.description,
101
+ ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
102
+ modelInvocable: invocation.modelInvocable,
103
+ }
104
+ }
105
+
106
+ /** New-conversation skill catalog: default workspace cwd + default preset scope. */
107
+ async function listDefaultSkills({ skills, workingDirectory, agentPresets }, signal) {
108
+ const cwd = workingDirectory?.defaultDirectory
109
+ if (!str(cwd)) throw Object.assign(new Error('default workspace is unavailable'), { code: 'unavailable' })
110
+ if (typeof skills?.list !== 'function') {
111
+ throw Object.assign(new Error('skill registry is absent'), { code: 'unavailable' })
112
+ }
113
+ return withDefaultProfileScope(agentPresets, async (scope) => {
114
+ const listed = await skills.list({ cwd, scope, ...(signal === undefined ? {} : { signal }) })
115
+ if (!Array.isArray(listed)) {
116
+ throw Object.assign(new Error('skill registry list did not return an array'), { code: 'internal' })
117
+ }
118
+ return { skills: listed.map(userInvocableSkillEntry).filter((entry) => entry !== undefined) }
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Slash commands (`/compact`, `/plan`, `/goal …`, `/feedback …`) via Host commands service.
124
+ * Line may be with or without a leading `/`.
125
+ *
126
+ * `commands.list` without `sessionId` uses the default preset standing scope —
127
+ * the same composition desktop shows on a new conversation.
128
+ */
129
+ export function commandOps(commands, agents, agentPresets) {
130
+ return {
131
+ 'commands.list': async (a) => {
132
+ if (str(a.sessionId)) return { commands: commands.list(agentOf(agents, a.sessionId)) }
133
+ return { commands: await withDefaultProfileScope(agentPresets, (scope) => commands.list(scope)) }
134
+ },
135
+ 'commands.execute': async (a, signal) => {
136
+ if (!str(a.sessionId) || !str(a.line)) throw bad('sessionId and line required')
137
+ const agent = agents.get(a.sessionId)
138
+ if (!agent) throw Object.assign(new Error('session has no live agent'), { code: 'session/not-found' })
139
+ const line = a.line.trimStart().startsWith('/') ? a.line.trim() : `/${a.line.trim()}`
140
+ const execution = await commands.execute(agent, line, Array.isArray(a.attachments) ? a.attachments : [], signal)
141
+ if (execution === undefined) throw Object.assign(new Error(`unknown command: ${line}`), { code: 'command/unknown' })
142
+ return { commandId: execution.commandId, result: execution.result }
143
+ },
144
+ }
145
+ }
146
+
147
+ /**
148
+ * User-invocable skill catalog for the `/` menu.
149
+ * With `sessionId`, forwards Host `sessionSkillCatalog.list` — the same
150
+ * `skills/list` Remote desktop `ui-skill` calls — as `{ skills: [...] }`.
151
+ * Without `sessionId`, lists through the Host skill registry with the default
152
+ * workspace cwd and default preset standing scope (desktop new conversation).
153
+ */
154
+ export function skillOps(sessionSkillCatalog, host = {}) {
155
+ return {
156
+ 'skills.list': async (a, signal) => {
157
+ if (str(a.sessionId)) return sessionSkillCatalog.list({ sessionId: a.sessionId }, signal)
158
+ return listDefaultSkills(host, signal)
159
+ },
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Read-only workspace file browse for the phone right panel.
165
+ * Scope is built from the live (or persisted) session header cwd.
166
+ */
167
+ export function filesOps(workspaceFiles, sessions, sandboxPolicy) {
168
+ const scopeOf = async (sessionId) => {
169
+ if (!str(sessionId)) throw bad('sessionId required')
170
+ const live = sessions.get?.(sessionId)?.header
171
+ const workspaceRoot = live?.cwd ?? sandboxPolicy?.workspaceRoot
172
+ if (!str(workspaceRoot)) throw Object.assign(new Error('session has no workspace root'), { code: 'session/not-found' })
173
+ return { sessionId, workspaceRoot }
174
+ }
175
+ return {
176
+ 'files.list': async (a, signal) => {
177
+ const scope = await scopeOf(a.sessionId)
178
+ return workspaceFiles.list(scope, str(a.path) ? a.path : '.', signal)
179
+ },
180
+ ...(typeof workspaceFiles.changes === 'function' ? {
181
+ 'files.follow': (a, _signal, client) => {
182
+ if (!str(a.sessionId) || !/^f-[a-zA-Z0-9-]{1,80}$/.test(a.subId ?? '')) throw bad('sessionId and file subscription id required')
183
+ const path = str(a.path) ? a.path : '.'
184
+ if (path.startsWith('/') || path.includes('\\') || path.includes('\0') ||
185
+ path.split('/').includes('..') || /^[a-z][a-z\d+.-]*:/iu.test(path)) {
186
+ throw Object.assign(new Error('directory must be inside the workspace'), { code: 'workspace-file/outside-workspace' })
187
+ }
188
+ if (!client?.subs || typeof client.push !== 'function') throw bad('live client required')
189
+ const subId = a.subId
190
+ if (client.subs.has(subId)) throw bad('subscription already exists')
191
+ const ac = new AbortController()
192
+ // Register before any await: cancellation before the reply can still unfollow.
193
+ client.subs.set(subId, ac)
194
+ void (async () => {
195
+ try {
196
+ const scope = await scopeOf(a.sessionId)
197
+ ac.signal.throwIfAborted()
198
+ // The directory API owns canonical containment, including symlinks.
199
+ const listed = await workspaceFiles.list(scope, path, ac.signal)
200
+ ac.signal.throwIfAborted()
201
+ for await (const frame of workspaceFiles.changes(scope, listed.path || '.', ac.signal)) {
202
+ if (ac.signal.aborted) break
203
+ client.push({ ev: 'files', subId, frame: { ...frame, path: listed.path || '.' } })
204
+ }
205
+ if (!ac.signal.aborted) client.push({ ev: 'files.end', subId })
206
+ } catch (error) {
207
+ if (!ac.signal.aborted) client.push({
208
+ ev: 'files.error', subId,
209
+ error: { code: error?.code ?? 'internal', message: String(error?.message ?? error) },
210
+ })
211
+ } finally {
212
+ ac.abort()
213
+ if (client.subs.get(subId) === ac) client.subs.delete(subId)
214
+ }
215
+ })()
216
+ return { subId }
217
+ },
218
+ } : {}),
219
+ 'files.read': async (a, signal) => {
220
+ if (!str(a.path)) throw bad('path required')
221
+ const scope = await scopeOf(a.sessionId)
222
+ return workspaceFiles.read(scope, a.path, a.range, signal)
223
+ },
224
+ /**
225
+ * Binary preview for images / PDF. Host `readBytes` returns a Uint8Array;
226
+ * JSON transport base64-encodes it as `{encoding:'base64', data}`.
227
+ */
228
+ 'files.readBytes': async (a, signal) => {
229
+ if (!str(a.path)) throw bad('path required')
230
+ const scope = await scopeOf(a.sessionId)
231
+ const options = a.options && typeof a.options === 'object' ? a.options : undefined
232
+ const r = await workspaceFiles.readBytes(scope, a.path, options, signal)
233
+ const raw = r?.data
234
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw ?? [])
235
+ return {
236
+ absolutePath: r.absolutePath,
237
+ version: r.version,
238
+ bytes: r.bytes,
239
+ offset: r.offset,
240
+ eof: r.eof,
241
+ encoding: 'base64',
242
+ data: buf.toString('base64'),
243
+ }
244
+ },
245
+ }
246
+ }
247
+
248
+ /**
249
+ * In-process Client→Host uplink for `TerminalController.attach`.
250
+ * Desktop attach is a duplex RemoteStream: Host reads `ctx.invocation.uplink()`.
251
+ */
252
+ function createTerminalUplink() {
253
+ const pending = []
254
+ let notify
255
+ let closed = false
256
+ const wake = () => {
257
+ const resolve = notify
258
+ notify = undefined
259
+ resolve?.()
260
+ }
261
+ const iterator = {
262
+ async next() {
263
+ while (!closed && pending.length === 0) {
264
+ await new Promise((resolve) => { notify = resolve })
265
+ }
266
+ if (pending.length > 0) return { done: false, value: pending.shift() }
267
+ return { done: true, value: undefined }
268
+ },
269
+ async return() {
270
+ closed = true
271
+ wake()
272
+ return { done: true, value: undefined }
273
+ },
274
+ [Symbol.asyncIterator]() { return this },
275
+ }
276
+ return {
277
+ [Symbol.asyncIterator]() { return iterator },
278
+ push(item) {
279
+ if (closed) {
280
+ throw Object.assign(new Error('terminal attachment is closed'), { code: 'terminal/unavailable' })
281
+ }
282
+ pending.push(item)
283
+ wake()
284
+ },
285
+ close() {
286
+ closed = true
287
+ wake()
288
+ },
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Bind the in-process uplink the same way Typert Gateway binds `ctx.invocation`:
294
+ * Gateway calls `receiverContext.extend({ invocation }).get(service)`, so the
295
+ * method reads `this.ctx.invocation` from a call-derived child Context.
296
+ *
297
+ * Never assign `ctx.invocation` directly: typert-protocol registers it on the
298
+ * root as a get-only Cordis accessor, so the Context proxy's set trap returns
299
+ * false and strict-mode code throws
300
+ * "'set' on proxy: trap returned falsish for property 'invocation'".
301
+ */
302
+ function attachWithUplink(controller, agent, id, signal, uplink) {
303
+ const ctx = controller.ctx
304
+ if (ctx === undefined || typeof ctx.extend !== 'function') {
305
+ throw Object.assign(new Error('terminalController.ctx is required for attach'), { code: 'internal' })
306
+ }
307
+ const invocation = { uplink: () => uplink, uplinkWindow: undefined }
308
+ const bound = ctx.extend({ invocation }).get('terminalController')
309
+ if (bound === undefined || typeof bound.attach !== 'function') {
310
+ throw Object.assign(new Error('terminalController is unavailable for attach'), { code: 'terminal/unavailable' })
311
+ }
312
+ return bound.attach(agent, id, signal)
313
+ }
314
+
315
+ /**
316
+ * Desktop window hold: `retain(sessionId, id, signal)` yields `{type:'retained'}`
317
+ * then stays open until cancellation. Attach waits for that acknowledgement.
318
+ */
319
+ function startRetain(controller, sessionId, id, signal) {
320
+ const ready = Promise.withResolvers()
321
+ const done = (async () => {
322
+ try {
323
+ let acknowledged = false
324
+ for await (const frame of controller.retain(sessionId, id, signal)) {
325
+ if (frame?.type !== 'retained') {
326
+ throw Object.assign(new Error('terminal retain yielded an unexpected frame'), { code: 'internal' })
327
+ }
328
+ if (!acknowledged) {
329
+ acknowledged = true
330
+ ready.resolve()
331
+ }
332
+ }
333
+ if (!acknowledged) {
334
+ throw Object.assign(new Error('terminal retain ended without acknowledgement'), { code: 'terminal/unavailable' })
335
+ }
336
+ } catch (error) {
337
+ ready.reject(error)
338
+ throw error
339
+ }
340
+ })()
341
+ return { ready: ready.promise, done }
342
+ }
343
+
344
+ function sendTerminalInput(attachment, command) {
345
+ const sequence = ++attachment.nextSeq
346
+ const done = Promise.withResolvers()
347
+ attachment.acks.set(sequence, done)
348
+ try {
349
+ attachment.send({ ...command, sequence })
350
+ } catch (error) {
351
+ attachment.acks.delete(sequence)
352
+ throw error
353
+ }
354
+ return done.promise
355
+ }
356
+
357
+ function failAttachment(attachment, error) {
358
+ attachment.ready.reject(error)
359
+ for (const waiter of attachment.acks.values()) waiter.reject(error)
360
+ attachment.acks.clear()
361
+ }
362
+
363
+ /**
364
+ * Host shell on the desktop machine (`terminalController`). Remote PTY is useful
365
+ * on the phone; only local-app/editor launchers stay hidden.
366
+ *
367
+ * Desktop `TerminalController` (0.2.1-alpha.5+) exposes `retain` / `attach`, not
368
+ * `follow` / `write` / `resize`. Pocket `terminal.follow` still returns
369
+ * `{subId, attachmentId}` immediately; frames arrive as `{ev:"terminal"}`.
370
+ * Write/resize ride the attach uplink as sequenced `{type:'write'|'resize'}`.
371
+ * Cancel with the existing `unfollow {subId}` (shared abort map on the WS client).
372
+ */
373
+ export function terminalOps(terminalController, agents) {
374
+ const attachments = new Map()
375
+ const requireAttachment = (a) => {
376
+ if (!str(a.id) || !str(a.attachmentId)) throw bad('sessionId, id, attachmentId required')
377
+ const attachment = attachments.get(a.attachmentId)
378
+ if (attachment === undefined || attachment.sessionId !== a.sessionId || attachment.id !== a.id) {
379
+ throw Object.assign(new Error('terminal attachment is not attached'), { code: 'terminal/unavailable' })
380
+ }
381
+ return attachment
382
+ }
383
+ return {
384
+ 'terminal.list': (a) => {
385
+ if (!str(a.sessionId)) throw bad('sessionId required')
386
+ return { terminals: terminalController.list(a.sessionId) ?? [], platform: process.platform }
387
+ },
388
+ 'terminal.create': async (a, signal) => {
389
+ const agent = agentOf(agents, a.sessionId)
390
+ const id = str(a.id) ? a.id : randomUUID().replace(/-/g, '').slice(0, 16)
391
+ const cols = Number.isSafeInteger(a.cols) ? a.cols : 80
392
+ const rows = Number.isSafeInteger(a.rows) ? a.rows : 24
393
+ const info = await terminalController.create(agent, {
394
+ id, cols, rows,
395
+ ...(str(a.shellPath) ? { shellPath: a.shellPath } : {}),
396
+ }, signal)
397
+ return { terminal: info, platform: process.platform }
398
+ },
399
+ 'terminal.write': async (a) => {
400
+ agentOf(agents, a.sessionId)
401
+ if (typeof a.data !== 'string') throw bad('sessionId, id, attachmentId, data required')
402
+ const attachment = requireAttachment(a)
403
+ await attachment.ready.promise
404
+ await sendTerminalInput(attachment, { type: 'write', data: a.data })
405
+ return { accepted: true }
406
+ },
407
+ 'terminal.resize': async (a) => {
408
+ agentOf(agents, a.sessionId)
409
+ const attachment = requireAttachment(a)
410
+ const cols = Number.isSafeInteger(a.cols) ? a.cols : 80
411
+ const rows = Number.isSafeInteger(a.rows) ? a.rows : 24
412
+ await attachment.ready.promise
413
+ await sendTerminalInput(attachment, { type: 'resize', cols, rows })
414
+ return { accepted: true }
415
+ },
416
+ 'terminal.close': async (a) => {
417
+ const agent = agentOf(agents, a.sessionId)
418
+ if (!str(a.id)) throw bad('id required')
419
+ await terminalController.close(agent, a.id)
420
+ return { closed: true }
421
+ },
422
+ 'terminal.follow': async (a, _signal, client) => {
423
+ const agent = agentOf(agents, a.sessionId)
424
+ if (!str(a.id)) throw bad('id required')
425
+ if (!client?.subs || typeof client.push !== 'function') {
426
+ throw Object.assign(new Error('terminal.follow requires a live pocket client'), { code: 'internal' })
427
+ }
428
+ const attachmentId = str(a.attachmentId) ? a.attachmentId : randomUUID().replace(/-/g, '').slice(0, 16)
429
+ const subId = `t${client.nextSub++}`
430
+ const ac = new AbortController()
431
+ const uplink = createTerminalUplink()
432
+ const attachment = {
433
+ sessionId: a.sessionId,
434
+ id: a.id,
435
+ nextSeq: 0,
436
+ acks: new Map(),
437
+ send: (item) => uplink.push(item),
438
+ ready: Promise.withResolvers(),
439
+ }
440
+ attachment.ready.promise.catch(() => {})
441
+ attachments.set(attachmentId, attachment)
442
+ client.subs.set(subId, ac)
443
+ const hold = startRetain(terminalController, a.sessionId, a.id, ac.signal)
444
+ void (async () => {
445
+ try {
446
+ await hold.ready
447
+ ac.signal.throwIfAborted()
448
+ const stream = attachWithUplink(terminalController, agent, a.id, ac.signal, uplink)
449
+ attachment.ready.resolve()
450
+ for await (const frame of stream) {
451
+ if (ac.signal.aborted) break
452
+ if (frame?.type === 'ack') {
453
+ const waiter = attachment.acks.get(frame.sequence)
454
+ if (waiter === undefined) {
455
+ throw Object.assign(new Error('terminal input acknowledgement is out of order'), { code: 'internal' })
456
+ }
457
+ attachment.acks.delete(frame.sequence)
458
+ if (frame.refusal !== undefined) {
459
+ waiter.reject(Object.assign(new Error(`terminal input refused: ${frame.refusal}`), {
460
+ code: 'terminal/control-unavailable',
461
+ details: { reason: frame.refusal },
462
+ }))
463
+ } else {
464
+ waiter.resolve()
465
+ }
466
+ continue
467
+ }
468
+ client.push({ ev: 'terminal', subId, frame })
469
+ }
470
+ if (!ac.signal.aborted) client.push({ ev: 'terminal.end', subId })
471
+ } catch (error) {
472
+ failAttachment(attachment, error)
473
+ if (!ac.signal.aborted) {
474
+ client.push({
475
+ ev: 'terminal.error', subId,
476
+ error: { code: error?.code ?? 'internal', message: String(error?.message ?? error) },
477
+ })
478
+ }
479
+ } finally {
480
+ ac.abort()
481
+ uplink.close()
482
+ failAttachment(attachment, Object.assign(new Error('terminal attachment is closed'), { code: 'terminal/unavailable' }))
483
+ attachments.delete(attachmentId)
484
+ client.subs.delete(subId)
485
+ await hold.done.catch(() => {})
486
+ }
487
+ })()
488
+ return { subId, attachmentId }
489
+ },
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Background jobs roster for one session. Host `job.list` is a stream; we take
495
+ * the first `rows` frame then abort (whole-set replacement).
496
+ */
497
+ export function jobsOps(jobController) {
498
+ return {
499
+ 'jobs.follow': (a, _signal, client) => {
500
+ if (!str(a.sessionId) || !str(a.jobId)) throw bad('sessionId and jobId required')
501
+ if (a.from !== undefined && (!Number.isSafeInteger(a.from) || a.from < 0)) throw bad('invalid output offset')
502
+ const subId = `j${client.nextSub++}`
503
+ const ac = new AbortController()
504
+ client.subs.set(subId, ac)
505
+ void (async () => {
506
+ try {
507
+ for await (const frame of jobController.follow(a, ac.signal)) {
508
+ if (ac.signal.aborted) break
509
+ client.push({ ev: 'job', subId, frame })
510
+ }
511
+ if (!ac.signal.aborted) client.push({ ev: 'job.end', subId })
512
+ } catch (error) {
513
+ if (!ac.signal.aborted) client.push({ ev: 'job.error', subId, error: { code: error?.code ?? 'internal', message: String(error?.message ?? error) } })
514
+ } finally { client.subs.delete(subId) }
515
+ })()
516
+ return { subId }
517
+ },
518
+ 'jobs.list': async (a, signal) => {
519
+ if (!str(a.sessionId)) throw bad('sessionId required')
520
+ const ac = new AbortController()
521
+ const onAbort = () => ac.abort()
522
+ signal?.addEventListener?.('abort', onAbort, { once: true })
523
+ const unavailable = (message, cause) => Object.assign(new Error(message), {
524
+ code: 'jobs/unavailable',
525
+ ...(cause === undefined ? {} : { cause }),
526
+ })
527
+ let jobs
528
+ try {
529
+ if (signal?.aborted) throw unavailable('jobs list timed out or was aborted')
530
+ for await (const frame of jobController.list({ sessionId: a.sessionId }, ac.signal)) {
531
+ if (signal?.aborted) throw unavailable('jobs list timed out or was aborted')
532
+ if (frame?.type === 'rows') {
533
+ jobs = Array.isArray(frame.jobs) ? frame.jobs : []
534
+ ac.abort()
535
+ break
536
+ }
537
+ }
538
+ } catch (error) {
539
+ if (jobs !== undefined) {
540
+ // Roster already captured; aborting the watch is the stop condition.
541
+ } else if (error?.code === 'jobs/unavailable') {
542
+ throw error
543
+ } else if (signal?.aborted) {
544
+ throw unavailable('jobs list timed out or was aborted', error)
545
+ } else {
546
+ throw error
547
+ }
548
+ } finally {
549
+ signal?.removeEventListener?.('abort', onAbort)
550
+ if (!ac.signal.aborted) ac.abort()
551
+ }
552
+ if (jobs === undefined) throw unavailable('jobs list ended without a rows frame')
553
+ return { jobs }
554
+ },
555
+ 'jobs.kill': (a) => {
556
+ if (!str(a.sessionId) || !str(a.jobId)) throw bad('sessionId and jobId required')
557
+ return jobController.kill({ sessionId: a.sessionId, jobId: a.jobId })
558
+ },
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Session log export: walk `sessionController.page` into JSONL and ZIP it
564
+ * (single entry). Matches desktop download intent without Host cookie auth.
565
+ */
566
+ export function exportOps(sc, { zipSync, strToU8 } = {}) {
567
+ if (typeof zipSync !== 'function' || typeof strToU8 !== 'function') {
568
+ throw new Error('exportOps requires fflate zipSync and strToU8')
569
+ }
570
+ return {
571
+ 'session.export': async (a, signal) => {
572
+ if (!str(a.sessionId)) throw bad('sessionId required')
573
+ const sessionId = a.sessionId
574
+ const includeDescendants = a.includeDescendants !== false
575
+ const lines = []
576
+ let throughSeq = Number.MAX_SAFE_INTEGER
577
+ let guard = 0
578
+ while (guard++ < 200) {
579
+ signal?.throwIfAborted?.()
580
+ const page = await sc.page({
581
+ address: { kind: 'session', sessionId },
582
+ throughSeq,
583
+ maxMessages: 200,
584
+ }, signal)
585
+ const records = page?.records ?? []
586
+ for (const rec of records) lines.push(JSON.stringify(rec))
587
+ if (!page?.hasMore || records.length === 0) break
588
+ const seqs = records.map((r) => r?.event?.seq).filter((n) => Number.isSafeInteger(n))
589
+ const min = seqs.length ? Math.min(...seqs) : null
590
+ if (min === null || min <= 0) break
591
+ throughSeq = min - 1
592
+ }
593
+ const body = `${lines.join('\n')}${lines.length ? '\n' : ''}`
594
+ const filename = `dsh-session-${sessionId.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80)}.zip`
595
+ const zipped = zipSync({ [`${sessionId}.jsonl`]: strToU8(body) }, { level: 6 })
596
+ return {
597
+ filename,
598
+ mediaType: 'application/zip',
599
+ includeDescendants,
600
+ data: Buffer.from(zipped).toString('base64'),
601
+ }
602
+ },
603
+ }
604
+ }