backeer-opencode 0.2.3 → 0.2.5

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.3";
471
+ var PLUGIN_VERSION = "0.2.5";
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,150 +706,220 @@ 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
- 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");
709
+ // src/feedback-session-store.js
710
+ var MAX_QUEUED_MESSAGES = 20;
711
+ var FeedbackSessionStore = class {
712
+ constructor({ send, reporter, flushDelayMs = 150 }) {
713
+ this.send = send;
714
+ this.reporter = reporter;
715
+ this.flushDelayMs = flushDelayMs;
716
+ this.sessions = /* @__PURE__ */ new Map();
717
+ this.disposed = false;
663
718
  }
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);
719
+ stateFor(sessionID) {
720
+ let state = this.sessions.get(sessionID);
681
721
  if (!state) {
682
722
  state = {
683
723
  idle: false,
684
724
  flushing: false,
685
725
  blockers: /* @__PURE__ */ new Set(),
686
726
  queue: [],
687
- queued: /* @__PURE__ */ new Set()
727
+ queued: /* @__PURE__ */ new Set(),
728
+ queueOverflowReported: false,
729
+ flushTimer: void 0
688
730
  };
689
- rememberBounded(sessions, sessionID, state);
731
+ rememberBounded(this.sessions, sessionID, state);
690
732
  }
691
733
  return state;
692
734
  }
693
- async function flush(sessionID, state) {
694
- if (disposed || state.flushing || !state.idle || state.blockers.size > 0 || state.queue.length === 0) return;
735
+ hasReadyMessage(state) {
736
+ return !this.disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0;
737
+ }
738
+ scheduleFlush(sessionID, state) {
739
+ if (state.flushTimer || !this.hasReadyMessage(state)) return;
740
+ state.flushTimer = setTimeout(() => {
741
+ state.flushTimer = void 0;
742
+ void this.flush(sessionID, state);
743
+ }, this.flushDelayMs);
744
+ }
745
+ async flush(sessionID, state) {
746
+ if (state.flushing || !this.hasReadyMessage(state)) return;
695
747
  state.flushing = true;
748
+ const items = state.queue.slice();
696
749
  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
- }
750
+ await this.send(sessionID, items);
751
+ state.queue.splice(0, items.length);
752
+ for (const item of items) state.queued.delete(item.key);
753
+ } catch (error) {
754
+ state.idle = false;
755
+ this.reporter.debug("Conversation feedback could not be displayed", error);
712
756
  } finally {
713
757
  state.flushing = false;
758
+ this.scheduleFlush(sessionID, state);
714
759
  }
715
760
  }
