backeer-opencode 0.2.2 → 0.2.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.
@@ -158,6 +158,70 @@ function loadConfig(options = {}) {
158
158
  };
159
159
  }
160
160
 
161
+ // src/delivery-tracker.js
162
+ function createDeliveryTracker() {
163
+ const states = /* @__PURE__ */ new Map();
164
+ return {
165
+ begin(key) {
166
+ if (states.has(key)) return false;
167
+ rememberBounded(states, key, "publishing");
168
+ return true;
169
+ },
170
+ succeed(key) {
171
+ if (states.get(key) === "publishing") {
172
+ rememberBounded(states, key, "published");
173
+ }
174
+ },
175
+ fail(key) {
176
+ if (states.get(key) === "publishing") states.delete(key);
177
+ },
178
+ clear() {
179
+ states.clear();
180
+ }
181
+ };
182
+ }
183
+ function createCompletionTracker() {
184
+ const states = /* @__PURE__ */ new Map();
185
+ function stateFor(key) {
186
+ let state = states.get(key);
187
+ if (!state) {
188
+ state = { complete: false, publishing: false, revision: 0 };
189
+ rememberBounded(states, key, state);
190
+ }
191
+ return state;
192
+ }
193
+ return {
194
+ update(key, complete) {
195
+ const state = stateFor(key);
196
+ if (!complete) {
197
+ state.complete = false;
198
+ state.publishing = false;
199
+ state.revision++;
200
+ return;
201
+ }
202
+ if (state.complete || state.publishing) return;
203
+ state.publishing = true;
204
+ state.revision++;
205
+ return { key, revision: state.revision };
206
+ },
207
+ succeed(attempt) {
208
+ const state = states.get(attempt?.key);
209
+ if (!state || state.revision !== attempt.revision) return;
210
+ state.complete = true;
211
+ state.publishing = false;
212
+ },
213
+ fail(attempt) {
214
+ const state = states.get(attempt?.key);
215
+ if (!state || state.revision !== attempt.revision) return;
216
+ state.complete = false;
217
+ state.publishing = false;
218
+ },
219
+ clear() {
220
+ states.clear();
221
+ }
222
+ };
223
+ }
224
+
161
225
  // src/http.js
162
226
  var RETRY_DELAYS_MS = [0, 500, 1500];
