backeer-opencode 0.2.3 → 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.3";
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,150 +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
- 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 }) {
713
+ this.send = send;
714
+ this.reporter = reporter;
715
+ this.sessions = /* @__PURE__ */ new Map();
716
+ this.disposed = false;
663
717
  }
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);
718
+ stateFor(sessionID) {
719
+ let state = this.sessions.get(sessionID);
681
720
  if (!state) {
682
721
  state = {
683
722
  idle: false,
684
723
  flushing: false,
685
724
  blockers: /* @__PURE__ */ new Set(),
686
725
  queue: [],
687
- queued: /* @__PURE__ */ new Set()
726
+ queued: /* @__PURE__ */ new Set(),
727
+ queueOverflowReported: false
688
728
  };
689
- rememberBounded(sessions, sessionID, state);
729
+ rememberBounded(this.sessions, sessionID, state);
690
730
  }
691
731
  return state;
692
732
  }
693
- async function flush(sessionID, state) {
694
- if (disposed || state.flushing || !state.idle || state.blockers.size > 0 || 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;
695
738
  state.flushing = true;
696
739
  try {
697
- while (!disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0) {
740
+ while (this.hasReadyMessage(state)) {
698
741
  const item = state.queue[0];
699
742
  try {
700
- await sendSessionMessage(
701
- client,
702
- sessionID,
703
- formatBackeerFeedback(item.kind, item.message)
704
- );
743
+ await this.send(sessionID, item.kind, item.message);
705
744
  state.queue.shift();
706
745
  state.queued.delete(item.key);
707
746
  } catch (error) {
708
747
  state.idle = false;
709
- reporter.debug("Conversation feedback could not be displayed", error);
748
+ this.reporter.debug("Conversation feedback could not be displayed", error);
710
749
  }
711
750
  }
712
751
  } finally {
713
752
  state.flushing = false;
714
753
  }
715
754
  }
716
- function enqueue(kind, sessionID, message) {
717
- if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
718
- 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);
719
758
  const key = `${sessionID}:${kind}:${message}`;
720
759
  if (state.queued.has(key)) return;
721
760
  state.queue.push({ kind, message: limit(message, 300), key });
722
761
  state.queued.add(key);
723
- while (state.queue.length > 20) {
762
+ if (state.queue.length > MAX_QUEUED_MESSAGES) {
724
763
  const removed = state.queue.shift();
725
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
+ }
726
769
  }
727
- if (state.idle && state.blockers.size === 0) void flush(sessionID, state);
770
+ if (state.idle && state.blockers.size === 0) void this.flush(sessionID, state);
728
771
  }
729
- function setIdle(sessionID, idle) {
772
+ setIdle(sessionID, idle) {
730
773
  if (typeof sessionID !== "string" || !sessionID) return;
731
- const state = sessionState(sessionID);
774
+ const state = this.stateFor(sessionID);
732
775
  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"}`;
776
+ if (idle) void this.flush(sessionID, state);
738
777
  }
739
- function beginInteraction(sessionID, kind, event) {
740
- const state = sessionState(sessionID);
778
+ beginInteraction(sessionID, key) {
779
+ const state = this.stateFor(sessionID);
741
780
  state.idle = false;
742
- state.blockers.add(interactionKey(kind, event));
781
+ state.blockers.add(key);
743
782
  }
744
- function endInteraction(sessionID, kind, event) {
745
- const state = sessionState(sessionID);
746
- const key = interactionKey(kind, event);
783
+ endInteraction(sessionID, key, kind) {
784
+ const state = this.stateFor(sessionID);
747
785
  if (!state.blockers.delete(key)) {
748
786
  const fallback = [...state.blockers].find((blocker) => blocker.startsWith(`${kind}:`));
749
787
  if (fallback) state.blockers.delete(fallback);
750
788
  }
751
789
  state.idle = false;
752
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");
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
+ });
753
888
  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),
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),
759
894
  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
- }
895
+ handleFeedbackEvent(sessions, event);
785
896
  },
786
897
  dispose() {
787
- disposed = true;
788
- sessions.clear();
898
+ sessions.dispose();
789
899
  }
790
900
  };
791
901
  }