716
- function enqueue(kind, sessionID, message) {
717
- if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
718
- const state = sessionState(sessionID);
761
+ enqueue(kind, sessionID, message) {
762
+ if (this.disposed || typeof sessionID !== "string" || !sessionID || !message) return;
763
+ const state = this.stateFor(sessionID);
719
764
  const key = `${sessionID}:${kind}:${message}`;
720
765
  if (state.queued.has(key)) return;
721
766
  state.queue.push({ kind, message: limit(message, 300), key });
722
767
  state.queued.add(key);
723
- while (state.queue.length > 20) {
768
+ if (state.queue.length > MAX_QUEUED_MESSAGES) {
724
769
  const removed = state.queue.shift();
725
770
  state.queued.delete(removed.key);
771
+ if (!state.queueOverflowReported) {
772
+ state.queueOverflowReported = true;
773
+ this.reporter.warning("Conversation feedback queue full \u2014 oldest message discarded");
774
+ }
726
775
  }
727
- if (state.idle && state.blockers.size === 0) void flush(sessionID, state);
776
+ this.scheduleFlush(sessionID, state);
728
777
  }
729
- function setIdle(sessionID, idle) {
778
+ setIdle(sessionID, idle) {
730
779
  if (typeof sessionID !== "string" || !sessionID) return;
731
- const state = sessionState(sessionID);
780
+ const state = this.stateFor(sessionID);
732
781
  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"}`;
782
+ if (idle) this.scheduleFlush(sessionID, state);
738
783
  }
739
- function beginInteraction(sessionID, kind, event) {
740
- const state = sessionState(sessionID);
784
+ beginInteraction(sessionID, key) {
785
+ const state = this.stateFor(sessionID);
741
786
  state.idle = false;
742
- state.blockers.add(interactionKey(kind, event));
787
+ state.blockers.add(key);
743
788
  }
744
- function endInteraction(sessionID, kind, event) {
745
- const state = sessionState(sessionID);
746
- const key = interactionKey(kind, event);
789
+ endInteraction(sessionID, key, kind) {
790
+ const state = this.stateFor(sessionID);
747
791
  if (!state.blockers.delete(key)) {
748
792
  const fallback = [...state.blockers].find((blocker) => blocker.startsWith(`${kind}:`));
749
793
  if (fallback) state.blockers.delete(fallback);
750
794
  }
751
795
  state.idle = false;
752
796
  }
797
+ failSession(sessionID) {
798
+ const state = this.stateFor(sessionID);
799
+ state.blockers.clear();
800
+ state.idle = false;
801
+ }
802
+ delete(sessionID) {
803
+ const state = this.sessions.get(sessionID);
804
+ if (state?.flushTimer) clearTimeout(state.flushTimer);
805
+ this.sessions.delete(sessionID);
806
+ }
807
+ dispose() {
808
+ this.disposed = true;
809
+ for (const state of this.sessions.values()) {
810
+ if (state.flushTimer) clearTimeout(state.flushTimer);
811
+ }
812
+ this.sessions.clear();
813
+ }
814
+ };
815
+
816
+ // src/session-messages.js
817
+ var FEEDBACK_SYMBOLS = {
818
+ info: "\u2022",
819
+ success: "\u2713",
820
+ applied: "\u2713",
821
+ warning: "!",
822
+ error: "\xD7"
823
+ };
824
+ var QUESTION_ASKED_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
825
+ var QUESTION_REPLIED_EVENTS = /* @__PURE__ */ new Set([
826
+ "question.replied",
827
+ "question.v2.replied",
828
+ "question.answered",
829
+ "question.v2.answered"
830
+ ]);
831
+ function interactionKey(kind, event) {
832
+ const value = event.properties?.requestID ?? event.properties?.id;
833
+ return `${kind}:${typeof value === "string" && value ? value : "pending"}`;
834
+ }
835
+ var INTERACTION_EVENTS = new Map([
836
+ ["permission.asked", { kind: "permission", phase: "begin" }],
837
+ ["permission.replied", { kind: "permission", phase: "end" }],
838
+ ...[...QUESTION_ASKED_EVENTS].map((type) => [type, { kind: "question", phase: "begin" }]),
839
+ ...[...QUESTION_REPLIED_EVENTS].map((type) => [type, { kind: "question", phase: "end" }])
840
+ ]);
841
+ var SESSION_EVENT_HANDLERS = {
842
+ "session.status": (sessions, event, sessionID) => {
843
+ const status = event.properties?.status?.type;
844
+ if (status === "idle") sessions.setIdle(sessionID, true);
845
+ else if (status === "busy" || status === "retry") {
846
+ sessions.setIdle(sessionID, false);
847
+ }
848
+ },
849
+ "session.idle": (sessions, _event, sessionID) => {
850
+ sessions.setIdle(sessionID, true);
851
+ },
852
+ "todo.updated": (sessions, _event, sessionID) => {
853
+ sessions.setIdle(sessionID, false);
854
+ },
855
+ "session.error": (sessions, _event, sessionID) => {
856
+ sessions.failSession(sessionID);
857
+ },
858
+ "session.deleted": (sessions, _event, sessionID) => {
859
+ sessions.delete(sessionID);
860
+ }
861
+ };
862
+ function handleFeedbackEvent(sessions, event) {
863
+ const sessionID = event?.properties?.sessionID;
864
+ if (typeof sessionID !== "string") return;
865
+ const interaction = INTERACTION_EVENTS.get(event.type);
866
+ if (interaction) {
867
+ const key = interactionKey(interaction.kind, event);
868
+ if (interaction.phase === "begin") sessions.beginInteraction(sessionID, key);
869
+ else sessions.endInteraction(sessionID, key, interaction.kind);
870
+ return;
871
+ }
872
+ SESSION_EVENT_HANDLERS[event.type]?.(sessions, event, sessionID);
873
+ }
874
+ async function sendSessionMessage(client, sessionID, text) {
875
+ if (typeof client?.session?.prompt !== "function") {
876
+ throw new Error("OpenCode session messaging is unavailable");
877
+ }
878
+ await client.session.prompt({
879
+ path: { id: sessionID },
880
+ body: {
881
+ noReply: true,
882
+ parts: [{ type: "text", text, ignored: true }]
883
+ }
884
+ });
885
+ }
886
+ function formatBackeerFeedback(kind, message) {
887
+ const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
888
+ return `\u25A3 Backeer | ${symbol} ${limit(message, 300)}`;
889
+ }
890
+ function formatBackeerFeedbackBatch(items) {
891
+ if (items.length === 1) {
892
+ return formatBackeerFeedback(items[0].kind, items[0].message);
893
+ }
894
+ return [
895
+ "\u25A3 Backeer",
896
+ ...items.map((item) => {
897
+ const symbol = FEEDBACK_SYMBOLS[item.kind] ?? FEEDBACK_SYMBOLS.info;
898
+ return `${symbol} ${limit(item.message, 300)}`;
899
+ })
900
+ ].join("\n");
901
+ }
902
+ function createConversationFeedback({ client, reporter, flushDelayMs = 150 }) {
903
+ const sessions = new FeedbackSessionStore({
904
+ reporter,
905
+ flushDelayMs,
906
+ send: (sessionID, items) => sendSessionMessage(
907
+ client,
908
+ sessionID,
909
+ formatBackeerFeedbackBatch(items)
910
+ )
911
+ });
753
912
  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),
913
+ info: (sessionID, message) => sessions.enqueue("info", sessionID, message),
914
+ success: (sessionID, message) => sessions.enqueue("success", sessionID, message),
915
+ applied: (sessionID, message) => sessions.enqueue("applied", sessionID, message),
916
+ warning: (sessionID, message) => sessions.enqueue("warning", sessionID, message),
917
+ error: (sessionID, message) => sessions.enqueue("error", sessionID, message),
759
918
  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
- }
919
+ handleFeedbackEvent(sessions, event);
785
920
  },
786
921
  dispose() {
787
- disposed = true;
788
- sessions.clear();
922
+ sessions.dispose();
789
923
  }
790
924
  };
791
925
  }