163
227
  var HttpError = class extends Error {
@@ -404,7 +468,7 @@ import { randomUUID } from "node:crypto";
404
468
  import { statSync } from "node:fs";
405
469
 
406
470
  // src/updates.js
407
- var PLUGIN_VERSION = "0.2.2";
471
+ var PLUGIN_VERSION = "0.2.4";
408
472
  var PACKAGE_NAME = "backeer-opencode";
409
473
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
410
474
  var CHECK_TIMEOUT_MS = 3e3;
@@ -642,107 +706,196 @@ async function publishTestNotification(config, reporter) {
642
706
  return testID;
643
707
  }
644
708
 
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");
709
+ // src/feedback-session-store.js
710
+ var MAX_QUEUED_MESSAGES = 20;
711
+ var FeedbackSessionStore = class {
712
+ constructor({ send, reporter }) {
713
+ this.send = send;
714
+ this.reporter = reporter;
715
+ this.sessions = /* @__PURE__ */ new Map();
716
+ this.disposed = false;
656
717
  }
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);
718
+ stateFor(sessionID) {
719
+ let state = this.sessions.get(sessionID);
674
720
  if (!state) {
675
- state = { idle: false, flushing: false, queue: [], queued: /* @__PURE__ */ new Set() };
676
- rememberBounded(sessions, sessionID, state);
721
+ state = {
722
+ idle: false,
723
+ flushing: false,
724
+ blockers: /* @__PURE__ */ new Set(),
725
+ queue: [],
726
+ queued: /* @__PURE__ */ new Set(),
727
+ queueOverflowReported: false
728
+ };
729
+ rememberBounded(this.sessions, sessionID, state);
677
730
  }
678
731
  return state;
679
732
  }
680
- async function flush(sessionID, state) {
681
- if (disposed || state.flushing || !state.idle || state.queue.length === 0) return;
733
+ hasReadyMessage(state) {
734
+ return !this.disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0;
735
+ }
736
+ async flush(sessionID, state) {
737
+ if (state.flushing || !this.hasReadyMessage(state)) return;
682
738
  state.flushing = true;
683
739
  try {
684
- while (!disposed && state.idle && state.queue.length > 0) {
740
+ while (this.hasReadyMessage(state)) {
685
741
  const item = state.queue[0];
686
742
  try {
687
- await sendSessionMessage(
688
- client,
689
- sessionID,
690
- formatBackeerFeedback(item.kind, item.message)
691
- );
743
+ await this.send(sessionID, item.kind, item.message);
692
744
  state.queue.shift();
693
745
  state.queued.delete(item.key);
694
746
  } catch (error) {
695
747
  state.idle = false;
696
- reporter.debug("Conversation feedback could not be displayed", error);
748
+ this.reporter.debug("Conversation feedback could not be displayed", error);
697
749
  }
698
750
  }
699
751
  } finally {
700
752
  state.flushing = false;
701
753
  }
702
754
  }
703
- function enqueue(kind, sessionID, message) {
704
- if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
705
- const state = sessionState(sessionID);
755
+ enqueue(kind, sessionID, message) {
756
+ if (this.disposed || typeof sessionID !== "string" || !sessionID || !message) return;
757
+ const state = this.stateFor(sessionID);
706
758
  const key = `${sessionID}:${kind}:${message}`;
707
759
  if (state.queued.has(key)) return;
708
760
  state.queue.push({ kind, message: limit(message, 300), key });
709
761
  state.queued.add(key);
710
- while (state.queue.length > 20) {
762
+ if (state.queue.length > MAX_QUEUED_MESSAGES) {
711
763
  const removed = state.queue.shift();
712
764
  state.queued.delete(removed.key);
765
+ if (!state.queueOverflowReported) {
766
+ state.queueOverflowReported = true;
767
+ this.reporter.warning("Conversation feedback queue full \u2014 oldest message discarded");
768
+ }
713
769
  }
714
- if (state.idle) void flush(sessionID, state);
770
+ if (state.idle && state.blockers.size === 0) void this.flush(sessionID, state);
715
771
  }
716
- function setIdle(sessionID, idle) {
772
+ setIdle(sessionID, idle) {
717
773
  if (typeof sessionID !== "string" || !sessionID) return;
718
- const state = sessionState(sessionID);
774
+ const state = this.stateFor(sessionID);
719
775
  state.idle = idle;
720
- if (idle) void flush(sessionID, state);
776
+ if (idle) void this.flush(sessionID, state);
777
+ }
778
+ beginInteraction(sessionID, key) {
779
+ const state = this.stateFor(sessionID);
780
+ state.idle = false;
781
+ state.blockers.add(key);
782
+ }
783
+ endInteraction(sessionID, key, kind) {
784
+ const state = this.stateFor(sessionID);
785
+ if (!state.blockers.delete(key)) {
786
+ const fallback = [...state.blockers].find((blocker) => blocker.startsWith(`${kind}:`));
787
+ if (fallback) state.blockers.delete(fallback);
788
+ }
789
+ state.idle = false;
790
+ }
791
+ failSession(sessionID) {
792
+ const state = this.stateFor(sessionID);
793
+ state.blockers.clear();
794
+ state.idle = false;
795
+ }
796
+ delete(sessionID) {
797
+ this.sessions.delete(sessionID);
798
+ }
799
+ dispose() {
800
+ this.disposed = true;
801
+ this.sessions.clear();
802
+ }
803
+ };
804
+
805
+ // src/session-messages.js
806
+ var FEEDBACK_SYMBOLS = {
807
+ info: "\u2022",
808
+ success: "\u2713",
809
+ applied: "\u2713",
810
+ warning: "!",
811
+ error: "\xD7"
812
+ };
813
+ var QUESTION_ASKED_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
814
+ var QUESTION_REPLIED_EVENTS = /* @__PURE__ */ new Set([
815
+ "question.replied",
816
+ "question.v2.replied",
817
+ "question.answered",
818
+ "question.v2.answered"
819
+ ]);
820
+ function interactionKey(kind, event) {
821
+ const value = event.properties?.requestID ?? event.properties?.id;
822
+ return `${kind}:${typeof value === "string" && value ? value : "pending"}`;
823
+ }
824
+ var INTERACTION_EVENTS = new Map([
825
+ ["permission.asked", { kind: "permission", phase: "begin" }],
826
+ ["permission.replied", { kind: "permission", phase: "end" }],
827
+ ...[...QUESTION_ASKED_EVENTS].map((type) => [type, { kind: "question", phase: "begin" }]),
828
+ ...[...QUESTION_REPLIED_EVENTS].map((type) => [type, { kind: "question", phase: "end" }])
829
+ ]);
830
+ var SESSION_EVENT_HANDLERS = {
831
+ "session.status": (sessions, event, sessionID) => {
832
+ const status = event.properties?.status?.type;
833
+ if (status === "idle") sessions.setIdle(sessionID, true);
834
+ else if (status === "busy" || status === "retry") {
835
+ sessions.setIdle(sessionID, false);
836
+ }
837
+ },
838
+ "session.idle": (sessions, _event, sessionID) => {
839
+ sessions.setIdle(sessionID, true);
840
+ },
841
+ "todo.updated": (sessions, _event, sessionID) => {
842
+ sessions.setIdle(sessionID, false);
843
+ },
844
+ "session.error": (sessions, _event, sessionID) => {
845
+ sessions.failSession(sessionID);
846
+ },
847
+ "session.deleted": (sessions, _event, sessionID) => {
848
+ sessions.delete(sessionID);
849
+ }
850
+ };
851
+ function handleFeedbackEvent(sessions, event) {
852
+ const sessionID = event?.properties?.sessionID;
853
+ if (typeof sessionID !== "string") return;
854
+ const interaction = INTERACTION_EVENTS.get(event.type);
855
+ if (interaction) {
856
+ const key = interactionKey(interaction.kind, event);
857
+ if (interaction.phase === "begin") sessions.beginInteraction(sessionID, key);
858
+ else sessions.endInteraction(sessionID, key, interaction.kind);
859
+ return;
860
+ }
861
+ SESSION_EVENT_HANDLERS[event.type]?.(sessions, event, sessionID);
862
+ }
863
+ async function sendSessionMessage(client, sessionID, text) {
864
+ if (typeof client?.session?.prompt !== "function") {
865
+ throw new Error("OpenCode session messaging is unavailable");
721
866
  }
867
+ await client.session.prompt({
868
+ path: { id: sessionID },
869
+ body: {
870
+ noReply: true,
871
+ parts: [{ type: "text", text, ignored: true }]
872
+ }
873
+ });
874
+ }
875
+ function formatBackeerFeedback(kind, message) {
876
+ const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
877
+ return `> **Backeer** ${symbol} ${limit(message, 300)}`;
878
+ }
879
+ function createConversationFeedback({ client, reporter }) {
880
+ const sessions = new FeedbackSessionStore({
881
+ reporter,
882
+ send: (sessionID, kind, message) => sendSessionMessage(
883
+ client,
884
+ sessionID,
885
+ formatBackeerFeedback(kind, message)
886
+ )
887
+ });
722
888
  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),
889
+ info: (sessionID, message) => sessions.enqueue("info", sessionID, message),
890
+ success: (sessionID, message) => sessions.enqueue("success", sessionID, message),
891
+ applied: (sessionID, message) => sessions.enqueue("applied", sessionID, message),
892
+ warning: (sessionID, message) => sessions.enqueue("warning", sessionID, message),
893
+ error: (sessionID, message) => sessions.enqueue("error", sessionID, message),
728
894
  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
- }
895
+ handleFeedbackEvent(sessions, event);
742
896
  },
743
897
  dispose() {
744
- disposed = true;
745
- sessions.clear();
898
+ sessions.dispose();
746
899
  }
747
900
  };
748
901
  }
@@ -1308,6 +1461,59 @@ function createLifecycleController({
1308
1461
  };
1309
1462
  }
1310
1463
 
1464
+ // src/permission-command.js
1465
+ var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
1466
+ var DECISION_LABELS = {
1467
+ once: "Allow once",
1468
+ always: "Always allow",
1469
+ reject: "Reject"
1470
+ };
1471
+ function validatePermissionCommand(command, now = Date.now()) {
1472
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
1473
+ return {
1474
+ status: "rejected",
1475
+ decision: command.decision,
1476
+ errorCode: "invalid_decision"
1477
+ };
1478
+ }
1479
+ const expiresAt = Date.parse(command.expiresAt);
1480
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
1481
+ return { status: "expired", decision: command.decision };
1482
+ }
1483
+ if (command.decision === "always" && command.confirmed !== true) {
1484
+ return {
1485
+ status: "rejected",
1486
+ decision: command.decision,
1487
+ errorCode: "confirmation_required"
1488
+ };
1489
+ }
1490
+ }
1491
+ function failureAcknowledgement(command, error) {
1492
+ const stale = error instanceof HttpError && error.status === 404;
1493
+ return {
1494
+ status: stale ? "stale" : "failed",
1495
+ decision: command.decision,
1496
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
1497
+ };
1498
+ }
1499
+ function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
1500
+ const messages = {
1501
+ applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
1502
+ expired: "Decision expired before it could be applied",
1503
+ stale: "Permission was already answered locally",
1504
+ rejected: "Decision was rejected by validation"
1505
+ };
1506
+ const message = messages[acknowledgement.status];
1507
+ if (!message) return;
1508
+ if (acknowledgement.status === "applied") {
1509
+ reporter.applied(message);
1510
+ conversation?.applied(pending.sessionID, message);
1511
+ return;
1512
+ }
1513
+ reporter.warning(message);
1514
+ conversation?.warning(pending.sessionID, message);
1515
+ }
1516
+
1311
1517
  // src/sse.js
