backeer-opencode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to `backeer-opencode` are documented here.
4
+
5
+ ## 0.1.0 - 2026-09-01
6
+
7
+ - Publish OpenCode permission requests to Backeer.
8
+ - Apply `once`, `always`, and `reject` decisions received from Backeer.
9
+ - Publish `question.asked` and `question.v2.asked` notifications.
10
+ - Publish one notification when a todo list transitions to fully completed.
11
+ - Support environment, protected-file, XDG, npm, and side-loaded configuration.
12
+ - Add idempotent command acknowledgement and duplicate notification protection.
package/LICENSE ADDED
@@ -0,0 +1,58 @@
1
+ Backeer No-Derivatives Software License 1.0
2
+
3
+ Copyright (c) 2026 Backeer. All rights reserved.
4
+
5
+ 1. Permission
6
+
7
+ Subject to the terms of this license, Backeer grants you a limited,
8
+ non-exclusive, non-transferable, non-sublicensable, royalty-free license to
9
+ download, install, and execute exact, unmodified copies of the Software for the
10
+ purpose of using Backeer services.
11
+
12
+ You may reproduce and redistribute exact, unmodified copies of the Software
13
+ solely when the complete package, this license, and all copyright, attribution,
14
+ and proprietary notices are preserved intact. You may not charge a separate fee
15
+ for the Software.
16
+
17
+ 2. No Modifications or Derivative Works
18
+
19
+ You may not modify, adapt, translate, patch, merge, alter, or create derivative
20
+ works based on the Software. You may not distribute, publish, sublicense, sell,
21
+ or otherwise make available a modified version of the Software.
22
+
23
+ 3. No Reverse Engineering
24
+
25
+ Except to the limited extent that applicable law expressly prohibits this
26
+ restriction, you may not reverse engineer, decompile, disassemble, or otherwise
27
+ attempt to derive non-public implementation details from the Software.
28
+
29
+ 4. Ownership and Trademarks
30
+
31
+ The Software is licensed, not sold. Backeer retains all right, title, and
32
+ interest in and to the Software. This license does not grant permission to use
33
+ Backeer names, logos, or trademarks except as necessary to identify the
34
+ unmodified Software.
35
+
36
+ 5. Termination
37
+
38
+ This license terminates automatically if you breach any of its terms. Upon
39
+ termination, you must stop using the Software and delete all copies in your
40
+ possession or control.
41
+
42
+ 6. Disclaimer of Warranty
43
+
44
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
45
+ IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
46
+ PURPOSE, TITLE, AND NON-INFRINGEMENT.
47
+
48
+ 7. Limitation of Liability
49
+
50
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, BACKEER WILL NOT BE LIABLE FOR ANY
51
+ INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY
52
+ LOSS OF DATA, PROFITS, REVENUE, OR BUSINESS, ARISING FROM OR RELATED TO THE
53
+ SOFTWARE OR THIS LICENSE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
54
+
55
+ 8. Entire License
56
+
57
+ This license is the complete agreement governing the Software unless Backeer
58
+ and you enter into a separate written agreement signed by Backeer.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Backeer for OpenCode
2
+
3
+ `backeer-opencode` connects OpenCode to Backeer. It sends mobile notifications
4
+ for permission requests, questions, and completed todo lists. While a permission
5
+ is pending, it also consumes an authenticated Backeer command stream so an
6
+ authorized user can answer **Allow once**, **Always allow**, or **Reject** from
7
+ the Backeer iOS application.
8
+
9
+ The plugin uses outbound HTTPS only. It does not start a server or require an
10
+ inbound firewall rule.
11
+
12
+ ## Requirements
13
+
14
+ - OpenCode with server plugin support
15
+ - Node.js 18 or newer for the local installer and tests
16
+ - Outbound HTTPS access to `external-gateway.backeer.com:443`
17
+ - A Backeer source ingest token
18
+
19
+ Create a separate Backeer source and ingest token for every server. Sharing a
20
+ token causes permission commands to be broadcast to every OpenCode process that
21
+ uses that token and weakens host-level auditing and revocation.
22
+
23
+ ## Local installation
24
+
25
+ The local installer copies a single dependency-free plugin file into OpenCode's
26
+ global plugin directory. OpenCode discovers it automatically.
27
+
28
+ ```sh
29
+ cd opencode-plugin
30
+ node bin/backeer-opencode.js --token-file /path/from/your/secret-manager/backeer-token
31
+ ```
32
+
33
+ The resulting files are:
34
+
35
+ ```text
36
+ ~/.config/opencode/plugins/backeer-permission.js
37
+ ~/.config/opencode/secrets/backeer-ingest-token
38
+ ```
39
+
40
+ The token file is written with mode `0600`. When `XDG_CONFIG_HOME` is set, the
41
+ installer and plugin use `$XDG_CONFIG_HOME/opencode` instead. To update only the
42
+ plugin while preserving the existing token, run:
43
+
44
+ ```sh
45
+ node bin/backeer-opencode.js
46
+ ```
47
+
48
+ Restart OpenCode after installation or update.
49
+
50
+ ## Installation from npm
51
+
52
+ Install the package in OpenCode's global configuration:
53
+
54
+ ```sh
55
+ opencode plugin backeer-opencode -g
56
+ ```
57
+
58
+ Alternatively, add it to `~/.config/opencode/opencode.json` manually:
59
+
60
+ ```json
61
+ {
62
+ "$schema": "https://opencode.ai/config.json",
63
+ "plugin": ["backeer-opencode"]
64
+ }
65
+ ```
66
+
67
+ OpenCode installs configured npm plugins automatically. Restart OpenCode after
68
+ installation. The npm package never contains a token; provide the token using
69
+ one of the methods below.
70
+
71
+ If npm package loading is not available in a particular OpenCode installation,
72
+ install the package as a side-loaded plugin instead:
73
+
74
+ ```sh
75
+ npx backeer-opencode@latest --token-file /path/from/your/secret-manager/backeer-token
76
+ ```
77
+
78
+ Use either the configured npm plugin or the side-loaded form, not both. Loading
79
+ both forms would register the integration twice.
80
+
81
+ ## Authentication
82
+
83
+ The plugin checks these locations in order:
84
+
85
+ 1. `BACKEER_INGEST_TOKEN`
86
+ 2. `BACKEER_TOKEN_FILE`
87
+ 3. `~/.config/opencode/secrets/backeer-ingest-token`
88
+
89
+ Do not commit the token to an OpenCode configuration file, shell script, plugin
90
+ file, or repository. Do not reuse an application-session token; this plugin
91
+ requires a source ingest token.
92
+
93
+ ## Configuration
94
+
95
+ The default gateway origin is `https://external-gateway.backeer.com`. These
96
+ environment variables are supported:
97
+
98
+ | Variable | Purpose |
99
+ |---|---|
100
+ | `BACKEER_INGEST_TOKEN` | Source credential; takes precedence over a token file |
101
+ | `BACKEER_TOKEN_FILE` | Absolute path to a protected token file |
102
+ | `BACKEER_ORIGIN` | Gateway origin; HTTPS is required |
103
+ | `BACKEER_NOTIFY_PERMISSIONS` | Set to `false` to disable permission notifications and remote replies |
104
+ | `BACKEER_NOTIFY_QUESTIONS` | Set to `false` to disable question notifications |
105
+ | `BACKEER_NOTIFY_TODOS` | Set to `false` to disable todo-completion notifications |
106
+
107
+ For npm installations, non-secret options can also be supplied through the
108
+ OpenCode plugin tuple syntax:
109
+
110
+ ```json
111
+ {
112
+ "plugin": [
113
+ [
114
+ "backeer-opencode",
115
+ {
116
+ "permissions": true,
117
+ "questions": true,
118
+ "todos": true
119
+ }
120
+ ]
121
+ ]
122
+ }
123
+ ```
124
+
125
+ Supported options are `origin`, `tokenFile`, `permissions`, `questions`, and
126
+ `todos`. The token itself is deliberately not a plugin option.
127
+
128
+ ## Events and decisions
129
+
130
+ The plugin publishes:
131
+
132
+ | OpenCode event | Backeer behavior |
133
+ |---|---|
134
+ | `permission.asked` | Notification plus remote decision support |
135
+ | `question.asked` | Notification-only question card |
136
+ | `question.v2.asked` | Notification-only question card, including plan-mode questions |
137
+ | `todo.updated` | One notification when a non-empty list transitions to fully completed |
138
+
139
+ Permission commands are delivered at least once. The plugin caches terminal
140
+ acknowledgements and resends the same acknowledgement on redelivery without
141
+ applying the OpenCode decision twice. Duplicate event callbacks are suppressed
142
+ within the plugin process and Backeer uses the stable `externalId` as the
143
+ cross-process idempotency key.
144
+
145
+ ## Verification
146
+
147
+ ```sh
148
+ npm run check
149
+ npm run pack:check
150
+ npm run publish:check
151
+ ```
152
+
153
+ ## Publishing
154
+
155
+ Maintainers can publish a release after authenticating the `Backeer` npm
156
+ account:
157
+
158
+ ```sh
159
+ npm login
160
+ npm whoami
161
+ npm run publish:check
162
+ npm publish --access public
163
+ ```
164
+
165
+ Increment `version` before every later release. npm does not allow an existing
166
+ name and version pair to be overwritten.
167
+
168
+ For support, contact [hello@backeer.com](mailto:hello@backeer.com).
169
+
170
+ ## License
171
+
172
+ Copyright © 2026 Backeer. The package may be installed and used in its
173
+ unmodified form. Modification, adaptation, derivative works, reverse
174
+ engineering, and distribution of modified versions are prohibited. See
175
+ [`LICENSE`](LICENSE) for the complete terms.
176
+
177
+ After restarting OpenCode, trigger a harmless permission such as `git status`.
178
+ Verify that one Backeer notification arrives, answer it from iOS, and confirm
179
+ that OpenCode receives the selected decision.
180
+
181
+ ## Upgrade behavior
182
+
183
+ Normal OpenCode upgrades do not remove locally installed plugins because they
184
+ live in the OpenCode configuration directory. Re-run the installer to deploy a
185
+ new Backeer plugin version. Preserve the token file and run the test suite after
186
+ OpenCode upgrades that change plugin events or the generated API client.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ chmodSync,
5
+ copyFileSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ writeFileSync,
10
+ } from "node:fs"
11
+ import { homedir } from "node:os"
12
+ import { dirname, join, resolve } from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+
15
+ function usage(exitCode = 0) {
16
+ console.log(`Usage: backeer-opencode [options]
17
+
18
+ Options:
19
+ --config-dir PATH OpenCode config directory (default: XDG_CONFIG_HOME/opencode)
20
+ --token-file PATH Read the Backeer ingest token from PATH
21
+ --token-stdin Read the Backeer ingest token from standard input
22
+ --help Show this help
23
+
24
+ If no token option is supplied, an existing token is preserved.`)
25
+ process.exit(exitCode)
26
+ }
27
+
28
+ function parseArguments(arguments_) {
29
+ const result = {}
30
+ for (let index = 0; index < arguments_.length; index++) {
31
+ const argument = arguments_[index]
32
+ if (argument === "--help") usage()
33
+ if (argument === "--token-stdin") {
34
+ result.tokenStdin = true
35
+ continue
36
+ }
37
+ if (argument === "--config-dir" || argument === "--token-file") {
38
+ const value = arguments_[++index]
39
+ if (!value) usage(1)
40
+ result[argument === "--config-dir" ? "configDir" : "tokenFile"] = value
41
+ continue
42
+ }
43
+ console.error(`Unknown option: ${argument}`)
44
+ usage(1)
45
+ }
46
+ if (result.tokenStdin && result.tokenFile) {
47
+ throw new Error("Use either --token-stdin or --token-file, not both")
48
+ }
49
+ return result
50
+ }
51
+
52
+ function defaultConfigDirectory() {
53
+ const base = process.env.XDG_CONFIG_HOME?.trim()
54
+ return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
55
+ }
56
+
57
+ function writeSecretAtomically(path, token) {
58
+ if (!token) throw new Error("The Backeer ingest token is empty")
59
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
60
+ const temporaryPath = `${path}.${process.pid}.tmp`
61
+ writeFileSync(temporaryPath, `${token}\n`, { encoding: "utf8", mode: 0o600 })
62
+ chmodSync(temporaryPath, 0o600)
63
+ renameSync(temporaryPath, path)
64
+ }
65
+
66
+ const arguments_ = parseArguments(process.argv.slice(2))
67
+ const configDirectory = resolve(arguments_.configDir ?? defaultConfigDirectory())
68
+ const pluginDirectory = join(configDirectory, "plugins")
69
+ const secretPath = join(configDirectory, "secrets", "backeer-ingest-token")
70
+ const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..")
71
+ const sourcePath = join(packageDirectory, "src", "index.js")
72
+ // Keep the legacy filename so upgrading an existing Backeer installation
73
+ // replaces it instead of loading two copies of the same plugin.
74
+ const installedPath = join(pluginDirectory, "backeer-permission.js")
75
+
76
+ mkdirSync(pluginDirectory, { recursive: true })
77
+ copyFileSync(sourcePath, installedPath)
78
+ chmodSync(installedPath, 0o644)
79
+
80
+ if (arguments_.tokenFile) {
81
+ writeSecretAtomically(secretPath, readFileSync(resolve(arguments_.tokenFile), "utf8").trim())
82
+ } else if (arguments_.tokenStdin) {
83
+ writeSecretAtomically(secretPath, readFileSync(0, "utf8").trim())
84
+ }
85
+
86
+ console.log(`Installed Backeer OpenCode plugin: ${installedPath}`)
87
+ console.log(
88
+ arguments_.tokenFile || arguments_.tokenStdin
89
+ ? `Installed protected ingest token: ${secretPath}`
90
+ : `Token unchanged; expected at ${secretPath} or in BACKEER_INGEST_TOKEN`,
91
+ )
92
+ console.log("Restart OpenCode to load the plugin.")
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "backeer-opencode",
3
+ "version": "0.1.0",
4
+ "description": "Backeer notifications and remote permission decisions for OpenCode",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "backeer-opencode": "bin/backeer-opencode.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "bin",
16
+ "README.md",
17
+ "CHANGELOG.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "check": "node --check src/index.js && node --check bin/backeer-opencode.js && node --test",
22
+ "test": "node --test",
23
+ "pack:check": "npm pack --dry-run",
24
+ "publish:check": "npm publish --dry-run --access public",
25
+ "prepublishOnly": "npm run check"
26
+ },
27
+ "keywords": [
28
+ "backeer",
29
+ "opencode",
30
+ "plugin",
31
+ "notifications"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "author": {
37
+ "name": "Backeer",
38
+ "email": "hello@backeer.com",
39
+ "url": "https://backeer.com/"
40
+ },
41
+ "homepage": "https://backeer.com/",
42
+ "bugs": {
43
+ "email": "hello@backeer.com"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "license": "SEE LICENSE IN LICENSE"
49
+ }
package/src/index.js ADDED
@@ -0,0 +1,716 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
6
+ const ACTION_PROTOCOL = "opencode-permission/v1"
7
+ const RETRY_DELAYS_MS = [0, 500, 1500]
8
+ const MAX_COMPLETED_COMMANDS = 500
9
+ const MAX_TRACKED_ITEMS = 500
10
+ const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
11
+
12
+ class HttpError extends Error {
13
+ constructor(message, status, responseBody = "") {
14
+ super(message)
15
+ this.name = "HttpError"
16
+ this.status = status
17
+ this.responseBody = responseBody
18
+ }
19
+ }
20
+
21
+ const limit = (value, length = 1000) =>
22
+ String(value ?? "").slice(0, length)
23
+
24
+ function defaultConfigDirectory() {
25
+ const base = process.env.XDG_CONFIG_HOME?.trim()
26
+ return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
27
+ }
28
+
29
+ function loadToken(tokenFile) {
30
+ const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
31
+ if (environmentToken) return environmentToken
32
+
33
+ try {
34
+ return readFileSync(tokenFile, "utf8").trim()
35
+ } catch {
36
+ return ""
37
+ }
38
+ }
39
+
40
+ function enabled(value, fallback = true) {
41
+ if (typeof value === "boolean") return value
42
+ if (typeof value !== "string") return fallback
43
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase())
44
+ }
45
+
46
+ function loadConfig(options = {}) {
47
+ const origin = new URL(
48
+ options.origin ?? process.env.BACKEER_ORIGIN ?? DEFAULT_ORIGIN,
49
+ )
50
+ if (origin.protocol !== "https:") {
51
+ throw new Error("Backeer origin must use HTTPS")
52
+ }
53
+
54
+ const tokenFile =
55
+ options.tokenFile ??
56
+ process.env.BACKEER_TOKEN_FILE ??
57
+ join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
58
+
59
+ return {
60
+ origin,
61
+ eventsEndpoint: new URL("/v1/events", origin),
62
+ commandStreamEndpoint: new URL("/v1/commands/stream", origin),
63
+ token: loadToken(tokenFile),
64
+ tokenFile,
65
+ permissions: enabled(
66
+ options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
67
+ ),
68
+ questions: enabled(
69
+ options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
70
+ ),
71
+ todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
72
+ }
73
+ }
74
+
75
+ function isRetryable(status) {
76
+ return status === 429 || status >= 500
77
+ }
78
+
79
+ function isAbortError(error) {
80
+ return error instanceof Error && error.name === "AbortError"
81
+ }
82
+
83
+ function sleep(milliseconds, signal) {
84
+ return new Promise((resolve) => {
85
+ if (signal?.aborted) {
86
+ resolve()
87
+ return
88
+ }
89
+
90
+ const timeout = setTimeout(done, milliseconds)
91
+
92
+ function done() {
93
+ clearTimeout(timeout)
94
+ signal?.removeEventListener("abort", done)
95
+ resolve()
96
+ }
97
+
98
+ signal?.addEventListener("abort", done, { once: true })
99
+ })
100
+ }
101
+
102
+ async function postJsonWithRetry(config, url, body, options = {}) {
103
+ const retryDelays = options.retryDelays ?? RETRY_DELAYS_MS
104
+
105
+ for (let attempt = 0; attempt < retryDelays.length; attempt++) {
106
+ if (retryDelays[attempt] > 0) {
107
+ await sleep(retryDelays[attempt], options.signal)
108
+ }
109
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
110
+
111
+ const controller = new AbortController()
112
+ const abort = () => controller.abort()
113
+ const timeout = setTimeout(abort, options.timeoutMs ?? 5000)
114
+ options.signal?.addEventListener("abort", abort, { once: true })
115
+
116
+ try {
117
+ const response = await fetch(url, {
118
+ method: "POST",
119
+ headers: {
120
+ Authorization: `Bearer ${config.token}`,
121
+ "Content-Type": "application/json",
122
+ ...options.headers,
123
+ },
124
+ body: JSON.stringify(body),
125
+ signal: controller.signal,
126
+ })
127
+ const responseBody = limit(await response.text(), 1000)
128
+
129
+ if (response.ok) return { status: response.status, responseBody }
130
+ if (!isRetryable(response.status) || attempt === retryDelays.length - 1) {
131
+ throw new HttpError(
132
+ `HTTP request failed with status ${response.status}`,
133
+ response.status,
134
+ responseBody,
135
+ )
136
+ }
137
+ } catch (error) {
138
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
139
+ if (error instanceof HttpError) throw error
140
+ if (attempt === retryDelays.length - 1) throw error
141
+ } finally {
142
+ clearTimeout(timeout)
143
+ options.signal?.removeEventListener("abort", abort)
144
+ }
145
+ }
146
+
147
+ throw new Error("HTTP retry loop ended unexpectedly")
148
+ }
149
+
150
+ function permissionIdentity(request) {
151
+ const requestID = request.id ?? request.requestID
152
+ const sessionID = request.sessionID
153
+ if (typeof requestID !== "string" || typeof sessionID !== "string") return
154
+
155
+ return {
156
+ requestID,
157
+ sessionID,
158
+ externalId: `opencode:${sessionID}:${requestID}`,
159
+ }
160
+ }
161
+
162
+ async function publishPermission(config, request, directory, identity) {
163
+ const permissionType = request.permission ?? request.action ?? "unknown"
164
+ const resources = Array.isArray(request.patterns)
165
+ ? request.patterns.join(", ")
166
+ : Array.isArray(request.resources)
167
+ ? request.resources.join(", ")
168
+ : ""
169
+
170
+ const payload = [
171
+ { key: "Permission", value: limit(permissionType) },
172
+ { key: "Session", value: limit(identity.sessionID) },
173
+ { key: "Request ID", value: limit(identity.requestID) },
174
+ { key: "Patterns", value: limit(resources) },
175
+ { key: "Working directory", value: limit(directory) },
176
+ { key: "Action protocol", value: ACTION_PROTOCOL },
177
+ { key: "Available decisions", value: "once, always, reject" },
178
+ ].filter((item) => item.value.length > 0)
179
+
180
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
181
+ type: "application",
182
+ severity: "warning",
183
+ title: `OpenCode permission required: ${limit(permissionType, 200)}`,
184
+ body: resources
185
+ ? `OpenCode is waiting for approval to access: ${limit(resources, 500)}`
186
+ : `OpenCode is waiting for approval for: ${limit(permissionType, 500)}`,
187
+ payload,
188
+ externalId: identity.externalId,
189
+ timestamp: new Date().toISOString(),
190
+ notify: true,
191
+ direction: "in",
192
+ })
193
+ console.log(`[backeer] permission event accepted (${result.status})`)
194
+ }
195
+
196
+ function questionIdentity(request) {
197
+ const requestID = request.id ?? request.requestID
198
+ const sessionID = request.sessionID
199
+ if (typeof requestID !== "string" || typeof sessionID !== "string") return
200
+
201
+ return {
202
+ requestID,
203
+ sessionID,
204
+ externalId: `opencode-question:${sessionID}:${requestID}`,
205
+ }
206
+ }
207
+
208
+ function formatQuestionOptions(options) {
209
+ if (!Array.isArray(options)) return ""
210
+
211
+ return options
212
+ .map((option) => {
213
+ const label = limit(option?.label, 200)
214
+ const description = limit(option?.description, 500)
215
+ if (!label) return ""
216
+ return description ? `${label} — ${description}` : label
217
+ })
218
+ .filter(Boolean)
219
+ .join(" | ")
220
+ }
221
+
222
+ async function resolveSessionTitle(client, directory, sessionID) {
223
+ if (typeof client?.session?.get !== "function") return ""
224
+
225
+ try {
226
+ const result = await client.session.get({
227
+ path: { id: sessionID },
228
+ query: { directory },
229
+ throwOnError: true,
230
+ })
231
+ return limit((result?.data ?? result)?.title)
232
+ } catch (error) {
233
+ console.error(`[backeer] could not resolve session title: ${limit(error, 500)}`)
234
+ return ""
235
+ }
236
+ }
237
+
238
+ async function publishQuestion(config, request, identity, client, directory) {
239
+ const questions = Array.isArray(request.questions) ? request.questions : []
240
+ if (questions.length === 0) return
241
+
242
+ const sessionTitle = await resolveSessionTitle(
243
+ client,
244
+ directory,
245
+ identity.sessionID,
246
+ )
247
+ const payload = [
248
+ { key: "OpenCode session", value: sessionTitle },
249
+ ...questions.flatMap((question, index) => {
250
+ const suffix = questions.length > 1 ? ` ${index + 1}` : ""
251
+ return [
252
+ { key: `Question title${suffix}`, value: limit(question?.header) },
253
+ { key: `Question${suffix}`, value: limit(question?.question) },
254
+ {
255
+ key: `Options${suffix}`,
256
+ value: limit(formatQuestionOptions(question?.options)),
257
+ },
258
+ ]
259
+ }),
260
+ ].filter((item) => item.value.length > 0)
261
+
262
+ const firstQuestion = questions[0]
263
+ const body = questions
264
+ .map((question) => {
265
+ const header = limit(question?.header, 200)
266
+ const text = limit(question?.question, 500)
267
+ const options = limit(formatQuestionOptions(question?.options), 500)
268
+ return [header, text, options ? `Options: ${options}` : ""]
269
+ .filter(Boolean)
270
+ .join("\n")
271
+ })
272
+ .join("\n\n")
273
+
274
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
275
+ type: "application",
276
+ severity: "info",
277
+ title: `OpenCode question: ${limit(firstQuestion?.header, 200) || "Question"}`,
278
+ body: limit(body, 2000),
279
+ payload,
280
+ externalId: identity.externalId,
281
+ timestamp: new Date().toISOString(),
282
+ notify: true,
283
+ direction: "in",
284
+ })
285
+ console.log(`[backeer] question event accepted (${result.status})`)
286
+ }
287
+
288
+ function stableHash(value) {
289
+ let hash = 2166136261
290
+ for (let index = 0; index < value.length; index++) {
291
+ hash ^= value.charCodeAt(index)
292
+ hash = Math.imul(hash, 16777619)
293
+ }
294
+ return (hash >>> 0).toString(16).padStart(8, "0")
295
+ }
296
+
297
+ async function publishTodoCompletion(config, request, eventID, client, directory) {
298
+ const todos = request.todos
299
+ const sessionTitle = await resolveSessionTitle(
300
+ client,
301
+ directory,
302
+ request.sessionID,
303
+ )
304
+ const correlation =
305
+ typeof eventID === "string" && eventID.length > 0
306
+ ? limit(eventID, 200)
307
+ : stableHash(JSON.stringify(todos))
308
+ const externalId = `opencode-todo:${request.sessionID}:${correlation}`
309
+ const visibleTodos = todos.slice(0, 20)
310
+ const payload = [
311
+ { key: "OpenCode session", value: sessionTitle },
312
+ { key: "Completed tasks", value: String(todos.length) },
313
+ ...visibleTodos.flatMap((todo, index) => [
314
+ { key: `Task ${index + 1}`, value: limit(todo?.content) },
315
+ { key: `Priority ${index + 1}`, value: limit(todo?.priority, 100) },
316
+ ]),
317
+ ...(todos.length > visibleTodos.length
318
+ ? [{ key: "Additional tasks", value: String(todos.length - visibleTodos.length) }]
319
+ : []),
320
+ ].filter((item) => item.value.length > 0)
321
+
322
+ const summary = todos
323
+ .slice(0, 5)
324
+ .map((todo) => limit(todo?.content, 200))
325
+ .filter(Boolean)
326
+ .join(" • ")
327
+ const location = sessionTitle ? ` in ${sessionTitle}` : ""
328
+ const body = [
329
+ `All ${todos.length} OpenCode tasks completed${location}.`,
330
+ summary,
331
+ ].filter(Boolean).join("\n")
332
+
333
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
334
+ type: "application",
335
+ severity: "info",
336
+ title: "OpenCode todo list completed",
337
+ body: limit(body, 2000),
338
+ payload,
339
+ externalId,
340
+ timestamp: new Date().toISOString(),
341
+ notify: true,
342
+ direction: "in",
343
+ })
344
+ console.log(`[backeer] todo completion accepted (${result.status})`)
345
+ }
346
+
347
+ function parseSseFrame(frame) {
348
+ const data = []
349
+ let event = "message"
350
+ let id
351
+
352
+ for (const line of frame.split(/\r?\n/)) {
353
+ if (!line || line.startsWith(":")) continue
354
+ const separator = line.indexOf(":")
355
+ const field = separator === -1 ? line : line.slice(0, separator)
356
+ let value = separator === -1 ? "" : line.slice(separator + 1)
357
+ if (value.startsWith(" ")) value = value.slice(1)
358
+
359
+ if (field === "event") event = value
360
+ if (field === "id") id = value
361
+ if (field === "data") data.push(value)
362
+ }
363
+
364
+ if (data.length === 0) return
365
+ return { event, id, data: data.join("\n") }
366
+ }
367
+
368
+ function nextSseFrame(buffer) {
369
+ const lfIndex = buffer.indexOf("\n\n")
370
+ const crlfIndex = buffer.indexOf("\r\n\r\n")
371
+ if (lfIndex === -1 && crlfIndex === -1) return
372
+
373
+ if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
374
+ return {
375
+ frame: buffer.slice(0, crlfIndex),
376
+ rest: buffer.slice(crlfIndex + 4),
377
+ }
378
+ }
379
+ return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
380
+ }
381
+
382
+ async function consumeSse(response, state, onCommand, signal) {
383
+ if (!response.body) throw new Error("Backeer command stream has no body")
384
+
385
+ const reader = response.body.getReader()
386
+ const decoder = new TextDecoder()
387
+ let buffer = ""
388
+
389
+ try {
390
+ while (!signal.aborted) {
391
+ const item = await reader.read()
392
+ if (item.done) break
393
+ buffer += decoder.decode(item.value, { stream: true })
394
+
395
+ for (;;) {
396
+ const next = nextSseFrame(buffer)
397
+ if (!next) break
398
+ buffer = next.rest
399
+ const message = parseSseFrame(next.frame)
400
+ if (!message) continue
401
+ if (message.id) state.lastEventID = message.id
402
+ if (message.event !== "message" && message.event !== "command") continue
403
+
404
+ try {
405
+ await onCommand(JSON.parse(message.data))
406
+ } catch (error) {
407
+ if (error instanceof SyntaxError) {
408
+ console.error("[backeer] command stream returned invalid JSON")
409
+ continue
410
+ }
411
+ throw error
412
+ }
413
+ }
414
+ }
415
+ } finally {
416
+ await reader.cancel().catch(() => {})
417
+ }
418
+ }
419
+
420
+ async function runCommandStream(config, { signal, hasPending, onCommand }) {
421
+ const state = { lastEventID: "" }
422
+ let reconnectDelay = 1000
423
+ let lastLoggedError = ""
424
+
425
+ while (!signal.aborted && hasPending()) {
426
+ try {
427
+ const headers = {
428
+ Accept: "text/event-stream",
429
+ Authorization: `Bearer ${config.token}`,
430
+ }
431
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
432
+
433
+ const response = await fetch(config.commandStreamEndpoint, {
434
+ headers,
435
+ signal,
436
+ })
437
+ if (!response.ok) {
438
+ const responseBody = limit(await response.text(), 500)
439
+ throw new HttpError(
440
+ `Backeer command stream failed with status ${response.status}`,
441
+ response.status,
442
+ responseBody,
443
+ )
444
+ }
445
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
446
+ throw new Error("Backeer command stream returned an invalid content type")
447
+ }
448
+
449
+ reconnectDelay = 1000
450
+ lastLoggedError = ""
451
+ await consumeSse(response, state, onCommand, signal)
452
+ } catch (error) {
453
+ if (signal.aborted || isAbortError(error)) return
454
+ const message = error instanceof HttpError
455
+ ? `${error.message}: ${error.responseBody}`
456
+ : limit(error, 500)
457
+ if (message !== lastLoggedError) {
458
+ console.error(`[backeer] ${message}`)
459
+ lastLoggedError = message
460
+ }
461
+ if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
462
+ reconnectDelay = 60_000
463
+ }
464
+ }
465
+
466
+ if (!signal.aborted && hasPending()) {
467
+ await sleep(reconnectDelay, signal)
468
+ reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
469
+ }
470
+ }
471
+ }
472
+
473
+ async function replyToOpenCode(client, pending, decision) {
474
+ const result = await client.postSessionIdPermissionsPermissionId({
475
+ path: { id: pending.sessionID, permissionID: pending.requestID },
476
+ query: { directory: pending.directory },
477
+ body: { response: decision },
478
+ throwOnError: true,
479
+ })
480
+
481
+ if (result?.data === false || result === false) {
482
+ throw new HttpError("OpenCode did not apply the permission reply", 409)
483
+ }
484
+ }
485
+
486
+ async function acknowledgeCommand(config, commandID, acknowledgement) {
487
+ const url = new URL(
488
+ `/v1/commands/${encodeURIComponent(commandID)}/ack`,
489
+ config.origin,
490
+ )
491
+ await postJsonWithRetry(config, url, acknowledgement)
492
+ }
493
+
494
+ function rememberBounded(map, key, value) {
495
+ map.delete(key)
496
+ map.set(key, value)
497
+ while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
498
+ }
499
+
500
+ /**
501
+ * OpenCode server plugin entrypoint.
502
+ *
503
+ * Supported options: origin, tokenFile, permissions, questions, todos.
504
+ * The ingest token is intentionally accepted only through the environment or a
505
+ * protected file so it cannot accidentally be committed in opencode.json.
506
+ */
507
+ export default async function BackeerPlugin({ client, directory }, options = {}) {
508
+ let config
509
+ try {
510
+ config = loadConfig(options)
511
+ } catch (error) {
512
+ console.error(`[backeer] plugin disabled: ${limit(error, 500)}`)
513
+ return {}
514
+ }
515
+
516
+ if (!config.token) {
517
+ console.error(
518
+ `[backeer] plugin disabled: set BACKEER_INGEST_TOKEN or create ${config.tokenFile}`,
519
+ )
520
+ return {}
521
+ }
522
+
523
+ const pendingByExternalID = new Map()
524
+ const externalIDByRequestID = new Map()
525
+ const processingCommands = new Set()
526
+ const completedCommands = new Map()
527
+ const todoCompletionBySessionID = new Map()
528
+ const publishedPermissions = new Map()
529
+ const publishedQuestions = new Map()
530
+ let commandStream
531
+ let disposed = false
532
+
533
+ function removePendingByRequestID(requestID) {
534
+ const externalId = externalIDByRequestID.get(requestID)
535
+ if (!externalId) return
536
+ externalIDByRequestID.delete(requestID)
537
+ pendingByExternalID.delete(externalId)
538
+ }
539
+
540
+ function stopCommandStreamIfIdle() {
541
+ if (pendingByExternalID.size === 0) commandStream?.controller.abort()
542
+ }
543
+
544
+ async function handleCommand(command) {
545
+ if (!command || typeof command !== "object") return
546
+ if (command.type !== "opencode_permission_reply") return
547
+ if (typeof command.id !== "string" || command.id.length > 200) return
548
+
549
+ const previousAcknowledgement = completedCommands.get(command.id)
550
+ if (previousAcknowledgement) {
551
+ await acknowledgeCommand(config, command.id, previousAcknowledgement).catch(
552
+ (error) => console.error(`[backeer] command ACK retry failed: ${limit(error, 500)}`),
553
+ )
554
+ return
555
+ }
556
+ if (processingCommands.has(command.id)) return
557
+ if (typeof command.externalId !== "string") return
558
+
559
+ const pending = pendingByExternalID.get(command.externalId)
560
+ if (!pending) return
561
+
562
+ processingCommands.add(command.id)
563
+ let acknowledgement
564
+ try {
565
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
566
+ acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
567
+ } else {
568
+ const expiresAt = Date.parse(command.expiresAt)
569
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
570
+ acknowledgement = { status: "expired", decision: command.decision }
571
+ } else if (command.decision === "always" && command.confirmed !== true) {
572
+ acknowledgement = {
573
+ status: "rejected",
574
+ decision: command.decision,
575
+ errorCode: "confirmation_required",
576
+ }
577
+ } else {
578
+ await replyToOpenCode(client, pending, command.decision)
579
+ acknowledgement = { status: "applied", decision: command.decision }
580
+ removePendingByRequestID(pending.requestID)
581
+ }
582
+ }
583
+ } catch (error) {
584
+ const stale = error instanceof HttpError && error.status === 404
585
+ acknowledgement = {
586
+ status: stale ? "stale" : "failed",
587
+ decision: ALLOWED_DECISIONS.has(command.decision)
588
+ ? command.decision
589
+ : undefined,
590
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
591
+ }
592
+ if (stale) removePendingByRequestID(pending.requestID)
593
+ console.error(`[backeer] permission command failed: ${limit(error, 500)}`)
594
+ } finally {
595
+ processingCommands.delete(command.id)
596
+ }
597
+
598
+ acknowledgement = {
599
+ ...acknowledgement,
600
+ type: "opencode_permission_reply_ack",
601
+ externalId: command.externalId,
602
+ processedAt: new Date().toISOString(),
603
+ }
604
+ rememberBounded(completedCommands, command.id, acknowledgement)
605
+ await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
606
+ console.error(`[backeer] command ACK failed: ${limit(error, 500)}`)
607
+ })
608
+ stopCommandStreamIfIdle()
609
+ }
610
+
611
+ function ensureCommandStream() {
612
+ if (disposed || commandStream || pendingByExternalID.size === 0) return
613
+
614
+ const controller = new AbortController()
615
+ const current = {
616
+ controller,
617
+ task: runCommandStream(config, {
618
+ signal: controller.signal,
619
+ hasPending: () => pendingByExternalID.size > 0,
620
+ onCommand: handleCommand,
621
+ }),
622
+ }
623
+ commandStream = current
624
+ void current.task.finally(() => {
625
+ if (commandStream === current) commandStream = undefined
626
+ if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
627
+ })
628
+ }
629
+
630
+ return {
631
+ event: async ({ event }) => {
632
+ if (config.permissions && event.type === "permission.asked") {
633
+ const identity = permissionIdentity(event.properties)
634
+ if (!identity) {
635
+ console.error("[backeer] permission event is missing IDs")
636
+ return
637
+ }
638
+
639
+ pendingByExternalID.set(identity.externalId, { ...identity, directory })
640
+ externalIDByRequestID.set(identity.requestID, identity.externalId)
641
+ ensureCommandStream()
642
+
643
+ if (publishedPermissions.has(identity.externalId)) return
644
+ rememberBounded(publishedPermissions, identity.externalId, true)
645
+ void publishPermission(config, event.properties, directory, identity).catch(
646
+ (error) => console.error(`[backeer] permission webhook failed: ${limit(error, 500)}`),
647
+ )
648
+ return
649
+ }
650
+
651
+ if (config.permissions && event.type === "permission.replied") {
652
+ removePendingByRequestID(event.properties.requestID ?? event.properties.id)
653
+ stopCommandStreamIfIdle()
654
+ return
655
+ }
656
+
657
+ if (config.todos && event.type === "todo.updated") {
658
+ const { sessionID, todos } = event.properties
659
+ if (typeof sessionID !== "string" || !Array.isArray(todos)) {
660
+ console.error("[backeer] todo event is missing a valid session or list")
661
+ return
662
+ }
663
+
664
+ const isComplete = todos.length > 0 &&
665
+ todos.every((todo) => todo?.status === "completed")
666
+ const wasComplete = todoCompletionBySessionID.get(sessionID) === true
667
+ rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
668
+ if (!isComplete || wasComplete) return
669
+
670
+ void publishTodoCompletion(
671
+ config,
672
+ event.properties,
673
+ event.id,
674
+ client,
675
+ directory,
676
+ ).catch(
677
+ (error) => console.error(`[backeer] todo webhook failed: ${limit(error, 500)}`),
678
+ )
679
+ return
680
+ }
681
+
682
+ if (
683
+ config.questions &&
684
+ (event.type === "question.asked" || event.type === "question.v2.asked")
685
+ ) {
686
+ const identity = questionIdentity(event.properties)
687
+ if (!identity) {
688
+ console.error("[backeer] question event is missing IDs")
689
+ return
690
+ }
691
+ if (publishedQuestions.has(identity.externalId)) return
692
+ rememberBounded(publishedQuestions, identity.externalId, true)
693
+
694
+ void publishQuestion(
695
+ config,
696
+ event.properties,
697
+ identity,
698
+ client,
699
+ directory,
700
+ ).catch(
701
+ (error) => console.error(`[backeer] question webhook failed: ${limit(error, 500)}`),
702
+ )
703
+ }
704
+ },
705
+ dispose: async () => {
706
+ disposed = true
707
+ commandStream?.controller.abort()
708
+ await commandStream?.task.catch(() => {})
709
+ pendingByExternalID.clear()
710
+ externalIDByRequestID.clear()
711
+ todoCompletionBySessionID.clear()
712
+ publishedPermissions.clear()
713
+ publishedQuestions.clear()
714
+ },
715
+ }
716
+ }