backeer-opencode 0.2.0 → 0.2.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.
@@ -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);
62
48
  }
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);
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) };
56
+ }
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";
@@ -140,6 +139,7 @@ function loadConfig(options = {}) {
140
139
  return {
141
140
  origin,
142
141
  eventsEndpoint: new URL("/v1/events", origin),
142
+ sourceHealthEndpoint: new URL("/v1/source/health", origin),
143
143
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
144
144
  token: loadedToken.token,
145
145
  tokenSource: loadedToken.source,
@@ -244,7 +244,7 @@ function permissionIdentity(request) {
244
244
  externalId: `opencode:${sessionID}:${requestID}`
245
245
  };
246
246
  }
247
- async function publishPermission(config, reporter, request, directory, identity) {
247
+ async function publishPermission(config, reporter, request, directory, identity, conversation) {
248
248
  const permissionType = request.permission ?? request.action ?? "unknown";
249
249
  const resources = Array.isArray(request.patterns) ? request.patterns.join(", ") : Array.isArray(request.resources) ? request.resources.join(", ") : "";
250
250
  const payload = [
@@ -268,6 +268,7 @@ async function publishPermission(config, reporter, request, directory, identity)
268
268
  direction: "in"
269
269
  });
270
270
  reporter.success("Permission request sent", `HTTP ${result.status}`);
271
+ conversation?.success(identity.sessionID, "Permission request sent");
271
272
  }
272
273
  function questionIdentity(request) {
273
274
  const requestID = request.id ?? request.requestID;
@@ -302,7 +303,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
302
303
  return "";
303
304
  }
304
305
  }
305
- async function publishQuestion(config, reporter, request, identity, client, directory) {
306
+ async function publishQuestion(config, reporter, request, identity, client, directory, conversation) {
306
307
  const questions = Array.isArray(request.questions) ? request.questions : [];
307
308
  if (questions.length === 0) return;
308
309
  const sessionTitle = await resolveSessionTitle(
@@ -344,8 +345,9 @@ async function publishQuestion(config, reporter, request, identity, client, dire
344
345
  direction: "in"
345
346
  });
346
347
  reporter.success("Question sent", `HTTP ${result.status}`);
348
+ conversation?.success(identity.sessionID, "Question sent");
347
349
  }
348
- async function publishTodoCompletion(config, reporter, request, eventID, client, directory) {
350
+ async function publishTodoCompletion(config, reporter, request, eventID, client, directory, conversation) {
349
351
  const todos = request.todos;
350
352
  const sessionTitle = await resolveSessionTitle(
351
353
  client,
@@ -383,6 +385,7 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
383
385
  direction: "in"
384
386
  });
385
387
  reporter.success("Todo completion sent", `HTTP ${result.status}`);
388
+ conversation?.success(request.sessionID, "Todo completion sent");
386
389
  }
387
390
 
388
391
  // src/management.js
@@ -391,13 +394,17 @@ import {
391
394
  mkdirSync,
392
395
  readFileSync as readFileSync2,
393
396
  renameSync,
394
- statSync,
397
+ statSync as statSync2,
395
398
  writeFileSync
396
399
  } from "node:fs";
397
400
  import { dirname, resolve } from "node:path";
398
401
 
402
+ // src/diagnostics.js
403
+ import { randomUUID } from "node:crypto";
404
+ import { statSync } from "node:fs";
405
+
399
406
  // src/updates.js
400
- var PLUGIN_VERSION = "0.2.0";
407
+ var PLUGIN_VERSION = "0.2.2";
401
408
  var PACKAGE_NAME = "backeer-opencode";
402
409
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
403
410
  var CHECK_TIMEOUT_MS = 3e3;
@@ -517,11 +524,234 @@ function createUpdateNoticeHook(updateChecker) {
517
524
  };
518
525
  }
519
526
 
