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