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 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 --token-file /path/from/your/secret-manager/backeer-token
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`, and
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
 
@@ -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 Backeer ingest token from PATH
21
- --token-stdin Read the Backeer ingest token from standard input
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
- If no token option is supplied, an existing token is preserved.`)
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 result = {}
30
- for (let index = 0; index < arguments_.length; index++) {
31
- const argument = arguments_[index]
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 = arguments_[++index]
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
- mkdirSync(pluginDirectory, { recursive: true })
77
- copyFileSync(sourcePath, installedPath)
78
- chmodSync(installedPath, 0o644)
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
- writeSecretAtomically(secretPath, readFileSync(resolve(arguments_.tokenFile), "utf8").trim())
82
- } else if (arguments_.tokenStdin) {
83
- writeSecretAtomically(secretPath, readFileSync(0, "utf8").trim())
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
- 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.")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Backeer notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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
- console.log(`[backeer] permission event accepted (${result.status})`)
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
- console.error(`[backeer] could not resolve session title: ${limit(error, 500)}`)
299
+ reporter.debug("Session title unavailable", error)
234
300
  return ""
235
301
  }
236
302
  }
237
303
 
238
- async function publishQuestion(config, request, identity, client, directory) {
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
- console.log(`[backeer] question event accepted (${result.status})`)
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(config, request, eventID, client, directory) {
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
- console.log(`[backeer] todo completion accepted (${result.status})`)
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
- console.error("[backeer] command stream returned invalid JSON")
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(config, { signal, hasPending, onCommand }) {
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
- console.error(`[backeer] ${message}`)
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
- console.error(`[backeer] plugin disabled: ${limit(error, 500)}`)
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
- console.error(
518
- `[backeer] plugin disabled: set BACKEER_INGEST_TOKEN or create ${config.tokenFile}`,
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).catch(
552
- (error) => console.error(`[backeer] command ACK retry failed: ${limit(error, 500)}`),
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
- console.error(`[backeer] permission command failed: ${limit(error, 500)}`)
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
- console.error(`[backeer] command ACK failed: ${limit(error, 500)}`)
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
- console.error("[backeer] permission event is missing IDs")
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(config, event.properties, directory, identity).catch(
646
- (error) => console.error(`[backeer] permission webhook failed: ${limit(error, 500)}`),
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
- console.error("[backeer] todo event is missing a valid session or list")
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
- console.error("[backeer] question event is missing IDs")
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 () => {