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.
- package/CHANGELOG.md +21 -0
- package/dist/backeer-permission.js +408 -209
- package/package.json +7 -3
- package/src/delivery-tracker.js +75 -0
- package/src/feedback-session-store.js +122 -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 +81 -127
- 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.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-
|
|
646
|
-
var
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
717
|
-
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
718
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
776
|
+
this.scheduleFlush(sessionID, state);
|
|
728
777
|
}
|
|
729
|
-
|
|
778
|
+
setIdle(sessionID, idle) {
|
|
730
779
|
if (typeof sessionID !== "string" || !sessionID) return;
|
|
731
|
-
const state =
|
|
780
|
+
const state = this.stateFor(sessionID);
|
|
732
781
|
state.idle = idle;
|
|
733
|
-
if (idle)
|
|
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
|
-
|
|
740
|
-
const state =
|
|
784
|
+
beginInteraction(sessionID, key) {
|
|
785
|
+
const state = this.stateFor(sessionID);
|
|
741
786
|
state.idle = false;
|
|
742
|
-
state.blockers.add(
|
|
787
|
+
state.blockers.add(key);
|
|
743
788
|
}
|
|
744
|
-
|
|
745
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1419
|
-
const
|
|
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
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
|
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 (!
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
1553
|
-
acknowledgement
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
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
|
|
1678
|
-
const
|
|
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
|
-
|
|
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
|
|
1721
|
-
|
|
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 (
|
|
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
|
-
|
|
1789
|
-
|
|
1986
|
+
permissionPublications.clear();
|
|
1987
|
+
questionPublications.clear();
|
|
1988
|
+
todoCompletions.clear();
|
|
1790
1989
|
conversation.dispose();
|
|
1791
1990
|
}
|
|
1792
1991
|
};
|