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/CHANGELOG.md +16 -0
- package/README.md +40 -2
- package/bin/backeer-opencode.js +1 -1
- package/dist/backeer-permission.js +730 -0
- package/package.json +9 -2
- package/src/config.js +56 -0
- package/src/event-publishers.js +214 -0
- package/src/http.js +87 -0
- package/src/index.js +1 -716
- package/src/permission-controller.js +183 -0
- package/src/plugin.js +143 -0
- package/src/reporter.js +60 -0
- package/src/sse.js +139 -0
- package/src/utilities.js +25 -0
package/src/index.js
CHANGED
|
@@ -1,716 +1 @@
|
|
|
1
|
-
|
|
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"
|