backeer-opencode 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.1.4 - 2026-09-01
6
+
7
+ - Add `/backeer` and `/backeer status` for an in-session integration report.
8
+ - Add secure, immediate token rotation with `/backeer token-file PATH`.
9
+ - Keep raw tokens out of slash-command arguments, session messages, and logs.
10
+ - Keep the management command available when notifications are disabled by a
11
+ missing token.
12
+
5
13
  ## 0.1.3 - 2026-09-01
6
14
 
7
15
  - Split configuration, logging, HTTP, SSE, event publishing, and permission
package/README.md CHANGED
@@ -149,6 +149,30 @@ connection errors are suppressed for 30 seconds. Normal `info` output omits HTTP
149
149
  status codes and internal errors; set `BACKEER_LOG_LEVEL=debug` when technical
150
150
  details are needed. Set it to `error` for failures only or `silent` for no output.
151
151
 
152
+ ## OpenCode command
153
+
154
+ The plugin registers a `/backeer` command inside OpenCode:
155
+
156
+ ```text
157
+ /backeer
158
+ /backeer status
159
+ /backeer token
160
+ /backeer token-file /absolute/path/to/a/protected-token-file
161
+ ```
162
+
163
+ The status report shows the plugin version, gateway reachability, token source,
164
+ notification switches, command-stream state, pending permissions, and log level.
165
+ It never displays the token. Gateway reachability does not validate the source
166
+ credential.
167
+
168
+ `/backeer token` displays safe rotation instructions. Never paste a raw token
169
+ into slash-command arguments because OpenCode may retain command text in session
170
+ history. To rotate from inside OpenCode, first place the new token in an absolute
171
+ file readable only by its owner (`chmod 600`), then use `token-file`. The plugin
172
+ atomically copies it to the configured token location, applies mode `0600`, and
173
+ activates it immediately without restarting OpenCode. The source file is not
174
+ removed.
175
+
152
176
  ## Events and decisions
153
177
 
154
178
  The plugin publishes:
@@ -83,11 +83,12 @@ function defaultConfigDirectory() {
83
83
  }
84
84
  function loadToken(tokenFile) {
85
85
  const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim();
86
- if (environmentToken) return environmentToken;
86
+ if (environmentToken) return { token: environmentToken, source: "environment" };
87
87
  try {
88
- return readFileSync(tokenFile, "utf8").trim();
88
+ const token = readFileSync(tokenFile, "utf8").trim();
89
+ return { token, source: token ? "file" : "missing" };
89
90
  } catch {
90
- return "";
91
+ return { token: "", source: "missing" };
91
92
  }
92
93
  }
