backeer-opencode 0.1.1 → 0.1.3

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/src/index.js CHANGED
@@ -1,716 +1 @@
1
- import { readFileSync } from "node:fs"
2
- import { homedir } from "node:os"
3
- import { join } from "node:path"
4
-
5
- const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
6
- const ACTION_PROTOCOL = "opencode-permission/v1"
7
- const RETRY_DELAYS_MS = [0, 500, 1500]
8
- const MAX_COMPLETED_COMMANDS = 500
9
- const MAX_TRACKED_ITEMS = 500
10
- const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
11
-
12
- class HttpError extends Error {
13
- constructor(message, status, responseBody = "") {
14
- super(message)
15
- this.name = "HttpError"
16
- this.status = status
17
- this.responseBody = responseBody
18
- }
19
- }
20
-
21
- const limit = (value, length = 1000) =>
22
- String(value ?? "").slice(0, length)
23
-
24
- function defaultConfigDirectory() {
25
- const base = process.env.XDG_CONFIG_HOME?.trim()
26
- return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
27
- }
28
-
29
- function loadToken(tokenFile) {
30
- const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
31
- if (environmentToken) return environmentToken
32
-
33
- try {
34
- return readFileSync(tokenFile, "utf8").trim()
35
- } catch {
36
- return ""
37
- }
38
- }
39
-
40
- function enabled(value, fallback = true) {
41
- if (typeof value === "boolean") return value
42
- if (typeof value !== "string") return fallback
43
- return !["0", "false", "no", "off"].includes(value.trim().toLowerCase())
44
- }
45
-
46
- function loadConfig(options = {}) {
47
- const origin = new URL(
48
- options.origin ?? process.env.BACKEER_ORIGIN ?? DEFAULT_ORIGIN,
49
- )
50
- if (origin.protocol !== "https:") {
51
- throw new Error("Backeer origin must use HTTPS")
52
- }
53
-
54
- const tokenFile =
55
- options.tokenFile ??
56
- process.env.BACKEER_TOKEN_FILE ??
57
- join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
58
-
59
- return {
60
- origin,
61
- eventsEndpoint: new URL("/v1/events", origin),
62
- commandStreamEndpoint: new URL("/v1/commands/stream", origin),
63
- token: loadToken(tokenFile),
64
- tokenFile,
65
- permissions: enabled(
66
- options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
67
- ),
68
- questions: enabled(
69
- options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
70
- ),
71
- todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
72
- }
73
- }
74
-
75
- function isRetryable(status) {
76
- return status === 429 || status >= 500
77
- }
78
-
79
- function isAbortError(error) {
80
- return error instanceof Error && error.name === "AbortError"
81
- }
82
-
83
- function sleep(milliseconds, signal) {
84
- return new Promise((resolve) => {
85
- if (signal?.aborted) {
86
- resolve()
87
- return
88
- }
89
-
90
- const timeout = setTimeout(done, milliseconds)
91
-
92
- function done() {
93
- clearTimeout(timeout)
94
- signal?.removeEventListener("abort", done)
95
- resolve()
96
- }
97
-
98
- signal?.addEventListener("abort", done, { once: true })
99
- })
100
- }
101
-
102
- async function postJsonWithRetry(config, url, body, options = {}) {
103
- const retryDelays = options.retryDelays ?? RETRY_DELAYS_MS
104
-
105
- for (let attempt = 0; attempt < retryDelays.length; attempt++) {
106
- if (retryDelays[attempt] > 0) {
107
- await sleep(retryDelays[attempt], options.signal)
108
- }
109
- if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
110
-
111
- const controller = new AbortController()
112
- const abort = () => controller.abort()
113
- const timeout = setTimeout(abort, options.timeoutMs ?? 5000)
114
- options.signal?.addEventListener("abort", abort, { once: true })
115
-
116
- try {
117
- const response = await fetch(url, {
118
- method: "POST",
119
- headers: {
120
- Authorization: `Bearer ${config.token}`,
121
- "Content-Type": "application/json",
122
- ...options.headers,
123
- },
124
- body: JSON.stringify(body),
125
- signal: controller.signal,
126
- })
127
- const responseBody = limit(await response.text(), 1000)
128
-
129
- if (response.ok) return { status: response.status, responseBody }
130
- if (!isRetryable(response.status) || attempt === retryDelays.length - 1) {
131
- throw new HttpError(
132
- `HTTP request failed with status ${response.status}`,
133
- response.status,
134
- responseBody,
135
- )
136
- }
137
- } catch (error) {
138
- if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
139
- if (error instanceof HttpError) throw error
140
- if (attempt === retryDelays.length - 1) throw error
141
- } finally {
142
- clearTimeout(timeout)
143
- options.signal?.removeEventListener("abort", abort)
144
- }
145
- }
146
-
147
- throw new Error("HTTP retry loop ended unexpectedly")
148
- }
149
-
150
- function permissionIdentity(request) {
151
- const requestID = request.id ?? request.requestID
152
- const sessionID = request.sessionID
153
- if (typeof requestID !== "string" || typeof sessionID !== "string") return
154
-
155
- return {
156
- requestID,
157
- sessionID,
158
- externalId: `opencode:${sessionID}:${requestID}`,
159
- }
160
- }
161
-
162
- async function publishPermission(config, request, directory, identity) {
163
- const permissionType = request.permission ?? request.action ?? "unknown"
164
- const resources = Array.isArray(request.patterns)
165
- ? request.patterns.join(", ")
166
- : Array.isArray(request.resources)
167
- ? request.resources.join(", ")
168
- : ""
169
-
170
- const payload = [
171
- { key: "Permission", value: limit(permissionType) },
172
- { key: "Session", value: limit(identity.sessionID) },
173
- { key: "Request ID", value: limit(identity.requestID) },
174
- { key: "Patterns", value: limit(resources) },
175
- { key: "Working directory", value: limit(directory) },
176
- { key: "Action protocol", value: ACTION_PROTOCOL },
177
- { key: "Available decisions", value: "once, always, reject" },
178
- ].filter((item) => item.value.length > 0)
179
-
180
- const result = await postJsonWithRetry(config, config.eventsEndpoint, {
181
- type: "application",
182
- severity: "warning",
183
- title: `OpenCode permission required: ${limit(permissionType, 200)}`,
184
- body: resources
185
- ? `OpenCode is waiting for approval to access: ${limit(resources, 500)}`
186
- : `OpenCode is waiting for approval for: ${limit(permissionType, 500)}`,
187
- payload,
188
- externalId: identity.externalId,
189
- timestamp: new Date().toISOString(),
190
- notify: true,
191
- direction: "in",
192
- })
193
- console.log(`[backeer] permission event accepted (${result.status})`)
194
- }
195
-
196
- function questionIdentity(request) {
197
- const requestID = request.id ?? request.requestID
198
- const sessionID = request.sessionID
199
- if (typeof requestID !== "string" || typeof sessionID !== "string") return
200
-
201
- return {
202
- requestID,
203
- sessionID,
204
- externalId: `opencode-question:${sessionID}:${requestID}`,
205
- }
206
- }
207
-
208
- function formatQuestionOptions(options) {
209
- if (!Array.isArray(options)) return ""
210
-
211
- return options
212
- .map((option) => {
213
- const label = limit(option?.label, 200)
214
- const description = limit(option?.description, 500)
215
- if (!label) return ""
216
- return description ? `${label} — ${description}` : label
217
- })
218
- .filter(Boolean)
219
- .join(" | ")
220
- }
221
-
222
- async function resolveSessionTitle(client, directory, sessionID) {
223
- if (typeof client?.session?.get !== "function") return ""
224
-
225
- try {
226
- const result = await client.session.get({
227
- path: { id: sessionID },
228
- query: { directory },
229
- throwOnError: true,
230
- })
231
- return limit((result?.data ?? result)?.title)
232
- } catch (error) {
233
- console.error(`[backeer] could not resolve session title: ${limit(error, 500)}`)
234
- return ""
235
- }
236
- }
237
-
238
- async function publishQuestion(config, request, identity, client, directory) {
239
- const questions = Array.isArray(request.questions) ? request.questions : []
240
- if (questions.length === 0) return
241
-
242
- const sessionTitle = await resolveSessionTitle(
243
- client,
244
- directory,
245
- identity.sessionID,
246
- )
247
- const payload = [
248
- { key: "OpenCode session", value: sessionTitle },
249
- ...questions.flatMap((question, index) => {
250
- const suffix = questions.length > 1 ? ` ${index + 1}` : ""
251
- return [
252
- { key: `Question title${suffix}`, value: limit(question?.header) },
253
- { key: `Question${suffix}`, value: limit(question?.question) },
254
- {
255
- key: `Options${suffix}`,
256
- value: limit(formatQuestionOptions(question?.options)),
257
- },
258
- ]
259
- }),
260
- ].filter((item) => item.value.length > 0)
261
-
262
- const firstQuestion = questions[0]
263
- const body = questions
264
- .map((question) => {
265
- const header = limit(question?.header, 200)
266
- const text = limit(question?.question, 500)
267
- const options = limit(formatQuestionOptions(question?.options), 500)
268
- return [header, text, options ? `Options: ${options}` : ""]
269
- .filter(Boolean)
270
- .join("\n")
271
- })
272
- .join("\n\n")
273
-
274
- const result = await postJsonWithRetry(config, config.eventsEndpoint, {
275
- type: "application",
276
- severity: "info",
277
- title: `OpenCode question: ${limit(firstQuestion?.header, 200) || "Question"}`,
278
- body: limit(body, 2000),
279
- payload,
280
- externalId: identity.externalId,
281
- timestamp: new Date().toISOString(),
282
- notify: true,
283
- direction: "in",
284
- })
285
- console.log(`[backeer] question event accepted (${result.status})`)
286
- }
287
-
288
- function stableHash(value) {
289
- let hash = 2166136261
290
- for (let index = 0; index < value.length; index++) {
291
- hash ^= value.charCodeAt(index)
292
- hash = Math.imul(hash, 16777619)
293
- }
294
- return (hash >>> 0).toString(16).padStart(8, "0")
295
- }
296
-
297
- async function publishTodoCompletion(config, request, eventID, client, directory) {
298
- const todos = request.todos
299
- const sessionTitle = await resolveSessionTitle(
300
- client,
301
- directory,
302
- request.sessionID,
303
- )
304
- const correlation =
305
- typeof eventID === "string" && eventID.length > 0
306
- ? limit(eventID, 200)
307
- : stableHash(JSON.stringify(todos))
308
- const externalId = `opencode-todo:${request.sessionID}:${correlation}`
309
- const visibleTodos = todos.slice(0, 20)
310
- const payload = [
311
- { key: "OpenCode session", value: sessionTitle },
312
- { key: "Completed tasks", value: String(todos.length) },
313
- ...visibleTodos.flatMap((todo, index) => [
314
- { key: `Task ${index + 1}`, value: limit(todo?.content) },
315
- { key: `Priority ${index + 1}`, value: limit(todo?.priority, 100) },
316
- ]),
317
- ...(todos.length > visibleTodos.length
318
- ? [{ key: "Additional tasks", value: String(todos.length - visibleTodos.length) }]
319
- : []),
320
- ].filter((item) => item.value.length > 0)
321
-
322
- const summary = todos
323
- .slice(0, 5)
324
- .map((todo) => limit(todo?.content, 200))
325
- .filter(Boolean)
326
- .join(" • ")
327
- const location = sessionTitle ? ` in ${sessionTitle}` : ""
328
- const body = [
329
- `All ${todos.length} OpenCode tasks completed${location}.`,
330
- summary,
331
- ].filter(Boolean).join("\n")
332
-
333
- const result = await postJsonWithRetry(config, config.eventsEndpoint, {
334
- type: "application",
335
- severity: "info",
336
- title: "OpenCode todo list completed",
337
- body: limit(body, 2000),
338
- payload,
339
- externalId,
340
- timestamp: new Date().toISOString(),
341
- notify: true,
342
- direction: "in",
343
- })
344
- console.log(`[backeer] todo completion accepted (${result.status})`)
345
- }
346
-
347
- function parseSseFrame(frame) {
348
- const data = []
349
- let event = "message"
350
- let id
351
-
352
- for (const line of frame.split(/\r?\n/)) {
353
- if (!line || line.startsWith(":")) continue
354
- const separator = line.indexOf(":")
355
- const field = separator === -1 ? line : line.slice(0, separator)
356
- let value = separator === -1 ? "" : line.slice(separator + 1)
357
- if (value.startsWith(" ")) value = value.slice(1)
358
-
359
- if (field === "event") event = value
360
- if (field === "id") id = value
361
- if (field === "data") data.push(value)
362
- }
363
-
364
- if (data.length === 0) return
365
- return { event, id, data: data.join("\n") }
366
- }
367
-
368
- function nextSseFrame(buffer) {
369
- const lfIndex = buffer.indexOf("\n\n")
370
- const crlfIndex = buffer.indexOf("\r\n\r\n")
371
- if (lfIndex === -1 && crlfIndex === -1) return
372
-
373
- if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
374
- return {
375
- frame: buffer.slice(0, crlfIndex),
376
- rest: buffer.slice(crlfIndex + 4),
377
- }
378
- }
379
- return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
380
- }
381
-
382
- async function consumeSse(response, state, onCommand, signal) {
383
- if (!response.body) throw new Error("Backeer command stream has no body")
384
-
385
- const reader = response.body.getReader()
386
- const decoder = new TextDecoder()
387
- let buffer = ""
388
-
389
- try {
390
- while (!signal.aborted) {
391
- const item = await reader.read()
392
- if (item.done) break
393
- buffer += decoder.decode(item.value, { stream: true })
394
-
395
- for (;;) {
396
- const next = nextSseFrame(buffer)
397
- if (!next) break
398
- buffer = next.rest
399
- const message = parseSseFrame(next.frame)
400
- if (!message) continue
401
- if (message.id) state.lastEventID = message.id
402
- if (message.event !== "message" && message.event !== "command") continue
403
-
404
- try {
405
- await onCommand(JSON.parse(message.data))
406
- } catch (error) {
407
- if (error instanceof SyntaxError) {
408
- console.error("[backeer] command stream returned invalid JSON")
409
- continue
410
- }
411
- throw error
412
- }
413
- }
414
- }
415
- } finally {
416
- await reader.cancel().catch(() => {})
417
- }
418
- }
419
-
420
- async function runCommandStream(config, { signal, hasPending, onCommand }) {
421
- const state = { lastEventID: "" }
422
- let reconnectDelay = 1000
423
- let lastLoggedError = ""
424
-
425
- while (!signal.aborted && hasPending()) {
426
- try {
427
- const headers = {
428
- Accept: "text/event-stream",
429
- Authorization: `Bearer ${config.token}`,
430
- }
431
- if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
432
-
433
- const response = await fetch(config.commandStreamEndpoint, {
434
- headers,
435
- signal,
436
- })
437
- if (!response.ok) {
438
- const responseBody = limit(await response.text(), 500)
439
- throw new HttpError(
440
- `Backeer command stream failed with status ${response.status}`,
441
- response.status,
442
- responseBody,
443
- )
444
- }
445
- if (!response.headers.get("content-type")?.includes("text/event-stream")) {
446
- throw new Error("Backeer command stream returned an invalid content type")
447
- }
448
-
449
- reconnectDelay = 1000
450
- lastLoggedError = ""
451
- await consumeSse(response, state, onCommand, signal)
452
- } catch (error) {
453
- if (signal.aborted || isAbortError(error)) return
454
- const message = error instanceof HttpError
455
- ? `${error.message}: ${error.responseBody}`
456
- : limit(error, 500)
457
- if (message !== lastLoggedError) {
458
- console.error(`[backeer] ${message}`)
459
- lastLoggedError = message
460
- }
461
- if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
462
- reconnectDelay = 60_000
463
- }
464
- }
465
-
466
- if (!signal.aborted && hasPending()) {
467
- await sleep(reconnectDelay, signal)
468
- reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
469
- }
470
- }
471
- }
472
-
473
- async function replyToOpenCode(client, pending, decision) {
474
- const result = await client.postSessionIdPermissionsPermissionId({
475
- path: { id: pending.sessionID, permissionID: pending.requestID },
476
- query: { directory: pending.directory },
477
- body: { response: decision },
478
- throwOnError: true,
479
- })
480
-
481
- if (result?.data === false || result === false) {
482
- throw new HttpError("OpenCode did not apply the permission reply", 409)
483
- }
484
- }
485
-
486
- async function acknowledgeCommand(config, commandID, acknowledgement) {
487
- const url = new URL(
488
- `/v1/commands/${encodeURIComponent(commandID)}/ack`,
489
- config.origin,
490
- )
491
- await postJsonWithRetry(config, url, acknowledgement)
492
- }
493
-
494
- function rememberBounded(map, key, value) {
495
- map.delete(key)
496
- map.set(key, value)
497
- while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
498
- }
499
-
500
- /**
501
- * OpenCode server plugin entrypoint.
502
- *
503
- * Supported options: origin, tokenFile, permissions, questions, todos.
504
- * The ingest token is intentionally accepted only through the environment or a
505
- * protected file so it cannot accidentally be committed in opencode.json.
506
- */
507
- export default async function BackeerPlugin({ client, directory }, options = {}) {
508
- let config
509
- try {
510
- config = loadConfig(options)
511
- } catch (error) {
512
- console.error(`[backeer] plugin disabled: ${limit(error, 500)}`)
513
- return {}
514
- }
515
-
516
- if (!config.token) {
517
- console.error(
518
- `[backeer] plugin disabled: set BACKEER_INGEST_TOKEN or create ${config.tokenFile}`,
519
- )
520
- return {}
521
- }
522
-
523
- const pendingByExternalID = new Map()
524
- const externalIDByRequestID = new Map()
525
- const processingCommands = new Set()
526
- const completedCommands = new Map()
527
- const todoCompletionBySessionID = new Map()
528
- const publishedPermissions = new Map()
529
- const publishedQuestions = new Map()
530
- let commandStream
531
- let disposed = false
532
-
533
- function removePendingByRequestID(requestID) {
534
- const externalId = externalIDByRequestID.get(requestID)
535
- if (!externalId) return
536
- externalIDByRequestID.delete(requestID)
537
- pendingByExternalID.delete(externalId)
538
- }
539
-
540
- function stopCommandStreamIfIdle() {
541
- if (pendingByExternalID.size === 0) commandStream?.controller.abort()
542
- }
543
-
544
- async function handleCommand(command) {
545
- if (!command || typeof command !== "object") return
546
- if (command.type !== "opencode_permission_reply") return
547
- if (typeof command.id !== "string" || command.id.length > 200) return
548
-
549
- const previousAcknowledgement = completedCommands.get(command.id)
550
- if (previousAcknowledgement) {
551
- await acknowledgeCommand(config, command.id, previousAcknowledgement).catch(
552
- (error) => console.error(`[backeer] command ACK retry failed: ${limit(error, 500)}`),
553
- )
554
- return
555
- }
556
- if (processingCommands.has(command.id)) return
557
- if (typeof command.externalId !== "string") return
558
-
559
- const pending = pendingByExternalID.get(command.externalId)
560
- if (!pending) return
561
-
562
- processingCommands.add(command.id)
563
- let acknowledgement
564
- try {
565
- if (!ALLOWED_DECISIONS.has(command.decision)) {
566
- acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
567
- } else {
568
- const expiresAt = Date.parse(command.expiresAt)
569
- if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
570
- acknowledgement = { status: "expired", decision: command.decision }
571
- } else if (command.decision === "always" && command.confirmed !== true) {
572
- acknowledgement = {
573
- status: "rejected",
574
- decision: command.decision,
575
- errorCode: "confirmation_required",
576
- }
577
- } else {
578
- await replyToOpenCode(client, pending, command.decision)
579
- acknowledgement = { status: "applied", decision: command.decision }
580
- removePendingByRequestID(pending.requestID)
581
- }
582
- }
583
- } catch (error) {
584
- const stale = error instanceof HttpError && error.status === 404
585
- acknowledgement = {
586
- status: stale ? "stale" : "failed",
587
- decision: ALLOWED_DECISIONS.has(command.decision)
588
- ? command.decision
589
- : undefined,
590
- errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
591
- }
592
- if (stale) removePendingByRequestID(pending.requestID)
593
- console.error(`[backeer] permission command failed: ${limit(error, 500)}`)
594
- } finally {
595
- processingCommands.delete(command.id)
596
- }
597
-
598
- acknowledgement = {
599
- ...acknowledgement,
600
- type: "opencode_permission_reply_ack",
601
- externalId: command.externalId,
602
- processedAt: new Date().toISOString(),
603
- }
604
- rememberBounded(completedCommands, command.id, acknowledgement)
605
- await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
606
- console.error(`[backeer] command ACK failed: ${limit(error, 500)}`)
607
- })
608
- stopCommandStreamIfIdle()
609
- }
610
-
611
- function ensureCommandStream() {
612
- if (disposed || commandStream || pendingByExternalID.size === 0) return
613
-
614
- const controller = new AbortController()
615
- const current = {
616
- controller,
617
- task: runCommandStream(config, {
618
- signal: controller.signal,
619
- hasPending: () => pendingByExternalID.size > 0,
620
- onCommand: handleCommand,
621
- }),
622
- }
623
- commandStream = current
624
- void current.task.finally(() => {
625
- if (commandStream === current) commandStream = undefined
626
- if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
627
- })
628
- }
629
-
630
- return {
631
- event: async ({ event }) => {
632
- if (config.permissions && event.type === "permission.asked") {
633
- const identity = permissionIdentity(event.properties)
634
- if (!identity) {
635
- console.error("[backeer] permission event is missing IDs")
636
- return
637
- }
638
-
639
- pendingByExternalID.set(identity.externalId, { ...identity, directory })
640
- externalIDByRequestID.set(identity.requestID, identity.externalId)
641
- ensureCommandStream()
642
-
643
- if (publishedPermissions.has(identity.externalId)) return
644
- rememberBounded(publishedPermissions, identity.externalId, true)
645
- void publishPermission(config, event.properties, directory, identity).catch(
646
- (error) => console.error(`[backeer] permission webhook failed: ${limit(error, 500)}`),
647
- )
648
- return
649
- }
650
-
651
- if (config.permissions && event.type === "permission.replied") {
652
- removePendingByRequestID(event.properties.requestID ?? event.properties.id)
653
- stopCommandStreamIfIdle()
654
- return
655
- }
656
-
657
- if (config.todos && event.type === "todo.updated") {
658
- const { sessionID, todos } = event.properties
659
- if (typeof sessionID !== "string" || !Array.isArray(todos)) {
660
- console.error("[backeer] todo event is missing a valid session or list")
661
- return
662
- }
663
-
664
- const isComplete = todos.length > 0 &&
665
- todos.every((todo) => todo?.status === "completed")
666
- const wasComplete = todoCompletionBySessionID.get(sessionID) === true
667
- rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
668
- if (!isComplete || wasComplete) return
669
-
670
- void publishTodoCompletion(
671
- config,
672
- event.properties,
673
- event.id,
674
- client,
675
- directory,
676
- ).catch(
677
- (error) => console.error(`[backeer] todo webhook failed: ${limit(error, 500)}`),
678
- )
679
- return
680
- }
681
-
682
- if (
683
- config.questions &&
684
- (event.type === "question.asked" || event.type === "question.v2.asked")
685
- ) {
686
- const identity = questionIdentity(event.properties)
687
- if (!identity) {
688
- console.error("[backeer] question event is missing IDs")
689
- return
690
- }
691
- if (publishedQuestions.has(identity.externalId)) return
692
- rememberBounded(publishedQuestions, identity.externalId, true)
693
-
694
- void publishQuestion(
695
- config,
696
- event.properties,
697
- identity,
698
- client,
699
- directory,
700
- ).catch(
701
- (error) => console.error(`[backeer] question webhook failed: ${limit(error, 500)}`),
702
- )
703
- }
704
- },
705
- dispose: async () => {
706
- disposed = true
707
- commandStream?.controller.abort()
708
- await commandStream?.task.catch(() => {})
709
- pendingByExternalID.clear()
710
- externalIDByRequestID.clear()
711
- todoCompletionBySessionID.clear()
712
- publishedPermissions.clear()
713
- publishedQuestions.clear()
714
- },
715
- }
716
- }
1
+ export { default } from "./plugin.js"