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.
- package/CHANGELOG.md +14 -0
- package/dist/backeer-permission.js +376 -201
- 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 +67 -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.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-
|
|
646
|
-
var
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
694
|
-
|
|
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 (
|
|
740
|
+
while (this.hasReadyMessage(state)) {
|
|
698
741
|
const item = state.queue[0];
|
|
699
742
|
try {
|
|
700
|
-
await
|
|
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
|
-
|
|
717
|
-
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
718
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
setIdle(sessionID, idle) {
|
|
730
773
|
if (typeof sessionID !== "string" || !sessionID) return;
|
|
731
|
-
const state =
|
|
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
|
-
|
|
740
|
-
const state =
|
|
778
|
+
beginInteraction(sessionID, key) {
|
|
779
|
+
const state = this.stateFor(sessionID);
|
|
741
780
|
state.idle = false;
|
|
742
|
-
state.blockers.add(
|
|
781
|
+
state.blockers.add(key);
|
|
743
782
|
}
|
|
744
|
-
|
|
745
|
-
const state =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1419
|
-
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 };
|
|
1420
1648
|
let reconnectDelay = 1e3;
|
|
1421
1649
|
let lastLoggedError = "";
|
|
1422
1650
|
while (!signal.aborted && hasPending()) {
|
|
1423
1651
|
try {
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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 (!
|
|
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
|
-
}
|
|
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
|
-
|
|
1553
|
-
acknowledgement
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
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
|
|
1678
|
-
const
|
|
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
|
-
|
|
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
|
|
1721
|
-
|
|
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 (
|
|
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
|
-
|
|
1789
|
-
|
|
1962
|
+
permissionPublications.clear();
|
|
1963
|
+
questionPublications.clear();
|
|
1964
|
+
todoCompletions.clear();
|
|
1790
1965
|
conversation.dispose();
|
|
1791
1966
|
}
|
|
1792
1967
|
};
|