1312
1518
  function parseSseFrame(frame) {
1313
1519
  const data = [];
@@ -1352,6 +1558,7 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1352
1558
  const next = nextSseFrame(buffer);
1353
1559
  if (!next) break;
1354
1560
  buffer = next.rest;
1561
+ state.framesReceived = (state.framesReceived ?? 0) + 1;
1355
1562
  const message = parseSseFrame(next.frame);
1356
1563
  if (!message) continue;
1357
1564
  if (message.id) state.lastEventID = message.id;
@@ -1372,64 +1579,109 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1372
1579
  });
1373
1580
  }
1374
1581
  }
1375
- async function runCommandStream(config, reporter, { signal, hasPending, onCommand }) {
1376
- const state = { lastEventID: "" };
1582
+ async function openCommandStream(config, state, signal, fetchImplementation) {
1583
+ const headers = {
1584
+ Accept: "text/event-stream",
1585
+ Authorization: `Bearer ${config.token}`
1586
+ };
1587
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
1588
+ const response = await fetchImplementation(config.commandStreamEndpoint, {
1589
+ headers,
1590
+ signal
1591
+ });
1592
+ if (!response.ok) {
1593
+ const responseBody = limit(await response.text(), 500);
1594
+ throw new HttpError(
1595
+ `Backeer command stream failed with status ${response.status}`,
1596
+ response.status,
1597
+ responseBody
1598
+ );
1599
+ }
1600
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
1601
+ throw new Error("Backeer command stream returned an invalid content type");
1602
+ }
1603
+ return response;
1604
+ }
1605
+ async function consumeConnection({
1606
+ response,
1607
+ state,
1608
+ onCommand,
1609
+ signal,
1610
+ reporter,
1611
+ now,
1612
+ stableConnectionMs,
1613
+ onStable
1614
+ }) {
1615
+ const connectedAt = now();
1616
+ const framesBeforeConnection = state.framesReceived;
1617
+ try {
1618
+ await consumeSse(response, state, onCommand, signal, reporter);
1619
+ } finally {
1620
+ const receivedFrame = state.framesReceived > framesBeforeConnection;
1621
+ if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
1622
+ }
1623
+ }
1624
+ function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
1625
+ const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
1626
+ if (message !== lastLoggedError) {
1627
+ const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
1628
+ reporter.error(
1629
+ authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
1630
+ message
1631
+ );
1632
+ }
1633
+ return {
1634
+ lastLoggedError: message,
1635
+ reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status) ? 6e4 : reconnectDelay
1636
+ };
1637
+ }
1638
+ async function runCommandStream(config, reporter, {
1639
+ signal,
1640
+ hasPending,
1641
+ onCommand,
1642
+ fetchImplementation = fetch,
1643
+ sleepImplementation = sleep,
1644
+ now = Date.now,
1645
+ stableConnectionMs = 5e3
1646
+ }) {
1647
+ const state = { lastEventID: "", framesReceived: 0 };
1377
1648
  let reconnectDelay = 1e3;
1378
1649
  let lastLoggedError = "";
1379
1650
  while (!signal.aborted && hasPending()) {
1380
1651
  try {
1381
- const headers = {
1382
- Accept: "text/event-stream",
1383
- Authorization: `Bearer ${config.token}`
1384
- };
1385
- if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
1386
- const response = await fetch(config.commandStreamEndpoint, {
1387
- headers,
1388
- signal
1652
+ const response = await openCommandStream(
1653
+ config,
1654
+ state,
1655
+ signal,
1656
+ fetchImplementation
1657
+ );
1658
+ await consumeConnection({
1659
+ response,
1660
+ state,
1661
+ onCommand,
1662
+ signal,
1663
+ reporter,
1664
+ now,
1665
+ stableConnectionMs,
1666
+ onStable: () => {
1667
+ reconnectDelay = 1e3;
1668
+ lastLoggedError = "";
1669
+ }
1389
1670
  });
1390
- if (!response.ok) {
1391
- const responseBody = limit(await response.text(), 500);
1392
- throw new HttpError(
1393
- `Backeer command stream failed with status ${response.status}`,
1394
- response.status,
1395
- responseBody
1396
- );
1397
- }
1398
- if (!response.headers.get("content-type")?.includes("text/event-stream")) {
1399
- throw new Error("Backeer command stream returned an invalid content type");
1400
- }
1401
- reconnectDelay = 1e3;
1402
- lastLoggedError = "";
1403
- await consumeSse(response, state, onCommand, signal, reporter);
1404
1671
  } catch (error) {
1405
1672
  if (signal.aborted || isAbortError(error)) return;
1406
- const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
1407
- if (message !== lastLoggedError) {
1408
- const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
1409
- reporter.error(
1410
- authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
1411
- message
1412
- );
1413
- lastLoggedError = message;
1414
- }
1415
- if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
1416
- reconnectDelay = 6e4;
1417
- }
1673
+ const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
1674
+ reconnectDelay = next.reconnectDelay;
1675
+ lastLoggedError = next.lastLoggedError;
1418
1676
  }
1419
1677
  if (!signal.aborted && hasPending()) {
1420
- await sleep(reconnectDelay, signal);
1678
+ await sleepImplementation(reconnectDelay, signal);
1421
1679
  reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
1422
1680
  }
1423
1681
  }
1424
1682
  }
1425
1683
 
1426
1684
  // src/permission-controller.js
1427
- var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
1428
- var DECISION_LABELS = {
1429
- once: "Allow once",
1430
- always: "Always allow",
1431
- reject: "Reject"
1432
- };
1433
1685
  async function replyToOpenCode(client, pending, decision) {
1434
1686
  const result = await client.postSessionIdPermissionsPermissionId({
1435
1687
  path: { id: pending.sessionID, permissionID: pending.requestID },
@@ -1459,7 +1711,6 @@ function createPermissionController({
1459
1711
  const externalIDByRequestID = /* @__PURE__ */ new Map();
1460
1712
  const processingCommands = /* @__PURE__ */ new Set();
1461
1713
  const completedCommands = /* @__PURE__ */ new Map();
1462
- const publishedPermissions = /* @__PURE__ */ new Map();
1463
1714
  let commandStream;
1464
1715
  let disposed = false;
1465
1716
  function removePendingByRequestID(requestID) {
@@ -1485,34 +1736,18 @@ function createPermissionController({
1485
1736
  const pending = pendingByExternalID.get(command.externalId);
1486
1737
  if (!pending) return;
1487
1738
  processingCommands.add(command.id);
1488
- let acknowledgement;
1739
+ let acknowledgement = validatePermissionCommand(command);
1489
1740
  try {
1490
- if (!ALLOWED_DECISIONS.has(command.decision)) {
1491
- acknowledgement = { status: "rejected", errorCode: "invalid_decision" };
1492
- } else {
1493
- const expiresAt = Date.parse(command.expiresAt);
1494
- if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
1495
- acknowledgement = { status: "expired", decision: command.decision };
1496
- } else if (command.decision === "always" && command.confirmed !== true) {
1497
- acknowledgement = {
1498
- status: "rejected",
1499
- decision: command.decision,
1500
- errorCode: "confirmation_required"
1501
- };
1502
- } else {
1503
- await replyToOpenCode(client, pending, command.decision);
1504
- acknowledgement = { status: "applied", decision: command.decision };
1505
- removePendingByRequestID(pending.requestID);
1506
- }
1741
+ if (!acknowledgement) {
1742
+ await replyToOpenCode(client, pending, command.decision);
1743
+ acknowledgement = { status: "applied", decision: command.decision };
1744
+ removePendingByRequestID(pending.requestID);
1507
1745
  }
1508
1746
  } catch (error) {
1509
- const stale = error instanceof HttpError && error.status === 404;
1510
- acknowledgement = {
1511
- status: stale ? "stale" : "failed",
1512
- decision: ALLOWED_DECISIONS.has(command.decision) ? command.decision : void 0,
1513
- errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
1514
- };
1515
- if (stale) removePendingByRequestID(pending.requestID);
1747
+ acknowledgement = failureAcknowledgement(command, error);
1748
+ if (acknowledgement.status === "stale") {
1749
+ removePendingByRequestID(pending.requestID);
1750
+ }
1516
1751
  reporter.error("Decision could not be applied", error);
1517
1752
  conversation?.error(pending.sessionID, "Decision could not be applied");
1518
1753
  } finally {
@@ -1525,24 +1760,7 @@ function createPermissionController({
1525
1760
  processedAt: (/* @__PURE__ */ new Date()).toISOString()
1526
1761
  };
1527
1762
  rememberBounded(completedCommands, command.id, acknowledgement);
1528
- if (acknowledgement.status === "applied") {
1529
- reporter.applied(
1530
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1531
- );
1532
- conversation?.applied(
1533
- pending.sessionID,
1534
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1535
- );
1536
- } else if (acknowledgement.status === "expired") {
1537
- reporter.warning("Decision expired before it could be applied");
1538
- conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
1539
- } else if (acknowledgement.status === "stale") {
1540
- reporter.warning("Permission was already answered locally");
1541
- conversation?.warning(pending.sessionID, "Permission was already answered locally");
1542
- } else if (acknowledgement.status === "rejected") {
1543
- reporter.warning("Decision was rejected by validation");
1544
- conversation?.warning(pending.sessionID, "Decision was rejected by validation");
1545
- }
1763
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation);
1546
1764
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1547
1765
  reporter.warning("Decision receipt could not be sent", error);
1548
1766
  });
@@ -1570,9 +1788,6 @@ function createPermissionController({
1570
1788
  pendingByExternalID.set(identity.externalId, { ...identity, directory });
1571
1789
  externalIDByRequestID.set(identity.requestID, identity.externalId);
1572
1790
  ensureCommandStream();
1573
- if (publishedPermissions.has(identity.externalId)) return false;
1574
- rememberBounded(publishedPermissions, identity.externalId, true);
1575
- return true;
1576
1791
  },
1577
1792
  resolveLocally(requestID) {
1578
1793
  removePendingByRequestID(requestID);
@@ -1597,7 +1812,6 @@ function createPermissionController({
1597
1812
  externalIDByRequestID.clear();
1598
1813
  processingCommands.clear();
1599
1814
  completedCommands.clear();
1600
- publishedPermissions.clear();
1601
1815
  }
1602
1816
  };
1603
1817
  }
@@ -1631,8 +1845,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1631
1845
  directory,
1632
1846
  conversation
1633
1847
  });
1634
- const todoCompletionBySessionID = /* @__PURE__ */ new Map();
1635
- const publishedQuestions = /* @__PURE__ */ new Map();
1848
+ const permissionPublications = createDeliveryTracker();
1849
+ const questionPublications = createDeliveryTracker();
1850
+ const todoCompletions = createCompletionTracker();
1636
1851
  const updateChecker = createUpdateChecker();
1637
1852
  const handleBackeerCommand = createBackeerCommandHandler({
1638
1853
  client,
@@ -1654,7 +1869,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1654
1869
  reporter.error("Permission event is missing correlation IDs");
1655
1870
  return;
1656
1871
  }
1657
- if (!permissionController.register(identity)) return;
1872
+ permissionController.register(identity);
1873
+ if (!permissionPublications.begin(identity.externalId)) return;
1658
1874
  void publishPermission(
1659
1875
  config,
1660
1876
  reporter,
@@ -1662,7 +1878,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1662
1878
  directory,
1663
1879
  identity,
1664
1880
  conversation
1665
- ).catch((error) => {
1881
+ ).then(() => permissionPublications.succeed(identity.externalId)).catch((error) => {
1882
+ permissionPublications.fail(identity.externalId);
1666
1883
  reporter.error("Permission request could not be sent", error);
1667
1884
  conversation.error(identity.sessionID, "Permission request could not be sent");
1668
1885
  });
@@ -1674,9 +1891,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1674
1891
  return;
1675
1892
  }
1676
1893
  const isComplete = todos.length > 0 && todos.every((todo) => todo?.status === "completed");
1677
- const wasComplete = todoCompletionBySessionID.get(sessionID) === true;
1678
- rememberBounded(todoCompletionBySessionID, sessionID, isComplete);
1679
- if (!isComplete || wasComplete) return;
1894
+ const attempt = todoCompletions.update(sessionID, isComplete);
1895
+ if (!attempt) return;
1680
1896
  void publishTodoCompletion(
1681
1897
  config,
1682
1898
  reporter,
@@ -1685,7 +1901,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1685
1901
  client,
1686
1902
  directory,
1687
1903
  conversation
1688
- ).catch((error) => {
1904
+ ).then(() => todoCompletions.succeed(attempt)).catch((error) => {
1905
+ todoCompletions.fail(attempt);
1689
1906
  reporter.error("Todo completion could not be sent", error);
1690
1907
  conversation.error(sessionID, "Todo completion could not be sent");
1691
1908
  });
@@ -1696,8 +1913,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1696
1913
  reporter.error("Question event is missing correlation IDs");
1697
1914
  return;
1698
1915
  }
1699
- if (publishedQuestions.has(identity.externalId)) return;
1700
- rememberBounded(publishedQuestions, identity.externalId, true);
1916
+ if (!questionPublications.begin(identity.externalId)) return;
1701
1917
  void publishQuestion(
1702
1918
  config,
1703
1919
  reporter,
@@ -1706,7 +1922,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1706
1922
  client,
1707
1923
  directory,
1708
1924
  conversation
1709
- ).catch((error) => {
1925
+ ).then(() => questionPublications.succeed(identity.externalId)).catch((error) => {
1926
+ questionPublications.fail(identity.externalId);
1710
1927
  reporter.error("Question could not be sent", error);
1711
1928
  conversation.error(identity.sessionID, "Question could not be sent");
1712
1929
  });
@@ -1742,8 +1959,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1742
1959
  dispose: async () => {
1743
1960
  await permissionController.dispose();
1744
1961
  lifecycleController.dispose();
1745
- todoCompletionBySessionID.clear();
1746
- publishedQuestions.clear();
1962
+ permissionPublications.clear();
1963
+ questionPublications.clear();
1964
+ todoCompletions.clear();
1747
1965
  conversation.dispose();
1748
1966
  }
1749
1967
  };