@@ -1351,6 +1461,59 @@ function createLifecycleController({
1351
1461
  };
1352
1462
  }
1353
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
+
1354
1517
  // src/sse.js
1355
1518
  function parseSseFrame(frame) {
1356
1519
  const data = [];
@@ -1395,6 +1558,7 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1395
1558
  const next = nextSseFrame(buffer);
1396
1559
  if (!next) break;
1397
1560
  buffer = next.rest;
1561
+ state.framesReceived = (state.framesReceived ?? 0) + 1;
1398
1562
  const message = parseSseFrame(next.frame);
1399
1563
  if (!message) continue;
1400
1564
  if (message.id) state.lastEventID = message.id;
@@ -1415,64 +1579,109 @@ async function consumeSse(response, state, onCommand, signal, reporter) {
1415
1579
  });
1416
1580
  }
1417
1581
  }
1418
- async function runCommandStream(config, reporter, { signal, hasPending, onCommand }) {
1419
- 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 };
1420
1648
  let reconnectDelay = 1e3;
1421
1649
  let lastLoggedError = "";
1422
1650
  while (!signal.aborted && hasPending()) {
1423
1651
  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
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
+ }
1432
1670
  });
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
1671
  } catch (error) {
1448
1672
  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
- }
1673
+ const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
1674
+ reconnectDelay = next.reconnectDelay;
1675
+ lastLoggedError = next.lastLoggedError;
1461
1676
  }
1462
1677
  if (!signal.aborted && hasPending()) {
1463
- await sleep(reconnectDelay, signal);
1678
+ await sleepImplementation(reconnectDelay, signal);
1464
1679
  reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
1465
1680
  }
1466
1681
  }
1467
1682
  }
1468
1683
 
1469
1684
  // 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
