backeer-opencode 0.1.0 → 0.1.2
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 +14 -0
- package/README.md +27 -3
- package/bin/backeer-opencode.js +140 -23
- package/package.json +1 -1
- package/src/index.js +147 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.2 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Replace raw HTTP log lines with compact Backeer console status messages.
|
|
8
|
+
- Add restrained TTY colors with `NO_COLOR` support.
|
|
9
|
+
- Suppress repeated connection errors for 30 seconds.
|
|
10
|
+
- Add `BACKEER_LOG_LEVEL` and the `logLevel` plugin option.
|
|
11
|
+
- Keep technical HTTP and error details in debug mode only.
|
|
12
|
+
|
|
13
|
+
## 0.1.1 - 2026-09-01
|
|
14
|
+
|
|
15
|
+
- Add `backeer-opencode configure` for hidden interactive token entry.
|
|
16
|
+
- Preserve existing tokens unless replacement is explicitly confirmed.
|
|
17
|
+
- Keep npm plugin configuration separate from side-loaded installation.
|
|
18
|
+
|
|
5
19
|
## 0.1.0 - 2026-09-01
|
|
6
20
|
|
|
7
21
|
- Publish OpenCode permission requests to Backeer.
|
package/README.md
CHANGED
|
@@ -53,8 +53,13 @@ Install the package in OpenCode's global configuration:
|
|
|
53
53
|
|
|
54
54
|
```sh
|
|
55
55
|
opencode plugin backeer-opencode -g
|
|
56
|
+
npx backeer-opencode@latest configure
|
|
56
57
|
```
|
|
57
58
|
|
|
59
|
+
`configure` asks for the source ingest token without echoing it, stores it with
|
|
60
|
+
mode `0600`, and does not install a second side-loaded plugin. If a token already
|
|
61
|
+
exists, replacement requires confirmation.
|
|
62
|
+
|
|
58
63
|
Alternatively, add it to `~/.config/opencode/opencode.json` manually:
|
|
59
64
|
|
|
60
65
|
```json
|
|
@@ -72,7 +77,8 @@ If npm package loading is not available in a particular OpenCode installation,
|
|
|
72
77
|
install the package as a side-loaded plugin instead:
|
|
73
78
|
|
|
74
79
|
```sh
|
|
75
|
-
npx backeer-opencode@latest
|
|
80
|
+
npx backeer-opencode@latest install \
|
|
81
|
+
--token-file /path/from/your/secret-manager/backeer-token
|
|
76
82
|
```
|
|
77
83
|
|
|
78
84
|
Use either the configured npm plugin or the side-loaded form, not both. Loading
|
|
@@ -103,6 +109,7 @@ environment variables are supported:
|
|
|
103
109
|
| `BACKEER_NOTIFY_PERMISSIONS` | Set to `false` to disable permission notifications and remote replies |
|
|
104
110
|
| `BACKEER_NOTIFY_QUESTIONS` | Set to `false` to disable question notifications |
|
|
105
111
|
| `BACKEER_NOTIFY_TODOS` | Set to `false` to disable todo-completion notifications |
|
|
112
|
+
| `BACKEER_LOG_LEVEL` | `info` (default), `error`, `debug`, or `silent` |
|
|
106
113
|
|
|
107
114
|
For npm installations, non-secret options can also be supplied through the
|
|
108
115
|
OpenCode plugin tuple syntax:
|
|
@@ -122,8 +129,25 @@ OpenCode plugin tuple syntax:
|
|
|
122
129
|
}
|
|
123
130
|
```
|
|
124
131
|
|
|
125
|
-
Supported options are `origin`, `tokenFile`, `permissions`, `questions`,
|
|
126
|
-
`todos`. The token itself is deliberately not a plugin option.
|
|
132
|
+
Supported options are `origin`, `tokenFile`, `permissions`, `questions`,
|
|
133
|
+
`todos`, and `logLevel`. The token itself is deliberately not a plugin option.
|
|
134
|
+
|
|
135
|
+
## Console feedback
|
|
136
|
+
|
|
137
|
+
The plugin writes compact, single-line status messages instead of raw HTTP logs:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
◆ Backeer Permission request sent
|
|
141
|
+
◆ Backeer Question sent
|
|
142
|
+
◆ Backeer Todo completion sent
|
|
143
|
+
✓ Backeer Decision applied: Always allow
|
|
144
|
+
! Backeer Connection interrupted — retrying
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
TTY output uses restrained status colors and respects `NO_COLOR`. Repeated
|
|
148
|
+
connection errors are suppressed for 30 seconds. Normal `info` output omits HTTP
|
|
149
|
+
status codes and internal errors; set `BACKEER_LOG_LEVEL=debug` when technical
|
|
150
|
+
details are needed. Set it to `error` for failures only or `silent` for no output.
|
|
127
151
|
|
|
128
152
|
## Events and decisions
|
|
129
153
|
|
package/bin/backeer-opencode.js
CHANGED
|
@@ -3,39 +3,61 @@
|
|
|
3
3
|
import {
|
|
4
4
|
chmodSync,
|
|
5
5
|
copyFileSync,
|
|
6
|
+
existsSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
readFileSync,
|
|
8
9
|
renameSync,
|
|
10
|
+
statSync,
|
|
9
11
|
writeFileSync,
|
|
10
12
|
} from "node:fs"
|
|
11
13
|
import { homedir } from "node:os"
|
|
12
14
|
import { dirname, join, resolve } from "node:path"
|
|
15
|
+
import { createInterface } from "node:readline/promises"
|
|
13
16
|
import { fileURLToPath } from "node:url"
|
|
14
17
|
|
|
15
18
|
function usage(exitCode = 0) {
|
|
16
|
-
console.log(`Usage: backeer-opencode [options]
|
|
19
|
+
console.log(`Usage: backeer-opencode [command] [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
configure Securely prompt for and store the Backeer ingest token
|
|
23
|
+
install Install the side-loaded plugin (default)
|
|
17
24
|
|
|
18
25
|
Options:
|
|
19
26
|
--config-dir PATH OpenCode config directory (default: XDG_CONFIG_HOME/opencode)
|
|
20
|
-
--token-file PATH Read the
|
|
21
|
-
--token-stdin Read the
|
|
27
|
+
--token-file PATH Read the token from PATH when using install
|
|
28
|
+
--token-stdin Read the token from standard input when using install
|
|
29
|
+
--force Replace an existing token without confirmation
|
|
22
30
|
--help Show this help
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
Examples:
|
|
33
|
+
backeer-opencode configure
|
|
34
|
+
backeer-opencode install --token-file /run/secrets/backeer-token
|
|
35
|
+
|
|
36
|
+
If install has no token option, an existing token is preserved.`)
|
|
25
37
|
process.exit(exitCode)
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
function parseArguments(arguments_) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
const remaining = [...arguments_]
|
|
42
|
+
let command = "install"
|
|
43
|
+
if (remaining[0] === "configure" || remaining[0] === "install") {
|
|
44
|
+
command = remaining.shift()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = { command }
|
|
48
|
+
for (let index = 0; index < remaining.length; index++) {
|
|
49
|
+
const argument = remaining[index]
|
|
32
50
|
if (argument === "--help") usage()
|
|
33
51
|
if (argument === "--token-stdin") {
|
|
34
52
|
result.tokenStdin = true
|
|
35
53
|
continue
|
|
36
54
|
}
|
|
55
|
+
if (argument === "--force") {
|
|
56
|
+
result.force = true
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
37
59
|
if (argument === "--config-dir" || argument === "--token-file") {
|
|
38
|
-
const value =
|
|
60
|
+
const value = remaining[++index]
|
|
39
61
|
if (!value) usage(1)
|
|
40
62
|
result[argument === "--config-dir" ? "configDir" : "tokenFile"] = value
|
|
41
63
|
continue
|
|
@@ -46,6 +68,12 @@ function parseArguments(arguments_) {
|
|
|
46
68
|
if (result.tokenStdin && result.tokenFile) {
|
|
47
69
|
throw new Error("Use either --token-stdin or --token-file, not both")
|
|
48
70
|
}
|
|
71
|
+
if (command === "configure" && (result.tokenStdin || result.tokenFile)) {
|
|
72
|
+
throw new Error("configure prompts securely; do not use token input options")
|
|
73
|
+
}
|
|
74
|
+
if (command === "install" && result.force) {
|
|
75
|
+
throw new Error("--force is supported only by configure")
|
|
76
|
+
}
|
|
49
77
|
return result
|
|
50
78
|
}
|
|
51
79
|
|
|
@@ -63,6 +91,83 @@ function writeSecretAtomically(path, token) {
|
|
|
63
91
|
renameSync(temporaryPath, path)
|
|
64
92
|
}
|
|
65
93
|
|
|
94
|
+
async function confirmReplacement(secretPath) {
|
|
95
|
+
if (!existsSync(secretPath) || statSync(secretPath).size === 0) return true
|
|
96
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`A token already exists at ${secretPath}; rerun in a terminal or use --force`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout })
|
|
103
|
+
try {
|
|
104
|
+
const answer = await prompt.question(
|
|
105
|
+
`A Backeer token already exists at ${secretPath}. Replace it? [y/N] `,
|
|
106
|
+
)
|
|
107
|
+
return ["y", "yes"].includes(answer.trim().toLowerCase())
|
|
108
|
+
} finally {
|
|
109
|
+
prompt.close()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readHiddenToken() {
|
|
114
|
+
const input = process.stdin
|
|
115
|
+
const output = process.stdout
|
|
116
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") {
|
|
117
|
+
throw new Error("Interactive token entry requires a terminal")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
121
|
+
let token = ""
|
|
122
|
+
const wasRaw = input.isRaw === true
|
|
123
|
+
output.write("Backeer source ingest token: ")
|
|
124
|
+
input.setEncoding("utf8")
|
|
125
|
+
input.setRawMode(true)
|
|
126
|
+
input.resume()
|
|
127
|
+
|
|
128
|
+
function cleanup() {
|
|
129
|
+
input.removeListener("data", onData)
|
|
130
|
+
input.setRawMode(wasRaw)
|
|
131
|
+
if (!wasRaw) input.pause()
|
|
132
|
+
output.write("\n")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onData(chunk) {
|
|
136
|
+
for (const character of chunk) {
|
|
137
|
+
if (character === "\u0003") {
|
|
138
|
+
cleanup()
|
|
139
|
+
rejectPromise(new Error("Token configuration cancelled"))
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (character === "\r" || character === "\n") {
|
|
143
|
+
cleanup()
|
|
144
|
+
resolvePromise(token.trim())
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (character === "\u007f" || character === "\b") {
|
|
148
|
+
token = token.slice(0, -1)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (character >= " ") token += character
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
input.on("data", onData)
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function configureToken(secretPath, force) {
|
|
160
|
+
if (!force && !(await confirmReplacement(secretPath))) {
|
|
161
|
+
console.log("Backeer token unchanged.")
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const token = await readHiddenToken()
|
|
166
|
+
writeSecretAtomically(secretPath, token)
|
|
167
|
+
console.log(`Configured protected Backeer token: ${secretPath}`)
|
|
168
|
+
console.log("Restart OpenCode to use the new token.")
|
|
169
|
+
}
|
|
170
|
+
|
|
66
171
|
const arguments_ = parseArguments(process.argv.slice(2))
|
|
67
172
|
const configDirectory = resolve(arguments_.configDir ?? defaultConfigDirectory())
|
|
68
173
|
const pluginDirectory = join(configDirectory, "plugins")
|
|
@@ -73,20 +178,32 @@ const sourcePath = join(packageDirectory, "src", "index.js")
|
|
|
73
178
|
// replaces it instead of loading two copies of the same plugin.
|
|
74
179
|
const installedPath = join(pluginDirectory, "backeer-permission.js")
|
|
75
180
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
181
|
+
try {
|
|
182
|
+
if (arguments_.command === "configure") {
|
|
183
|
+
await configureToken(secretPath, arguments_.force)
|
|
184
|
+
} else {
|
|
185
|
+
mkdirSync(pluginDirectory, { recursive: true })
|
|
186
|
+
copyFileSync(sourcePath, installedPath)
|
|
187
|
+
chmodSync(installedPath, 0o644)
|
|
79
188
|
|
|
80
|
-
if (arguments_.tokenFile) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
189
|
+
if (arguments_.tokenFile) {
|
|
190
|
+
writeSecretAtomically(
|
|
191
|
+
secretPath,
|
|
192
|
+
readFileSync(resolve(arguments_.tokenFile), "utf8").trim(),
|
|
193
|
+
)
|
|
194
|
+
} else if (arguments_.tokenStdin) {
|
|
195
|
+
writeSecretAtomically(secretPath, readFileSync(0, "utf8").trim())
|
|
196
|
+
}
|
|
85
197
|
|
|
86
|
-
console.log(`Installed Backeer OpenCode plugin: ${installedPath}`)
|
|
87
|
-
console.log(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
console.log("Restart OpenCode to load the plugin.")
|
|
198
|
+
console.log(`Installed Backeer OpenCode plugin: ${installedPath}`)
|
|
199
|
+
console.log(
|
|
200
|
+
arguments_.tokenFile || arguments_.tokenStdin
|
|
201
|
+
? `Installed protected ingest token: ${secretPath}`
|
|
202
|
+
: `Token unchanged; expected at ${secretPath} or in BACKEER_INGEST_TOKEN`,
|
|
203
|
+
)
|
|
204
|
+
console.log("Restart OpenCode to load the plugin.")
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error(`Backeer configuration failed: ${error.message}`)
|
|
208
|
+
process.exitCode = 1
|
|
209
|
+
}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -8,6 +8,14 @@ const RETRY_DELAYS_MS = [0, 500, 1500]
|
|
|
8
8
|
const MAX_COMPLETED_COMMANDS = 500
|
|
9
9
|
const MAX_TRACKED_ITEMS = 500
|
|
10
10
|
const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
|
|
11
|
+
const DECISION_LABELS = {
|
|
12
|
+
once: "Allow once",
|
|
13
|
+
always: "Always allow",
|
|
14
|
+
reject: "Reject",
|
|
15
|
+
}
|
|
16
|
+
const LOG_LEVELS = new Set(["silent", "error", "info", "debug"])
|
|
17
|
+
const LOG_RANK = { silent: 0, error: 1, info: 2, debug: 3 }
|
|
18
|
+
const LOG_DEDUPLICATION_MS = 30_000
|
|
11
19
|
|
|
12
20
|
class HttpError extends Error {
|
|
13
21
|
constructor(message, status, responseBody = "") {
|
|
@@ -21,6 +29,61 @@ class HttpError extends Error {
|
|
|
21
29
|
const limit = (value, length = 1000) =>
|
|
22
30
|
String(value ?? "").slice(0, length)
|
|
23
31
|
|
|
32
|
+
function normalizeLogLevel(value) {
|
|
33
|
+
const level = String(value ?? "info").trim().toLowerCase()
|
|
34
|
+
return LOG_LEVELS.has(level) ? level : "info"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createConsoleReporter(levelValue) {
|
|
38
|
+
const level = normalizeLogLevel(levelValue)
|
|
39
|
+
const useColor = Boolean(process.stdout?.isTTY) &&
|
|
40
|
+
!("NO_COLOR" in process.env) && process.env.TERM !== "dumb"
|
|
41
|
+
const recentlyEmitted = new Map()
|
|
42
|
+
|
|
43
|
+
const decorate = (code, text) => useColor ? `\u001b[${code}m${text}\u001b[0m` : text
|
|
44
|
+
const symbols = {
|
|
45
|
+
info: decorate("36", "◆"),
|
|
46
|
+
success: decorate("32", "◆"),
|
|
47
|
+
applied: decorate("32", "✓"),
|
|
48
|
+
warning: decorate("33", "!"),
|
|
49
|
+
error: decorate("31", "×"),
|
|
50
|
+
debug: decorate("2", "·"),
|
|
51
|
+
}
|
|
52
|
+
const brand = decorate("1", "Backeer")
|
|
53
|
+
|
|
54
|
+
function emit(requiredLevel, kind, message, detail, deduplicate = false) {
|
|
55
|
+
if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return
|
|
56
|
+
|
|
57
|
+
const key = `${kind}:${message}`
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
if (deduplicate) {
|
|
60
|
+
const previous = recentlyEmitted.get(key)
|
|
61
|
+
if (previous && now - previous < LOG_DEDUPLICATION_MS) return
|
|
62
|
+
recentlyEmitted.delete(key)
|
|
63
|
+
recentlyEmitted.set(key, now)
|
|
64
|
+
while (recentlyEmitted.size > 100) {
|
|
65
|
+
recentlyEmitted.delete(recentlyEmitted.keys().next().value)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const technical = level === "debug" && detail
|
|
70
|
+
? decorate("2", ` · ${limit(detail, 500)}`)
|
|
71
|
+
: ""
|
|
72
|
+
const line = `${symbols[kind]} ${brand} ${message}${technical}`
|
|
73
|
+
if (kind === "error" || kind === "warning") console.error(line)
|
|
74
|
+
else console.log(line)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
info: (message, detail) => emit("info", "info", message, detail),
|
|
79
|
+
success: (message, detail) => emit("info", "success", message, detail),
|
|
80
|
+
applied: (message, detail) => emit("info", "applied", message, detail),
|
|
81
|
+
warning: (message, detail) => emit("error", "warning", message, detail, true),
|
|
82
|
+
error: (message, detail) => emit("error", "error", message, detail, true),
|
|
83
|
+
debug: (message, detail) => emit("debug", "debug", message, detail),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
24
87
|
function defaultConfigDirectory() {
|
|
25
88
|
const base = process.env.XDG_CONFIG_HOME?.trim()
|
|
26
89
|
return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
|
|
@@ -69,6 +132,9 @@ function loadConfig(options = {}) {
|
|
|
69
132
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
|
|
70
133
|
),
|
|
71
134
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
135
|
+
logLevel: normalizeLogLevel(
|
|
136
|
+
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
137
|
+
),
|
|
72
138
|
}
|
|
73
139
|
}
|
|
74
140
|
|
|
@@ -159,7 +225,7 @@ function permissionIdentity(request) {
|
|
|
159
225
|
}
|
|
160
226
|
}
|
|
161
227
|
|
|
162
|
-
async function publishPermission(config, request, directory, identity) {
|
|
228
|
+
async function publishPermission(config, reporter, request, directory, identity) {
|
|
163
229
|
const permissionType = request.permission ?? request.action ?? "unknown"
|
|
164
230
|
const resources = Array.isArray(request.patterns)
|
|
165
231
|
? request.patterns.join(", ")
|
|
@@ -190,7 +256,7 @@ async function publishPermission(config, request, directory, identity) {
|
|
|
190
256
|
notify: true,
|
|
191
257
|
direction: "in",
|
|
192
258
|
})
|
|
193
|
-
|
|
259
|
+
reporter.success("Permission request sent", `HTTP ${result.status}`)
|
|
194
260
|
}
|
|
195
261
|
|
|
196
262
|
function questionIdentity(request) {
|
|
@@ -219,7 +285,7 @@ function formatQuestionOptions(options) {
|
|
|
219
285
|
.join(" | ")
|
|
220
286
|
}
|
|
221
287
|
|
|
222
|
-
async function resolveSessionTitle(client, directory, sessionID) {
|
|
288
|
+
async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
223
289
|
if (typeof client?.session?.get !== "function") return ""
|
|
224
290
|
|
|
225
291
|
try {
|
|
@@ -230,12 +296,19 @@ async function resolveSessionTitle(client, directory, sessionID) {
|
|
|
230
296
|
})
|
|
231
297
|
return limit((result?.data ?? result)?.title)
|
|
232
298
|
} catch (error) {
|
|
233
|
-
|
|
299
|
+
reporter.debug("Session title unavailable", error)
|
|
234
300
|
return ""
|
|
235
301
|
}
|
|
236
302
|
}
|
|
237
303
|
|
|
238
|
-
async function publishQuestion(
|
|
304
|
+
async function publishQuestion(
|
|
305
|
+
config,
|
|
306
|
+
reporter,
|
|
307
|
+
request,
|
|
308
|
+
identity,
|
|
309
|
+
client,
|
|
310
|
+
directory,
|
|
311
|
+
) {
|
|
239
312
|
const questions = Array.isArray(request.questions) ? request.questions : []
|
|
240
313
|
if (questions.length === 0) return
|
|
241
314
|
|
|
@@ -243,6 +316,7 @@ async function publishQuestion(config, request, identity, client, directory) {
|
|
|
243
316
|
client,
|
|
244
317
|
directory,
|
|
245
318
|
identity.sessionID,
|
|
319
|
+
reporter,
|
|
246
320
|
)
|
|
247
321
|
const payload = [
|
|
248
322
|
{ key: "OpenCode session", value: sessionTitle },
|
|
@@ -282,7 +356,7 @@ async function publishQuestion(config, request, identity, client, directory) {
|
|
|
282
356
|
notify: true,
|
|
283
357
|
direction: "in",
|
|
284
358
|
})
|
|
285
|
-
|
|
359
|
+
reporter.success("Question sent", `HTTP ${result.status}`)
|
|
286
360
|
}
|
|
287
361
|
|
|
288
362
|
function stableHash(value) {
|
|
@@ -294,12 +368,20 @@ function stableHash(value) {
|
|
|
294
368
|
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
295
369
|
}
|
|
296
370
|
|
|
297
|
-
async function publishTodoCompletion(
|
|
371
|
+
async function publishTodoCompletion(
|
|
372
|
+
config,
|
|
373
|
+
reporter,
|
|
374
|
+
request,
|
|
375
|
+
eventID,
|
|
376
|
+
client,
|
|
377
|
+
directory,
|
|
378
|
+
) {
|
|
298
379
|
const todos = request.todos
|
|
299
380
|
const sessionTitle = await resolveSessionTitle(
|
|
300
381
|
client,
|
|
301
382
|
directory,
|
|
302
383
|
request.sessionID,
|
|
384
|
+
reporter,
|
|
303
385
|
)
|
|
304
386
|
const correlation =
|
|
305
387
|
typeof eventID === "string" && eventID.length > 0
|
|
@@ -341,7 +423,7 @@ async function publishTodoCompletion(config, request, eventID, client, directory
|
|
|
341
423
|
notify: true,
|
|
342
424
|
direction: "in",
|
|
343
425
|
})
|
|
344
|
-
|
|
426
|
+
reporter.success("Todo completion sent", `HTTP ${result.status}`)
|
|
345
427
|
}
|
|
346
428
|
|
|
347
429
|
function parseSseFrame(frame) {
|
|
@@ -379,7 +461,7 @@ function nextSseFrame(buffer) {
|
|
|
379
461
|
return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
|
|
380
462
|
}
|
|
381
463
|
|
|
382
|
-
async function consumeSse(response, state, onCommand, signal) {
|
|
464
|
+
async function consumeSse(response, state, onCommand, signal, reporter) {
|
|
383
465
|
if (!response.body) throw new Error("Backeer command stream has no body")
|
|
384
466
|
|
|
385
467
|
const reader = response.body.getReader()
|
|
@@ -405,7 +487,7 @@ async function consumeSse(response, state, onCommand, signal) {
|
|
|
405
487
|
await onCommand(JSON.parse(message.data))
|
|
406
488
|
} catch (error) {
|
|
407
489
|
if (error instanceof SyntaxError) {
|
|
408
|
-
|
|
490
|
+
reporter.error("Invalid command received", "SSE data is not valid JSON")
|
|
409
491
|
continue
|
|
410
492
|
}
|
|
411
493
|
throw error
|
|
@@ -417,7 +499,11 @@ async function consumeSse(response, state, onCommand, signal) {
|
|
|
417
499
|
}
|
|
418
500
|
}
|
|
419
501
|
|
|
420
|
-
async function runCommandStream(
|
|
502
|
+
async function runCommandStream(
|
|
503
|
+
config,
|
|
504
|
+
reporter,
|
|
505
|
+
{ signal, hasPending, onCommand },
|
|
506
|
+
) {
|
|
421
507
|
const state = { lastEventID: "" }
|
|
422
508
|
let reconnectDelay = 1000
|
|
423
509
|
let lastLoggedError = ""
|
|
@@ -448,14 +534,21 @@ async function runCommandStream(config, { signal, hasPending, onCommand }) {
|
|
|
448
534
|
|
|
449
535
|
reconnectDelay = 1000
|
|
450
536
|
lastLoggedError = ""
|
|
451
|
-
await consumeSse(response, state, onCommand, signal)
|
|
537
|
+
await consumeSse(response, state, onCommand, signal, reporter)
|
|
452
538
|
} catch (error) {
|
|
453
539
|
if (signal.aborted || isAbortError(error)) return
|
|
454
540
|
const message = error instanceof HttpError
|
|
455
541
|
? `${error.message}: ${error.responseBody}`
|
|
456
542
|
: limit(error, 500)
|
|
457
543
|
if (message !== lastLoggedError) {
|
|
458
|
-
|
|
544
|
+
const authenticationFailure = error instanceof HttpError &&
|
|
545
|
+
[401, 403].includes(error.status)
|
|
546
|
+
reporter.error(
|
|
547
|
+
authenticationFailure
|
|
548
|
+
? "Authentication failed — run: npx backeer-opencode@latest configure"
|
|
549
|
+
: "Connection interrupted — retrying",
|
|
550
|
+
message,
|
|
551
|
+
)
|
|
459
552
|
lastLoggedError = message
|
|
460
553
|
}
|
|
461
554
|
if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
|
|
@@ -505,17 +598,23 @@ function rememberBounded(map, key, value) {
|
|
|
505
598
|
* protected file so it cannot accidentally be committed in opencode.json.
|
|
506
599
|
*/
|
|
507
600
|
export default async function BackeerPlugin({ client, directory }, options = {}) {
|
|
601
|
+
const bootstrapReporter = createConsoleReporter(
|
|
602
|
+
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
603
|
+
)
|
|
508
604
|
let config
|
|
509
605
|
try {
|
|
510
606
|
config = loadConfig(options)
|
|
511
607
|
} catch (error) {
|
|
512
|
-
|
|
608
|
+
bootstrapReporter.error("Plugin disabled — invalid configuration", error)
|
|
513
609
|
return {}
|
|
514
610
|
}
|
|
515
611
|
|
|
612
|
+
const reporter = createConsoleReporter(config.logLevel)
|
|
613
|
+
|
|
516
614
|
if (!config.token) {
|
|
517
|
-
|
|
518
|
-
|
|
615
|
+
reporter.error(
|
|
616
|
+
"Plugin disabled — run: npx backeer-opencode@latest configure",
|
|
617
|
+
`Token not found at ${config.tokenFile}`,
|
|
519
618
|
)
|
|
520
619
|
return {}
|
|
521
620
|
}
|
|
@@ -548,9 +647,8 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
548
647
|
|
|
549
648
|
const previousAcknowledgement = completedCommands.get(command.id)
|
|
550
649
|
if (previousAcknowledgement) {
|
|
551
|
-
await acknowledgeCommand(config, command.id, previousAcknowledgement)
|
|
552
|
-
(error) =>
|
|
553
|
-
)
|
|
650
|
+
await acknowledgeCommand(config, command.id, previousAcknowledgement)
|
|
651
|
+
.catch((error) => reporter.warning("Decision receipt retry failed", error))
|
|
554
652
|
return
|
|
555
653
|
}
|
|
556
654
|
if (processingCommands.has(command.id)) return
|
|
@@ -590,7 +688,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
590
688
|
errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
|
|
591
689
|
}
|
|
592
690
|
if (stale) removePendingByRequestID(pending.requestID)
|
|
593
|
-
|
|
691
|
+
reporter.error("Decision could not be applied", error)
|
|
594
692
|
} finally {
|
|
595
693
|
processingCommands.delete(command.id)
|
|
596
694
|
}
|
|
@@ -602,8 +700,21 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
602
700
|
processedAt: new Date().toISOString(),
|
|
603
701
|
}
|
|
604
702
|
rememberBounded(completedCommands, command.id, acknowledgement)
|
|
703
|
+
|
|
704
|
+
if (acknowledgement.status === "applied") {
|
|
705
|
+
reporter.applied(
|
|
706
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
|
|
707
|
+
)
|
|
708
|
+
} else if (acknowledgement.status === "expired") {
|
|
709
|
+
reporter.warning("Decision expired before it could be applied")
|
|
710
|
+
} else if (acknowledgement.status === "stale") {
|
|
711
|
+
reporter.warning("Permission was already answered locally")
|
|
712
|
+
} else if (acknowledgement.status === "rejected") {
|
|
713
|
+
reporter.warning("Decision was rejected by validation")
|
|
714
|
+
}
|
|
715
|
+
|
|
605
716
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
606
|
-
|
|
717
|
+
reporter.warning("Decision receipt could not be sent", error)
|
|
607
718
|
})
|
|
608
719
|
stopCommandStreamIfIdle()
|
|
609
720
|
}
|
|
@@ -614,7 +725,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
614
725
|
const controller = new AbortController()
|
|
615
726
|
const current = {
|
|
616
727
|
controller,
|
|
617
|
-
task: runCommandStream(config, {
|
|
728
|
+
task: runCommandStream(config, reporter, {
|
|
618
729
|
signal: controller.signal,
|
|
619
730
|
hasPending: () => pendingByExternalID.size > 0,
|
|
620
731
|
onCommand: handleCommand,
|
|
@@ -632,7 +743,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
632
743
|
if (config.permissions && event.type === "permission.asked") {
|
|
633
744
|
const identity = permissionIdentity(event.properties)
|
|
634
745
|
if (!identity) {
|
|
635
|
-
|
|
746
|
+
reporter.error("Permission event is missing correlation IDs")
|
|
636
747
|
return
|
|
637
748
|
}
|
|
638
749
|
|
|
@@ -642,9 +753,13 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
642
753
|
|
|
643
754
|
if (publishedPermissions.has(identity.externalId)) return
|
|
644
755
|
rememberBounded(publishedPermissions, identity.externalId, true)
|
|
645
|
-
void publishPermission(
|
|
646
|
-
|
|
647
|
-
|
|
756
|
+
void publishPermission(
|
|
757
|
+
config,
|
|
758
|
+
reporter,
|
|
759
|
+
event.properties,
|
|
760
|
+
directory,
|
|
761
|
+
identity,
|
|
762
|
+
).catch((error) => reporter.error("Permission request could not be sent", error))
|
|
648
763
|
return
|
|
649
764
|
}
|
|
650
765
|
|
|
@@ -657,7 +772,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
657
772
|
if (config.todos && event.type === "todo.updated") {
|
|
658
773
|
const { sessionID, todos } = event.properties
|
|
659
774
|
if (typeof sessionID !== "string" || !Array.isArray(todos)) {
|
|
660
|
-
|
|
775
|
+
reporter.error("Todo event is missing a valid session or list")
|
|
661
776
|
return
|
|
662
777
|
}
|
|
663
778
|
|
|
@@ -669,13 +784,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
669
784
|
|
|
670
785
|
void publishTodoCompletion(
|
|
671
786
|
config,
|
|
787
|
+
reporter,
|
|
672
788
|
event.properties,
|
|
673
789
|
event.id,
|
|
674
790
|
client,
|
|
675
791
|
directory,
|
|
676
|
-
).catch(
|
|
677
|
-
(error) => console.error(`[backeer] todo webhook failed: ${limit(error, 500)}`),
|
|
678
|
-
)
|
|
792
|
+
).catch((error) => reporter.error("Todo completion could not be sent", error))
|
|
679
793
|
return
|
|
680
794
|
}
|
|
681
795
|
|
|
@@ -685,7 +799,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
685
799
|
) {
|
|
686
800
|
const identity = questionIdentity(event.properties)
|
|
687
801
|
if (!identity) {
|
|
688
|
-
|
|
802
|
+
reporter.error("Question event is missing correlation IDs")
|
|
689
803
|
return
|
|
690
804
|
}
|
|
691
805
|
if (publishedQuestions.has(identity.externalId)) return
|
|
@@ -693,13 +807,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
693
807
|
|
|
694
808
|
void publishQuestion(
|
|
695
809
|
config,
|
|
810
|
+
reporter,
|
|
696
811
|
event.properties,
|
|
697
812
|
identity,
|
|
698
813
|
client,
|
|
699
814
|
directory,
|
|
700
|
-
).catch(
|
|
701
|
-
(error) => console.error(`[backeer] question webhook failed: ${limit(error, 500)}`),
|
|
702
|
-
)
|
|
815
|
+
).catch((error) => reporter.error("Question could not be sent", error))
|
|
703
816
|
}
|
|
704
817
|
},
|
|
705
818
|
dispose: async () => {
|