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 +12 -0
- package/LICENSE +58 -0
- package/README.md +186 -0
- package/bin/backeer-opencode.js +92 -0
- package/package.json +49 -0
- package/src/index.js +716 -0
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
|
+
}
|