@@ -1351,6 +1485,59 @@ function createLifecycleController({
1351
1485
  };
1352
1486
  }
1353
1487
 
1488
+ // src/permission-command.js
1489
+ var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
1490
+ var DECISION_LABELS = {
1491
+ once: "Allow once",
1492
+ always: "Always allow",
1493
+ reject: "Reject"
1494
+ };
1495
+ function validatePermissionCommand(command, now = Date.now()) {
1496
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
1497
+ return {
1498
+ status: "rejected",
1499
+ decision: command.decision,
1500
+ errorCode: "invalid_decision"
1501
+ };
1502
+ }
1503
+ const expiresAt = Date.parse(command.expiresAt);
1504
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
1505
+ return { status: "expired", decision: command.decision };
1506
+ }
1507
+ if (command.decision === "always" && command.confirmed !== true) {
1508
+ return {
1509
+ status: "rejected",
1510
+ decision: command.decision,
1511
+ errorCode: "confirmation_required"
1512
+ };
1513
+ }
1514
+ }
1515
+ function failureAcknowledgement(command, error) {
1516
+ const stale = error instanceof HttpError && error.status === 404;
1517
+ return {
1518
+ status: stale ? "stale" : "failed",
1519
+ decision: command.decision,
1520
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
1521
+ };
1522
+ }
1523
+ function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
1524
+ const messages = {
1525
+ applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
1526
+ expired: "Decision expired before it could be applied",
1527
+ stale: "Permission was already answered locally",
1528
+ rejected: "Decision was rejected by validation"
1529
+ };
1530
+ const message = messages[acknowledgement.status];
1531
+ if (!message) return;
1532
+ if (acknowledgement.status === "applied") {
1533
+ reporter.applied(message);
1534
+ conversation?.applied(pending.sessionID, message);
1535
+ return;
1536
+ }
1537
+ reporter.warning(message);
1538
+ conversation?.warning(pending.sessionID, message);
1539
+ }
1540
+
1354
1541
  // src/sse.js
