@soffits/dsh-deltachat-bot 0.0.0-stage → 0.2.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +653 -0
- package/src/commands.js +454 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/sessions.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DSH side of a chat: creating, resuming and prompting sessions through
|
|
3
|
+
* the same Host services the Web UI uses (`sessionController`), with the raw
|
|
4
|
+
* agent registry as the headless fallback.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { basename } from 'node:path'
|
|
8
|
+
import { REQUEST_PREFIX } from './constants.js'
|
|
9
|
+
|
|
10
|
+
/** @returns the Web session controller, when this deployment mounts it. */
|
|
11
|
+
export function controllerOf(ctx) {
|
|
12
|
+
const controller = ctx.get('sessionController')
|
|
13
|
+
return typeof controller?.create === 'function' && typeof controller?.resolveAgent === 'function' ? controller : undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The live agent of a session, resuming it when needed.
|
|
18
|
+
* @param ctx - Host context.
|
|
19
|
+
* @param sessionId - session id.
|
|
20
|
+
* @returns the agent; throws when the session cannot be resumed.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveAgent(ctx, sessionId) {
|
|
23
|
+
const agents = ctx.get('agents')
|
|
24
|
+
const live = agents?.get(sessionId)
|
|
25
|
+
if (live !== undefined) return live
|
|
26
|
+
const controller = controllerOf(ctx)
|
|
27
|
+
if (controller !== undefined) {
|
|
28
|
+
const found = await controller.resolveAgent(sessionId)
|
|
29
|
+
if (found?.error !== undefined) throw found.error
|
|
30
|
+
const agent = found?.agent ?? agents?.get(sessionId)
|
|
31
|
+
if (agent === undefined) throw new Error(`session ${sessionId} has no live agent`)
|
|
32
|
+
return agent
|
|
33
|
+
}
|
|
34
|
+
if (agents === undefined) throw new Error('dsh-agent is not mounted in this profile')
|
|
35
|
+
const resumed = await agents.resume({ resumeSessionId: sessionId })
|
|
36
|
+
if (resumed?.agent === undefined) throw new Error(`session ${sessionId} could not be resumed`)
|
|
37
|
+
return resumed.agent
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attach a session to the DSH workspace of its directory, creating the
|
|
42
|
+
* workspace when needed, so it shows up in the Web UI sidebar.
|
|
43
|
+
* @param ctx - Host context.
|
|
44
|
+
* @param sessionId - session id.
|
|
45
|
+
* @param cwd - session directory.
|
|
46
|
+
* @param warn - logger.
|
|
47
|
+
*/
|
|
48
|
+
async function attachToWorkspace(ctx, sessionId, cwd, warn) {
|
|
49
|
+
const registry = ctx.get('workspaceRegistry')
|
|
50
|
+
if (registry === undefined) return
|
|
51
|
+
try {
|
|
52
|
+
let workspace = registry.list().find((entry) => entry.path === cwd)
|
|
53
|
+
if (workspace === undefined) workspace = await registry.create(cwd)
|
|
54
|
+
await workspace.attachSession(sessionId)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
warn(`workspace attach failed for ${sessionId}: ${error?.message ?? error}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* End a new session's blank state with an empty turn that never reaches a
|
|
62
|
+
* model. DSH hands a blank session (one that never started a turn) to the
|
|
63
|
+
* next New conversation in the Web UI; a chat's session must not be taken
|
|
64
|
+
* that way. The marker message is rejected at `agent/pre-step`, the same gate
|
|
65
|
+
* DSH uses for archived sessions, so the turn ends as blocked and the marker
|
|
66
|
+
* is never recorded.
|
|
67
|
+
* @param agent - the new session's agent.
|
|
68
|
+
* @param chatId - Delta Chat chat id, for the marker text.
|
|
69
|
+
*/
|
|
70
|
+
async function claimSession(agent, chatId) {
|
|
71
|
+
if (typeof agent?.followup !== 'function' || typeof agent?.ctx?.on !== 'function') return
|
|
72
|
+
const id = randomUUID()
|
|
73
|
+
const dispose = agent.ctx.on('agent/pre-step', (payload, next) => {
|
|
74
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : []
|
|
75
|
+
if (messages.length > 0 && messages.every((message) => message?.id === id)) return Promise.resolve({ kind: 'reject' })
|
|
76
|
+
return next()
|
|
77
|
+
})
|
|
78
|
+
agent.followup({
|
|
79
|
+
id,
|
|
80
|
+
role: 'user',
|
|
81
|
+
content: [{ type: 'text', text: `[This session belongs to Delta Chat chat ${chatId}]` }],
|
|
82
|
+
source: { kind: 'user', rpcId: `deltachat-claim:${id}` },
|
|
83
|
+
})
|
|
84
|
+
const idle = typeof agent.whenIdle === 'function' ? agent.whenIdle() : Promise.resolve()
|
|
85
|
+
void idle.catch(() => undefined).finally(() => { if (typeof dispose === 'function') dispose() })
|
|
86
|
+
let timer
|
|
87
|
+
await Promise.race([
|
|
88
|
+
idle.catch(() => undefined),
|
|
89
|
+
new Promise((resolveWait) => { timer = setTimeout(resolveWait, 10_000) }),
|
|
90
|
+
])
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a session for a chat, composed the way the Web UI composes one.
|
|
96
|
+
* @param ctx - Host context.
|
|
97
|
+
* @param options - `{ cwd, preset, model, chatId, claim, warn }`; `claim` ends
|
|
98
|
+
* the blank state at once (for sessions that will not get a prompt now).
|
|
99
|
+
* @returns the new session's agent.
|
|
100
|
+
*/
|
|
101
|
+
export async function createSession(ctx, { cwd, preset, model, chatId, claim, warn }) {
|
|
102
|
+
const controller = controllerOf(ctx)
|
|
103
|
+
let agent
|
|
104
|
+
if (controller !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
const created = await controller.create({ cwd, ...(preset ? { agentPreset: preset } : {}) })
|
|
107
|
+
agent = await resolveAgent(ctx, created.sessionId)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
warn(`sessionController create failed, using the agent registry: ${error?.message ?? error}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (agent === undefined) {
|
|
113
|
+
const agents = ctx.get('agents')
|
|
114
|
+
if (agents === undefined) throw new Error('dsh-agent is not mounted in this profile')
|
|
115
|
+
const meta = { cwd, ...(preset && ctx.get('agentPresets') !== undefined ? { agentPreset: preset } : {}) }
|
|
116
|
+
const presets = ctx.get('agentPresets')
|
|
117
|
+
const setup = preset && presets !== undefined
|
|
118
|
+
? async (agentCtx) => {
|
|
119
|
+
try {
|
|
120
|
+
await presets.mount(agentCtx, preset)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
warn(`preset ${preset} not mounted: ${error?.message ?? error}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
: undefined
|
|
126
|
+
agent = (await agents.create({ sessionId: `session-${randomUUID()}`, meta, ...(setup ? { setup } : {}) })).agent
|
|
127
|
+
}
|
|
128
|
+
const sessionId = agent.session?.id ?? agent.id
|
|
129
|
+
if (model !== undefined) {
|
|
130
|
+
try {
|
|
131
|
+
agent.model.select(model)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
warn(`could not start ${sessionId} on ${model.provider}/${model.model}: ${error?.message ?? error}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (claim) await claimSession(agent, chatId)
|
|
137
|
+
await attachToWorkspace(ctx, sessionId, cwd, warn)
|
|
138
|
+
return agent
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Bring an archived session back before a chat message starts a turn in it:
|
|
143
|
+
* DSH admits prompts into archived sessions but rejects every model step.
|
|
144
|
+
* @param ctx - Host context.
|
|
145
|
+
* @param sessionId - session id.
|
|
146
|
+
* @returns true when the session was unarchived.
|
|
147
|
+
*/
|
|
148
|
+
export async function unarchive(ctx, sessionId) {
|
|
149
|
+
const registry = ctx.get('workspaceRegistry')
|
|
150
|
+
if (registry?.archivedSessionIds?.includes(sessionId) !== true || typeof registry.unarchiveSession !== 'function') return false
|
|
151
|
+
await registry.unarchiveSession(sessionId)
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @returns whether a message the agent has not taken yet carries this request id. */
|
|
156
|
+
export function inInbox(agent, requestId) {
|
|
157
|
+
const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
|
|
158
|
+
const inbox = agent?.inbox
|
|
159
|
+
return inbox?.nextTurn?.some?.(matches) === true || inbox?.nextStep?.some?.(matches) === true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @returns whether a pending or recorded message carries this request id. */
|
|
163
|
+
export function hasRequest(agent, requestId) {
|
|
164
|
+
if (inInbox(agent, requestId)) return true
|
|
165
|
+
const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
|
|
166
|
+
const events = typeof agent?.session?.snapshotEvents === 'function' ? agent.session.snapshotEvents() : []
|
|
167
|
+
return events.some((event) => event?.type === 'user/message' && matches(event.data))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Hand one prompt to a session. Text goes through the Session controller's
|
|
172
|
+
* `prompt`, the Web UI's own path; prompts with stored attachments go to the
|
|
173
|
+
* agent's `followup` (queue) or `steer` (into the running turn), the methods
|
|
174
|
+
* that path calls. A request id already admitted is skipped, so a message
|
|
175
|
+
* handled twice (a restart between admission and marking it seen) reaches the
|
|
176
|
+
* agent once.
|
|
177
|
+
* @param ctx - Host context.
|
|
178
|
+
* @param agent - the session's agent.
|
|
179
|
+
* @param prompt - `{ requestId, text, attachments, mode }`; `mode` is `queue` or `steer`.
|
|
180
|
+
* @param signal - request lifetime.
|
|
181
|
+
* @returns whether the prompt was handed over (false for a duplicate or an empty one).
|
|
182
|
+
*/
|
|
183
|
+
export async function admitPrompt(ctx, agent, { requestId, text, attachments = [], mode }, signal) {
|
|
184
|
+
if (hasRequest(agent, requestId)) return false
|
|
185
|
+
const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
|
|
186
|
+
if (content.length === 0) return false
|
|
187
|
+
const controller = ctx.get('sessionController')
|
|
188
|
+
const sessionId = agent.session?.id ?? agent.id
|
|
189
|
+
if (attachments.length === 0 && typeof controller?.prompt === 'function') {
|
|
190
|
+
await controller.prompt({ sessionId, requestId, mode, content }, signal)
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
|
|
194
|
+
if (mode === 'steer' && typeof agent.steer === 'function') agent.steer(message)
|
|
195
|
+
else if (typeof agent.followup === 'function') agent.followup(message)
|
|
196
|
+
else agent.send(message, mode === 'steer' ? 'next-step' : 'next-turn', true)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Remove prompts from one chat that the agent has not taken yet.
|
|
202
|
+
* @param agent - the session's agent.
|
|
203
|
+
* @param chatId - Delta Chat chat id.
|
|
204
|
+
* @returns how many were removed.
|
|
205
|
+
*/
|
|
206
|
+
export function clearChatInbox(agent, chatId) {
|
|
207
|
+
const inbox = agent?.inbox
|
|
208
|
+
if (typeof inbox?.remove !== 'function') return 0
|
|
209
|
+
const prefix = `${REQUEST_PREFIX}${chatId}:`
|
|
210
|
+
const ours = (message) => message?.source?.kind === 'user' && typeof message.source.rpcId === 'string' && message.source.rpcId.startsWith(prefix)
|
|
211
|
+
const pending = [...(inbox.nextStep ?? []), ...(inbox.nextTurn ?? [])].filter(ours)
|
|
212
|
+
for (const message of pending) inbox.remove(message.id)
|
|
213
|
+
return pending.length
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @returns whether the agent still holds input it has not taken. */
|
|
217
|
+
export function hasPendingInput(agent) {
|
|
218
|
+
const inbox = agent?.inbox
|
|
219
|
+
if (inbox === undefined) return false
|
|
220
|
+
if (typeof inbox.hasPending === 'boolean') return inbox.hasPending
|
|
221
|
+
return (inbox.nextStep?.length ?? 0) > 0 || (inbox.nextTurn?.length ?? 0) > 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether a session's first surface node is its system message, the only
|
|
226
|
+
* state in which a message may be appended outside a step.
|
|
227
|
+
* @param session - DSH session.
|
|
228
|
+
*/
|
|
229
|
+
function hasSystemHead(session) {
|
|
230
|
+
try {
|
|
231
|
+
const nodes = session.surface?.nodes
|
|
232
|
+
if (nodes !== undefined) {
|
|
233
|
+
if (nodes.length === 0) return false
|
|
234
|
+
const event = typeof session.eventAt === 'function'
|
|
235
|
+
? session.eventAt(nodes[0])
|
|
236
|
+
: session.snapshotEvents?.().find((item) => item.seq === nodes[0])
|
|
237
|
+
return event?.type === 'system/message'
|
|
238
|
+
}
|
|
239
|
+
const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events
|
|
240
|
+
if (!Array.isArray(events)) return false
|
|
241
|
+
const surfaceTypes = new Set(['system/message', 'user/message', 'developer/message', 'assistant/message', 'tool/result'])
|
|
242
|
+
return events.find((event) => surfaceTypes.has(event.type))?.type === 'system/message'
|
|
243
|
+
} catch {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Put a message into a session without starting a turn: appended now to an
|
|
250
|
+
* idle session, otherwise queued for its next step. The Web UI shows it and
|
|
251
|
+
* the model reads it at its next step.
|
|
252
|
+
* @param agent - the session's agent.
|
|
253
|
+
* @param message - a user-role message (`{ id, role, content, source }`).
|
|
254
|
+
*/
|
|
255
|
+
export function recordMessage(agent, message) {
|
|
256
|
+
if (agent.status === 'idle' && typeof agent.session?.append === 'function' && hasSystemHead(agent.session)) {
|
|
257
|
+
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
258
|
+
} else if (typeof agent.inject === 'function') {
|
|
259
|
+
agent.inject(message)
|
|
260
|
+
} else if (typeof agent.send === 'function') {
|
|
261
|
+
agent.send(message, 'next-step', false)
|
|
262
|
+
} else {
|
|
263
|
+
throw new Error('The session cannot record messages.')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A session's title, or a short fallback.
|
|
269
|
+
* @param ctx - Host context.
|
|
270
|
+
* @param sessionId - session id.
|
|
271
|
+
* @param header - session header when known.
|
|
272
|
+
*/
|
|
273
|
+
export async function sessionTitle(ctx, sessionId, header) {
|
|
274
|
+
try {
|
|
275
|
+
const snapshot = await ctx.get('sessionQuery')?.readTitleSnapshot(sessionId)
|
|
276
|
+
const title = snapshot?.title?.title
|
|
277
|
+
if (typeof title === 'string' && title.trim().length > 0) return title.trim()
|
|
278
|
+
} catch {
|
|
279
|
+
// No title yet.
|
|
280
|
+
}
|
|
281
|
+
const base = typeof header?.cwd === 'string' && header.cwd.length > 0 ? basename(header.cwd) : 'Session'
|
|
282
|
+
return `${base} · ${String(sessionId).slice(-6)}`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The latest assistant texts of a session, newest first.
|
|
287
|
+
* @param ctx - Host context.
|
|
288
|
+
* @param sessionId - session id.
|
|
289
|
+
* @param limit - most texts.
|
|
290
|
+
*/
|
|
291
|
+
export async function recentAssistantTexts(ctx, sessionId, limit) {
|
|
292
|
+
const texts = []
|
|
293
|
+
try {
|
|
294
|
+
const surface = await ctx.get('sessionQuery')?.readSurface(sessionId)
|
|
295
|
+
const events = surface?.events ?? []
|
|
296
|
+
for (let index = events.length - 1; index >= 0 && texts.length < limit; index -= 1) {
|
|
297
|
+
const event = events[index]
|
|
298
|
+
if (event?.type !== 'assistant/message') continue
|
|
299
|
+
const message = event.data?.message ?? event.data
|
|
300
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
301
|
+
const text = content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n').trim()
|
|
302
|
+
if (text.length > 0) texts.push(text)
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// An empty result is reported by the caller.
|
|
306
|
+
}
|
|
307
|
+
return texts
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @returns the registered workspaces, or an empty list. */
|
|
311
|
+
export function listWorkspaces(ctx) {
|
|
312
|
+
try {
|
|
313
|
+
return ctx.get('workspaceRegistry')?.list() ?? []
|
|
314
|
+
} catch {
|
|
315
|
+
return []
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Recent ordinary sessions of a workspace, newest first.
|
|
321
|
+
* @param ctx - Host context.
|
|
322
|
+
* @param workspace - workspace record.
|
|
323
|
+
* @param limit - most sessions.
|
|
324
|
+
* @param signal - request lifetime.
|
|
325
|
+
*/
|
|
326
|
+
export async function sessionsInWorkspace(ctx, workspace, limit, signal) {
|
|
327
|
+
const query = ctx.get('sessionQuery')
|
|
328
|
+
if (query === undefined) return []
|
|
329
|
+
const archived = new Set(ctx.get('workspaceRegistry')?.archivedSessionIds ?? [])
|
|
330
|
+
const records = await query.listSessions(signal)
|
|
331
|
+
return records
|
|
332
|
+
.filter((record) => !archived.has(record.header.id))
|
|
333
|
+
.filter((record) => record.header?.origin !== 'subagent')
|
|
334
|
+
.filter((record) => (workspace.sessionIds ?? []).includes(record.header.id) || record.header.cwd === workspace.path)
|
|
335
|
+
.sort((left, right) => (right.header.createdAt ?? 0) - (left.header.createdAt ?? 0))
|
|
336
|
+
.slice(0, limit)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The access mode (permission preset) of a session.
|
|
341
|
+
* @param ctx - Host context.
|
|
342
|
+
* @param agent - live agent, or undefined.
|
|
343
|
+
* @returns the preset value, or undefined when unknown.
|
|
344
|
+
*/
|
|
345
|
+
export function currentPermission(ctx, agent) {
|
|
346
|
+
const service = ctx.get('permissionPresets')
|
|
347
|
+
try {
|
|
348
|
+
if (agent !== undefined && typeof service?.current === 'function') return service.current(agent.session)
|
|
349
|
+
} catch {
|
|
350
|
+
// Unknown.
|
|
351
|
+
}
|
|
352
|
+
return service?.defaultPreset
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Access modes offered by this deployment.
|
|
357
|
+
* @param ctx - Host context.
|
|
358
|
+
* @returns `{ value, name }` entries.
|
|
359
|
+
*/
|
|
360
|
+
export function permissionOptions(ctx) {
|
|
361
|
+
try {
|
|
362
|
+
const options = ctx.get('permissionPresets')?.catalog()?.options
|
|
363
|
+
if (Array.isArray(options) && options.length > 0) return options.map((option) => ({ value: option.value, name: option.name ?? option.value }))
|
|
364
|
+
} catch {
|
|
365
|
+
// Fall through.
|
|
366
|
+
}
|
|
367
|
+
return [{ value: 'workspace-write', name: 'workspace-write' }, { value: 'danger-full-access', name: 'danger-full-access' }]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Set a session's access mode.
|
|
372
|
+
* @param ctx - Host context.
|
|
373
|
+
* @param agent - live agent.
|
|
374
|
+
* @param value - preset value.
|
|
375
|
+
*/
|
|
376
|
+
export function setPermission(ctx, agent, value) {
|
|
377
|
+
const service = ctx.get('permissionPresets')
|
|
378
|
+
if (typeof service?.set === 'function') {
|
|
379
|
+
service.set(agent.session, value)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
const specs = { 'workspace-write': ['workspace-write', 'ask'], 'danger-full-access': ['danger-full-access', 'never'], 'read-only': ['read-only', 'ask'] }
|
|
383
|
+
const spec = specs[value]
|
|
384
|
+
if (spec === undefined) throw new Error(`unknown access mode ${value}`)
|
|
385
|
+
agent.session.append('permission/preset', { preset: value })
|
|
386
|
+
agent.session.append('sandbox/mode', { mode: spec[0] })
|
|
387
|
+
agent.session.append('approval/policy', { policy: spec[1] })
|
|
388
|
+
}
|
package/src/spool.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File spool for requests from outside the Host process (the `dsh-deltachat`
|
|
3
|
+
* command): a sender writes one request file, the running plugin handles it
|
|
4
|
+
* and writes a result file next to it. Senders need no network access to the
|
|
5
|
+
* Host, only write access to the DSH home, and requests wait while the bot is
|
|
6
|
+
* stopped.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import { watch } from 'node:fs'
|
|
10
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { spoolDir } from './constants.js'
|
|
13
|
+
|
|
14
|
+
/** Result files older than this are removed by the plugin. */
|
|
15
|
+
const RESULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write one request atomically.
|
|
19
|
+
* @param request - `{ op, ... }`.
|
|
20
|
+
* @param dir - spool directory.
|
|
21
|
+
* @returns the request id.
|
|
22
|
+
*/
|
|
23
|
+
export async function enqueue(request, dir = spoolDir()) {
|
|
24
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
25
|
+
const id = `${Date.now()}-${randomUUID().slice(0, 8)}`
|
|
26
|
+
const temporary = join(dir, `${id}.tmp`)
|
|
27
|
+
await writeFile(temporary, JSON.stringify({ ...request, id, createdAt: Date.now() }) + '\n', { mode: 0o600 })
|
|
28
|
+
await rename(temporary, join(dir, `${id}.json`))
|
|
29
|
+
return id
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wait for the plugin's result for one request.
|
|
34
|
+
* @param id - request id from {@link enqueue}.
|
|
35
|
+
* @param timeoutMs - how long to wait.
|
|
36
|
+
* @param dir - spool directory.
|
|
37
|
+
* @returns the result document, or undefined after the timeout.
|
|
38
|
+
*/
|
|
39
|
+
export async function waitForResult(id, timeoutMs, dir = spoolDir()) {
|
|
40
|
+
const path = join(dir, `${id}.result.json`)
|
|
41
|
+
const deadline = Date.now() + timeoutMs
|
|
42
|
+
for (;;) {
|
|
43
|
+
try {
|
|
44
|
+
const result = JSON.parse(await readFile(path, 'utf8'))
|
|
45
|
+
await rm(path, { force: true })
|
|
46
|
+
return result
|
|
47
|
+
} catch {
|
|
48
|
+
// Not written yet.
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() >= deadline) return undefined
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a request that was never picked up.
|
|
57
|
+
* @param id - request id.
|
|
58
|
+
* @param dir - spool directory.
|
|
59
|
+
* @returns true when it was still waiting and is gone now.
|
|
60
|
+
*/
|
|
61
|
+
export async function withdraw(id, dir = spoolDir()) {
|
|
62
|
+
try {
|
|
63
|
+
await rm(join(dir, `${id}.json`))
|
|
64
|
+
return true
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Plugin side: handle spooled requests one at a time.
|
|
72
|
+
* @param options - `{ handle, ready, onError, dir, intervalMs }`: `handle`
|
|
73
|
+
* resolves one request to its result; `ready` says whether requests can be
|
|
74
|
+
* handled now.
|
|
75
|
+
* @returns `{ start, stop, drain }`.
|
|
76
|
+
*/
|
|
77
|
+
export function createSpool({ handle, ready, onError, dir = spoolDir(), intervalMs = 1000 }) {
|
|
78
|
+
let timer
|
|
79
|
+
let watcher
|
|
80
|
+
let draining
|
|
81
|
+
let again = false
|
|
82
|
+
let running = false
|
|
83
|
+
|
|
84
|
+
async function recover() {
|
|
85
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
86
|
+
if (name.endsWith('.processing')) {
|
|
87
|
+
await rename(join(dir, name), join(dir, name.replace(/\.processing$/u, '.json'))).catch(() => {})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function sweep(names) {
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (!name.endsWith('.result.json')) continue
|
|
95
|
+
const path = join(dir, name)
|
|
96
|
+
const info = await stat(path).catch(() => undefined)
|
|
97
|
+
if (info !== undefined && Date.now() - info.mtimeMs > RESULT_TTL_MS) await rm(path, { force: true })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drainOnce() {
|
|
102
|
+
if (!ready()) return
|
|
103
|
+
const names = (await readdir(dir).catch(() => [])).sort()
|
|
104
|
+
await sweep(names)
|
|
105
|
+
for (const name of names) {
|
|
106
|
+
if (!name.endsWith('.json') || name.endsWith('.result.json')) continue
|
|
107
|
+
if (!ready()) return
|
|
108
|
+
const id = name.slice(0, -'.json'.length)
|
|
109
|
+
const claimed = join(dir, `${id}.processing`)
|
|
110
|
+
try {
|
|
111
|
+
await rename(join(dir, name), claimed)
|
|
112
|
+
} catch {
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
let outcome
|
|
116
|
+
try {
|
|
117
|
+
const request = JSON.parse(await readFile(claimed, 'utf8'))
|
|
118
|
+
outcome = { ok: true, result: await handle(request) }
|
|
119
|
+
} catch (error) {
|
|
120
|
+
outcome = { ok: false, error: typeof error?.message === 'string' ? error.message : String(error) }
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(join(dir, `${id}.result.json`), JSON.stringify(outcome) + '\n', { mode: 0o600 })
|
|
124
|
+
await rm(claimed, { force: true })
|
|
125
|
+
} catch (error) {
|
|
126
|
+
onError(error)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function drain() {
|
|
132
|
+
if (draining !== undefined) {
|
|
133
|
+
again = true
|
|
134
|
+
return draining
|
|
135
|
+
}
|
|
136
|
+
draining = (async () => {
|
|
137
|
+
do {
|
|
138
|
+
again = false
|
|
139
|
+
await drainOnce()
|
|
140
|
+
} while (again)
|
|
141
|
+
})().catch(onError).finally(() => { draining = undefined })
|
|
142
|
+
return draining
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function start() {
|
|
146
|
+
if (running) return
|
|
147
|
+
running = true
|
|
148
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
149
|
+
await recover()
|
|
150
|
+
// Stopped while getting ready.
|
|
151
|
+
if (!running) return
|
|
152
|
+
try {
|
|
153
|
+
watcher = watch(dir, () => { void drain() })
|
|
154
|
+
watcher.on('error', () => {})
|
|
155
|
+
} catch {
|
|
156
|
+
// The interval still picks requests up.
|
|
157
|
+
}
|
|
158
|
+
timer = setInterval(() => { void drain() }, intervalMs)
|
|
159
|
+
timer.unref?.()
|
|
160
|
+
void drain()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stop() {
|
|
164
|
+
running = false
|
|
165
|
+
if (timer !== undefined) clearInterval(timer)
|
|
166
|
+
timer = undefined
|
|
167
|
+
watcher?.close()
|
|
168
|
+
watcher = undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { start, stop, drain }
|
|
172
|
+
}
|