@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/extra-ops.js
ADDED
|
@@ -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
|
+
}
|