93
94
  function loadConfig(options = {}) {
@@ -98,11 +99,13 @@ function loadConfig(options = {}) {
98
99
  throw new Error("Backeer origin must use HTTPS");
99
100
  }
100
101
  const tokenFile = options.tokenFile ?? process.env.BACKEER_TOKEN_FILE ?? join(defaultConfigDirectory(), "secrets", "backeer-ingest-token");
102
+ const loadedToken = loadToken(tokenFile);
101
103
  return {
102
104
  origin,
103
105
  eventsEndpoint: new URL("/v1/events", origin),
104
106
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
105
- token: loadToken(tokenFile),
107
+ token: loadedToken.token,
108
+ tokenSource: loadedToken.source,
106
109
  tokenFile,
107
110
  permissions: enabled(
108
111
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS
@@ -134,16 +137,16 @@ function isAbortError(error) {
134
137
  return error instanceof Error && error.name === "AbortError";
135
138
  }
136
139
  function sleep(milliseconds, signal) {
137
- return new Promise((resolve) => {
140
+ return new Promise((resolve2) => {
138
141
  if (signal?.aborted) {
139
- resolve();
142
+ resolve2();
140
143
  return;
141
144
  }
142
145
  const timeout = setTimeout(done, milliseconds);
143
146
  function done() {
144
147
  clearTimeout(timeout);
145
148
  signal?.removeEventListener("abort", done);
146
- resolve();
149
+ resolve2();
147
150
  }
148
151
  signal?.addEventListener("abort", done, { once: true });
149
152
  });
@@ -344,6 +347,188 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
344
347
  reporter.success("Todo completion sent", `HTTP ${result.status}`);
345
348
  }
346
349
 
350
+ // src/management.js
351
+ import {
352
+ chmodSync,
353
+ mkdirSync,
354
+ readFileSync as readFileSync2,
355
+ renameSync,
356
+ statSync,
357
+ writeFileSync
358
+ } from "node:fs";
359
+ import { dirname, resolve } from "node:path";
360
+ var PLUGIN_VERSION = "0.1.4";
361
+ var COMMAND_DESCRIPTION = "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]";
362
+ function tokenFileState(path) {
363
+ try {
364
+ const stats = statSync(path);
365
+ const mode = (stats.mode & 511).toString(8).padStart(3, "0");
366
+ return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
367
+ } catch {
368
+ return "missing";
369
+ }
370
+ }
371
+ function cleanPathArgument(value) {
372
+ const path = value.trim();
373
+ if (path.length >= 2 && (path.startsWith('"') && path.endsWith('"') || path.startsWith("'") && path.endsWith("'"))) {
374
+ return path.slice(1, -1);
375
+ }
376
+ return path;
377
+ }
378
+ function writeTokenAtomically(path, token) {
379
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
380
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
381
+ writeFileSync(temporaryPath, `${token}
382
+ `, { encoding: "utf8", mode: 384 });
383
+ chmodSync(temporaryPath, 384);
384
+ renameSync(temporaryPath, path);
385
+ }
386
+ function rotateTokenFromFile(config, sourcePathValue) {
387
+ if (!sourcePathValue.trim()) throw new Error("A token file path is required");
388
+ const sourcePath = resolve(cleanPathArgument(sourcePathValue));
389
+ const sourceStats = statSync(sourcePath);
390
+ if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
391
+ if ((sourceStats.mode & 63) !== 0) {
392
+ throw new Error("The token source must not be readable by group or other users");
393
+ }
394
+ const token = readFileSync2(sourcePath, "utf8").trim();
395
+ if (!token || /\s/.test(token)) {
396
+ throw new Error("The token source must contain exactly one non-empty token");
397
+ }
398
+ const targetPath = resolve(config.tokenFile);
399
+ if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token);
400
+ else chmodSync(targetPath, 384);
401
+ config.token = token;
402
+ config.tokenSource = "file";
403
+ return targetPath;
404
+ }
405
+ async function probeGateway(config) {
406
+ const controller = new AbortController();
407
+ const timeout = setTimeout(() => controller.abort(), 3e3);
408
+ try {
409
+ const response = await fetch(config.origin, {
410
+ method: "HEAD",
411
+ redirect: "manual",
412
+ signal: controller.signal
413
+ });
414
+ return `reachable (HTTP ${response.status}; authentication not tested)`;
415
+ } catch {
416
+ return "unreachable";
417
+ } finally {
418
+ clearTimeout(timeout);
419
+ }
420
+ }
421
+ async function buildStatus(config, permissionController) {
422
+ const gateway = await probeGateway(config);
423
+ const permissionStatus = permissionController.status();
424
+ return [
425
+ "## Backeer status",
426
+ "",
427
+ `- Plugin version: ${PLUGIN_VERSION}`,
428
+ `- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
429
+ `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
430
+ `- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
431
+ `- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
432
+ `- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
433
+ `- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
434
+ `- Command stream: ${permissionStatus.stream}`,
435
+ `- Pending permissions: ${permissionStatus.pending}`,
436
+ `- Log level: ${config.logLevel}`,
437
+ "",
438
+ "The token value is never displayed."
439
+ ].join("\n");
440
+ }
441
+ function helpMessage() {
442
+ return [
443
+ "## Backeer commands",
444
+ "",
445
+ "- `/backeer` or `/backeer status` \u2014 show integration status",
446
+ "- `/backeer token` \u2014 show secure token-change instructions",
447
+ "- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
448
+ "",
449
+ "Never place a token directly in a slash command. Slash-command arguments may be stored in session history."
450
+ ].join("\n");
451
+ }
452
+ function tokenInstructions() {
453
+ return [
454
+ "## Change the Backeer token securely",
455
+ "",
456
+ "Do not paste the token into OpenCode or `/backeer` arguments.",
457
+ "",
458
+ "Use either:",
459
+ "",
460
+ "1. Hidden terminal input: `npx backeer-opencode@latest configure`",
461
+ "2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
462
+ "",
463
+ "`token-file` activates the new token immediately; no OpenCode restart is required."
464
+ ].join("\n");
465
+ }
466
+ async function sendCommandMessage(client, sessionID, text) {
467
+ if (typeof client?.session?.prompt !== "function") {
468
+ throw new Error("OpenCode session messaging is unavailable");
469
+ }
470
+ await client.session.prompt({
471
+ path: { id: sessionID },
472
+ body: {
473
+ noReply: true,
474
+ parts: [{ type: "text", text, ignored: true }]
475
+ }
476
+ });
477
+ }
478
+ function registerBackeerCommand(opencodeConfig) {
479
+ opencodeConfig.command ??= {};
480
+ opencodeConfig.command.backeer = {
481
+ template: "",
482
+ description: COMMAND_DESCRIPTION
483
+ };
484
+ }
485
+ function createBackeerCommandHandler({
486
+ client,
487
+ config,
488
+ permissionController,
489
+ reporter
490
+ }) {
491
+ return async (input, output) => {
492
+ if (input.command !== "backeer") return;
493
+ output.parts.length = 0;
494
+ const argumentsValue = (input.arguments ?? "").trim();
495
+ const separator = argumentsValue.indexOf(" ");
496
+ const subcommand = (separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)).toLowerCase();
497
+ const subcommandArguments = separator === -1 ? "" : argumentsValue.slice(separator + 1).trim();
498
+ let message;
499
+ try {
500
+ if (!subcommand || subcommand === "status") {
501
+ message = await buildStatus(config, permissionController);
502
+ } else if (subcommand === "token") {
503
+ message = tokenInstructions();
504
+ } else if (subcommand === "token-file") {
505
+ const targetPath = rotateTokenFromFile(config, subcommandArguments);
506
+ permissionController.refreshCredentials();
507
+ reporter.success("Access token updated");
508
+ message = [
509
+ "## Backeer token updated",
510
+ "",
511
+ `- Protected token file: ${targetPath}`,
512
+ "- File mode: 600",
513
+ "- The new token is active immediately."
514
+ ].join("\n");
515
+ } else {
516
+ message = helpMessage();
517
+ }
518
+ } catch (error) {
519
+ reporter.error("Access token could not be updated", error);
520
+ message = [
521
+ "## Backeer command failed",
522
+ "",
523
+ error instanceof Error ? error.message : String(error),
524
+ "",
525
+ "Use `/backeer token` for secure token-change instructions."
526
+ ].join("\n");
527
+ }
528
+ await sendCommandMessage(client, input.sessionID, message);
529
+ };
530
+ }
531
+
347
532
  // src/sse.js
348
533
  function parseSseFrame(frame) {
349
534
  const data = [];
@@ -605,6 +790,16 @@ function createPermissionController({
605
790
  removePendingByRequestID(requestID);
606
791
  stopCommandStreamIfIdle();
607
792
  },
793
+ refreshCredentials() {
794
+ commandStream?.controller.abort();
795
+ if (!commandStream) ensureCommandStream();
796
+ },
797
+ status() {
798
+ return {
799
+ pending: pendingByExternalID.size,
800
+ stream: commandStream ? "active" : "idle"
801
+ };
802
+ },
608
803
  async dispose() {
609
804
  disposed = true;
610
805
  commandStream?.controller.abort();
@@ -632,13 +827,6 @@ async function BackeerPlugin({ client, directory }, options = {}) {
632
827
  return {};
633
828
  }
634
829
  const reporter = createConsoleReporter(config.logLevel);
635
- if (!config.token) {
636
- reporter.error(
637
- "Plugin disabled \u2014 run: npx backeer-opencode@latest configure",
638
- `Token not found at ${config.tokenFile}`
639
- );
640
- return {};
641
- }
642
830
  const permissionController = createPermissionController({
643
831
  config,
644
832
  reporter,
@@ -647,6 +835,18 @@ async function BackeerPlugin({ client, directory }, options = {}) {
647
835
  });
648
836
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
649
837
  const publishedQuestions = /* @__PURE__ */ new Map();
838
+ const handleBackeerCommand = createBackeerCommandHandler({
839
+ client,
840
+ config,
841
+ permissionController,
842
+ reporter
843
+ });
844
+ if (!config.token) {
845
+ reporter.error(
846
+ "Notifications disabled \u2014 use /backeer token",
847
+ `Token not found at ${config.tokenFile}`
848
+ );
849
+ }
650
850
  async function handlePermissionAsked(event) {
651
851
  const identity = permissionIdentity(event.properties);
652
852
  if (!identity) {
@@ -699,7 +899,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
699
899
  ).catch((error) => reporter.error("Question could not be sent", error));
700
900
  }
701
901
  return {
902
+ config: async (opencodeConfig) => {
903
+ registerBackeerCommand(opencodeConfig);
904
+ },
905
+ "command.execute.before": handleBackeerCommand,
702
906
  event: async ({ event }) => {
907
+ if (!config.token) return;
703
908
  if (config.permissions && event.type === "permission.asked") {
704
909
  await handlePermissionAsked(event);
705
910
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Backeer notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/config.js CHANGED
@@ -14,12 +14,13 @@ function defaultConfigDirectory() {
14
14
 
15
15
  function loadToken(tokenFile) {
16
16
  const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
17
- if (environmentToken) return environmentToken
17
+ if (environmentToken) return { token: environmentToken, source: "environment" }
18
18
 
19
19
  try {
20
- return readFileSync(tokenFile, "utf8").trim()
20
+ const token = readFileSync(tokenFile, "utf8").trim()
21
+ return { token, source: token ? "file" : "missing" }
21
22
  } catch {
22
- return ""
23
+ return { token: "", source: "missing" }
23
24
  }
24
25
  }
25
26
 
@@ -36,11 +37,13 @@ export function loadConfig(options = {}) {
36
37
  process.env.BACKEER_TOKEN_FILE ??
37
38
  join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
38
39
 
40
+ const loadedToken = loadToken(tokenFile)
39
41
  return {
40
42
  origin,
41
43
  eventsEndpoint: new URL("/v1/events", origin),
42
44
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
43
- token: loadToken(tokenFile),
45
+ token: loadedToken.token,
46
+ tokenSource: loadedToken.source,
44
47
  tokenFile,
45
48
  permissions: enabled(
46
49
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
@@ -0,0 +1,207 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs"
9
+ import { dirname, resolve } from "node:path"
10
+
11
+ export const PLUGIN_VERSION = "0.1.4"
12
+
13
+ const COMMAND_DESCRIPTION =
14
+ "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]"
15
+
16
+ function tokenFileState(path) {
17
+ try {
18
+ const stats = statSync(path)
19
+ const mode = (stats.mode & 0o777).toString(8).padStart(3, "0")
20
+ return stats.isFile() ? `present (mode ${mode})` : "not a regular file"
21
+ } catch {
22
+ return "missing"
23
+ }
24
+ }
25
+
26
+ function cleanPathArgument(value) {
27
+ const path = value.trim()
28
+ if (
29
+ path.length >= 2 &&
30
+ ((path.startsWith('"') && path.endsWith('"')) ||
31
+ (path.startsWith("'") && path.endsWith("'")))
32
+ ) {
33
+ return path.slice(1, -1)
34
+ }
35
+ return path
36
+ }
37
+
38
+ function writeTokenAtomically(path, token) {
39
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
40
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`
41
+ writeFileSync(temporaryPath, `${token}\n`, { encoding: "utf8", mode: 0o600 })
42
+ chmodSync(temporaryPath, 0o600)
43
+ renameSync(temporaryPath, path)
44
+ }
45
+
46
+ export function rotateTokenFromFile(config, sourcePathValue) {
47
+ if (!sourcePathValue.trim()) throw new Error("A token file path is required")
48
+ const sourcePath = resolve(cleanPathArgument(sourcePathValue))
49
+
50
+ const sourceStats = statSync(sourcePath)
51
+ if (!sourceStats.isFile()) throw new Error("The token source is not a regular file")
52
+ if ((sourceStats.mode & 0o077) !== 0) {
53
+ throw new Error("The token source must not be readable by group or other users")
54
+ }
55
+
56
+ const token = readFileSync(sourcePath, "utf8").trim()
57
+ if (!token || /\s/.test(token)) {
58
+ throw new Error("The token source must contain exactly one non-empty token")
59
+ }
60
+
61
+ const targetPath = resolve(config.tokenFile)
62
+ if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token)
63
+ else chmodSync(targetPath, 0o600)
64
+ config.token = token
65
+ config.tokenSource = "file"
66
+ return targetPath
67
+ }
68
+
69
+ async function probeGateway(config) {
70
+ const controller = new AbortController()
71
+ const timeout = setTimeout(() => controller.abort(), 3000)
72
+ try {
73
+ const response = await fetch(config.origin, {
74
+ method: "HEAD",
75
+ redirect: "manual",
76
+ signal: controller.signal,
77
+ })
78
+ return `reachable (HTTP ${response.status}; authentication not tested)`
79
+ } catch {
80
+ return "unreachable"
81
+ } finally {
82
+ clearTimeout(timeout)
83
+ }
84
+ }
85
+
86
+ async function buildStatus(config, permissionController) {
87
+ const gateway = await probeGateway(config)
88
+ const permissionStatus = permissionController.status()
89
+ return [
90
+ "## Backeer status",
91
+ "",
92
+ `- Plugin version: ${PLUGIN_VERSION}`,
93
+ `- Gateway: ${config.origin.origin} — ${gateway}`,
94
+ `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
95
+ `- Token file: ${config.tokenFile} — ${tokenFileState(config.tokenFile)}`,
96
+ `- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
97
+ `- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
98
+ `- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
99
+ `- Command stream: ${permissionStatus.stream}`,
100
+ `- Pending permissions: ${permissionStatus.pending}`,
101
+ `- Log level: ${config.logLevel}`,
102
+ "",
103
+ "The token value is never displayed.",
104
+ ].join("\n")
105
+ }
106
+
107
+ function helpMessage() {
108
+ return [
109
+ "## Backeer commands",
110
+ "",
111
+ "- `/backeer` or `/backeer status` — show integration status",
112
+ "- `/backeer token` — show secure token-change instructions",
113
+ "- `/backeer token-file /absolute/protected/path` — import and activate a token",
114
+ "",
115
+ "Never place a token directly in a slash command. Slash-command arguments may be stored in session history.",
116
+ ].join("\n")
117
+ }
118
+
119
+ function tokenInstructions() {
120
+ return [
121
+ "## Change the Backeer token securely",
122
+ "",
123
+ "Do not paste the token into OpenCode or `/backeer` arguments.",
124
+ "",
125
+ "Use either:",
126
+ "",
127
+ "1. Hidden terminal input: `npx backeer-opencode@latest configure`",
128
+ "2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
129
+ "",
130
+ "`token-file` activates the new token immediately; no OpenCode restart is required.",
131
+ ].join("\n")
132
+ }
133
+
134
+ async function sendCommandMessage(client, sessionID, text) {
135
+ if (typeof client?.session?.prompt !== "function") {
136
+ throw new Error("OpenCode session messaging is unavailable")
137
+ }
138
+ await client.session.prompt({
139
+ path: { id: sessionID },
140
+ body: {
141
+ noReply: true,
142
+ parts: [{ type: "text", text, ignored: true }],
143
+ },
144
+ })
145
+ }
146
+
147
+ export function registerBackeerCommand(opencodeConfig) {
148
+ opencodeConfig.command ??= {}
149
+ opencodeConfig.command.backeer = {
150
+ template: "",
151
+ description: COMMAND_DESCRIPTION,
152
+ }
153
+ }
154
+
155
+ export function createBackeerCommandHandler({
156
+ client,
157
+ config,
158
+ permissionController,
159
+ reporter,
160
+ }) {
161
+ return async (input, output) => {
162
+ if (input.command !== "backeer") return
163
+ output.parts.length = 0
164
+
165
+ const argumentsValue = (input.arguments ?? "").trim()
166
+ const separator = argumentsValue.indexOf(" ")
167
+ const subcommand = (
168
+ separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)
169
+ ).toLowerCase()
170
+ const subcommandArguments = separator === -1
171
+ ? ""
172
+ : argumentsValue.slice(separator + 1).trim()
173
+
174
+ let message
175
+ try {
176
+ if (!subcommand || subcommand === "status") {
177
+ message = await buildStatus(config, permissionController)
178
+ } else if (subcommand === "token") {
179
+ message = tokenInstructions()
180
+ } else if (subcommand === "token-file") {
181
+ const targetPath = rotateTokenFromFile(config, subcommandArguments)
182
+ permissionController.refreshCredentials()
183
+ reporter.success("Access token updated")
184
+ message = [
185
+ "## Backeer token updated",
186
+ "",
187
+ `- Protected token file: ${targetPath}`,
188
+ "- File mode: 600",
189
+ "- The new token is active immediately.",
190
+ ].join("\n")
191
+ } else {
192
+ message = helpMessage()
193
+ }
194
+ } catch (error) {
195
+ reporter.error("Access token could not be updated", error)
196
+ message = [
197
+ "## Backeer command failed",
198
+ "",
199
+ error instanceof Error ? error.message : String(error),
200
+ "",
201
+ "Use `/backeer token` for secure token-change instructions.",
202
+ ].join("\n")
203
+ }
204
+
205
+ await sendCommandMessage(client, input.sessionID, message)
206
+ }
207
+ }
@@ -169,6 +169,18 @@ export function createPermissionController({
169
169
  stopCommandStreamIfIdle()
170
170
  },
171
171
 
172
+ refreshCredentials() {
173
+ commandStream?.controller.abort()
174
+ if (!commandStream) ensureCommandStream()
175
+ },
176
+
177
+ status() {
178
+ return {
179
+ pending: pendingByExternalID.size,
180
+ stream: commandStream ? "active" : "idle",
181
+ }
182
+ },
183
+
172
184
  async dispose() {
173
185
  disposed = true
174
186
  commandStream?.controller.abort()
package/src/plugin.js CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  publishTodoCompletion,
7
7
  questionIdentity,
8
8
  } from "./event-publishers.js"
9
+ import {
10
+ createBackeerCommandHandler,
11
+ registerBackeerCommand,
12
+ } from "./management.js"
9
13
  import { createPermissionController } from "./permission-controller.js"
10
14
  import { createConsoleReporter } from "./reporter.js"
11
15
  import { rememberBounded } from "./utilities.js"
@@ -31,14 +35,6 @@ export default async function BackeerPlugin({ client, directory }, options = {})
31
35
 
32
36
  const reporter = createConsoleReporter(config.logLevel)
33
37
 
34
- if (!config.token) {
35
- reporter.error(
36
- "Plugin disabled — run: npx backeer-opencode@latest configure",
37
- `Token not found at ${config.tokenFile}`,
38
- )
39
- return {}
40
- }
41
-
42
38
  const permissionController = createPermissionController({
43
39
  config,
44
40
  reporter,
@@ -47,6 +43,19 @@ export default async function BackeerPlugin({ client, directory }, options = {})
47
43
  })
48
44
  const todoCompletionBySessionID = new Map()
49
45
  const publishedQuestions = new Map()
46
+ const handleBackeerCommand = createBackeerCommandHandler({
47
+ client,
48
+ config,
49
+ permissionController,
50
+ reporter,
51
+ })
52
+
53
+ if (!config.token) {
54
+ reporter.error(
55
+ "Notifications disabled — use /backeer token",
56
+ `Token not found at ${config.tokenFile}`,
57
+ )
58
+ }
50
59
 
51
60
  async function handlePermissionAsked(event) {
52
61
  const identity = permissionIdentity(event.properties)
@@ -108,7 +117,15 @@ export default async function BackeerPlugin({ client, directory }, options = {})
108
117
  }
109
118
 
110
119
  return {
120
+ config: async (opencodeConfig) => {
121
+ registerBackeerCommand(opencodeConfig)
122
+ },
123
+
124
+ "command.execute.before": handleBackeerCommand,
125
+
111
126
  event: async ({ event }) => {
127
+ if (!config.token) return
128
+
112
129
  if (config.permissions && event.type === "permission.asked") {
113
130
  await handlePermissionAsked(event)
114
131
  return