backeer-opencode 0.2.1 → 0.2.3

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,23 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.2.3 - 2026-09-01
6
+
7
+ - Keep conversation feedback queued while OpenCode permission or question UI is
8
+ active, even if OpenCode emits an intermediate `session.idle` event.
9
+ - Wait for the interaction reply and the following real idle transition before
10
+ injecting an ignored Backeer status message into the conversation.
11
+ - Add regression coverage for permission and question UI lifecycles.
12
+
13
+ ## 0.2.2 - 2026-09-01
14
+
15
+ - Show user-facing Backeer delivery and decision results in the OpenCode
16
+ conversation instead of writing them into the TUI input area.
17
+ - Queue conversation feedback until the session is idle and mark it as ignored
18
+ so it cannot trigger another agent response or enter model context.
19
+ - Send technical diagnostics through OpenCode's structured application log API
20
+ instead of stdout.
21
+
5
22
  ## 0.2.1 - 2026-09-01
6
23
 
7
24
  - Add `/backeer doctor` for read-only local configuration, protected token,
package/README.md CHANGED
@@ -150,25 +150,29 @@ Supported options are `origin`, `tokenFile`, `permissions`, `questions`,
150
150
  `todos`, `lifecycle`, and `logLevel`. Use `"lifecycle": false` to disable the
151
151
  entire lifecycle group. The token itself is deliberately not a plugin option.
152
152
 
153
- ## Console feedback
153
+ ## Conversation feedback
154
154
 
155
- The plugin writes compact, single-line status messages instead of raw HTTP logs:
155
+ User-facing status messages appear as compact Backeer entries in the OpenCode
156
+ conversation:
156
157
 
157
158
  ```text
158
- ◆ Backeer Permission request sent
159
- ◆ Backeer Question sent
160
- ◆ Backeer Todo completion sent
161
- ◆ Backeer Task completion sent
162
- ◆ Backeer Retry warning sent
163
- ◆ Backeer Task failure sent
164
- ✓ Backeer Decision applied: Always allow
165
- ! Backeer Connection interrupted — retrying
159
+ Backeer ✓ Permission request sent
160
+ Backeer ✓ Question sent
161
+ Backeer ✓ Todo completion sent
162
+ Backeer ✓ Task completion sent
163
+ Backeer ✓ Decision applied: Always allow
166
164
  ```
167
165
 
168
- TTY output uses restrained status colors and respects `NO_COLOR`. Repeated
169
- connection errors are suppressed for 30 seconds. Normal `info` output omits HTTP
170
- status codes and internal errors; set `BACKEER_LOG_LEVEL=debug` when technical
171
- details are needed. Set it to `error` for failures only or `silent` for no output.
166
+ These entries are marked `noReply` and `ignored`, so they are visible without
167
+ starting another agent turn or entering model context. Feedback generated while
168
+ an agent is active, a permission prompt is open, or a question is awaiting an
169
+ answer remains queued until the interaction closes and the session becomes idle.
170
+ It never writes to the user's input field or replaces interactive OpenCode UI.
171
+
172
+ Technical diagnostics use OpenCode's structured application log API instead of
173
+ stdout. Normal `info` logging omits HTTP details; set
174
+ `BACKEER_LOG_LEVEL=debug` when technical details are needed. Set it to `error`
175
+ for failures only or `silent` to disable structured logs.
172
176
 
173
177
  ## OpenCode command
174
178
 
@@ -263,10 +267,11 @@ npm run publish:check
263
267
 
264
268
  ## Development architecture
265
269
 
266
- The npm plugin runs from the modular source in `src`. Configuration, console
267
- reporting, HTTP transport, SSE consumption, event publishing, permission command
268
- handling, and OpenCode event routing are kept in separate modules. The package
269
- root continues to expose only the default OpenCode plugin entrypoint.
270
+ The npm plugin runs from the modular source in `src`. Configuration, structured
271
+ logging, conversation feedback, HTTP transport, SSE consumption, event
272
+ publishing, permission command handling, and OpenCode event routing are kept in
273
+ separate modules. The package root continues to expose only the default OpenCode
274
+ plugin entrypoint.
270
275
 
271
276
  The legacy side-loaded installer still deploys one dependency-free file. Build
272
277
  that generated artifact before running the installer directly from a source
@@ -33,37 +33,31 @@ function normalizeLogLevel(value) {
33
33
  const level = String(value ?? "info").trim().toLowerCase();
34
34
  return LOG_LEVELS.has(level) ? level : "info";
35
35
  }
