@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.
@@ -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
+ }