527
+ // src/diagnostics.js
528
+ var PASS = "\u2713";
529
+ var FAIL = "\xD7";
530
+ var INFO = "\u2022";
531
+ function tokenFileCheck(config) {
532
+ if (config.tokenSource === "environment") {
533
+ return { ok: true, text: "Token loaded from the environment; token file not required" };
534
+ }
535
+ try {
536
+ const stats = statSync(config.tokenFile);
537
+ const mode = stats.mode & 511;
538
+ if (!stats.isFile()) return { ok: false, text: "Token path is not a regular file" };
539
+ if ((mode & 63) !== 0) {
540
+ return {
541
+ ok: false,
542
+ text: `Token file mode ${mode.toString(8).padStart(3, "0")} is too permissive; expected 600`
543
+ };
544
+ }
545
+ return {
546
+ ok: true,
547
+ text: `Token file is protected (mode ${mode.toString(8).padStart(3, "0")})`
548
+ };
549
+ } catch {
550
+ return { ok: false, text: "Token file is missing or unreadable" };
551
+ }
552
+ }
553
+ async function probeSource(config, fetchImplementation = globalThis.fetch) {
554
+ const controller = new AbortController();
555
+ const timeout = setTimeout(() => controller.abort(), 5e3);
556
+ try {
557
+ const response = await fetchImplementation(config.sourceHealthEndpoint, {
558
+ method: "GET",
559
+ headers: { Authorization: `Bearer ${config.token}` },
560
+ signal: controller.signal
561
+ });
562
+ const responseBody = limit(await response.text(), 2e3);
563
+ if (!response.ok) {
564
+ throw new HttpError(
565
+ `Gateway source check failed with status ${response.status}`,
566
+ response.status,
567
+ responseBody
568
+ );
569
+ }
570
+ try {
571
+ return JSON.parse(responseBody);
572
+ } catch {
573
+ throw new Error("Gateway source check returned malformed JSON");
574
+ }
575
+ } finally {
576
+ clearTimeout(timeout);
577
+ }
578
+ }
579
+ async function buildDoctorReport(config, { fetchImplementation = globalThis.fetch } = {}) {
580
+ const checks = [];
581
+ checks.push({ ok: true, text: `Plugin version ${PLUGIN_VERSION}` });
582
+ checks.push({
583
+ ok: config.origin.protocol === "https:",
584
+ text: `Gateway uses ${config.origin.protocol === "https:" ? "HTTPS" : "an insecure protocol"}: ${config.origin.origin}`
585
+ });
586
+ checks.push({
587
+ ok: Boolean(config.token),
588
+ text: config.token ? `Source token loaded from ${config.tokenSource}` : "Source token is missing"
589
+ });
590
+ checks.push(tokenFileCheck(config));
591
+ let source;
592
+ if (config.token) {
593
+ try {
594
+ const result = await probeSource(config, fetchImplementation);
595
+ source = result?.source;
596
+ checks.push({
597
+ ok: result?.ok === true && typeof source?.id === "string",
598
+ text: source?.id ? `Source authentication accepted: ${source.name || source.id} (${source.id})` : "Gateway did not return a source identity"
599
+ });
600
+ checks.push({
601
+ ok: result?.capabilities?.events === true && result?.capabilities?.commands === true,
602
+ text: result?.capabilities?.events === true && result?.capabilities?.commands === true ? "Event ingest and command stream are available" : "Event ingest or command stream is unavailable"
603
+ });
604
+ checks.push({
605
+ ok: result?.capabilities?.apns === true,
606
+ text: result?.capabilities?.apns === true ? "Gateway APNs delivery is enabled" : "Gateway APNs delivery is disabled"
607
+ });
608
+ } catch (error) {
609
+ const detail = error instanceof HttpError && error.status === 401 ? "Source authentication rejected (HTTP 401)" : error?.name === "AbortError" ? "Gateway source check timed out" : `Gateway source check failed: ${limit(error?.message || error, 300)}`;
610
+ checks.push({ ok: false, text: detail });
611
+ }
612
+ }
613
+ const healthy = checks.every((check) => check.ok);
614
+ return [
615
+ "## Backeer doctor",
616
+ "",
617
+ ...checks.map((check) => `${check.ok ? PASS : FAIL} ${check.text}`),
618
+ "",
619
+ healthy ? `${PASS} Result: healthy. Use \`/backeer test\` to verify phone delivery.` : `${FAIL} Result: issues found. Resolve failed checks and run \`/backeer doctor\` again.`,
620
+ "",
621
+ `${INFO} No event was created and the token value was not displayed.`
622
+ ].join("\n");
623
+ }
624
+ async function publishTestNotification(config, reporter) {
625
+ if (!config.token) throw new Error("Backeer source token is missing");
626
+ const testID = randomUUID();
627
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
628
+ type: "application",
629
+ severity: "info",
630
+ title: "Backeer test notification",
631
+ body: "OpenCode integration is connected and push delivery is working.",
632
+ payload: [
633
+ { key: "Plugin version", value: PLUGIN_VERSION },
634
+ { key: "Test ID", value: testID }
635
+ ],
636
+ externalId: `opencode:test:${testID}`,
637
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
638
+ notify: true,
639
+ direction: "in"
640
+ });
641
+ reporter.success("Test notification sent", `HTTP ${result.status}`);
642
+ return testID;
643
+ }
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
+ async function sendSessionMessage(client, sessionID, text) {
654
+ if (typeof client?.session?.prompt !== "function") {
655
+ throw new Error("OpenCode session messaging is unavailable");
656
+ }
657
+ await client.session.prompt({
658
+ path: { id: sessionID },
659
+ body: {
660
+ noReply: true,
661
+ parts: [{ type: "text", text, ignored: true }]
662
+ }
663
+ });
664
+ }
665
+ function formatBackeerFeedback(kind, message) {
666
+ const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
667
+ return `> **Backeer** ${symbol} ${limit(message, 300)}`;
668
+ }
669
+ function createConversationFeedback({ client, reporter }) {
670
+ const sessions = /* @__PURE__ */ new Map();
671
+ let disposed = false;
672
+ function sessionState(sessionID) {
673
+ let state = sessions.get(sessionID);
674
+ if (!state) {
675
+ state = { idle: false, flushing: false, queue: [], queued: /* @__PURE__ */ new Set() };
676
+ rememberBounded(sessions, sessionID, state);
677
+ }
678
+ return state;
679
+ }
680
+ async function flush(sessionID, state) {
681
+ if (disposed || state.flushing || !state.idle || state.queue.length === 0) return;
682
+ state.flushing = true;
683
+ try {
684
+ while (!disposed && state.idle && state.queue.length > 0) {
685
+ const item = state.queue[0];
686
+ try {
687
+ await sendSessionMessage(
688
+ client,
689
+ sessionID,
690
+ formatBackeerFeedback(item.kind, item.message)
691
+ );
692
+ state.queue.shift();
693
+ state.queued.delete(item.key);
694
+ } catch (error) {
695
+ state.idle = false;
696
+ reporter.debug("Conversation feedback could not be displayed", error);
697
+ }
698
+ }
699
+ } finally {
700
+ state.flushing = false;
701
+ }
702
+ }
703
+ function enqueue(kind, sessionID, message) {
704
+ if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
705
+ const state = sessionState(sessionID);
706
+ const key = `${sessionID}:${kind}:${message}`;
707
+ if (state.queued.has(key)) return;
708
+ state.queue.push({ kind, message: limit(message, 300), key });
709
+ state.queued.add(key);
710
+ while (state.queue.length > 20) {
711
+ const removed = state.queue.shift();
712
+ state.queued.delete(removed.key);
713
+ }
714
+ if (state.idle) void flush(sessionID, state);
715
+ }
716
+ function setIdle(sessionID, idle) {
717
+ if (typeof sessionID !== "string" || !sessionID) return;
718
+ const state = sessionState(sessionID);
719
+ state.idle = idle;
720
+ if (idle) void flush(sessionID, state);
721
+ }
722
+ return {
723
+ info: (sessionID, message) => enqueue("info", sessionID, message),
724
+ success: (sessionID, message) => enqueue("success", sessionID, message),
725
+ applied: (sessionID, message) => enqueue("applied", sessionID, message),
726
+ warning: (sessionID, message) => enqueue("warning", sessionID, message),
727
+ error: (sessionID, message) => enqueue("error", sessionID, message),
728
+ handle(event) {
729
+ const sessionID = event?.properties?.sessionID;
730
+ if (typeof sessionID !== "string") return;
731
+ if (event.type === "session.status") {
732
+ const status = event.properties?.status?.type;
733
+ if (status === "idle") setIdle(sessionID, true);
734
+ else if (status === "busy" || status === "retry") setIdle(sessionID, false);
735
+ } else if (event.type === "session.idle") {
736
+ setIdle(sessionID, true);
737
+ } else if (event.type === "permission.asked" || event.type === "question.asked" || event.type === "question.v2.asked" || event.type === "todo.updated") {
738
+ setIdle(sessionID, false);
739
+ } else if (event.type === "session.deleted") {
740
+ sessions.delete(sessionID);
741
+ }
742
+ },
743
+ dispose() {
744
+ disposed = true;
745
+ sessions.clear();
746
+ }
747
+ };
748
+ }
749
+
520
750
  // src/management.js
