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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/backeer-permission.js +390 -172
- package/package.json +7 -3
- package/src/delivery-tracker.js +75 -0
- package/src/feedback-session-store.js +110 -0
- package/src/lifecycle-publishers.js +2 -0
- package/src/permission-command.js +60 -0
- package/src/permission-controller.js +14 -57
- package/src/plugin.js +33 -23
- package/src/session-messages.js +75 -84
- package/src/sse.js +99 -43
- package/src/updates.js +1 -1
|
@@ -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.
|
|
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-
|
|
646
|
-
var
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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 = {
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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 (
|
|
740
|
+
while (this.hasReadyMessage(state)) {
|
|
685
741
|
const item = state.queue[0];
|
|
686
742
|
try {
|
|
687
|
-
await
|
|
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
|
-
|
|
704
|
-
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
705
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
setIdle(sessionID, idle) {
|
|
717
773
|
if (typeof sessionID !== "string" || !sessionID) return;
|
|
718
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1376
|
-
const
|
|
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
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
|
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 (!
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1510
|
-
acknowledgement
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
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
|
|
1635
|
-
const
|
|
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
|
-
|
|
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
|
|
1678
|
-
|
|
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 (
|
|
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
|
-
|
|
1746
|
-
|
|
1962
|
+
permissionPublications.clear();
|
|
1963
|
+
questionPublications.clear();
|
|
1964
|
+
todoCompletions.clear();
|
|
1747
1965
|
conversation.dispose();
|
|
1748
1966
|
}
|
|
1749
1967
|
};
|