36
- function createConsoleReporter(levelValue) {
36
+ function createOpenCodeReporter(client, levelValue) {
37
37
  const level = normalizeLogLevel(levelValue);
38
- const useColor = Boolean(process.stdout?.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
39
38
  const recentlyEmitted = /* @__PURE__ */ new Map();
40
- const decorate = (code, text) => useColor ? `\x1B[${code}m${text}\x1B[0m` : text;
41
- const symbols = {
42
- info: decorate("36", "\u25C6"),
43
- success: decorate("32", "\u25C6"),
44
- applied: decorate("32", "\u2713"),
45
- warning: decorate("33", "!"),
46
- error: decorate("31", "\xD7"),
47
- debug: decorate("2", "\xB7")
48
- };
49
- const brand = decorate("1", "Backeer");
39
+ const method = client?.app?.log;
50
40
  function emit(requiredLevel, kind, message, detail, deduplicate = false) {
51
- if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return;
41
+ if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return;
52
42
  const key = `${kind}:${message}`;
53
43
  const now = Date.now();
54
44
  if (deduplicate) {
55
45
  const previous = recentlyEmitted.get(key);
56
46
  if (previous && now - previous < LOG_DEDUPLICATION_MS) return;
57
- recentlyEmitted.delete(key);
58
- recentlyEmitted.set(key, now);
59
- while (recentlyEmitted.size > 100) {
60
- recentlyEmitted.delete(recentlyEmitted.keys().next().value);
61
- }
47
+ rememberRecent(recentlyEmitted, key, now);
48
+ }
49
+ const body = {
50
+ service: "backeer-opencode",
51
+ level: kind === "warning" ? "warn" : kind === "applied" || kind === "success" ? "info" : kind,
52
+ message: limit(message, 500)
53
+ };
54
+ if (level === "debug" && detail) {
55
+ body.extra = { detail: limit(detail?.message ?? detail, 500) };
62
56
  }
63
- const technical = level === "debug" && detail ? decorate("2", ` \xB7 ${limit(detail, 500)}`) : "";
64
- const line = `${symbols[kind]} ${brand} ${message}${technical}`;
65
- if (kind === "error" || kind === "warning") console.error(line);
66
- else console.log(line);
57
+ setTimeout(() => {
58
+ void Promise.resolve(method.call(client.app, { body })).catch(() => {
59
+ });
60
+ }, 0);
67
61
  }
68
62
  return {
69
63
  info: (message, detail) => emit("info", "info", message, detail),
@@ -74,6 +68,11 @@ function createConsoleReporter(levelValue) {
74
68
  debug: (message, detail) => emit("debug", "debug", message, detail)
75
69
  };
76
70
  }
71
+ function rememberRecent(map, key, value) {
72
+ map.delete(key);
73
+ map.set(key, value);
74
+ while (map.size > 100) map.delete(map.keys().next().value);
75
+ }
77
76
 
78
77
  // src/config.js
79
78
  var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
@@ -245,7 +244,7 @@ function permissionIdentity(request) {
245
244
  externalId: `opencode:${sessionID}:${requestID}`
246
245
  };
247
246
  }
248
- async function publishPermission(config, reporter, request, directory, identity) {
247
+ async function publishPermission(config, reporter, request, directory, identity, conversation) {
249
248
  const permissionType = request.permission ?? request.action ?? "unknown";
250
249
  const resources = Array.isArray(request.patterns) ? request.patterns.join(", ") : Array.isArray(request.resources) ? request.resources.join(", ") : "";
251
250
  const payload = [
@@ -269,6 +268,7 @@ async function publishPermission(config, reporter, request, directory, identity)
269
268
  direction: "in"
270
269
  });
271
270
  reporter.success("Permission request sent", `HTTP ${result.status}`);
271
+ conversation?.success(identity.sessionID, "Permission request sent");
272
272
  }
273
273
  function questionIdentity(request) {
274
274
  const requestID = request.id ?? request.requestID;
@@ -303,7 +303,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
303
303
  return "";
304
304
  }
305
305
  }
306
- async function publishQuestion(config, reporter, request, identity, client, directory) {
306
+ async function publishQuestion(config, reporter, request, identity, client, directory, conversation) {
307
307
  const questions = Array.isArray(request.questions) ? request.questions : [];
308
308
  if (questions.length === 0) return;
309
309
  const sessionTitle = await resolveSessionTitle(
@@ -345,8 +345,9 @@ async function publishQuestion(config, reporter, request, identity, client, dire
345
345
  direction: "in"
346
346
  });
347
347
  reporter.success("Question sent", `HTTP ${result.status}`);
348
+ conversation?.success(identity.sessionID, "Question sent");
348
349
  }
349
- async function publishTodoCompletion(config, reporter, request, eventID, client, directory) {
350
+ async function publishTodoCompletion(config, reporter, request, eventID, client, directory, conversation) {
350
351
  const todos = request.todos;
351
352
  const sessionTitle = await resolveSessionTitle(
352
353
  client,
@@ -384,6 +385,7 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
384
385
  direction: "in"
385
386
  });
386
387
  reporter.success("Todo completion sent", `HTTP ${result.status}`);
388
+ conversation?.success(request.sessionID, "Todo completion sent");
387
389
  }
388
390
 
389
391
  // src/management.js
@@ -402,7 +404,7 @@ import { randomUUID } from "node:crypto";
402
404
  import { statSync } from "node:fs";
403
405
 
404
406
  // src/updates.js
405
- var PLUGIN_VERSION = "0.2.1";
407
+ var PLUGIN_VERSION = "0.2.3";
406
408
  var PACKAGE_NAME = "backeer-opencode";
407
409
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
408
410
  var CHECK_TIMEOUT_MS = 3e3;
@@ -640,6 +642,154 @@ async function publishTestNotification(config, reporter) {
640
642
  return testID;
641
643
  }
642
644
 
645
+ // src/session-messages.js
646
+ var FEEDBACK_SYMBOLS = {
647
+ info: "\u2022",
648
+ success: "\u2713",
649
+ applied: "\u2713",
650
+ warning: "!",
651
+ error: "\xD7"
652
+ };
653
+ var QUESTION_ASKED_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
654
+ var QUESTION_REPLIED_EVENTS = /* @__PURE__ */ new Set([
655
+ "question.replied",
656
+ "question.v2.replied",
657
+ "question.answered",
658
+ "question.v2.answered"
659
+ ]);
660
+ async function sendSessionMessage(client, sessionID, text) {
661
+ if (typeof client?.session?.prompt !== "function") {
662
+ throw new Error("OpenCode session messaging is unavailable");
663
+ }
664
+ await client.session.prompt({
665
+ path: { id: sessionID },
666
+ body: {
667
+ noReply: true,
668
+ parts: [{ type: "text", text, ignored: true }]
669
+ }
670
+ });
671
+ }
672
+ function formatBackeerFeedback(kind, message) {
673
+ const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
674
+ return `> **Backeer** ${symbol} ${limit(message, 300)}`;
675
+ }
676
+ function createConversationFeedback({ client, reporter }) {
677
+ const sessions = /* @__PURE__ */ new Map();
678
+ let disposed = false;
679
+ function sessionState(sessionID) {
680
+ let state = sessions.get(sessionID);
681
+ if (!state) {
682
+ state = {
683
+ idle: false,
684
+ flushing: false,
685
+ blockers: /* @__PURE__ */ new Set(),
686
+ queue: [],
687
+ queued: /* @__PURE__ */ new Set()
688
+ };
689
+ rememberBounded(sessions, sessionID, state);
690
+ }
691
+ return state;
692
+ }
693
+ async function flush(sessionID, state) {
694
+ if (disposed || state.flushing || !state.idle || state.blockers.size > 0 || state.queue.length === 0) return;
695
+ state.flushing = true;
696
+ try {
697
+ while (!disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0) {
698
+ const item = state.queue[0];
699
+ try {
700
+ await sendSessionMessage(
701
+ client,
702
+ sessionID,
703
+ formatBackeerFeedback(item.kind, item.message)
704
+ );
705
+ state.queue.shift();
706
+ state.queued.delete(item.key);
707
+ } catch (error) {
708
+ state.idle = false;
709
+ reporter.debug("Conversation feedback could not be displayed", error);
710
+ }
711
+ }
712
+ } finally {
713
+ state.flushing = false;
714
+ }
715
+ }
716
+ function enqueue(kind, sessionID, message) {
717
+ if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
718
+ const state = sessionState(sessionID);
719
+ const key = `${sessionID}:${kind}:${message}`;
720
+ if (state.queued.has(key)) return;
721
+ state.queue.push({ kind, message: limit(message, 300), key });
722
+ state.queued.add(key);
723
+ while (state.queue.length > 20) {
724
+ const removed = state.queue.shift();
725
+ state.queued.delete(removed.key);
726
+ }
727
+ if (state.idle && state.blockers.size === 0) void flush(sessionID, state);
728
+ }
729
+ function setIdle(sessionID, idle) {
730
+ if (typeof sessionID !== "string" || !sessionID) return;
731
+ const state = sessionState(sessionID);
732
+ state.idle = idle;
733
+ if (idle) void flush(sessionID, state);
734
+ }
735
+ function interactionKey(kind, event) {
736
+ const value = event.properties?.requestID ?? event.properties?.id;
737
+ return `${kind}:${typeof value === "string" && value ? value : "pending"}`;
738
+ }
739
+ function beginInteraction(sessionID, kind, event) {
740
+ const state = sessionState(sessionID);
741
+ state.idle = false;
742
+ state.blockers.add(interactionKey(kind, event));
743
+ }
744
+ function endInteraction(sessionID, kind, event) {
745
+ const state = sessionState(sessionID);
746
+ const key = interactionKey(kind, event);
747
+ if (!state.blockers.delete(key)) {
748
+ const fallback = [...state.blockers].find((blocker) => blocker.startsWith(`${kind}:`));
749
+ if (fallback) state.blockers.delete(fallback);
750
+ }
751
+ state.idle = false;
752
+ }
753
+ return {
754
+ info: (sessionID, message) => enqueue("info", sessionID, message),
755
+ success: (sessionID, message) => enqueue("success", sessionID, message),
756
+ applied: (sessionID, message) => enqueue("applied", sessionID, message),
757
+ warning: (sessionID, message) => enqueue("warning", sessionID, message),
758
+ error: (sessionID, message) => enqueue("error", sessionID, message),
759
+ handle(event) {
760
+ const sessionID = event?.properties?.sessionID;
761
+ if (typeof sessionID !== "string") return;
762
+ if (event.type === "session.status") {
763
+ const status = event.properties?.status?.type;
764
+ if (status === "idle") setIdle(sessionID, true);
765
+ else if (status === "busy" || status === "retry") setIdle(sessionID, false);
766
+ } else if (event.type === "session.idle") {
767
+ setIdle(sessionID, true);
768
+ } else if (event.type === "permission.asked") {
769
+ beginInteraction(sessionID, "permission", event);
770
+ } else if (QUESTION_ASKED_EVENTS.has(event.type)) {
771
+ beginInteraction(sessionID, "question", event);
772
+ } else if (event.type === "permission.replied") {
773
+ endInteraction(sessionID, "permission", event);
774
+ } else if (QUESTION_REPLIED_EVENTS.has(event.type)) {
775
+ endInteraction(sessionID, "question", event);
776
+ } else if (event.type === "todo.updated") {
777
+ setIdle(sessionID, false);
778
+ } else if (event.type === "session.error") {
779
+ const state = sessionState(sessionID);
780
+ state.blockers.clear();
781
+ state.idle = false;
782
+ } else if (event.type === "session.deleted") {
783
+ sessions.delete(sessionID);
784
+ }
785
+ },
786
+ dispose() {
787
+ disposed = true;
788
+ sessions.clear();
789
+ }
790
+ };
791
+ }
792
+
643
793
  // src/management.js
644
794
  var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
645
795
  function tokenFileState(path) {
@@ -766,18 +916,6 @@ function tokenInstructions() {
766
916
  "`token-file` activates the new token immediately; no OpenCode restart is required."
767
917
  ].join("\n");
768
918
  }
769
- async function sendCommandMessage(client, sessionID, text) {
770
- if (typeof client?.session?.prompt !== "function") {
771
- throw new Error("OpenCode session messaging is unavailable");
772
- }
773
- await client.session.prompt({
774
- path: { id: sessionID },
775
- body: {
776
- noReply: true,
777
- parts: [{ type: "text", text, ignored: true }]
778
- }
779
- });
780
- }
781
919
  function registerBackeerCommand(opencodeConfig) {
782
920
  opencodeConfig.command ??= {};
783
921
  opencodeConfig.command.backeer = {
@@ -859,7 +997,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
859
997
  "Use `/backeer token` for secure token-change instructions."
860
998
  ].join("\n");
861
999
  }
862
- await sendCommandMessage(client, input.sessionID, message);
1000
+ await sendSessionMessage(client, input.sessionID, message);
863
1001
  };
864
1002
  }