521
- var COMMAND_DESCRIPTION = "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]";
751
+ var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
522
752
  function tokenFileState(path) {
523
753
  try {
524
- const stats = statSync(path);
754
+ const stats = statSync2(path);
525
755
  const mode = (stats.mode & 511).toString(8).padStart(3, "0");
526
756
  return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
527
757
  } catch {
@@ -546,7 +776,7 @@ function writeTokenAtomically(path, token) {
546
776
  function rotateTokenFromFile(config, sourcePathValue) {
547
777
  if (!sourcePathValue.trim()) throw new Error("A token file path is required");
548
778
  const sourcePath = resolve(cleanPathArgument(sourcePathValue));
549
- const sourceStats = statSync(sourcePath);
779
+ const sourceStats = statSync2(sourcePath);
550
780
  if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
551
781
  if ((sourceStats.mode & 63) !== 0) {
552
782
  throw new Error("The token source must not be readable by group or other users");
@@ -619,6 +849,8 @@ function helpMessage() {
619
849
  "## Backeer commands",
620
850
  "",
621
851
  "- `/backeer` or `/backeer status` \u2014 show integration status",
852
+ "- `/backeer doctor` \u2014 diagnose config, credentials, gateway, and APNs without creating an event",
853
+ "- `/backeer test` \u2014 send a real test notification to the Backeer application",
622
854
  "- `/backeer update-check` \u2014 check NPM for a newer version",
623
855
  "- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
624
856
  "- `/backeer token` \u2014 show secure token-change instructions",
@@ -641,18 +873,6 @@ function tokenInstructions() {
641
873
  "`token-file` activates the new token immediately; no OpenCode restart is required."
642
874
  ].join("\n");
643
875
  }
644
- async function sendCommandMessage(client, sessionID, text) {
645
- if (typeof client?.session?.prompt !== "function") {
646
- throw new Error("OpenCode session messaging is unavailable");
647
- }
648
- await client.session.prompt({
649
- path: { id: sessionID },
650
- body: {
651
- noReply: true,
652
- parts: [{ type: "text", text, ignored: true }]
653
- }
654
- });
655
- }
656
876
  function registerBackeerCommand(opencodeConfig) {
657
877
  opencodeConfig.command ??= {};
658
878
  opencodeConfig.command.backeer = {
@@ -678,6 +898,17 @@ function createBackeerCommandHandler({
678
898
  try {
679
899
  if (!subcommand || subcommand === "status") {
680
900
  message = await buildStatus(config, permissionController, updateChecker);
901
+ } else if (subcommand === "doctor") {
902
+ message = await buildDoctorReport(config);
903
+ } else if (subcommand === "test") {
904
+ const testID = await publishTestNotification(config, reporter);
905
+ message = [
906
+ "## Backeer test notification",
907
+ "",
908
+ "\u2713 The gateway accepted the test event.",
909
+ `- Test ID: ${testID}`,
910
+ "- Check the Backeer application for the notification."
911
+ ].join("\n");
681
912
  } else if (subcommand === "update-check") {
682
913
  const update = await updateChecker.check({ force: true });
683
914
  message = [
@@ -714,7 +945,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
714
945
  message = helpMessage();
715
946
  }
716
947
  } catch (error) {
717
- reporter.error("Access token could not be updated", error);
948
+ reporter.error("Backeer command failed", error);
718
949
  message = [
719
950
  "## Backeer command failed",
720
951
  "",
@@ -723,7 +954,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
723
954
  "Use `/backeer token` for secure token-change instructions."
724
955
  ].join("\n");
725
956
  }
726
- await sendCommandMessage(client, input.sessionID, message);
957
+ await sendSessionMessage(client, input.sessionID, message);
727
958
  };
728
959
  }
729
960
 
@@ -764,7 +995,7 @@ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
764
995
  return "";
765
996
  }
766
997
  }
767
- async function publish(config, reporter, event, feedback) {
998
+ async function publish(config, reporter, conversation, sessionID, event, feedback) {
768
999
  const result = await postJsonWithRetry(config, config.eventsEndpoint, {
769
1000
  type: "application",
770
1001
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -773,6 +1004,7 @@ async function publish(config, reporter, event, feedback) {
773
1004
  ...event
774
1005
  });
775
1006
  reporter.success(feedback, `HTTP ${result.status}`);
1007
+ conversation?.success(sessionID, feedback);
776
1008
  }
777
1009
  function secondsLabel(durationMs) {
778
1010
  const seconds = Math.max(0, Math.round(durationMs / 1e3));
@@ -788,7 +1020,8 @@ async function publishSessionCompleted({
788
1020
  directory,
789
1021
  sessionID,
790
1022
  messageID,
791
- durationMs
1023
+ durationMs,
1024
+ conversation
792
1025
  }) {
793
1026
  const sessionTitle = await resolveSessionTitle2(
794
1027
  client,
@@ -798,7 +1031,7 @@ async function publishSessionCompleted({
798
1031
  );
799
1032
  const duration = secondsLabel(durationMs);
800
1033
  const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
801
- await publish(config, reporter, {
1034
+ await publish(config, reporter, conversation, sessionID, {
802
1035
  severity: "info",
803
1036
  title: "OpenCode task completed",
804
1037
  body: `OpenCode finished${location} in ${duration}.`,
@@ -818,7 +1051,8 @@ async function publishSessionRetry({
818
1051
  sessionID,
819
1052
  correlation,
820
1053
  attempt,
821
- next
1054
+ next,
1055
+ conversation
822
1056
  }) {
823
1057
  const sessionTitle = await resolveSessionTitle2(
824
1058
  client,
@@ -827,7 +1061,7 @@ async function publishSessionRetry({
827
1061
  reporter
828
1062
  );
829
1063
  const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
830
- await publish(config, reporter, {
1064
+ await publish(config, reporter, conversation, sessionID, {
831
1065
  severity: "warning",
832
1066
  title: "OpenCode is retrying",
833
1067
  body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
@@ -846,7 +1080,8 @@ async function publishSessionError({
846
1080
  directory,
847
1081
  sessionID,
848
1082
  correlation,
849
- error
1083
+ error,
1084
+ conversation
850
1085
  }) {
851
1086
  const sessionTitle = await resolveSessionTitle2(
852
1087
  client,
@@ -857,7 +1092,7 @@ async function publishSessionError({
857
1092
  const details = safeErrorDetails(error);
858
1093
  const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
859
1094
  const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
860
- await publish(config, reporter, {
1095
+ await publish(config, reporter, conversation, sessionID, {
861
1096
  // Backeer/iOS currently support info, warning and critical severities.
862
1097
  severity: "critical",
863
1098
  title: "OpenCode task failed",
@@ -874,7 +1109,7 @@ async function publishSessionError({
874
1109
  }
875
1110
  async function publishOpenCodeUpdate({ config, reporter, version }) {
876
1111
  const safeVersion = limit(version, 100);
877
- await publish(config, reporter, {
1112
+ await publish(config, reporter, void 0, void 0, {
878
1113
  severity: "info",
879
1114
  title: "OpenCode update available",
880
1115
  body: `OpenCode ${safeVersion} is available.`,
@@ -902,6 +1137,7 @@ function createLifecycleController({
902
1137
  reporter,
903
1138
  client,
904
1139
  directory,
1140
+ conversation,
905
1141
  now = () => Date.now(),
906
1142
  publishers = {}
907
1143
  }) {
@@ -927,8 +1163,11 @@ function createLifecycleController({
927
1163
  }
928
1164
  return state;
929
1165
  }
930
- function dispatch(label, operation) {
931
- void Promise.resolve(operation).catch((error) => reporter.error(label, error));
1166
+ function dispatch(label, operation, sessionID) {
1167
+ void Promise.resolve(operation).catch((error) => {
1168
+ reporter.error(label, error);
1169
+ conversation?.error(sessionID, label);
1170
+ });
932
1171
  }
933
1172
  function beginCycle(state) {
934
1173
  if (state.busy) return;
@@ -956,8 +1195,10 @@ function createLifecycleController({
956
1195
  directory,
957
1196
  sessionID,
958
1197
  messageID: limit(assistant.id, 200),
959
- durationMs
960
- })
1198
+ durationMs,
1199
+ conversation
1200
+ }),
1201
+ sessionID
961
1202
  );
962
1203
  }
963
1204
  function handleMessage(event) {
@@ -1002,8 +1243,10 @@ function createLifecycleController({
1002
1243
  sessionID,
1003
1244
  correlation: limit(correlation, 200),
1004
1245
  attempt: status.attempt,
1005
- next: status.next
1006
- })
1246
+ next: status.next,
1247
+ conversation
1248
+ }),
1249
+ sessionID
1007
1250
  );
1008
1251
  }
1009
1252
  function handleError(event) {
@@ -1020,8 +1263,10 @@ function createLifecycleController({
1020
1263
  directory,
1021
1264
  sessionID,
1022
1265
  correlation: errorCorrelation(event, state),
1023
- error: event.properties?.error
1024
- })
1266
+ error: event.properties?.error,
1267
+ conversation
1268
+ }),
1269
+ sessionID
1025
1270
  );
1026
1271
  }
1027
1272
  function handleWaiting(event, waiting) {
@@ -1207,7 +1452,8 @@ function createPermissionController({
1207
1452
  config,
1208
1453
  reporter,
1209
1454
  client,
1210
- directory
1455
+ directory,
1456
+ conversation
1211
1457
  }) {
1212
1458
  const pendingByExternalID = /* @__PURE__ */ new Map();
1213
1459
  const externalIDByRequestID = /* @__PURE__ */ new Map();
@@ -1268,6 +1514,7 @@ function createPermissionController({
1268
1514
  };
1269
1515
  if (stale) removePendingByRequestID(pending.requestID);
1270
1516
  reporter.error("Decision could not be applied", error);
1517
+ conversation?.error(pending.sessionID, "Decision could not be applied");
1271
1518
  } finally {
1272
1519
  processingCommands.delete(command.id);
1273
1520
  }
@@ -1282,12 +1529,19 @@ function createPermissionController({
1282
1529
  reporter.applied(
1283
1530
  `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1284
1531
  );
1532
+ conversation?.applied(
1533
+ pending.sessionID,
1534
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1535
+ );
1285
1536
  } else if (acknowledgement.status === "expired") {
1286
1537
  reporter.warning("Decision expired before it could be applied");
1538
+ conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
1287
1539
  } else if (acknowledgement.status === "stale") {
1288
1540
  reporter.warning("Permission was already answered locally");
1541
+ conversation?.warning(pending.sessionID, "Permission was already answered locally");
1289
1542
  } else if (acknowledgement.status === "rejected") {
1290
1543
  reporter.warning("Decision was rejected by validation");
1544
+ conversation?.warning(pending.sessionID, "Decision was rejected by validation");
1291
1545
  }
1292
1546
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1293
1547
  reporter.warning("Decision receipt could not be sent", error);
@@ -1350,7 +1604,8 @@ function createPermissionController({
1350
1604
 
1351
1605
  // src/plugin.js
1352
1606
  async function BackeerPlugin({ client, directory }, options = {}) {
1353
- const bootstrapReporter = createConsoleReporter(
1607
+ const bootstrapReporter = createOpenCodeReporter(
1608
+ client,
1354
1609
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL
1355
1610
  );
1356
1611
  let config;
@@ -1360,18 +1615,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1360
1615
  bootstrapReporter.error("Plugin disabled \u2014 invalid configuration", error);
1361
1616
  return {};
1362
1617
  }
1363
- const reporter = createConsoleReporter(config.logLevel);
1618
+ const reporter = createOpenCodeReporter(client, config.logLevel);
1619
+ const conversation = createConversationFeedback({ client, reporter });
1364
1620
  const permissionController = createPermissionController({
1365
1621
  config,
1366
1622
  reporter,
1367
1623
  client,
1368
- directory
1624
+ directory,
1625
+ conversation
1369
1626
  });
1370
1627
  const lifecycleController = createLifecycleController({
1371
1628
  config,
1372
1629
  reporter,
1373
1630
  client,
1374
- directory
1631
+ directory,
1632
+ conversation
1375
1633
  });
1376
1634
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
1377
1635
  const publishedQuestions = /* @__PURE__ */ new Map();
@@ -1402,8 +1660,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1402
1660
  reporter,
1403
1661
  event.properties,
1404
1662
  directory,
1405
- identity
1406
- ).catch((error) => reporter.error("Permission request could not be sent", error));
1663
+ identity,
1664
+ conversation
1665
+ ).catch((error) => {
1666
+ reporter.error("Permission request could not be sent", error);
1667
+ conversation.error(identity.sessionID, "Permission request could not be sent");
1668
+ });
1407
1669
  }
1408
1670
  async function handleTodoUpdated(event) {
1409
1671
  const { sessionID, todos } = event.properties;
@@ -1421,8 +1683,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1421
1683
  event.properties,
1422
1684
  event.id,
1423
1685
  client,
1424
- directory
1425
- ).catch((error) => reporter.error("Todo completion could not be sent", error));
1686
+ directory,
1687
+ conversation
1688
+ ).catch((error) => {
1689
+ reporter.error("Todo completion could not be sent", error);
1690
+ conversation.error(sessionID, "Todo completion could not be sent");
1691
+ });
1426
1692
  }
1427
1693
  async function handleQuestionAsked(event) {
1428
1694
  const identity = questionIdentity(event.properties);
@@ -1438,8 +1704,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1438
1704
  event.properties,
1439
1705
  identity,
1440
1706
  client,
1441
- directory
1442
- ).catch((error) => reporter.error("Question could not be sent", error));
1707
+ directory,
1708
+ conversation
1709
+ ).catch((error) => {
1710
+ reporter.error("Question could not be sent", error);
1711
+ conversation.error(identity.sessionID, "Question could not be sent");
1712
+ });
1443
1713
  }
1444
1714
  return {
1445
1715
  config: async (opencodeConfig) => {
@@ -1449,6 +1719,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1449
1719
  "chat.message": handleUpdateNotice,
1450
1720
  event: async ({ event }) => {
1451
1721
  if (!config.token) return;
1722
+ conversation.handle(event);
1452
1723
  lifecycleController.handle(event);
1453
1724
  if (config.permissions && event.type === "permission.asked") {
1454
1725
  await handlePermissionAsked(event);
@@ -1473,6 +1744,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1473
1744
  lifecycleController.dispose();
1474
1745
  todoCompletionBySessionID.clear();
1475
1746
  publishedQuestions.clear();
1747
+ conversation.dispose();
1476
1748
  }
1477
1749
  };
1478
1750
  }