backeer-opencode 0.2.4 → 0.3.0
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 +17 -0
- package/README.md +7 -6
- package/dist/backeer-permission.js +273 -40
- package/package.json +2 -2
- package/src/event-publishers.js +28 -0
- package/src/feedback-session-store.js +26 -14
- package/src/permission-controller.js +149 -16
- package/src/plugin.js +26 -1
- package/src/question-command.js +65 -0
- package/src/session-messages.js +18 -4
- package/src/updates.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.3.0 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Publish structured `question.asked` and `question.v2.asked` action metadata.
|
|
8
|
+
- Apply single-choice, multi-choice, custom, skipped, and rejected question
|
|
9
|
+
responses from the Backeer iOS application through OpenCode's native APIs.
|
|
10
|
+
- Share the source-authenticated SSE connection with permission commands and
|
|
11
|
+
use stable, idempotent question acknowledgements.
|
|
12
|
+
- Renew five-minute question leases while OpenCode is waiting and resolve the
|
|
13
|
+
Backeer event when a question is answered locally.
|
|
14
|
+
|
|
15
|
+
## 0.2.5 - 2026-09-01
|
|
16
|
+
|
|
17
|
+
- Group consecutive OpenCode conversation feedback into one ignored Backeer
|
|
18
|
+
message while preserving permission and question UI blocking.
|
|
19
|
+
- Render feedback with plain terminal-friendly text so OpenCode never exposes
|
|
20
|
+
raw Markdown markers such as `**Backeer**`.
|
|
21
|
+
|
|
5
22
|
## 0.2.4 - 2026-09-01
|
|
6
23
|
|
|
7
24
|
- Retry permission, question, and todo publications after a terminal delivery
|
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
`backeer-opencode` connects OpenCode to Backeer. It sends mobile notifications
|
|
4
4
|
for permission requests, questions, completed todo lists, completed assistant
|
|
5
5
|
runs, session failures, prolonged retries, and OpenCode updates. While a
|
|
6
|
-
permission is pending, it also consumes an authenticated Backeer
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
permission or question is pending, it also consumes an authenticated Backeer
|
|
7
|
+
command stream. An authorized user can choose **Allow once**, **Always allow**,
|
|
8
|
+
or **Reject** for permissions and can answer or reject AI questions from the
|
|
9
|
+
Backeer iOS application.
|
|
9
10
|
|
|
10
11
|
The plugin uses outbound HTTPS only. It does not start a server or require an
|
|
11
12
|
inbound firewall rule.
|
|
@@ -108,7 +109,7 @@ environment variables are supported:
|
|
|
108
109
|
| `BACKEER_TOKEN_FILE` | Absolute path to a protected token file |
|
|
109
110
|
| `BACKEER_ORIGIN` | Gateway origin; HTTPS is required |
|
|
110
111
|
| `BACKEER_NOTIFY_PERMISSIONS` | Set to `false` to disable permission notifications and remote replies |
|
|
111
|
-
| `BACKEER_NOTIFY_QUESTIONS` | Set to `false` to disable question notifications |
|
|
112
|
+
| `BACKEER_NOTIFY_QUESTIONS` | Set to `false` to disable question notifications and remote answers |
|
|
112
113
|
| `BACKEER_NOTIFY_TODOS` | Set to `false` to disable todo-completion notifications |
|
|
113
114
|
| `BACKEER_NOTIFY_LIFECYCLE` | Set to `false` to disable every lifecycle notification |
|
|
114
115
|
| `BACKEER_NOTIFY_SESSION_COMPLETED` | Set to `false` to disable successful-run notifications |
|
|
@@ -232,8 +233,8 @@ The plugin publishes:
|
|
|
232
233
|
| OpenCode event | Backeer behavior |
|
|
233
234
|
|---|---|
|
|
234
235
|
| `permission.asked` | Notification plus remote decision support |
|
|
235
|
-
| `question.asked` |
|
|
236
|
-
| `question.v2.asked` |
|
|
236
|
+
| `question.asked` | Interactive question card with remote answer/reject support |
|
|
237
|
+
| `question.v2.asked` | Interactive question card with remote answer/reject support, including plan-mode questions |
|
|
237
238
|
| `todo.updated` | One notification when a non-empty list transitions to fully completed |
|
|
238
239
|
| `message.updated` + `session.status`/`session.idle` | One notification for each successfully completed assistant run |
|
|
239
240
|
| `session.error` | Critical failure notification with a redacted error summary |
|
|
@@ -298,6 +298,7 @@ async function postJsonWithRetry(config, url, body, options = {}) {
|
|
|
298
298
|
|
|
299
299
|
// src/event-publishers.js
|
|
300
300
|
var ACTION_PROTOCOL = "opencode-permission/v1";
|
|
301
|
+
var QUESTION_ACTION_PROTOCOL = "opencode-question/v1";
|
|
301
302
|
function permissionIdentity(request) {
|
|
302
303
|
const requestID = request.id ?? request.requestID;
|
|
303
304
|
const sessionID = request.sessionID;
|
|
@@ -367,7 +368,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
367
368
|
return "";
|
|
368
369
|
}
|
|
369
370
|
}
|
|
370
|
-
async function publishQuestion(config, reporter, request, identity, client, directory, conversation) {
|
|
371
|
+
async function publishQuestion(config, reporter, request, identity, client, directory, conversation, sourceEvent) {
|
|
371
372
|
const questions = Array.isArray(request.questions) ? request.questions : [];
|
|
372
373
|
if (questions.length === 0) return;
|
|
373
374
|
const sessionTitle = await resolveSessionTitle(
|
|
@@ -378,6 +379,9 @@ async function publishQuestion(config, reporter, request, identity, client, dire
|
|
|
378
379
|
);
|
|
379
380
|
const payload = [
|
|
380
381
|
{ key: "OpenCode session", value: sessionTitle },
|
|
382
|
+
{ key: "Session", value: limit(identity.sessionID) },
|
|
383
|
+
{ key: "Request ID", value: limit(identity.requestID) },
|
|
384
|
+
{ key: "Action protocol", value: QUESTION_ACTION_PROTOCOL },
|
|
381
385
|
...questions.flatMap((question, index) => {
|
|
382
386
|
const suffix = questions.length > 1 ? ` ${index + 1}` : "";
|
|
383
387
|
return [
|
|
@@ -391,6 +395,16 @@ async function publishQuestion(config, reporter, request, identity, client, dire
|
|
|
391
395
|
})
|
|
392
396
|
].filter((item) => item.value.length > 0);
|
|
393
397
|
const firstQuestion = questions[0];
|
|
398
|
+
const actionQuestions = questions.map((question) => ({
|
|
399
|
+
header: limit(question?.header, 200),
|
|
400
|
+
question: limit(question?.question, 2e3),
|
|
401
|
+
options: Array.isArray(question?.options) ? question.options.slice(0, 20).map((option) => ({
|
|
402
|
+
label: limit(option?.label, 200),
|
|
403
|
+
description: limit(option?.description, 500)
|
|
404
|
+
})).filter((option) => option.label) : [],
|
|
405
|
+
multiple: question?.multiple === true,
|
|
406
|
+
custom: question?.custom !== false
|
|
407
|
+
}));
|
|
394
408
|
const body = questions.map((question) => {
|
|
395
409
|
const header = limit(question?.header, 200);
|
|
396
410
|
const text = limit(question?.question, 500);
|
|
@@ -406,10 +420,21 @@ async function publishQuestion(config, reporter, request, identity, client, dire
|
|
|
406
420
|
externalId: identity.externalId,
|
|
407
421
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
408
422
|
notify: true,
|
|
409
|
-
direction: "in"
|
|
423
|
+
direction: "in",
|
|
424
|
+
action: {
|
|
425
|
+
protocol: QUESTION_ACTION_PROTOCOL,
|
|
426
|
+
sourceEvent,
|
|
427
|
+
questions: actionQuestions
|
|
428
|
+
}
|
|
410
429
|
});
|
|
411
430
|
reporter.success("Question sent", `HTTP ${result.status}`);
|
|
412
431
|
conversation?.success(identity.sessionID, "Question sent");
|
|
432
|
+
try {
|
|
433
|
+
const response = JSON.parse(result.responseBody);
|
|
434
|
+
return typeof response?.id === "string" ? response.id : void 0;
|
|
435
|
+
} catch {
|
|
436
|
+
return void 0;
|
|
437
|
+
}
|
|
413
438
|
}
|
|
414
439
|
async function publishTodoCompletion(config, reporter, request, eventID, client, directory, conversation) {
|
|
415
440
|
const todos = request.todos;
|
|
@@ -468,7 +493,7 @@ import { randomUUID } from "node:crypto";
|
|
|
468
493
|
import { statSync } from "node:fs";
|
|
469
494
|
|
|
470
495
|
// src/updates.js
|
|
471
|
-
var PLUGIN_VERSION = "0.2.
|
|
496
|
+
var PLUGIN_VERSION = "0.2.5";
|
|
472
497
|
var PACKAGE_NAME = "backeer-opencode";
|
|
473
498
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
474
499
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -709,9 +734,10 @@ async function publishTestNotification(config, reporter) {
|
|
|
709
734
|
// src/feedback-session-store.js
|
|
710
735
|
var MAX_QUEUED_MESSAGES = 20;
|
|
711
736
|
var FeedbackSessionStore = class {
|
|
712
|
-
constructor({ send, reporter }) {
|
|
737
|
+
constructor({ send, reporter, flushDelayMs = 150 }) {
|
|
713
738
|
this.send = send;
|
|
714
739
|
this.reporter = reporter;
|
|
740
|
+
this.flushDelayMs = flushDelayMs;
|
|
715
741
|
this.sessions = /* @__PURE__ */ new Map();
|
|
716
742
|
this.disposed = false;
|
|
717
743
|
}
|
|
@@ -724,7 +750,8 @@ var FeedbackSessionStore = class {
|
|
|
724
750
|
blockers: /* @__PURE__ */ new Set(),
|
|
725
751
|
queue: [],
|
|
726
752
|
queued: /* @__PURE__ */ new Set(),
|
|
727
|
-
queueOverflowReported: false
|
|
753
|
+
queueOverflowReported: false,
|
|
754
|
+
flushTimer: void 0
|
|
728
755
|
};
|
|
729
756
|
rememberBounded(this.sessions, sessionID, state);
|
|
730
757
|
}
|
|
@@ -733,23 +760,27 @@ var FeedbackSessionStore = class {
|
|
|
733
760
|
hasReadyMessage(state) {
|
|
734
761
|
return !this.disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0;
|
|
735
762
|
}
|
|
763
|
+
scheduleFlush(sessionID, state) {
|
|
764
|
+
if (state.flushTimer || !this.hasReadyMessage(state)) return;
|
|
765
|
+
state.flushTimer = setTimeout(() => {
|
|
766
|
+
state.flushTimer = void 0;
|
|
767
|
+
void this.flush(sessionID, state);
|
|
768
|
+
}, this.flushDelayMs);
|
|
769
|
+
}
|
|
736
770
|
async flush(sessionID, state) {
|
|
737
771
|
if (state.flushing || !this.hasReadyMessage(state)) return;
|
|
738
772
|
state.flushing = true;
|
|
773
|
+
const items = state.queue.slice();
|
|
739
774
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
} catch (error) {
|
|
747
|
-
state.idle = false;
|
|
748
|
-
this.reporter.debug("Conversation feedback could not be displayed", error);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
775
|
+
await this.send(sessionID, items);
|
|
776
|
+
state.queue.splice(0, items.length);
|
|
777
|
+
for (const item of items) state.queued.delete(item.key);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
state.idle = false;
|
|
780
|
+
this.reporter.debug("Conversation feedback could not be displayed", error);
|
|
751
781
|
} finally {
|
|
752
782
|
state.flushing = false;
|
|
783
|
+
this.scheduleFlush(sessionID, state);
|
|
753
784
|
}
|
|
754
785
|
}
|
|
755
786
|
enqueue(kind, sessionID, message) {
|
|
@@ -767,13 +798,13 @@ var FeedbackSessionStore = class {
|
|
|
767
798
|
this.reporter.warning("Conversation feedback queue full \u2014 oldest message discarded");
|
|
768
799
|
}
|
|
769
800
|
}
|
|
770
|
-
|
|
801
|
+
this.scheduleFlush(sessionID, state);
|
|
771
802
|
}
|
|
772
803
|
setIdle(sessionID, idle) {
|
|
773
804
|
if (typeof sessionID !== "string" || !sessionID) return;
|
|
774
805
|
const state = this.stateFor(sessionID);
|
|
775
806
|
state.idle = idle;
|
|
776
|
-
if (idle)
|
|
807
|
+
if (idle) this.scheduleFlush(sessionID, state);
|
|
777
808
|
}
|
|
778
809
|
beginInteraction(sessionID, key) {
|
|
779
810
|
const state = this.stateFor(sessionID);
|
|
@@ -794,10 +825,15 @@ var FeedbackSessionStore = class {
|
|
|
794
825
|
state.idle = false;
|
|
795
826
|
}
|
|
796
827
|
delete(sessionID) {
|
|
828
|
+
const state = this.sessions.get(sessionID);
|
|
829
|
+
if (state?.flushTimer) clearTimeout(state.flushTimer);
|
|
797
830
|
this.sessions.delete(sessionID);
|
|
798
831
|
}
|
|
799
832
|
dispose() {
|
|
800
833
|
this.disposed = true;
|
|
834
|
+
for (const state of this.sessions.values()) {
|
|
835
|
+
if (state.flushTimer) clearTimeout(state.flushTimer);
|
|
836
|
+
}
|
|
801
837
|
this.sessions.clear();
|
|
802
838
|
}
|
|
803
839
|
};
|
|
@@ -874,15 +910,28 @@ async function sendSessionMessage(client, sessionID, text) {
|
|
|
874
910
|
}
|
|
875
911
|
function formatBackeerFeedback(kind, message) {
|
|
876
912
|
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
|
|
877
|
-
return
|
|
913
|
+
return `\u25A3 Backeer | ${symbol} ${limit(message, 300)}`;
|
|
914
|
+
}
|
|
915
|
+
function formatBackeerFeedbackBatch(items) {
|
|
916
|
+
if (items.length === 1) {
|
|
917
|
+
return formatBackeerFeedback(items[0].kind, items[0].message);
|
|
918
|
+
}
|
|
919
|
+
return [
|
|
920
|
+
"\u25A3 Backeer",
|
|
921
|
+
...items.map((item) => {
|
|
922
|
+
const symbol = FEEDBACK_SYMBOLS[item.kind] ?? FEEDBACK_SYMBOLS.info;
|
|
923
|
+
return `${symbol} ${limit(item.message, 300)}`;
|
|
924
|
+
})
|
|
925
|
+
].join("\n");
|
|
878
926
|
}
|
|
879
|
-
function createConversationFeedback({ client, reporter }) {
|
|
927
|
+
function createConversationFeedback({ client, reporter, flushDelayMs = 150 }) {
|
|
880
928
|
const sessions = new FeedbackSessionStore({
|
|
881
929
|
reporter,
|
|
882
|
-
|
|
930
|
+
flushDelayMs,
|
|
931
|
+
send: (sessionID, items) => sendSessionMessage(
|
|
883
932
|
client,
|
|
884
933
|
sessionID,
|
|
885
|
-
|
|
934
|
+
formatBackeerFeedbackBatch(items)
|
|
886
935
|
)
|
|
887
936
|
});
|
|
888
937
|
return {
|
|
@@ -1514,6 +1563,64 @@ function reportAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
|
1514
1563
|
conversation?.warning(pending.sessionID, message);
|
|
1515
1564
|
}
|
|
1516
1565
|
|
|
1566
|
+
// src/question-command.js
|
|
1567
|
+
var QUESTION_ACTIONS = /* @__PURE__ */ new Set(["answer", "reject"]);
|
|
1568
|
+
function validateQuestionCommand(command, pending, now = Date.now()) {
|
|
1569
|
+
const response = command?.response;
|
|
1570
|
+
if (!response || !QUESTION_ACTIONS.has(response.action)) {
|
|
1571
|
+
return { status: "rejected", action: response?.action, errorCode: "invalid_action" };
|
|
1572
|
+
}
|
|
1573
|
+
const expiresAt = Date.parse(command.expiresAt);
|
|
1574
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
1575
|
+
return { status: "expired", action: response.action };
|
|
1576
|
+
}
|
|
1577
|
+
if (response.action === "reject") return;
|
|
1578
|
+
if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
|
|
1579
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
1580
|
+
}
|
|
1581
|
+
for (let index = 0; index < pending.questions.length; index++) {
|
|
1582
|
+
const question = pending.questions[index];
|
|
1583
|
+
const answers = response.answers[index];
|
|
1584
|
+
if (!Array.isArray(answers) || !question.multiple && answers.length > 1) {
|
|
1585
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
1586
|
+
}
|
|
1587
|
+
for (const answer of answers) {
|
|
1588
|
+
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
1589
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
1590
|
+
}
|
|
1591
|
+
const known = question.options.some((option) => option.label === answer);
|
|
1592
|
+
if (!known && !question.custom) {
|
|
1593
|
+
return { status: "rejected", action: response.action, errorCode: "custom_not_allowed" };
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function questionFailureAcknowledgement(command, error) {
|
|
1599
|
+
const stale = error instanceof HttpError && error.status === 404;
|
|
1600
|
+
return {
|
|
1601
|
+
status: stale ? "stale" : "failed",
|
|
1602
|
+
action: command?.response?.action,
|
|
1603
|
+
errorCode: stale ? "question_not_found" : "opencode_question_reply_failed"
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
function reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation) {
|
|
1607
|
+
const messages = {
|
|
1608
|
+
applied: acknowledgement.action === "reject" ? "Question rejected from Backeer" : "Question answered from Backeer",
|
|
1609
|
+
expired: "Question response expired before it could be applied",
|
|
1610
|
+
stale: "Question was already answered locally",
|
|
1611
|
+
rejected: "Question response was rejected by validation"
|
|
1612
|
+
};
|
|
1613
|
+
const message = messages[acknowledgement.status];
|
|
1614
|
+
if (!message) return;
|
|
1615
|
+
if (acknowledgement.status === "applied") {
|
|
1616
|
+
reporter.applied(message);
|
|
1617
|
+
conversation?.applied(pending.sessionID, message);
|
|
1618
|
+
} else {
|
|
1619
|
+
reporter.warning(message);
|
|
1620
|
+
conversation?.warning(pending.sessionID, message);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1517
1624
|
// src/sse.js
|
|
1518
1625
|
function parseSseFrame(frame) {
|
|
1519
1626
|
const data = [];
|
|
@@ -1700,6 +1807,32 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
|
1700
1807
|
);
|
|
1701
1808
|
await postJsonWithRetry(config, url, acknowledgement);
|
|
1702
1809
|
}
|
|
1810
|
+
async function replyToQuestion(client, pending, response) {
|
|
1811
|
+
const isV2 = pending.sourceEvent === "question.v2.asked";
|
|
1812
|
+
const api = isV2 ? client?.v2?.session?.question : client?.question;
|
|
1813
|
+
const method = response.action === "reject" ? api?.reject : api?.reply;
|
|
1814
|
+
if (typeof method !== "function") {
|
|
1815
|
+
throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
|
|
1816
|
+
}
|
|
1817
|
+
const input = isV2 ? {
|
|
1818
|
+
sessionID: pending.sessionID,
|
|
1819
|
+
requestID: pending.requestID,
|
|
1820
|
+
directory: pending.directory,
|
|
1821
|
+
...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
|
|
1822
|
+
} : {
|
|
1823
|
+
requestID: pending.requestID,
|
|
1824
|
+
directory: pending.directory,
|
|
1825
|
+
...response.action === "answer" ? { answers: response.answers } : {}
|
|
1826
|
+
};
|
|
1827
|
+
const result = await method.call(api, input);
|
|
1828
|
+
if (result?.data === false || result === false) {
|
|
1829
|
+
throw new HttpError("OpenCode did not apply the question response", 409);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
async function postQuestionState(config, eventID, path, body = {}) {
|
|
1833
|
+
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
|
|
1834
|
+
await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
|
|
1835
|
+
}
|
|
1703
1836
|
function createPermissionController({
|
|
1704
1837
|
config,
|
|
1705
1838
|
reporter,
|
|
@@ -1709,9 +1842,13 @@ function createPermissionController({
|
|
|
1709
1842
|
}) {
|
|
1710
1843
|
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
1711
1844
|
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
1845
|
+
const questionsByExternalID = /* @__PURE__ */ new Map();
|
|
1846
|
+
const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
|
|
1847
|
+
const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
|
|
1712
1848
|
const processingCommands = /* @__PURE__ */ new Set();
|
|
1713
1849
|
const completedCommands = /* @__PURE__ */ new Map();
|
|
1714
1850
|
let commandStream;
|
|
1851
|
+
let leaseTimer;
|
|
1715
1852
|
let disposed = false;
|
|
1716
1853
|
function removePendingByRequestID(requestID) {
|
|
1717
1854
|
const externalId = externalIDByRequestID.get(requestID);
|
|
@@ -1720,11 +1857,36 @@ function createPermissionController({
|
|
|
1720
1857
|
pendingByExternalID.delete(externalId);
|
|
1721
1858
|
}
|
|
1722
1859
|
function stopCommandStreamIfIdle() {
|
|
1723
|
-
if (pendingByExternalID.size === 0
|
|
1860
|
+
if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
|
|
1861
|
+
commandStream?.controller.abort();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function removeQuestionByRequestID(requestID) {
|
|
1865
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
1866
|
+
if (!externalId) return;
|
|
1867
|
+
questionExternalIDByRequestID.delete(requestID);
|
|
1868
|
+
questionsByExternalID.delete(externalId);
|
|
1869
|
+
if (questionsByExternalID.size === 0 && leaseTimer) {
|
|
1870
|
+
clearInterval(leaseTimer);
|
|
1871
|
+
leaseTimer = void 0;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
async function renewQuestionLeases() {
|
|
1875
|
+
for (const pending of questionsByExternalID.values()) {
|
|
1876
|
+
if (!pending.eventID) continue;
|
|
1877
|
+
await postQuestionState(config, pending.eventID, "lease").catch((error) => {
|
|
1878
|
+
reporter.warning("Question lease could not be renewed", error);
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
function ensureLeaseTimer() {
|
|
1883
|
+
if (leaseTimer || questionsByExternalID.size === 0) return;
|
|
1884
|
+
leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
|
|
1885
|
+
leaseTimer.unref?.();
|
|
1724
1886
|
}
|
|
1725
1887
|
async function handleCommand(command) {
|
|
1726
1888
|
if (!command || typeof command !== "object") return;
|
|
1727
|
-
if (command.type
|
|
1889
|
+
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
|
|
1728
1890
|
if (typeof command.id !== "string" || command.id.length > 200) return;
|
|
1729
1891
|
const previousAcknowledgement = completedCommands.get(command.id);
|
|
1730
1892
|
if (previousAcknowledgement) {
|
|
@@ -1733,54 +1895,75 @@ function createPermissionController({
|
|
|
1733
1895
|
}
|
|
1734
1896
|
if (processingCommands.has(command.id)) return;
|
|
1735
1897
|
if (typeof command.externalId !== "string") return;
|
|
1736
|
-
const
|
|
1898
|
+
const isQuestion = command.type === "opencode_question_reply";
|
|
1899
|
+
const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
|
|
1737
1900
|
if (!pending) return;
|
|
1738
1901
|
processingCommands.add(command.id);
|
|
1739
|
-
let acknowledgement = validatePermissionCommand(command);
|
|
1902
|
+
let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
|
|
1740
1903
|
try {
|
|
1741
1904
|
if (!acknowledgement) {
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1905
|
+
if (isQuestion) {
|
|
1906
|
+
remotelyResolvingQuestions.add(pending.requestID);
|
|
1907
|
+
await replyToQuestion(client, pending, command.response);
|
|
1908
|
+
acknowledgement = { status: "applied", action: command.response.action };
|
|
1909
|
+
removeQuestionByRequestID(pending.requestID);
|
|
1910
|
+
const cleanup = setTimeout(
|
|
1911
|
+
() => remotelyResolvingQuestions.delete(pending.requestID),
|
|
1912
|
+
3e4
|
|
1913
|
+
);
|
|
1914
|
+
cleanup.unref?.();
|
|
1915
|
+
} else {
|
|
1916
|
+
await replyToOpenCode(client, pending, command.decision);
|
|
1917
|
+
acknowledgement = { status: "applied", decision: command.decision };
|
|
1918
|
+
removePendingByRequestID(pending.requestID);
|
|
1919
|
+
}
|
|
1745
1920
|
}
|
|
1746
1921
|
} catch (error) {
|
|
1747
|
-
|
|
1922
|
+
if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
|
|
1923
|
+
acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
|
|
1748
1924
|
if (acknowledgement.status === "stale") {
|
|
1749
|
-
|
|
1925
|
+
if (isQuestion) removeQuestionByRequestID(pending.requestID);
|
|
1926
|
+
else removePendingByRequestID(pending.requestID);
|
|
1750
1927
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
1928
|
+
const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
|
|
1929
|
+
reporter.error(message, error);
|
|
1930
|
+
conversation?.error(pending.sessionID, message);
|
|
1753
1931
|
} finally {
|
|
1754
1932
|
processingCommands.delete(command.id);
|
|
1755
1933
|
}
|
|
1756
1934
|
acknowledgement = {
|
|
1757
1935
|
...acknowledgement,
|
|
1758
|
-
type: "opencode_permission_reply_ack",
|
|
1936
|
+
type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
|
|
1759
1937
|
externalId: command.externalId,
|
|
1760
1938
|
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1761
1939
|
};
|
|
1762
1940
|
rememberBounded(completedCommands, command.id, acknowledgement);
|
|
1763
|
-
|
|
1941
|
+
if (isQuestion) {
|
|
1942
|
+
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
1943
|
+
} else {
|
|
1944
|
+
reportAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
1945
|
+
}
|
|
1764
1946
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
1765
1947
|
reporter.warning("Decision receipt could not be sent", error);
|
|
1766
1948
|
});
|
|
1767
1949
|
stopCommandStreamIfIdle();
|
|
1768
1950
|
}
|
|
1769
1951
|
function ensureCommandStream() {
|
|
1770
|
-
|
|
1952
|
+
const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
|
|
1953
|
+
if (disposed || commandStream || !hasPending()) return;
|
|
1771
1954
|
const controller = new AbortController();
|
|
1772
1955
|
const current = {
|
|
1773
1956
|
controller,
|
|
1774
1957
|
task: runCommandStream(config, reporter, {
|
|
1775
1958
|
signal: controller.signal,
|
|
1776
|
-
hasPending
|
|
1959
|
+
hasPending,
|
|
1777
1960
|
onCommand: handleCommand
|
|
1778
1961
|
})
|
|
1779
1962
|
};
|
|
1780
1963
|
commandStream = current;
|
|
1781
1964
|
void current.task.finally(() => {
|
|
1782
1965
|
if (commandStream === current) commandStream = void 0;
|
|
1783
|
-
if (!disposed &&
|
|
1966
|
+
if (!disposed && hasPending()) ensureCommandStream();
|
|
1784
1967
|
});
|
|
1785
1968
|
}
|
|
1786
1969
|
return {
|
|
@@ -1793,6 +1976,31 @@ function createPermissionController({
|
|
|
1793
1976
|
removePendingByRequestID(requestID);
|
|
1794
1977
|
stopCommandStreamIfIdle();
|
|
1795
1978
|
},
|
|
1979
|
+
registerQuestion(identity, details) {
|
|
1980
|
+
questionsByExternalID.set(identity.externalId, {
|
|
1981
|
+
...identity,
|
|
1982
|
+
...details,
|
|
1983
|
+
directory
|
|
1984
|
+
});
|
|
1985
|
+
questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
|
|
1986
|
+
ensureLeaseTimer();
|
|
1987
|
+
ensureCommandStream();
|
|
1988
|
+
},
|
|
1989
|
+
resolveQuestionLocally(requestID, outcome) {
|
|
1990
|
+
if (remotelyResolvingQuestions.delete(requestID)) {
|
|
1991
|
+
removeQuestionByRequestID(requestID);
|
|
1992
|
+
stopCommandStreamIfIdle();
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
1996
|
+
const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
|
|
1997
|
+
if (!pending) return;
|
|
1998
|
+
removeQuestionByRequestID(requestID);
|
|
1999
|
+
if (pending.eventID) {
|
|
2000
|
+
void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
|
|
2001
|
+
}
|
|
2002
|
+
stopCommandStreamIfIdle();
|
|
2003
|
+
},
|
|
1796
2004
|
refreshCredentials() {
|
|
1797
2005
|
commandStream?.controller.abort();
|
|
1798
2006
|
if (!commandStream) ensureCommandStream();
|
|
@@ -1800,18 +2008,23 @@ function createPermissionController({
|
|
|
1800
2008
|
status() {
|
|
1801
2009
|
return {
|
|
1802
2010
|
pending: pendingByExternalID.size,
|
|
2011
|
+
pendingQuestions: questionsByExternalID.size,
|
|
1803
2012
|
stream: commandStream ? "active" : "idle"
|
|
1804
2013
|
};
|
|
1805
2014
|
},
|
|
1806
2015
|
async dispose() {
|
|
1807
2016
|
disposed = true;
|
|
1808
2017
|
commandStream?.controller.abort();
|
|
2018
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
1809
2019
|
await commandStream?.task.catch(() => {
|
|
1810
2020
|
});
|
|
1811
2021
|
pendingByExternalID.clear();
|
|
1812
2022
|
externalIDByRequestID.clear();
|
|
1813
2023
|
processingCommands.clear();
|
|
1814
2024
|
completedCommands.clear();
|
|
2025
|
+
questionsByExternalID.clear();
|
|
2026
|
+
questionExternalIDByRequestID.clear();
|
|
2027
|
+
remotelyResolvingQuestions.clear();
|
|
1815
2028
|
}
|
|
1816
2029
|
};
|
|
1817
2030
|
}
|
|
@@ -1921,8 +2134,18 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1921
2134
|
identity,
|
|
1922
2135
|
client,
|
|
1923
2136
|
directory,
|
|
1924
|
-
conversation
|
|
1925
|
-
|
|
2137
|
+
conversation,
|
|
2138
|
+
event.type
|
|
2139
|
+
).then((eventID) => {
|
|
2140
|
+
questionPublications.succeed(identity.externalId);
|
|
2141
|
+
if (eventID) {
|
|
2142
|
+
permissionController.registerQuestion(identity, {
|
|
2143
|
+
eventID,
|
|
2144
|
+
sourceEvent: event.type,
|
|
2145
|
+
questions: event.properties.questions
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}).catch((error) => {
|
|
1926
2149
|
questionPublications.fail(identity.externalId);
|
|
1927
2150
|
reporter.error("Question could not be sent", error);
|
|
1928
2151
|
conversation.error(identity.sessionID, "Question could not be sent");
|
|
@@ -1954,6 +2177,16 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1954
2177
|
}
|
|
1955
2178
|
if (config.questions && (event.type === "question.asked" || event.type === "question.v2.asked")) {
|
|
1956
2179
|
await handleQuestionAsked(event);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
if (config.questions && ["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
|
|
2183
|
+
const requestID = event.properties.requestID ?? event.properties.id;
|
|
2184
|
+
if (typeof requestID === "string") {
|
|
2185
|
+
permissionController.resolveQuestionLocally(
|
|
2186
|
+
requestID,
|
|
2187
|
+
event.type.endsWith("rejected") ? "rejected" : "answered"
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
1957
2190
|
}
|
|
1958
2191
|
},
|
|
1959
2192
|
dispose: async () => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backeer-opencode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Backeer lifecycle notifications and remote permission
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Backeer lifecycle notifications and remote permission and question replies for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
package/src/event-publishers.js
CHANGED
|
@@ -2,6 +2,7 @@ import { postJsonWithRetry } from "./http.js"
|
|
|
2
2
|
import { limit, stableHash } from "./utilities.js"
|
|
3
3
|
|
|
4
4
|
const ACTION_PROTOCOL = "opencode-permission/v1"
|
|
5
|
+
const QUESTION_ACTION_PROTOCOL = "opencode-question/v1"
|
|
5
6
|
|
|
6
7
|
export function permissionIdentity(request) {
|
|
7
8
|
const requestID = request.id ?? request.requestID
|
|
@@ -107,6 +108,7 @@ export async function publishQuestion(
|
|
|
107
108
|
client,
|
|
108
109
|
directory,
|
|
109
110
|
conversation,
|
|
111
|
+
sourceEvent,
|
|
110
112
|
) {
|
|
111
113
|
const questions = Array.isArray(request.questions) ? request.questions : []
|
|
112
114
|
if (questions.length === 0) return
|
|
@@ -119,6 +121,9 @@ export async function publishQuestion(
|
|
|
119
121
|
)
|
|
120
122
|
const payload = [
|
|
121
123
|
{ key: "OpenCode session", value: sessionTitle },
|
|
124
|
+
{ key: "Session", value: limit(identity.sessionID) },
|
|
125
|
+
{ key: "Request ID", value: limit(identity.requestID) },
|
|
126
|
+
{ key: "Action protocol", value: QUESTION_ACTION_PROTOCOL },
|
|
122
127
|
...questions.flatMap((question, index) => {
|
|
123
128
|
const suffix = questions.length > 1 ? ` ${index + 1}` : ""
|
|
124
129
|
return [
|
|
@@ -133,6 +138,18 @@ export async function publishQuestion(
|
|
|
133
138
|
].filter((item) => item.value.length > 0)
|
|
134
139
|
|
|
135
140
|
const firstQuestion = questions[0]
|
|
141
|
+
const actionQuestions = questions.map((question) => ({
|
|
142
|
+
header: limit(question?.header, 200),
|
|
143
|
+
question: limit(question?.question, 2000),
|
|
144
|
+
options: Array.isArray(question?.options)
|
|
145
|
+
? question.options.slice(0, 20).map((option) => ({
|
|
146
|
+
label: limit(option?.label, 200),
|
|
147
|
+
description: limit(option?.description, 500),
|
|
148
|
+
})).filter((option) => option.label)
|
|
149
|
+
: [],
|
|
150
|
+
multiple: question?.multiple === true,
|
|
151
|
+
custom: question?.custom !== false,
|
|
152
|
+
}))
|
|
136
153
|
const body = questions
|
|
137
154
|
.map((question) => {
|
|
138
155
|
const header = limit(question?.header, 200)
|
|
@@ -154,9 +171,20 @@ export async function publishQuestion(
|
|
|
154
171
|
timestamp: new Date().toISOString(),
|
|
155
172
|
notify: true,
|
|
156
173
|
direction: "in",
|
|
174
|
+
action: {
|
|
175
|
+
protocol: QUESTION_ACTION_PROTOCOL,
|
|
176
|
+
sourceEvent,
|
|
177
|
+
questions: actionQuestions,
|
|
178
|
+
},
|
|
157
179
|
})
|
|
158
180
|
reporter.success("Question sent", `HTTP ${result.status}`)
|
|
159
181
|
conversation?.success(identity.sessionID, "Question sent")
|
|
182
|
+
try {
|
|
183
|
+
const response = JSON.parse(result.responseBody)
|
|
184
|
+
return typeof response?.id === "string" ? response.id : undefined
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
160
188
|
}
|
|
161
189
|
|
|
162
190
|
export async function publishTodoCompletion(
|
|
@@ -3,9 +3,10 @@ import { limit, rememberBounded } from "./utilities.js"
|
|
|
3
3
|
const MAX_QUEUED_MESSAGES = 20
|
|
4
4
|
|
|
5
5
|
export class FeedbackSessionStore {
|
|
6
|
-
constructor({ send, reporter }) {
|
|
6
|
+
constructor({ send, reporter, flushDelayMs = 150 }) {
|
|
7
7
|
this.send = send
|
|
8
8
|
this.reporter = reporter
|
|
9
|
+
this.flushDelayMs = flushDelayMs
|
|
9
10
|
this.sessions = new Map()
|
|
10
11
|
this.disposed = false
|
|
11
12
|
}
|
|
@@ -20,6 +21,7 @@ export class FeedbackSessionStore {
|
|
|
20
21
|
queue: [],
|
|
21
22
|
queued: new Set(),
|
|
22
23
|
queueOverflowReported: false,
|
|
24
|
+
flushTimer: undefined,
|
|
23
25
|
}
|
|
24
26
|
rememberBounded(this.sessions, sessionID, state)
|
|
25
27
|
}
|
|
@@ -31,23 +33,28 @@ export class FeedbackSessionStore {
|
|
|
31
33
|
state.blockers.size === 0 && state.queue.length > 0
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
scheduleFlush(sessionID, state) {
|
|
37
|
+
if (state.flushTimer || !this.hasReadyMessage(state)) return
|
|
38
|
+
state.flushTimer = setTimeout(() => {
|
|
39
|
+
state.flushTimer = undefined
|
|
40
|
+
void this.flush(sessionID, state)
|
|
41
|
+
}, this.flushDelayMs)
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
async flush(sessionID, state) {
|
|
35
45
|
if (state.flushing || !this.hasReadyMessage(state)) return
|
|
36
46
|
state.flushing = true
|
|
47
|
+
const items = state.queue.slice()
|
|
37
48
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} catch (error) {
|
|
45
|
-
state.idle = false
|
|
46
|
-
this.reporter.debug("Conversation feedback could not be displayed", error)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
+
await this.send(sessionID, items)
|
|
50
|
+
state.queue.splice(0, items.length)
|
|
51
|
+
for (const item of items) state.queued.delete(item.key)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
state.idle = false
|
|
54
|
+
this.reporter.debug("Conversation feedback could not be displayed", error)
|
|
49
55
|
} finally {
|
|
50
56
|
state.flushing = false
|
|
57
|
+
this.scheduleFlush(sessionID, state)
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -67,14 +74,14 @@ export class FeedbackSessionStore {
|
|
|
67
74
|
this.reporter.warning("Conversation feedback queue full — oldest message discarded")
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
|
-
|
|
77
|
+
this.scheduleFlush(sessionID, state)
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
setIdle(sessionID, idle) {
|
|
74
81
|
if (typeof sessionID !== "string" || !sessionID) return
|
|
75
82
|
const state = this.stateFor(sessionID)
|
|
76
83
|
state.idle = idle
|
|
77
|
-
if (idle)
|
|
84
|
+
if (idle) this.scheduleFlush(sessionID, state)
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
beginInteraction(sessionID, key) {
|
|
@@ -100,11 +107,16 @@ export class FeedbackSessionStore {
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
delete(sessionID) {
|
|
110
|
+
const state = this.sessions.get(sessionID)
|
|
111
|
+
if (state?.flushTimer) clearTimeout(state.flushTimer)
|
|
103
112
|
this.sessions.delete(sessionID)
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
dispose() {
|
|
107
116
|
this.disposed = true
|
|
117
|
+
for (const state of this.sessions.values()) {
|
|
118
|
+
if (state.flushTimer) clearTimeout(state.flushTimer)
|
|
119
|
+
}
|
|
108
120
|
this.sessions.clear()
|
|
109
121
|
}
|
|
110
122
|
}
|
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
reportAcknowledgement,
|
|
5
5
|
validatePermissionCommand,
|
|
6
6
|
} from "./permission-command.js"
|
|
7
|
+
import {
|
|
8
|
+
questionFailureAcknowledgement,
|
|
9
|
+
reportQuestionAcknowledgement,
|
|
10
|
+
validateQuestionCommand,
|
|
11
|
+
} from "./question-command.js"
|
|
7
12
|
import { runCommandStream } from "./sse.js"
|
|
8
13
|
import { rememberBounded } from "./utilities.js"
|
|
9
14
|
|
|
@@ -28,6 +33,38 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
|
28
33
|
await postJsonWithRetry(config, url, acknowledgement)
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
async function replyToQuestion(client, pending, response) {
|
|
37
|
+
const isV2 = pending.sourceEvent === "question.v2.asked"
|
|
38
|
+
const api = isV2 ? client?.v2?.session?.question : client?.question
|
|
39
|
+
const method = response.action === "reject" ? api?.reject : api?.reply
|
|
40
|
+
if (typeof method !== "function") {
|
|
41
|
+
throw new HttpError("This OpenCode version cannot apply remote question replies", 501)
|
|
42
|
+
}
|
|
43
|
+
const input = isV2
|
|
44
|
+
? {
|
|
45
|
+
sessionID: pending.sessionID,
|
|
46
|
+
requestID: pending.requestID,
|
|
47
|
+
directory: pending.directory,
|
|
48
|
+
...(response.action === "answer"
|
|
49
|
+
? { questionV2Reply: { answers: response.answers } }
|
|
50
|
+
: {}),
|
|
51
|
+
}
|
|
52
|
+
: {
|
|
53
|
+
requestID: pending.requestID,
|
|
54
|
+
directory: pending.directory,
|
|
55
|
+
...(response.action === "answer" ? { answers: response.answers } : {}),
|
|
56
|
+
}
|
|
57
|
+
const result = await method.call(api, input)
|
|
58
|
+
if (result?.data === false || result === false) {
|
|
59
|
+
throw new HttpError("OpenCode did not apply the question response", 409)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function postQuestionState(config, eventID, path, body = {}) {
|
|
64
|
+
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin)
|
|
65
|
+
await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] })
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
export function createPermissionController({
|
|
32
69
|
config,
|
|
33
70
|
reporter,
|
|
@@ -37,9 +74,13 @@ export function createPermissionController({
|
|
|
37
74
|
}) {
|
|
38
75
|
const pendingByExternalID = new Map()
|
|
39
76
|
const externalIDByRequestID = new Map()
|
|
77
|
+
const questionsByExternalID = new Map()
|
|
78
|
+
const questionExternalIDByRequestID = new Map()
|
|
79
|
+
const remotelyResolvingQuestions = new Set()
|
|
40
80
|
const processingCommands = new Set()
|
|
41
81
|
const completedCommands = new Map()
|
|
42
82
|
let commandStream
|
|
83
|
+
let leaseTimer
|
|
43
84
|
let disposed = false
|
|
44
85
|
|
|
45
86
|
function removePendingByRequestID(requestID) {
|
|
@@ -50,12 +91,40 @@ export function createPermissionController({
|
|
|
50
91
|
}
|
|
51
92
|
|
|
52
93
|
function stopCommandStreamIfIdle() {
|
|
53
|
-
if (pendingByExternalID.size === 0
|
|
94
|
+
if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
|
|
95
|
+
commandStream?.controller.abort()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function removeQuestionByRequestID(requestID) {
|
|
100
|
+
const externalId = questionExternalIDByRequestID.get(requestID)
|
|
101
|
+
if (!externalId) return
|
|
102
|
+
questionExternalIDByRequestID.delete(requestID)
|
|
103
|
+
questionsByExternalID.delete(externalId)
|
|
104
|
+
if (questionsByExternalID.size === 0 && leaseTimer) {
|
|
105
|
+
clearInterval(leaseTimer)
|
|
106
|
+
leaseTimer = undefined
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renewQuestionLeases() {
|
|
111
|
+
for (const pending of questionsByExternalID.values()) {
|
|
112
|
+
if (!pending.eventID) continue
|
|
113
|
+
await postQuestionState(config, pending.eventID, "lease").catch((error) => {
|
|
114
|
+
reporter.warning("Question lease could not be renewed", error)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ensureLeaseTimer() {
|
|
120
|
+
if (leaseTimer || questionsByExternalID.size === 0) return
|
|
121
|
+
leaseTimer = setInterval(() => void renewQuestionLeases(), 60_000)
|
|
122
|
+
leaseTimer.unref?.()
|
|
54
123
|
}
|
|
55
124
|
|
|
56
125
|
async function handleCommand(command) {
|
|
57
126
|
if (!command || typeof command !== "object") return
|
|
58
|
-
if (command.type
|
|
127
|
+
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return
|
|
59
128
|
if (typeof command.id !== "string" || command.id.length > 200) return
|
|
60
129
|
|
|
61
130
|
const previousAcknowledgement = completedCommands.get(command.id)
|
|
@@ -67,37 +136,67 @@ export function createPermissionController({
|
|
|
67
136
|
if (processingCommands.has(command.id)) return
|
|
68
137
|
if (typeof command.externalId !== "string") return
|
|
69
138
|
|
|
70
|
-
const
|
|
139
|
+
const isQuestion = command.type === "opencode_question_reply"
|
|
140
|
+
const pending = isQuestion
|
|
141
|
+
? questionsByExternalID.get(command.externalId)
|
|
142
|
+
: pendingByExternalID.get(command.externalId)
|
|
71
143
|
if (!pending) return
|
|
72
144
|
|
|
73
145
|
processingCommands.add(command.id)
|
|
74
|
-
let acknowledgement =
|
|
146
|
+
let acknowledgement = isQuestion
|
|
147
|
+
? validateQuestionCommand(command, pending)
|
|
148
|
+
: validatePermissionCommand(command)
|
|
75
149
|
try {
|
|
76
150
|
if (!acknowledgement) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
151
|
+
if (isQuestion) {
|
|
152
|
+
remotelyResolvingQuestions.add(pending.requestID)
|
|
153
|
+
await replyToQuestion(client, pending, command.response)
|
|
154
|
+
acknowledgement = { status: "applied", action: command.response.action }
|
|
155
|
+
removeQuestionByRequestID(pending.requestID)
|
|
156
|
+
const cleanup = setTimeout(
|
|
157
|
+
() => remotelyResolvingQuestions.delete(pending.requestID),
|
|
158
|
+
30_000,
|
|
159
|
+
)
|
|
160
|
+
cleanup.unref?.()
|
|
161
|
+
} else {
|
|
162
|
+
await replyToOpenCode(client, pending, command.decision)
|
|
163
|
+
acknowledgement = { status: "applied", decision: command.decision }
|
|
164
|
+
removePendingByRequestID(pending.requestID)
|
|
165
|
+
}
|
|
80
166
|
}
|
|
81
167
|
} catch (error) {
|
|
82
|
-
|
|
168
|
+
if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID)
|
|
169
|
+
acknowledgement = isQuestion
|
|
170
|
+
? questionFailureAcknowledgement(command, error)
|
|
171
|
+
: failureAcknowledgement(command, error)
|
|
83
172
|
if (acknowledgement.status === "stale") {
|
|
84
|
-
|
|
173
|
+
if (isQuestion) removeQuestionByRequestID(pending.requestID)
|
|
174
|
+
else removePendingByRequestID(pending.requestID)
|
|
85
175
|
}
|
|
86
|
-
|
|
87
|
-
|
|
176
|
+
const message = isQuestion
|
|
177
|
+
? "Question response could not be applied"
|
|
178
|
+
: "Decision could not be applied"
|
|
179
|
+
reporter.error(message, error)
|
|
180
|
+
conversation?.error(pending.sessionID, message)
|
|
88
181
|
} finally {
|
|
89
182
|
processingCommands.delete(command.id)
|
|
90
183
|
}
|
|
91
184
|
|
|
92
185
|
acknowledgement = {
|
|
93
186
|
...acknowledgement,
|
|
94
|
-
type:
|
|
187
|
+
type: isQuestion
|
|
188
|
+
? "opencode_question_reply_ack"
|
|
189
|
+
: "opencode_permission_reply_ack",
|
|
95
190
|
externalId: command.externalId,
|
|
96
191
|
processedAt: new Date().toISOString(),
|
|
97
192
|
}
|
|
98
193
|
rememberBounded(completedCommands, command.id, acknowledgement)
|
|
99
194
|
|
|
100
|
-
|
|
195
|
+
if (isQuestion) {
|
|
196
|
+
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
197
|
+
} else {
|
|
198
|
+
reportAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
199
|
+
}
|
|
101
200
|
|
|
102
201
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
103
202
|
reporter.warning("Decision receipt could not be sent", error)
|
|
@@ -106,21 +205,22 @@ export function createPermissionController({
|
|
|
106
205
|
}
|
|
107
206
|
|
|
108
207
|
function ensureCommandStream() {
|
|
109
|
-
|
|
208
|
+
const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0
|
|
209
|
+
if (disposed || commandStream || !hasPending()) return
|
|
110
210
|
|
|
111
211
|
const controller = new AbortController()
|
|
112
212
|
const current = {
|
|
113
213
|
controller,
|
|
114
214
|
task: runCommandStream(config, reporter, {
|
|
115
215
|
signal: controller.signal,
|
|
116
|
-
hasPending
|
|
216
|
+
hasPending,
|
|
117
217
|
onCommand: handleCommand,
|
|
118
218
|
}),
|
|
119
219
|
}
|
|
120
220
|
commandStream = current
|
|
121
221
|
void current.task.finally(() => {
|
|
122
222
|
if (commandStream === current) commandStream = undefined
|
|
123
|
-
if (!disposed &&
|
|
223
|
+
if (!disposed && hasPending()) ensureCommandStream()
|
|
124
224
|
})
|
|
125
225
|
}
|
|
126
226
|
|
|
@@ -136,6 +236,34 @@ export function createPermissionController({
|
|
|
136
236
|
stopCommandStreamIfIdle()
|
|
137
237
|
},
|
|
138
238
|
|
|
239
|
+
registerQuestion(identity, details) {
|
|
240
|
+
questionsByExternalID.set(identity.externalId, {
|
|
241
|
+
...identity,
|
|
242
|
+
...details,
|
|
243
|
+
directory,
|
|
244
|
+
})
|
|
245
|
+
questionExternalIDByRequestID.set(identity.requestID, identity.externalId)
|
|
246
|
+
ensureLeaseTimer()
|
|
247
|
+
ensureCommandStream()
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
resolveQuestionLocally(requestID, outcome) {
|
|
251
|
+
if (remotelyResolvingQuestions.delete(requestID)) {
|
|
252
|
+
removeQuestionByRequestID(requestID)
|
|
253
|
+
stopCommandStreamIfIdle()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const externalId = questionExternalIDByRequestID.get(requestID)
|
|
257
|
+
const pending = externalId ? questionsByExternalID.get(externalId) : undefined
|
|
258
|
+
if (!pending) return
|
|
259
|
+
removeQuestionByRequestID(requestID)
|
|
260
|
+
if (pending.eventID) {
|
|
261
|
+
void postQuestionState(config, pending.eventID, "source-resolution", { outcome })
|
|
262
|
+
.catch((error) => reporter.warning("Local question state could not be sent", error))
|
|
263
|
+
}
|
|
264
|
+
stopCommandStreamIfIdle()
|
|
265
|
+
},
|
|
266
|
+
|
|
139
267
|
refreshCredentials() {
|
|
140
268
|
commandStream?.controller.abort()
|
|
141
269
|
if (!commandStream) ensureCommandStream()
|
|
@@ -144,6 +272,7 @@ export function createPermissionController({
|
|
|
144
272
|
status() {
|
|
145
273
|
return {
|
|
146
274
|
pending: pendingByExternalID.size,
|
|
275
|
+
pendingQuestions: questionsByExternalID.size,
|
|
147
276
|
stream: commandStream ? "active" : "idle",
|
|
148
277
|
}
|
|
149
278
|
},
|
|
@@ -151,11 +280,15 @@ export function createPermissionController({
|
|
|
151
280
|
async dispose() {
|
|
152
281
|
disposed = true
|
|
153
282
|
commandStream?.controller.abort()
|
|
283
|
+
if (leaseTimer) clearInterval(leaseTimer)
|
|
154
284
|
await commandStream?.task.catch(() => {})
|
|
155
285
|
pendingByExternalID.clear()
|
|
156
286
|
externalIDByRequestID.clear()
|
|
157
287
|
processingCommands.clear()
|
|
158
288
|
completedCommands.clear()
|
|
289
|
+
questionsByExternalID.clear()
|
|
290
|
+
questionExternalIDByRequestID.clear()
|
|
291
|
+
remotelyResolvingQuestions.clear()
|
|
159
292
|
},
|
|
160
293
|
}
|
|
161
294
|
}
|
package/src/plugin.js
CHANGED
|
@@ -149,7 +149,17 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
149
149
|
client,
|
|
150
150
|
directory,
|
|
151
151
|
conversation,
|
|
152
|
-
|
|
152
|
+
event.type,
|
|
153
|
+
).then((eventID) => {
|
|
154
|
+
questionPublications.succeed(identity.externalId)
|
|
155
|
+
if (eventID) {
|
|
156
|
+
permissionController.registerQuestion(identity, {
|
|
157
|
+
eventID,
|
|
158
|
+
sourceEvent: event.type,
|
|
159
|
+
questions: event.properties.questions,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
})
|
|
153
163
|
.catch((error) => {
|
|
154
164
|
questionPublications.fail(identity.externalId)
|
|
155
165
|
reporter.error("Question could not be sent", error)
|
|
@@ -194,6 +204,21 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
194
204
|
(event.type === "question.asked" || event.type === "question.v2.asked")
|
|
195
205
|
) {
|
|
196
206
|
await handleQuestionAsked(event)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
config.questions &&
|
|
212
|
+
["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"]
|
|
213
|
+
.includes(event.type)
|
|
214
|
+
) {
|
|
215
|
+
const requestID = event.properties.requestID ?? event.properties.id
|
|
216
|
+
if (typeof requestID === "string") {
|
|
217
|
+
permissionController.resolveQuestionLocally(
|
|
218
|
+
requestID,
|
|
219
|
+
event.type.endsWith("rejected") ? "rejected" : "answered",
|
|
220
|
+
)
|
|
221
|
+
}
|
|
197
222
|
}
|
|
198
223
|
},
|
|
199
224
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HttpError } from "./http.js"
|
|
2
|
+
|
|
3
|
+
export const QUESTION_ACTIONS = new Set(["answer", "reject"])
|
|
4
|
+
|
|
5
|
+
export function validateQuestionCommand(command, pending, now = Date.now()) {
|
|
6
|
+
const response = command?.response
|
|
7
|
+
if (!response || !QUESTION_ACTIONS.has(response.action)) {
|
|
8
|
+
return { status: "rejected", action: response?.action, errorCode: "invalid_action" }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const expiresAt = Date.parse(command.expiresAt)
|
|
12
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
13
|
+
return { status: "expired", action: response.action }
|
|
14
|
+
}
|
|
15
|
+
if (response.action === "reject") return
|
|
16
|
+
|
|
17
|
+
if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
|
|
18
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
|
|
19
|
+
}
|
|
20
|
+
for (let index = 0; index < pending.questions.length; index++) {
|
|
21
|
+
const question = pending.questions[index]
|
|
22
|
+
const answers = response.answers[index]
|
|
23
|
+
if (!Array.isArray(answers) || (!question.multiple && answers.length > 1)) {
|
|
24
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
|
|
25
|
+
}
|
|
26
|
+
for (const answer of answers) {
|
|
27
|
+
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
28
|
+
return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
|
|
29
|
+
}
|
|
30
|
+
const known = question.options.some((option) => option.label === answer)
|
|
31
|
+
if (!known && !question.custom) {
|
|
32
|
+
return { status: "rejected", action: response.action, errorCode: "custom_not_allowed" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function questionFailureAcknowledgement(command, error) {
|
|
39
|
+
const stale = error instanceof HttpError && error.status === 404
|
|
40
|
+
return {
|
|
41
|
+
status: stale ? "stale" : "failed",
|
|
42
|
+
action: command?.response?.action,
|
|
43
|
+
errorCode: stale ? "question_not_found" : "opencode_question_reply_failed",
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation) {
|
|
48
|
+
const messages = {
|
|
49
|
+
applied: acknowledgement.action === "reject"
|
|
50
|
+
? "Question rejected from Backeer"
|
|
51
|
+
: "Question answered from Backeer",
|
|
52
|
+
expired: "Question response expired before it could be applied",
|
|
53
|
+
stale: "Question was already answered locally",
|
|
54
|
+
rejected: "Question response was rejected by validation",
|
|
55
|
+
}
|
|
56
|
+
const message = messages[acknowledgement.status]
|
|
57
|
+
if (!message) return
|
|
58
|
+
if (acknowledgement.status === "applied") {
|
|
59
|
+
reporter.applied(message)
|
|
60
|
+
conversation?.applied(pending.sessionID, message)
|
|
61
|
+
} else {
|
|
62
|
+
reporter.warning(message)
|
|
63
|
+
conversation?.warning(pending.sessionID, message)
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/session-messages.js
CHANGED
|
@@ -82,16 +82,30 @@ export async function sendSessionMessage(client, sessionID, text) {
|
|
|
82
82
|
|
|
83
83
|
export function formatBackeerFeedback(kind, message) {
|
|
84
84
|
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
|
|
85
|
-
return
|
|
85
|
+
return `▣ Backeer | ${symbol} ${limit(message, 300)}`
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export function
|
|
88
|
+
export function formatBackeerFeedbackBatch(items) {
|
|
89
|
+
if (items.length === 1) {
|
|
90
|
+
return formatBackeerFeedback(items[0].kind, items[0].message)
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
"▣ Backeer",
|
|
94
|
+
...items.map((item) => {
|
|
95
|
+
const symbol = FEEDBACK_SYMBOLS[item.kind] ?? FEEDBACK_SYMBOLS.info
|
|
96
|
+
return `${symbol} ${limit(item.message, 300)}`
|
|
97
|
+
}),
|
|
98
|
+
].join("\n")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createConversationFeedback({ client, reporter, flushDelayMs = 150 }) {
|
|
89
102
|
const sessions = new FeedbackSessionStore({
|
|
90
103
|
reporter,
|
|
91
|
-
|
|
104
|
+
flushDelayMs,
|
|
105
|
+
send: (sessionID, items) => sendSessionMessage(
|
|
92
106
|
client,
|
|
93
107
|
sessionID,
|
|
94
|
-
|
|
108
|
+
formatBackeerFeedbackBatch(items),
|
|
95
109
|
),
|
|
96
110
|
})
|
|
97
111
|
|
package/src/updates.js
CHANGED