backeer-opencode 0.1.2 → 0.1.4

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