1355
1542
  function parseSseFrame(frame) {
1356
1543
  const data = [];
@@ -1395,6 +1582,7 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1395
1582
  const next = nextSseFrame(buffer);
1396
1583
  if (!next) break;
1397
1584
  buffer = next.rest;
1585
+ state.framesReceived = (state.framesReceived ?? 0) + 1;
1398
1586
  const message = parseSseFrame(next.frame);
1399
1587
  if (!message) continue;
1400
1588
  if (message.id) state.lastEventID = message.id;
@@ -1415,64 +1603,109 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1415
1603
  });
1416
1604
  }
1417
1605
  }
1418
- async function runCommandStream(config, reporter, { signal, hasPending, onCommand }) {
1419
- const state = { lastEventID: "" };
1606
+ async function openCommandStream(config, state, signal, fetchImplementation) {
1607
+ const headers = {
1608
+ Accept: "text/event-stream",
1609
+ Authorization: `Bearer ${config.token}`
1610
+ };
1611
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
1612
+ const response = await fetchImplementation(config.commandStreamEndpoint, {
1613
+ headers,
1614
+ signal
1615
+ });
1616
+ if (!response.ok) {
1617
+ const responseBody = limit(await response.text(), 500);
1618
+ throw new HttpError(
1619
+ `Backeer command stream failed with status ${response.status}`,
1620
+ response.status,
1621
+ responseBody
1622
+ );
1623
+ }
1624
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
1625
+ throw new Error("Backeer command stream returned an invalid content type");
1626
+ }
1627
+ return response;
1628
+ }
1629
+ async function consumeConnection({
1630
+ response,
1631
+ state,
1632
+ onCommand,
1633
+ signal,
1634
+ reporter,
1635
+ now,
1636
+ stableConnectionMs,
1637
+ onStable
1638
+ }) {
1639
+ const connectedAt = now();
1640
+ const framesBeforeConnection = state.framesReceived;
1641
+ try {
1642
+ await consumeSse(response, state, onCommand, signal, reporter);
1643
+ } finally {
1644
+ const receivedFrame = state.framesReceived > framesBeforeConnection;
1645
+ if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
1646
+ }
1647
+ }
1648
+ function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
1649
+ const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
1650
+ if (message !== lastLoggedError) {
1651
+ const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
1652
+ reporter.error(
1653
+ authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
1654
+ message
1655
+ );
1656
+ }
1657
+ return {
1658
+ lastLoggedError: message,
1659
+ reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status) ? 6e4 : reconnectDelay
1660
+ };
1661
+ }
1662
+ async function runCommandStream(config, reporter, {
1663
+ signal,
1664
+ hasPending,
1665
+ onCommand,
1666
+ fetchImplementation = fetch,
1667
+ sleepImplementation = sleep,
1668
+ now = Date.now,
1669
+ stableConnectionMs = 5e3
1670
+ }) {
1671
+ const state = { lastEventID: "", framesReceived: 0 };
1420
1672
  let reconnectDelay = 1e3;
1421
1673
  let lastLoggedError = "";
1422
1674
  while (!signal.aborted && hasPending()) {
1423
1675
  try {
1424
- const headers = {
1425
- Accept: "text/event-stream",
1426
- Authorization: `Bearer ${config.token}`
1427
- };
1428
- if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
1429
- const response = await fetch(config.commandStreamEndpoint, {
1430
- headers,
1431
- signal
1676
+ const response = await openCommandStream(
1677
+ config,
1678
+ state,
1679
+ signal,
1680
+ fetchImplementation
1681
+ );
1682
+ await consumeConnection({
1683
+ response,
1684
+ state,
1685
+ onCommand,
1686
+ signal,
1687
+ reporter,
1688
+ now,
1689
+ stableConnectionMs,
1690
+ onStable: () => {
1691
+ reconnectDelay = 1e3;
1692
+ lastLoggedError = "";
1693
+ }
1432
1694
  });
1433
- if (!response.ok) {
1434
- const responseBody = limit(await response.text(), 500);
1435
- throw new HttpError(
1436
- `Backeer command stream failed with status ${response.status}`,
1437
- response.status,
1438
- responseBody
1439
- );
1440
- }
1441
- if (!response.headers.get("content-type")?.includes("text/event-stream")) {
1442
- throw new Error("Backeer command stream returned an invalid content type");
1443
- }
1444
- reconnectDelay = 1e3;
1445
- lastLoggedError = "";
1446
- await consumeSse(response, state, onCommand, signal, reporter);
1447
1695
  } catch (error) {
1448
1696
  if (signal.aborted || isAbortError(error)) return;
1449
- const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
1450
- if (message !== lastLoggedError) {
1451
- const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
1452
- reporter.error(
1453
- authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
1454
- message
1455
- );
1456
- lastLoggedError = message;
1457
- }
1458
- if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
1459
- reconnectDelay = 6e4;
1460
- }
1697
+ const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
1698
+ reconnectDelay = next.reconnectDelay;
1699
+ lastLoggedError = next.lastLoggedError;
1461
1700
  }
1462
1701
  if (!signal.aborted && hasPending()) {
1463
- await sleep(reconnectDelay, signal);
1702
+ await sleepImplementation(reconnectDelay, signal);
1464
1703
  reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
1465
1704
  }
1466
1705
  }
1467
1706
  }
1468
1707
 
1469
1708
  // src/permission-controller.js
1470
- var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
1471
- var DECISION_LABELS = {
1472
- once: "Allow once",
1473
- always: "Always allow",
1474
- reject: "Reject"
1475
- };
1476
1709
  async function replyToOpenCode(client, pending, decision) {
1477
1710
  const result = await client.postSessionIdPermissionsPermissionId({
1478
1711
  path: { id: pending.sessionID, permissionID: pending.requestID },
@@ -1502,7 +1735,6 @@ function createPermissionController({
1502
1735
  const externalIDByRequestID = /* @__PURE__ */ new Map();
1503
1736
  const processingCommands = /* @__PURE__ */ new Set();
1504
1737
  const completedCommands = /* @__PURE__ */ new Map();
1505
- const publishedPermissions = /* @__PURE__ */ new Map();
1506
1738
  let commandStream;
1507
1739
  let disposed = false;
1508
1740
  function removePendingByRequestID(requestID) {
@@ -1528,34 +1760,18 @@ function createPermissionController({
1528
1760
  const pending = pendingByExternalID.get(command.externalId);
1529
1761
  if (!pending) return;
1530
1762
  processingCommands.add(command.id);
1531
- let acknowledgement;
1763
+ let acknowledgement = validatePermissionCommand(command);
1532
1764
  try {
1533
- if (!ALLOWED_DECISIONS.has(command.decision)) {
1534
- acknowledgement = { status: "rejected", errorCode: "invalid_decision" };
1535
- } else {
1536
- const expiresAt = Date.parse(command.expiresAt);
1537
- if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
1538
- acknowledgement = { status: "expired", decision: command.decision };
1539
- } else if (command.decision === "always" && command.confirmed !== true) {
1540
- acknowledgement = {
1541
- status: "rejected",
1542
- decision: command.decision,
1543
- errorCode: "confirmation_required"
1544
- };
1545
- } else {
1546
- await replyToOpenCode(client, pending, command.decision);
1547
- acknowledgement = { status: "applied", decision: command.decision };
1548
- removePendingByRequestID(pending.requestID);
1549
- }
1765
+ if (!acknowledgement) {
1766
+ await replyToOpenCode(client, pending, command.decision);
1767
+ acknowledgement = { status: "applied", decision: command.decision };
1768
+ removePendingByRequestID(pending.requestID);
1550
1769
  }
1551
1770
  } catch (error) {
1552
- const stale = error instanceof HttpError && error.status === 404;
1553
- acknowledgement = {
1554
- status: stale ? "stale" : "failed",
1555
- decision: ALLOWED_DECISIONS.has(command.decision) ? command.decision : void 0,
1556
- errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
1557
- };
1558
- if (stale) removePendingByRequestID(pending.requestID);
1771
+ acknowledgement = failureAcknowledgement(command, error);
1772
+ if (acknowledgement.status === "stale") {
1773
+ removePendingByRequestID(pending.requestID);
1774
+ }
1559
1775
  reporter.error("Decision could not be applied", error);
1560
1776
  conversation?.error(pending.sessionID, "Decision could not be applied");
1561
1777
  } finally {
@@ -1568,24 +1784,7 @@ function createPermissionController({
1568
1784
  processedAt: (/* @__PURE__ */ new Date()).toISOString()
1569
1785
  };
1570
1786
  rememberBounded(completedCommands, command.id, acknowledgement);
1571
- if (acknowledgement.status === "applied") {
1572
- reporter.applied(
1573
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1574
- );
1575
- conversation?.applied(
1576
- pending.sessionID,
1577
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
1578
- );
1579
- } else if (acknowledgement.status === "expired") {
1580
- reporter.warning("Decision expired before it could be applied");
1581
- conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
1582
- } else if (acknowledgement.status === "stale") {
1583
- reporter.warning("Permission was already answered locally");
1584
- conversation?.warning(pending.sessionID, "Permission was already answered locally");
1585
- } else if (acknowledgement.status === "rejected") {
1586
- reporter.warning("Decision was rejected by validation");
1587
- conversation?.warning(pending.sessionID, "Decision was rejected by validation");
1588
- }
1787
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation);
1589
1788
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1590
1789
  reporter.warning("Decision receipt could not be sent", error);
1591
1790
  });
@@ -1613,9 +1812,6 @@ function createPermissionController({
1613
1812
  pendingByExternalID.set(identity.externalId, { ...identity, directory });
1614
1813
  externalIDByRequestID.set(identity.requestID, identity.externalId);
1615
1814
  ensureCommandStream();
1616
- if (publishedPermissions.has(identity.externalId)) return false;
1617
- rememberBounded(publishedPermissions, identity.externalId, true);
1618
- return true;
1619
1815
  },
1620
1816
  resolveLocally(requestID) {
1621
1817
  removePendingByRequestID(requestID);
@@ -1640,7 +1836,6 @@ function createPermissionController({
1640
1836
  externalIDByRequestID.clear();
1641
1837
  processingCommands.clear();
1642
1838
  completedCommands.clear();
1643
- publishedPermissions.clear();
1644
1839
  }
1645
1840
  };
1646
1841
  }
@@ -1674,8 +1869,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1674
1869
  directory,
1675
1870
  conversation
1676
1871
  });
1677
- const todoCompletionBySessionID = /* @__PURE__ */ new Map();
1678
- const publishedQuestions = /* @__PURE__ */ new Map();
1872
+ const permissionPublications = createDeliveryTracker();
1873
+ const questionPublications = createDeliveryTracker();
1874
+ const todoCompletions = createCompletionTracker();
1679
1875
  const updateChecker = createUpdateChecker();
1680
1876
  const handleBackeerCommand = createBackeerCommandHandler({
1681
1877
  client,
@@ -1697,7 +1893,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1697
1893
  reporter.error("Permission event is missing correlation IDs");
1698
1894
  return;
1699
1895
  }
1700
- if (!permissionController.register(identity)) return;
1896
+ permissionController.register(identity);
1897
+ if (!permissionPublications.begin(identity.externalId)) return;
1701
1898
  void publishPermission(
1702
1899
  config,
1703
1900
  reporter,
@@ -1705,7 +1902,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1705
1902
  directory,
1706
1903
  identity,
1707
1904
  conversation
1708
- ).catch((error) => {
1905
+ ).then(() => permissionPublications.succeed(identity.externalId)).catch((error) => {
1906
+ permissionPublications.fail(identity.externalId);
1709
1907
  reporter.error("Permission request could not be sent", error);
1710
1908
  conversation.error(identity.sessionID, "Permission request could not be sent");
1711
1909
  });
@@ -1717,9 +1915,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1717
1915
  return;
1718
1916
  }
1719
1917
  const isComplete = todos.length > 0 && todos.every((todo) => todo?.status === "completed");
1720
- const wasComplete = todoCompletionBySessionID.get(sessionID) === true;
1721
- rememberBounded(todoCompletionBySessionID, sessionID, isComplete);
1722
- if (!isComplete || wasComplete) return;
1918
+ const attempt = todoCompletions.update(sessionID, isComplete);
1919
+ if (!attempt) return;
1723
1920
  void publishTodoCompletion(
1724
1921
  config,
1725
1922
  reporter,
@@ -1728,7 +1925,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1728
1925
  client,
1729
1926
  directory,
1730
1927
  conversation
1731
- ).catch((error) => {
1928
+ ).then(() => todoCompletions.succeed(attempt)).catch((error) => {
1929
+ todoCompletions.fail(attempt);
1732
1930
  reporter.error("Todo completion could not be sent", error);
1733
1931
  conversation.error(sessionID, "Todo completion could not be sent");
1734
1932
  });
@@ -1739,8 +1937,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1739
1937
  reporter.error("Question event is missing correlation IDs");
1740
1938
  return;
1741
1939
  }
1742
- if (publishedQuestions.has(identity.externalId)) return;
1743
- rememberBounded(publishedQuestions, identity.externalId, true);
1940
+ if (!questionPublications.begin(identity.externalId)) return;
1744
1941
  void publishQuestion(
1745
1942
  config,
1746
1943
  reporter,
@@ -1749,7 +1946,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1749
1946
  client,
1750
1947
  directory,
1751
1948
  conversation
1752
- ).catch((error) => {
1949
+ ).then(() => questionPublications.succeed(identity.externalId)).catch((error) => {
1950
+ questionPublications.fail(identity.externalId);
1753
1951
  reporter.error("Question could not be sent", error);
1754
1952
  conversation.error(identity.sessionID, "Question could not be sent");
1755
1953
  });
@@ -1785,8 +1983,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1785
1983
  dispose: async () => {
1786
1984
  await permissionController.dispose();
1787
1985
  lifecycleController.dispose();
1788
- todoCompletionBySessionID.clear();
1789
- publishedQuestions.clear();
1986
+ permissionPublications.clear();
1987
+ questionPublications.clear();
1988
+ todoCompletions.clear();
1790
1989
  conversation.dispose();
1791
1990
  }
1792
1991
  };