1685
  async function replyToOpenCode(client, pending, decision) {
1477
1686
  const result = await client.postSessionIdPermissionsPermissionId({
1478
1687
  path: { id: pending.sessionID, permissionID: pending.requestID },
@@ -1502,7 +1711,6 @@ function createPermissionController({
1502
1711
  const externalIDByRequestID = /* @__PURE__ */ new Map();
1503
1712
  const processingCommands = /* @__PURE__ */ new Set();
1504
1713
  const completedCommands = /* @__PURE__ */ new Map();
1505
- const publishedPermissions = /* @__PURE__ */ new Map();
1506
1714
  let commandStream;
1507
1715
  let disposed = false;
1508
1716
  function removePendingByRequestID(requestID) {
@@ -1528,34 +1736,18 @@ function createPermissionController({
1528
1736
  const pending = pendingByExternalID.get(command.externalId);
1529
1737
  if (!pending) return;
1530
1738
  processingCommands.add(command.id);
1531
- let acknowledgement;
1739
+ let acknowledgement = validatePermissionCommand(command);
1532
1740
  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
- }
1741
+ if (!acknowledgement) {
1742
+ await replyToOpenCode(client, pending, command.decision);
1743
+ acknowledgement = { status: "applied", decision: command.decision };
1744
+ removePendingByRequestID(pending.requestID);
1550
1745
  }
1551
1746
  } 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);
1747
+ acknowledgement = failureAcknowledgement(command, error);
1748
+ if (acknowledgement.status === "stale") {
1749
+ removePendingByRequestID(pending.requestID);
1750
+ }
1559
1751
  reporter.error("Decision could not be applied", error);
1560
1752
  conversation?.error(pending.sessionID, "Decision could not be applied");
1561
1753
  } finally {
@@ -1568,24 +1760,7 @@ function createPermissionController({
1568
1760
  processedAt: (/* @__PURE__ */ new Date()).toISOString()
1569
1761
  };
1570
1762
  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
- }
1763
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation);
1589
1764
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1590
1765
  reporter.warning("Decision receipt could not be sent", error);
1591
1766
  });
@@ -1613,9 +1788,6 @@ function createPermissionController({
1613
1788
  pendingByExternalID.set(identity.externalId, { ...identity, directory });
1614
1789
  externalIDByRequestID.set(identity.requestID, identity.externalId);
1615
1790
  ensureCommandStream();
1616
- if (publishedPermissions.has(identity.externalId)) return false;
1617
- rememberBounded(publishedPermissions, identity.externalId, true);
1618
- return true;
1619
1791
  },
1620
1792
  resolveLocally(requestID) {
1621
1793
  removePendingByRequestID(requestID);
@@ -1640,7 +1812,6 @@ function createPermissionController({
1640
1812
  externalIDByRequestID.clear();
1641
1813
  processingCommands.clear();
1642
1814
  completedCommands.clear();
1643
- publishedPermissions.clear();
1644
1815
  }
1645
1816
  };
1646
1817
  }
@@ -1674,8 +1845,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1674
1845
  directory,
1675
1846
  conversation
1676
1847
  });
1677
- const todoCompletionBySessionID = /* @__PURE__ */ new Map();
1678
- const publishedQuestions = /* @__PURE__ */ new Map();
1848
+ const permissionPublications = createDeliveryTracker();
1849
+ const questionPublications = createDeliveryTracker();
1850
+ const todoCompletions = createCompletionTracker();
1679
1851
  const updateChecker = createUpdateChecker();
1680
1852
  const handleBackeerCommand = createBackeerCommandHandler({
1681
1853
  client,
@@ -1697,7 +1869,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1697
1869
  reporter.error("Permission event is missing correlation IDs");
1698
1870
  return;
1699
1871
  }
1700
- if (!permissionController.register(identity)) return;
1872
+ permissionController.register(identity);
1873
+ if (!permissionPublications.begin(identity.externalId)) return;
1701
1874
  void publishPermission(
1702
1875
  config,
1703
1876
  reporter,
@@ -1705,7 +1878,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1705
1878
  directory,
1706
1879
  identity,
1707
1880
  conversation
1708
- ).catch((error) => {
1881
+ ).then(() => permissionPublications.succeed(identity.externalId)).catch((error) => {
1882
+ permissionPublications.fail(identity.externalId);
1709
1883
  reporter.error("Permission request could not be sent", error);
1710
1884
  conversation.error(identity.sessionID, "Permission request could not be sent");
1711
1885
  });
@@ -1717,9 +1891,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1717
1891
  return;
1718
1892
  }
1719
1893
  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;
1894
+ const attempt = todoCompletions.update(sessionID, isComplete);
1895
+ if (!attempt) return;
1723
1896
  void publishTodoCompletion(
1724
1897
  config,
1725
1898
  reporter,
@@ -1728,7 +1901,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1728
1901
  client,
1729
1902
  directory,
1730
1903
  conversation
1731
- ).catch((error) => {
1904
+ ).then(() => todoCompletions.succeed(attempt)).catch((error) => {
1905
+ todoCompletions.fail(attempt);
1732
1906
  reporter.error("Todo completion could not be sent", error);
1733
1907
  conversation.error(sessionID, "Todo completion could not be sent");
1734
1908
  });
@@ -1739,8 +1913,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1739
1913
  reporter.error("Question event is missing correlation IDs");
1740
1914
  return;
1741
1915
  }
1742
- if (publishedQuestions.has(identity.externalId)) return;
1743
- rememberBounded(publishedQuestions, identity.externalId, true);
1916
+ if (!questionPublications.begin(identity.externalId)) return;
1744
1917
  void publishQuestion(
1745
1918
  config,
1746
1919
  reporter,
@@ -1749,7 +1922,8 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1749
1922
  client,
1750
1923
  directory,
1751
1924
  conversation
1752
- ).catch((error) => {
1925
+ ).then(() => questionPublications.succeed(identity.externalId)).catch((error) => {
1926
+ questionPublications.fail(identity.externalId);
1753
1927
  reporter.error("Question could not be sent", error);
1754
1928
  conversation.error(identity.sessionID, "Question could not be sent");
1755
1929
  });
@@ -1785,8 +1959,9 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1785
1959
  dispose: async () => {
1786
1960
  await permissionController.dispose();
1787
1961
  lifecycleController.dispose();
1788
- todoCompletionBySessionID.clear();
1789
- publishedQuestions.clear();
1962
+ permissionPublications.clear();
1963
+ questionPublications.clear();
1964
+ todoCompletions.clear();
1790
1965
  conversation.dispose();
1791
1966
  }
1792
1967
  };