865
1003
 
@@ -900,7 +1038,7 @@ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
900
1038
  return "";
901
1039
  }
902
1040
  }
903
- async function publish(config, reporter, event, feedback) {
1041
+ async function publish(config, reporter, conversation, sessionID, event, feedback) {
904
1042
  const result = await postJsonWithRetry(config, config.eventsEndpoint, {
905
1043
  type: "application",
906
1044
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -909,6 +1047,7 @@ async function publish(config, reporter, event, feedback) {
909
1047
  ...event
910
1048
  });
911
1049
  reporter.success(feedback, `HTTP ${result.status}`);
1050
+ conversation?.success(sessionID, feedback);
912
1051
  }
913
1052
  function secondsLabel(durationMs) {
914
1053
  const seconds = Math.max(0, Math.round(durationMs / 1e3));
@@ -924,7 +1063,8 @@ async function publishSessionCompleted({
924
1063
  directory,
925
1064
  sessionID,
926
1065
  messageID,
927
- durationMs
1066
+ durationMs,
1067
+ conversation
928
1068
  }) {
929
1069
  const sessionTitle = await resolveSessionTitle2(
930
1070
  client,
@@ -934,7 +1074,7 @@ async function publishSessionCompleted({
934
1074
  );
935
1075
  const duration = secondsLabel(durationMs);
936
1076
  const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
937
- await publish(config, reporter, {
1077
+ await publish(config, reporter, conversation, sessionID, {
938
1078
  severity: "info",
939
1079
  title: "OpenCode task completed",
940
1080
  body: `OpenCode finished${location} in ${duration}.`,
@@ -954,7 +1094,8 @@ async function publishSessionRetry({
954
1094
  sessionID,
955
1095
  correlation,
956
1096
  attempt,
957
- next
1097
+ next,
1098
+ conversation
958
1099
  }) {
959
1100
  const sessionTitle = await resolveSessionTitle2(
960
1101
  client,
@@ -963,7 +1104,7 @@ async function publishSessionRetry({
963
1104
  reporter
964
1105
  );
965
1106
  const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
966
- await publish(config, reporter, {
1107
+ await publish(config, reporter, conversation, sessionID, {
967
1108
  severity: "warning",
968
1109
  title: "OpenCode is retrying",
969
1110
  body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
@@ -982,7 +1123,8 @@ async function publishSessionError({
982
1123
  directory,
983
1124
  sessionID,
984
1125
  correlation,
985
- error
1126
+ error,
1127
+ conversation
986
1128
  }) {
987
1129
  const sessionTitle = await resolveSessionTitle2(
988
1130
  client,
@@ -993,7 +1135,7 @@ async function publishSessionError({
993
1135
  const details = safeErrorDetails(error);
994
1136
  const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
995
1137
  const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
996
- await publish(config, reporter, {
1138
+ await publish(config, reporter, conversation, sessionID, {
997
1139
  // Backeer/iOS currently support info, warning and critical severities.
998
1140
  severity: "critical",
999
1141
  title: "OpenCode task failed",
@@ -1010,7 +1152,7 @@ async function publishSessionError({
1010
1152
  }
1011
1153
  async function publishOpenCodeUpdate({ config, reporter, version }) {
1012
1154
  const safeVersion = limit(version, 100);
1013
- await publish(config, reporter, {
1155
+ await publish(config, reporter, void 0, void 0, {
1014
1156
  severity: "info",
1015
1157
  title: "OpenCode update available",
1016
1158
  body: `OpenCode ${safeVersion} is available.`,
@@ -1038,6 +1180,7 @@ function createLifecycleController({
1038
1180
  reporter,
1039
1181
  client,
1040
1182
  directory,
1183
+ conversation,
1041
1184
  now = () => Date.now(),
1042
1185
  publishers = {}
1043
1186
  }) {
@@ -1063,8 +1206,11 @@ function createLifecycleController({
1063
1206
  }
1064
1207
  return state;
1065
1208
  }
1066
- function dispatch(label, operation) {
1067
- void Promise.resolve(operation).catch((error) => reporter.error(label, error));
1209
+ function dispatch(label, operation, sessionID) {
1210
+ void Promise.resolve(operation).catch((error) => {
1211
+ reporter.error(label, error);
1212
+ conversation?.error(sessionID, label);
1213
+ });
1068
1214
  }
1069
1215
  function beginCycle(state) {
1070
1216
  if (state.busy) return;
@@ -1092,8 +1238,10 @@ function createLifecycleController({
1092
1238
  directory,
1093
1239
  sessionID,
1094
1240
  messageID: limit(assistant.id, 200),
1095
- durationMs
1096
- })
1241
+ durationMs,
1242
+ conversation
1243
+ }),
1244
+ sessionID
1097
1245
  );
1098
1246
  }
1099
1247
  function handleMessage(event) {
@@ -1138,8 +1286,10 @@ function createLifecycleController({
1138
1286
  sessionID,
1139
1287
  correlation: limit(correlation, 200),
1140
1288
  attempt: status.attempt,
1141
- next: status.next
1142
- })
1289
+ next: status.next,
1290
+ conversation
1291
+ }),
1292
+ sessionID
1143
1293
  );
1144
1294
  }
1145
1295
  function handleError(event) {
@@ -1156,8 +1306,10 @@ function createLifecycleController({
1156
1306
  directory,
1157
1307
  sessionID,
1158
1308
  correlation: errorCorrelation(event, state),
1159
- error: event.properties?.error
1160
- })
1309
+ error: event.properties?.error,
1310
+ conversation
1311
+ }),
1312
+ sessionID
1161
1313
  );
1162
1314
  }
1163
1315
  function handleWaiting(event, waiting) {
@@ -1343,7 +1495,8 @@ function createPermissionController({
1343
1495
  config,
1344
1496
  reporter,
1345
1497
  client,
1346
- directory
1498
+ directory,
1499
+ conversation
1347
1500
  }) {
1348
1501
  const pendingByExternalID = /* @__PURE__ */ new Map();
1349
1502
  const externalIDByRequestID = /* @__PURE__ */ new Map();
@@ -1404,6 +1557,7 @@ function createPermissionController({
1404
1557
  };
1405
1558
  if (stale) removePendingByRequestID(pending.requestID);
1406
1559
  reporter.error("Decision could not be applied", error);
1560
+ conversation?.error(pending.sessionID, "Decision could not be applied");
1407
1561
  } finally {
1408
1562
  processingCommands.delete(command.id);
1409
1563
  }
@@ -1418,12 +1572,19 @@ function createPermissionController({
1418
1572
  reporter.applied(
1419
1573
  `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1420
1574
  );
1575
+ conversation?.applied(
1576
+ pending.sessionID,
1577
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1578
+ );
1421
1579
  } else if (acknowledgement.status === "expired") {
1422
1580
  reporter.warning("Decision expired before it could be applied");
1581
+ conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
1423
1582
  } else if (acknowledgement.status === "stale") {
1424
1583
  reporter.warning("Permission was already answered locally");
1584
+ conversation?.warning(pending.sessionID, "Permission was already answered locally");
1425
1585
  } else if (acknowledgement.status === "rejected") {
1426
1586
  reporter.warning("Decision was rejected by validation");
1587
+ conversation?.warning(pending.sessionID, "Decision was rejected by validation");
1427
1588
  }
1428
1589
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1429
1590
  reporter.warning("Decision receipt could not be sent", error);
@@ -1486,7 +1647,8 @@ function createPermissionController({
1486
1647
 
1487
1648
  // src/plugin.js
1488
1649
  async function BackeerPlugin({ client, directory }, options = {}) {
1489
- const bootstrapReporter = createConsoleReporter(
1650
+ const bootstrapReporter = createOpenCodeReporter(
1651
+ client,
1490
1652
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL
1491
1653
  );
1492
1654
  let config;
@@ -1496,18 +1658,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1496
1658
  bootstrapReporter.error("Plugin disabled \u2014 invalid configuration", error);
1497
1659
  return {};
1498
1660
  }
1499
- const reporter = createConsoleReporter(config.logLevel);
1661
+ const reporter = createOpenCodeReporter(client, config.logLevel);
1662
+ const conversation = createConversationFeedback({ client, reporter });
1500
1663
  const permissionController = createPermissionController({
1501
1664
  config,
1502
1665
  reporter,
1503
1666
  client,
1504
- directory
1667
+ directory,
1668
+ conversation
1505
1669
  });
1506
1670
  const lifecycleController = createLifecycleController({
1507
1671
  config,
1508
1672
  reporter,
1509
1673
  client,
1510
- directory
1674
+ directory,
1675
+ conversation
1511
1676
  });
1512
1677
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
1513
1678
  const publishedQuestions = /* @__PURE__ */ new Map();
@@ -1538,8 +1703,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1538
1703
  reporter,
1539
1704
  event.properties,
1540
1705
  directory,
1541
- identity
1542
- ).catch((error) => reporter.error("Permission request could not be sent", error));
1706
+ identity,
1707
+ conversation
1708
+ ).catch((error) => {
1709
+ reporter.error("Permission request could not be sent", error);
1710
+ conversation.error(identity.sessionID, "Permission request could not be sent");
1711
+ });
1543
1712
  }
1544
1713
  async function handleTodoUpdated(event) {
1545
1714
  const { sessionID, todos } = event.properties;
@@ -1557,8 +1726,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1557
1726
  event.properties,
1558
1727
  event.id,
1559
1728
  client,
1560
- directory
1561
- ).catch((error) => reporter.error("Todo completion could not be sent", error));
1729
+ directory,
1730
+ conversation
1731
+ ).catch((error) => {
1732
+ reporter.error("Todo completion could not be sent", error);
1733
+ conversation.error(sessionID, "Todo completion could not be sent");
1734
+ });
1562
1735
  }
1563
1736
  async function handleQuestionAsked(event) {
1564
1737
  const identity = questionIdentity(event.properties);
@@ -1574,8 +1747,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1574
1747
  event.properties,
1575
1748
  identity,
1576
1749
  client,
1577
- directory
1578
- ).catch((error) => reporter.error("Question could not be sent", error));
1750
+ directory,
1751
+ conversation
1752
+ ).catch((error) => {
1753
+ reporter.error("Question could not be sent", error);
1754
+ conversation.error(identity.sessionID, "Question could not be sent");
1755
+ });
1579
1756
  }
1580
1757
  return {
1581
1758
  config: async (opencodeConfig) => {
@@ -1585,6 +1762,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1585
1762
  "chat.message": handleUpdateNotice,
1586
1763
  event: async ({ event }) => {
1587
1764
  if (!config.token) return;
1765
+ conversation.handle(event);
1588
1766
  lifecycleController.handle(event);
1589
1767
  if (config.permissions && event.type === "permission.asked") {
1590
1768
  await handlePermissionAsked(event);
@@ -1609,6 +1787,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1609
1787
  lifecycleController.dispose();
1610
1788
  todoCompletionBySessionID.clear();
1611
1789
  publishedQuestions.clear();
1790
+ conversation.dispose();
1612
1791
  }
1613
1792
  };
1614
1793
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,6 +21,7 @@ export async function publishPermission(
21
21
  request,
22
22
  directory,
23
23
  identity,
24
+ conversation,
24
25
  ) {
25
26
  const permissionType = request.permission ?? request.action ?? "unknown"
26
27
  const resources = Array.isArray(request.patterns)
@@ -53,6 +54,7 @@ export async function publishPermission(
53
54
  direction: "in",
54
55
  })
55
56
  reporter.success("Permission request sent", `HTTP ${result.status}`)
57
+ conversation?.success(identity.sessionID, "Permission request sent")
56
58
  }
57
59
 
58
60
  export function questionIdentity(request) {
@@ -104,6 +106,7 @@ export async function publishQuestion(
104
106
  identity,
105
107
  client,
106
108
  directory,
109
+ conversation,
107
110
  ) {
108
111
  const questions = Array.isArray(request.questions) ? request.questions : []
109
112
  if (questions.length === 0) return
@@ -153,6 +156,7 @@ export async function publishQuestion(
153
156
  direction: "in",
154
157
  })
155
158
  reporter.success("Question sent", `HTTP ${result.status}`)
159
+ conversation?.success(identity.sessionID, "Question sent")
156
160
  }
157
161
 
158
162
  export async function publishTodoCompletion(
@@ -162,6 +166,7 @@ export async function publishTodoCompletion(
162
166
  eventID,
163
167
  client,
164
168
  directory,
169
+ conversation,
165
170
  ) {
166
171
  const todos = request.todos
167
172
  const sessionTitle = await resolveSessionTitle(
@@ -211,4 +216,5 @@ export async function publishTodoCompletion(
211
216
  direction: "in",
212
217
  })
213
218
  reporter.success("Todo completion sent", `HTTP ${result.status}`)
219
+ conversation?.success(request.sessionID, "Todo completion sent")
214
220
  }
@@ -20,6 +20,7 @@ export function createLifecycleController({
20
20
  reporter,
21
21
  client,
22
22
  directory,
23
+ conversation,
23
24
  now = () => Date.now(),
24
25
  publishers = {},
25
26
  }) {
@@ -47,8 +48,11 @@ export function createLifecycleController({
47
48
  return state
48
49
  }
49
50
 
50
- function dispatch(label, operation) {
51
- void Promise.resolve(operation).catch((error) => reporter.error(label, error))
51
+ function dispatch(label, operation, sessionID) {
52
+ void Promise.resolve(operation).catch((error) => {
53
+ reporter.error(label, error)
54
+ conversation?.error(sessionID, label)
55
+ })
52
56
  }
53
57
 
54
58
  function beginCycle(state) {
@@ -83,7 +87,9 @@ export function createLifecycleController({
83
87
  sessionID,
84
88
  messageID: limit(assistant.id, 200),
85
89
  durationMs,
90
+ conversation,
86
91
  }),
92
+ sessionID,
87
93
  )
88
94
  }
89
95
 
@@ -135,7 +141,9 @@ export function createLifecycleController({
135
141
  correlation: limit(correlation, 200),
136
142
  attempt: status.attempt,
137
143
  next: status.next,
144
+ conversation,
138
145
  }),
146
+ sessionID,
139
147
  )
140
148
  }
141
149
 
@@ -157,7 +165,9 @@ export function createLifecycleController({
157
165
  sessionID,
158
166
  correlation: errorCorrelation(event, state),
159
167
  error: event.properties?.error,
168
+ conversation,
160
169
  }),
170
+ sessionID,
161
171
  )
162
172
  }
163
173
 
@@ -53,7 +53,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
53
53
  }
54
54
  }
55
55
 
56
- async function publish(config, reporter, event, feedback) {
56
+ async function publish(config, reporter, conversation, sessionID, event, feedback) {
57
57
  const result = await postJsonWithRetry(config, config.eventsEndpoint, {
58
58
  type: "application",
59
59
  timestamp: new Date().toISOString(),
@@ -62,6 +62,7 @@ async function publish(config, reporter, event, feedback) {
62
62
  ...event,
63
63
  })
64
64
  reporter.success(feedback, `HTTP ${result.status}`)
65
+ conversation?.success(sessionID, feedback)
65
66
  }
66
67
 
67
68
  function secondsLabel(durationMs) {
@@ -80,6 +81,7 @@ export async function publishSessionCompleted({
80
81
  sessionID,
81
82
  messageID,
82
83
  durationMs,
84
+ conversation,
83
85
  }) {
84
86
  const sessionTitle = await resolveSessionTitle(
85
87
  client,
@@ -89,7 +91,7 @@ export async function publishSessionCompleted({
89
91
  )
90
92
  const duration = secondsLabel(durationMs)
91
93
  const location = sessionTitle ? ` “${sessionTitle}”` : ""
92
- await publish(config, reporter, {
94
+ await publish(config, reporter, conversation, sessionID, {
93
95
  severity: "info",
94
96
  title: "OpenCode task completed",
95
97
  body: `OpenCode finished${location} in ${duration}.`,
@@ -111,6 +113,7 @@ export async function publishSessionRetry({
111
113
  correlation,
112
114
  attempt,
113
115
  next,
116
+ conversation,
114
117
  }) {
115
118
  const sessionTitle = await resolveSessionTitle(
116
119
  client,
@@ -119,7 +122,7 @@ export async function publishSessionRetry({
119
122
  reporter,
120
123
  )
121
124
  const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : ""
122
- await publish(config, reporter, {
125
+ await publish(config, reporter, conversation, sessionID, {
123
126
  severity: "warning",
124
127
  title: "OpenCode is retrying",
125
128
  body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
@@ -140,6 +143,7 @@ export async function publishSessionError({
140
143
  sessionID,
141
144
  correlation,
142
145
  error,
146
+ conversation,
143
147
  }) {
144
148
  const sessionTitle = await resolveSessionTitle(
145
149
  client,
@@ -152,7 +156,7 @@ export async function publishSessionError({
152
156
  const body = details.message
153
157
  ? `OpenCode failed${location}: ${details.message}`
154
158
  : `OpenCode failed${location}.`
155
- await publish(config, reporter, {
159
+ await publish(config, reporter, conversation, sessionID, {
156
160
  // Backeer/iOS currently support info, warning and critical severities.
157
161
  severity: "critical",
158
162
  title: "OpenCode task failed",
@@ -170,7 +174,7 @@ export async function publishSessionError({
170
174
 
171
175
  export async function publishOpenCodeUpdate({ config, reporter, version }) {
172
176
  const safeVersion = limit(version, 100)
173
- await publish(config, reporter, {
177
+ await publish(config, reporter, undefined, undefined, {
174
178
  severity: "info",
175
179
  title: "OpenCode update available",
176
180
  body: `OpenCode ${safeVersion} is available.`,
package/src/management.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { dirname, resolve } from "node:path"
10
10
 
11
11
  import { buildDoctorReport, publishTestNotification } from "./diagnostics.js"
12
+ import { sendSessionMessage } from "./session-messages.js"
12
13
  import {
13
14
  formatAgentUpdateInstruction,
14
15
  PLUGIN_VERSION,
@@ -156,19 +157,6 @@ function tokenInstructions() {
156
157
  ].join("\n")
157
158
  }
158
159
 
159
- async function sendCommandMessage(client, sessionID, text) {
160
- if (typeof client?.session?.prompt !== "function") {
161
- throw new Error("OpenCode session messaging is unavailable")
162
- }
163
- await client.session.prompt({
164
- path: { id: sessionID },
165
- body: {
166
- noReply: true,
167
- parts: [{ type: "text", text, ignored: true }],
168
- },
169
- })
170
- }
171
-
172
160
  export function registerBackeerCommand(opencodeConfig) {
173
161
  opencodeConfig.command ??= {}
174
162
  opencodeConfig.command.backeer = {
@@ -262,6 +250,6 @@ export function createBackeerCommandHandler({
262
250
  ].join("\n")
263
251
  }
264
252
 
265
- await sendCommandMessage(client, input.sessionID, message)
253
+ await sendSessionMessage(client, input.sessionID, message)
266
254
  }
267
255
  }
@@ -35,6 +35,7 @@ export function createPermissionController({
35
35
  reporter,
36
36
  client,
37
37
  directory,
38
+ conversation,
38
39
  }) {
39
40
  const pendingByExternalID = new Map()
40
41
  const externalIDByRequestID = new Map()
@@ -104,6 +105,7 @@ export function createPermissionController({
104
105
  }
105
106
  if (stale) removePendingByRequestID(pending.requestID)
106
107
  reporter.error("Decision could not be applied", error)
108
+ conversation?.error(pending.sessionID, "Decision could not be applied")
107
109
  } finally {
108
110
  processingCommands.delete(command.id)
109
111
  }
@@ -120,12 +122,19 @@ export function createPermissionController({
120
122
  reporter.applied(
121
123
  `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
122
124
  )
125
+ conversation?.applied(
126
+ pending.sessionID,
127
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
128
+ )
123
129
  } else if (acknowledgement.status === "expired") {
124
130
  reporter.warning("Decision expired before it could be applied")
131
+ conversation?.warning(pending.sessionID, "Decision expired before it could be applied")
125
132
  } else if (acknowledgement.status === "stale") {
126
133
  reporter.warning("Permission was already answered locally")
134
+ conversation?.warning(pending.sessionID, "Permission was already answered locally")
127
135
  } else if (acknowledgement.status === "rejected") {
128
136
  reporter.warning("Decision was rejected by validation")
137
+ conversation?.warning(pending.sessionID, "Decision was rejected by validation")
129
138
  }
130
139
 
131
140
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
package/src/plugin.js CHANGED
@@ -12,7 +12,8 @@ import {
12
12
  } from "./management.js"
13
13
  import { createLifecycleController } from "./lifecycle-controller.js"
14
14
  import { createPermissionController } from "./permission-controller.js"
15
- import { createConsoleReporter } from "./reporter.js"
15
+ import { createOpenCodeReporter } from "./reporter.js"
16
+ import { createConversationFeedback } from "./session-messages.js"
16
17
  import {
17
18
  createUpdateChecker,
18
19
  createUpdateNoticeHook,
@@ -28,7 +29,8 @@ import { rememberBounded } from "./utilities.js"
28
29
  * protected file so it cannot accidentally be committed in opencode.json.
29
30
  */
30
31
  export default async function BackeerPlugin({ client, directory }, options = {}) {
31
- const bootstrapReporter = createConsoleReporter(
32
+ const bootstrapReporter = createOpenCodeReporter(
33
+ client,
32
34
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
33
35
  )
34
36
  let config
@@ -39,19 +41,22 @@ export default async function BackeerPlugin({ client, directory }, options = {})
39
41
  return {}
40
42
  }
41
43
 
42
- const reporter = createConsoleReporter(config.logLevel)
44
+ const reporter = createOpenCodeReporter(client, config.logLevel)
45
+ const conversation = createConversationFeedback({ client, reporter })
43
46
 
44
47
  const permissionController = createPermissionController({
45
48
  config,
46
49
  reporter,
47
50
  client,
48
51
  directory,
52
+ conversation,
49
53
  })
50
54
  const lifecycleController = createLifecycleController({
51
55
  config,
52
56
  reporter,
53
57
  client,
54
58
  directory,
59
+ conversation,
55
60
  })
56
61
  const todoCompletionBySessionID = new Map()
57
62
  const publishedQuestions = new Map()
@@ -86,7 +91,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
86
91
  event.properties,
87
92
  directory,
88
93
  identity,
89
- ).catch((error) => reporter.error("Permission request could not be sent", error))
94
+ conversation,
95
+ ).catch((error) => {
96
+ reporter.error("Permission request could not be sent", error)
97
+ conversation.error(identity.sessionID, "Permission request could not be sent")
98
+ })
90
99
  }
91
100
 
92
101
  async function handleTodoUpdated(event) {
@@ -109,7 +118,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
109
118
  event.id,
110
119
  client,
111
120
  directory,
112
- ).catch((error) => reporter.error("Todo completion could not be sent", error))
121
+ conversation,
122
+ ).catch((error) => {
123
+ reporter.error("Todo completion could not be sent", error)
124
+ conversation.error(sessionID, "Todo completion could not be sent")
125
+ })
113
126
  }
114
127
 
115
128
  async function handleQuestionAsked(event) {
@@ -128,7 +141,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
128
141
  identity,
129
142
  client,
130
143
  directory,
131
- ).catch((error) => reporter.error("Question could not be sent", error))
144
+ conversation,
145
+ ).catch((error) => {
146
+ reporter.error("Question could not be sent", error)
147
+ conversation.error(identity.sessionID, "Question could not be sent")
148
+ })
132
149
  }
133
150
 
134
151
  return {
@@ -143,6 +160,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
143
160
  event: async ({ event }) => {
144
161
  if (!config.token) return
145
162
 
163
+ conversation.handle(event)
146
164
  lifecycleController.handle(event)
147
165
 
148
166
  if (config.permissions && event.type === "permission.asked") {
@@ -175,6 +193,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
175
193
  lifecycleController.dispose()
176
194
  todoCompletionBySessionID.clear()
177
195
  publishedQuestions.clear()
196
+ conversation.dispose()
178
197
  },
179
198
  }
180
199
  }
package/src/reporter.js CHANGED
@@ -58,3 +58,53 @@ export function createConsoleReporter(levelValue) {
58
58
  debug: (message, detail) => emit("debug", "debug", message, detail),
59
59
  }
60
60
  }
61
+
62
+ export function createOpenCodeReporter(client, levelValue) {
63
+ const level = normalizeLogLevel(levelValue)
64
+ const recentlyEmitted = new Map()
65
+ const method = client?.app?.log
66
+
67
+ function emit(requiredLevel, kind, message, detail, deduplicate = false) {
68
+ if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return
69
+
70
+ const key = `${kind}:${message}`
71
+ const now = Date.now()
72
+ if (deduplicate) {
73
+ const previous = recentlyEmitted.get(key)
74
+ if (previous && now - previous < LOG_DEDUPLICATION_MS) return
75
+ rememberRecent(recentlyEmitted, key, now)
76
+ }
77
+
78
+ const body = {
79
+ service: "backeer-opencode",
80
+ level: kind === "warning" ? "warn" : kind === "applied" || kind === "success"
81
+ ? "info"
82
+ : kind,
83
+ message: limit(message, 500),
84
+ }
85
+ if (level === "debug" && detail) {
86
+ body.extra = { detail: limit(detail?.message ?? detail, 500) }
87
+ }
88
+
89
+ // Logging during plugin initialization can deadlock older OpenCode builds.
90
+ // Defer it and never fall back to stdout, because stdout corrupts the TUI prompt.
91
+ setTimeout(() => {
92
+ void Promise.resolve(method.call(client.app, { body })).catch(() => {})
93
+ }, 0)
94
+ }
95
+
96
+ return {
97
+ info: (message, detail) => emit("info", "info", message, detail),
98
+ success: (message, detail) => emit("info", "success", message, detail),
99
+ applied: (message, detail) => emit("info", "applied", message, detail),
100
+ warning: (message, detail) => emit("error", "warning", message, detail, true),
101
+ error: (message, detail) => emit("error", "error", message, detail, true),
102
+ debug: (message, detail) => emit("debug", "debug", message, detail),
103
+ }
104
+ }
105
+
106
+ function rememberRecent(map, key, value) {
107
+ map.delete(key)
108
+ map.set(key, value)
109
+ while (map.size > 100) map.delete(map.keys().next().value)
110
+ }
@@ -0,0 +1,173 @@
1
+ import { limit, rememberBounded } from "./utilities.js"
2
+
3
+ const FEEDBACK_SYMBOLS = {
4
+ info: "•",
5
+ success: "✓",
6
+ applied: "✓",
7
+ warning: "!",
8
+ error: "×",
9
+ }
10
+
11
+ const QUESTION_ASKED_EVENTS = new Set(["question.asked", "question.v2.asked"])
12
+ const QUESTION_REPLIED_EVENTS = new Set([
13
+ "question.replied",
14
+ "question.v2.replied",
15
+ "question.answered",
16
+ "question.v2.answered",
17
+ ])
18
+
19
+ export async function sendSessionMessage(client, sessionID, text) {
20
+ if (typeof client?.session?.prompt !== "function") {
21
+ throw new Error("OpenCode session messaging is unavailable")
22
+ }
23
+ await client.session.prompt({
24
+ path: { id: sessionID },
25
+ body: {
26
+ noReply: true,
27
+ parts: [{ type: "text", text, ignored: true }],
28
+ },
29
+ })
30
+ }
31
+
32
+ export function formatBackeerFeedback(kind, message) {
33
+ const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
34
+ return `> **Backeer** ${symbol} ${limit(message, 300)}`
35
+ }
36
+
37
+ export function createConversationFeedback({ client, reporter }) {
38
+ const sessions = new Map()
39
+ let disposed = false
40
+
41
+ function sessionState(sessionID) {
42
+ let state = sessions.get(sessionID)
43
+ if (!state) {
44
+ state = {
45
+ idle: false,
46
+ flushing: false,
47
+ blockers: new Set(),
48
+ queue: [],
49
+ queued: new Set(),
50
+ }
51
+ rememberBounded(sessions, sessionID, state)
52
+ }
53
+ return state
54
+ }
55
+
56
+ async function flush(sessionID, state) {
57
+ if (
58
+ disposed || state.flushing || !state.idle ||
59
+ state.blockers.size > 0 || state.queue.length === 0
60
+ ) return
61
+ state.flushing = true
62
+ try {
63
+ while (
64
+ !disposed && state.idle && state.blockers.size === 0 &&
65
+ state.queue.length > 0
66
+ ) {
67
+ const item = state.queue[0]
68
+ try {
69
+ await sendSessionMessage(
70
+ client,
71
+ sessionID,
72
+ formatBackeerFeedback(item.kind, item.message),
73
+ )
74
+ state.queue.shift()
75
+ state.queued.delete(item.key)
76
+ } catch (error) {
77
+ state.idle = false
78
+ reporter.debug("Conversation feedback could not be displayed", error)
79
+ }
80
+ }
81
+ } finally {
82
+ state.flushing = false
83
+ }
84
+ }
85
+
86
+ function enqueue(kind, sessionID, message) {
87
+ if (disposed || typeof sessionID !== "string" || !sessionID || !message) return
88
+ const state = sessionState(sessionID)
89
+ const key = `${sessionID}:${kind}:${message}`
90
+ if (state.queued.has(key)) return
91
+
92
+ state.queue.push({ kind, message: limit(message, 300), key })
93
+ state.queued.add(key)
94
+ while (state.queue.length > 20) {
95
+ const removed = state.queue.shift()
96
+ state.queued.delete(removed.key)
97
+ }
98
+ if (state.idle && state.blockers.size === 0) void flush(sessionID, state)
99
+ }
100
+
101
+ function setIdle(sessionID, idle) {
102
+ if (typeof sessionID !== "string" || !sessionID) return
103
+ const state = sessionState(sessionID)
104
+ state.idle = idle
105
+ if (idle) void flush(sessionID, state)
106
+ }
107
+
108
+ function interactionKey(kind, event) {
109
+ const value = event.properties?.requestID ?? event.properties?.id
110
+ return `${kind}:${typeof value === "string" && value ? value : "pending"}`
111
+ }
112
+
113
+ function beginInteraction(sessionID, kind, event) {
114
+ const state = sessionState(sessionID)
115
+ state.idle = false
116
+ state.blockers.add(interactionKey(kind, event))
117
+ }
118
+
119
+ function endInteraction(sessionID, kind, event) {
120
+ const state = sessionState(sessionID)
121
+ const key = interactionKey(kind, event)
122
+ if (!state.blockers.delete(key)) {
123
+ const fallback = [...state.blockers]
124
+ .find((blocker) => blocker.startsWith(`${kind}:`))
125
+ if (fallback) state.blockers.delete(fallback)
126
+ }
127
+ // The reply event can arrive before OpenCode has removed its permission UI
128
+ // or resumed the agent. Wait for the next idle event before injecting text.
129
+ state.idle = false
130
+ }
131
+
132
+ return {
133
+ info: (sessionID, message) => enqueue("info", sessionID, message),
134
+ success: (sessionID, message) => enqueue("success", sessionID, message),
135
+ applied: (sessionID, message) => enqueue("applied", sessionID, message),
136
+ warning: (sessionID, message) => enqueue("warning", sessionID, message),
137
+ error: (sessionID, message) => enqueue("error", sessionID, message),
138
+
139
+ handle(event) {
140
+ const sessionID = event?.properties?.sessionID
141
+ if (typeof sessionID !== "string") return
142
+
143
+ if (event.type === "session.status") {
144
+ const status = event.properties?.status?.type
145
+ if (status === "idle") setIdle(sessionID, true)
146
+ else if (status === "busy" || status === "retry") setIdle(sessionID, false)
147
+ } else if (event.type === "session.idle") {
148
+ setIdle(sessionID, true)
149
+ } else if (event.type === "permission.asked") {
150
+ beginInteraction(sessionID, "permission", event)
151
+ } else if (QUESTION_ASKED_EVENTS.has(event.type)) {
152
+ beginInteraction(sessionID, "question", event)
153
+ } else if (event.type === "permission.replied") {
154
+ endInteraction(sessionID, "permission", event)
155
+ } else if (QUESTION_REPLIED_EVENTS.has(event.type)) {
156
+ endInteraction(sessionID, "question", event)
157
+ } else if (event.type === "todo.updated") {
158
+ setIdle(sessionID, false)
159
+ } else if (event.type === "session.error") {
160
+ const state = sessionState(sessionID)
161
+ state.blockers.clear()
162
+ state.idle = false
163
+ } else if (event.type === "session.deleted") {
164
+ sessions.delete(sessionID)
165
+ }
166
+ },
167
+
168
+ dispose() {
169
+ disposed = true
170
+ sessions.clear()
171
+ },
172
+ }
173
+ }
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.2.1"
1
+ export const PLUGIN_VERSION = "0.2.3"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"