backeer-opencode 0.2.5 → 0.3.1
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 +19 -0
- package/README.md +7 -6
- package/dist/backeer-permission.js +269 -20
- package/package.json +2 -2
- package/src/event-publishers.js +28 -0
- package/src/permission-controller.js +197 -16
- package/src/plugin.js +26 -1
- package/src/question-command.js +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.3.1 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Apply remote question replies on OpenCode 1.18.x even when the plugin-provided
|
|
8
|
+
client does not expose the legacy or v2 question helpers, by falling back to
|
|
9
|
+
OpenCode's underlying authenticated HTTP transport.
|
|
10
|
+
- Normalize native API failures so Backeer receives an accurate terminal ACK
|
|
11
|
+
instead of reporting success for an error response.
|
|
12
|
+
- Add regression coverage for legacy, v2, and error question reply paths.
|
|
13
|
+
|
|
14
|
+
## 0.3.0 - 2026-09-01
|
|
15
|
+
|
|
16
|
+
- Publish structured `question.asked` and `question.v2.asked` action metadata.
|
|
17
|
+
- Apply single-choice, multi-choice, custom, skipped, and rejected question
|
|
18
|
+
responses from the Backeer iOS application through OpenCode's native APIs.
|
|
19
|
+
- Share the source-authenticated SSE connection with permission commands and
|
|
20
|
+
use stable, idempotent question acknowledgements.
|
|
21
|
+
- Renew five-minute question leases while OpenCode is waiting and resolve the
|
|
22
|
+
Backeer event when a question is answered locally.
|
|
23
|
+
|
|
5
24
|
## 0.2.5 - 2026-09-01
|
|
6
25
|
|
|
7
26
|
- Group consecutive OpenCode conversation feedback into one ignored Backeer
|
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;
|
|
@@ -1538,6 +1563,64 @@ function reportAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
|
1538
1563
|
conversation?.warning(pending.sessionID, message);
|
|
1539
1564
|
}
|
|
1540
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
|
+
|
|
1541
1624
|
// src/sse.js
|
|
1542
1625
|
function parseSseFrame(frame) {
|
|
1543
1626
|
const data = [];
|
|
@@ -1724,6 +1807,72 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
|
1724
1807
|
);
|
|
1725
1808
|
await postJsonWithRetry(config, url, acknowledgement);
|
|
1726
1809
|
}
|
|
1810
|
+
function questionResponseError(result) {
|
|
1811
|
+
if (!result?.error) return;
|
|
1812
|
+
const status = Number(result?.response?.status) || 502;
|
|
1813
|
+
const detail = result.error?.data?.message ?? result.error?.message ?? "OpenCode rejected the question response";
|
|
1814
|
+
return new HttpError(String(detail), status);
|
|
1815
|
+
}
|
|
1816
|
+
function normalizeQuestionError(error) {
|
|
1817
|
+
if (error instanceof HttpError) return error;
|
|
1818
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.cause?.status);
|
|
1819
|
+
if (!Number.isFinite(status)) return error;
|
|
1820
|
+
return new HttpError(error?.message || "OpenCode rejected the question response", status);
|
|
1821
|
+
}
|
|
1822
|
+
function rawQuestionRequest(client, pending, response, isV2) {
|
|
1823
|
+
const transport = client?._client;
|
|
1824
|
+
if (typeof transport?.post !== "function") {
|
|
1825
|
+
throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
|
|
1826
|
+
}
|
|
1827
|
+
const rejecting = response.action === "reject";
|
|
1828
|
+
if (isV2) {
|
|
1829
|
+
return transport.post({
|
|
1830
|
+
url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
1831
|
+
path: { sessionID: pending.sessionID, requestID: pending.requestID },
|
|
1832
|
+
...rejecting ? {} : { body: { answers: response.answers } },
|
|
1833
|
+
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
1834
|
+
throwOnError: true
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
return transport.post({
|
|
1838
|
+
url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
1839
|
+
path: { requestID: pending.requestID },
|
|
1840
|
+
query: { directory: pending.directory },
|
|
1841
|
+
...rejecting ? {} : { body: { answers: response.answers } },
|
|
1842
|
+
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
1843
|
+
throwOnError: true
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
async function replyToQuestion(client, pending, response) {
|
|
1847
|
+
const isV2 = pending.sourceEvent === "question.v2.asked";
|
|
1848
|
+
const api = isV2 ? client?.v2?.session?.question : client?.question;
|
|
1849
|
+
const method = response.action === "reject" ? api?.reject : api?.reply;
|
|
1850
|
+
const input = isV2 ? {
|
|
1851
|
+
sessionID: pending.sessionID,
|
|
1852
|
+
requestID: pending.requestID,
|
|
1853
|
+
directory: pending.directory,
|
|
1854
|
+
...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
|
|
1855
|
+
} : {
|
|
1856
|
+
requestID: pending.requestID,
|
|
1857
|
+
directory: pending.directory,
|
|
1858
|
+
...response.action === "answer" ? { answers: response.answers } : {}
|
|
1859
|
+
};
|
|
1860
|
+
let result;
|
|
1861
|
+
try {
|
|
1862
|
+
result = typeof method === "function" ? await method.call(api, input, { throwOnError: true }) : await rawQuestionRequest(client, pending, response, isV2);
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
throw normalizeQuestionError(error);
|
|
1865
|
+
}
|
|
1866
|
+
const responseError = questionResponseError(result);
|
|
1867
|
+
if (responseError) throw responseError;
|
|
1868
|
+
if (result?.data === false || result === false) {
|
|
1869
|
+
throw new HttpError("OpenCode did not apply the question response", 409);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
async function postQuestionState(config, eventID, path, body = {}) {
|
|
1873
|
+
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
|
|
1874
|
+
await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
|
|
1875
|
+
}
|
|
1727
1876
|
function createPermissionController({
|
|
1728
1877
|
config,
|
|
1729
1878
|
reporter,
|
|
@@ -1733,9 +1882,13 @@ function createPermissionController({
|
|
|
1733
1882
|
}) {
|
|
1734
1883
|
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
1735
1884
|
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
1885
|
+
const questionsByExternalID = /* @__PURE__ */ new Map();
|
|
1886
|
+
const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
|
|
1887
|
+
const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
|
|
1736
1888
|
const processingCommands = /* @__PURE__ */ new Set();
|
|
1737
1889
|
const completedCommands = /* @__PURE__ */ new Map();
|
|
1738
1890
|
let commandStream;
|
|
1891
|
+
let leaseTimer;
|
|
1739
1892
|
let disposed = false;
|
|
1740
1893
|
function removePendingByRequestID(requestID) {
|
|
1741
1894
|
const externalId = externalIDByRequestID.get(requestID);
|
|
@@ -1744,11 +1897,36 @@ function createPermissionController({
|
|
|
1744
1897
|
pendingByExternalID.delete(externalId);
|
|
1745
1898
|
}
|
|
1746
1899
|
function stopCommandStreamIfIdle() {
|
|
1747
|
-
if (pendingByExternalID.size === 0
|
|
1900
|
+
if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
|
|
1901
|
+
commandStream?.controller.abort();
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
function removeQuestionByRequestID(requestID) {
|
|
1905
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
1906
|
+
if (!externalId) return;
|
|
1907
|
+
questionExternalIDByRequestID.delete(requestID);
|
|
1908
|
+
questionsByExternalID.delete(externalId);
|
|
1909
|
+
if (questionsByExternalID.size === 0 && leaseTimer) {
|
|
1910
|
+
clearInterval(leaseTimer);
|
|
1911
|
+
leaseTimer = void 0;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
async function renewQuestionLeases() {
|
|
1915
|
+
for (const pending of questionsByExternalID.values()) {
|
|
1916
|
+
if (!pending.eventID) continue;
|
|
1917
|
+
await postQuestionState(config, pending.eventID, "lease").catch((error) => {
|
|
1918
|
+
reporter.warning("Question lease could not be renewed", error);
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
function ensureLeaseTimer() {
|
|
1923
|
+
if (leaseTimer || questionsByExternalID.size === 0) return;
|
|
1924
|
+
leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
|
|
1925
|
+
leaseTimer.unref?.();
|
|
1748
1926
|
}
|
|
1749
1927
|
async function handleCommand(command) {
|
|
1750
1928
|
if (!command || typeof command !== "object") return;
|
|
1751
|
-
if (command.type
|
|
1929
|
+
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
|
|
1752
1930
|
if (typeof command.id !== "string" || command.id.length > 200) return;
|
|
1753
1931
|
const previousAcknowledgement = completedCommands.get(command.id);
|
|
1754
1932
|
if (previousAcknowledgement) {
|
|
@@ -1757,54 +1935,75 @@ function createPermissionController({
|
|
|
1757
1935
|
}
|
|
1758
1936
|
if (processingCommands.has(command.id)) return;
|
|
1759
1937
|
if (typeof command.externalId !== "string") return;
|
|
1760
|
-
const
|
|
1938
|
+
const isQuestion = command.type === "opencode_question_reply";
|
|
1939
|
+
const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
|
|
1761
1940
|
if (!pending) return;
|
|
1762
1941
|
processingCommands.add(command.id);
|
|
1763
|
-
let acknowledgement = validatePermissionCommand(command);
|
|
1942
|
+
let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
|
|
1764
1943
|
try {
|
|
1765
1944
|
if (!acknowledgement) {
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1945
|
+
if (isQuestion) {
|
|
1946
|
+
remotelyResolvingQuestions.add(pending.requestID);
|
|
1947
|
+
await replyToQuestion(client, pending, command.response);
|
|
1948
|
+
acknowledgement = { status: "applied", action: command.response.action };
|
|
1949
|
+
removeQuestionByRequestID(pending.requestID);
|
|
1950
|
+
const cleanup = setTimeout(
|
|
1951
|
+
() => remotelyResolvingQuestions.delete(pending.requestID),
|
|
1952
|
+
3e4
|
|
1953
|
+
);
|
|
1954
|
+
cleanup.unref?.();
|
|
1955
|
+
} else {
|
|
1956
|
+
await replyToOpenCode(client, pending, command.decision);
|
|
1957
|
+
acknowledgement = { status: "applied", decision: command.decision };
|
|
1958
|
+
removePendingByRequestID(pending.requestID);
|
|
1959
|
+
}
|
|
1769
1960
|
}
|
|
1770
1961
|
} catch (error) {
|
|
1771
|
-
|
|
1962
|
+
if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
|
|
1963
|
+
acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
|
|
1772
1964
|
if (acknowledgement.status === "stale") {
|
|
1773
|
-
|
|
1965
|
+
if (isQuestion) removeQuestionByRequestID(pending.requestID);
|
|
1966
|
+
else removePendingByRequestID(pending.requestID);
|
|
1774
1967
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1968
|
+
const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
|
|
1969
|
+
reporter.error(message, error);
|
|
1970
|
+
conversation?.error(pending.sessionID, message);
|
|
1777
1971
|
} finally {
|
|
1778
1972
|
processingCommands.delete(command.id);
|
|
1779
1973
|
}
|
|
1780
1974
|
acknowledgement = {
|
|
1781
1975
|
...acknowledgement,
|
|
1782
|
-
type: "opencode_permission_reply_ack",
|
|
1976
|
+
type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
|
|
1783
1977
|
externalId: command.externalId,
|
|
1784
1978
|
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1785
1979
|
};
|
|
1786
1980
|
rememberBounded(completedCommands, command.id, acknowledgement);
|
|
1787
|
-
|
|
1981
|
+
if (isQuestion) {
|
|
1982
|
+
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
1983
|
+
} else {
|
|
1984
|
+
reportAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
1985
|
+
}
|
|
1788
1986
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
1789
1987
|
reporter.warning("Decision receipt could not be sent", error);
|
|
1790
1988
|
});
|
|
1791
1989
|
stopCommandStreamIfIdle();
|
|
1792
1990
|
}
|
|
1793
1991
|
function ensureCommandStream() {
|
|
1794
|
-
|
|
1992
|
+
const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
|
|
1993
|
+
if (disposed || commandStream || !hasPending()) return;
|
|
1795
1994
|
const controller = new AbortController();
|
|
1796
1995
|
const current = {
|
|
1797
1996
|
controller,
|
|
1798
1997
|
task: runCommandStream(config, reporter, {
|
|
1799
1998
|
signal: controller.signal,
|
|
1800
|
-
hasPending
|
|
1999
|
+
hasPending,
|
|
1801
2000
|
onCommand: handleCommand
|
|
1802
2001
|
})
|
|
1803
2002
|
};
|
|
1804
2003
|
commandStream = current;
|
|
1805
2004
|
void current.task.finally(() => {
|
|
1806
2005
|
if (commandStream === current) commandStream = void 0;
|
|
1807
|
-
if (!disposed &&
|
|
2006
|
+
if (!disposed && hasPending()) ensureCommandStream();
|
|
1808
2007
|
});
|
|
1809
2008
|
}
|
|
1810
2009
|
return {
|
|
@@ -1817,6 +2016,31 @@ function createPermissionController({
|
|
|
1817
2016
|
removePendingByRequestID(requestID);
|
|
1818
2017
|
stopCommandStreamIfIdle();
|
|
1819
2018
|
},
|
|
2019
|
+
registerQuestion(identity, details) {
|
|
2020
|
+
questionsByExternalID.set(identity.externalId, {
|
|
2021
|
+
...identity,
|
|
2022
|
+
...details,
|
|
2023
|
+
directory
|
|
2024
|
+
});
|
|
2025
|
+
questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
|
|
2026
|
+
ensureLeaseTimer();
|
|
2027
|
+
ensureCommandStream();
|
|
2028
|
+
},
|
|
2029
|
+
resolveQuestionLocally(requestID, outcome) {
|
|
2030
|
+
if (remotelyResolvingQuestions.delete(requestID)) {
|
|
2031
|
+
removeQuestionByRequestID(requestID);
|
|
2032
|
+
stopCommandStreamIfIdle();
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
2036
|
+
const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
|
|
2037
|
+
if (!pending) return;
|
|
2038
|
+
removeQuestionByRequestID(requestID);
|
|
2039
|
+
if (pending.eventID) {
|
|
2040
|
+
void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
|
|
2041
|
+
}
|
|
2042
|
+
stopCommandStreamIfIdle();
|
|
2043
|
+
},
|
|
1820
2044
|
refreshCredentials() {
|
|
1821
2045
|
commandStream?.controller.abort();
|
|
1822
2046
|
if (!commandStream) ensureCommandStream();
|
|
@@ -1824,18 +2048,23 @@ function createPermissionController({
|
|
|
1824
2048
|
status() {
|
|
1825
2049
|
return {
|
|
1826
2050
|
pending: pendingByExternalID.size,
|
|
2051
|
+
pendingQuestions: questionsByExternalID.size,
|
|
1827
2052
|
stream: commandStream ? "active" : "idle"
|
|
1828
2053
|
};
|
|
1829
2054
|
},
|
|
1830
2055
|
async dispose() {
|
|
1831
2056
|
disposed = true;
|
|
1832
2057
|
commandStream?.controller.abort();
|
|
2058
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
1833
2059
|
await commandStream?.task.catch(() => {
|
|
1834
2060
|
});
|
|
1835
2061
|
pendingByExternalID.clear();
|
|
1836
2062
|
externalIDByRequestID.clear();
|
|
1837
2063
|
processingCommands.clear();
|
|
1838
2064
|
completedCommands.clear();
|
|
2065
|
+
questionsByExternalID.clear();
|
|
2066
|
+
questionExternalIDByRequestID.clear();
|
|
2067
|
+
remotelyResolvingQuestions.clear();
|
|
1839
2068
|
}
|
|
1840
2069
|
};
|
|
1841
2070
|
}
|
|
@@ -1945,8 +2174,18 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1945
2174
|
identity,
|
|
1946
2175
|
client,
|
|
1947
2176
|
directory,
|
|
1948
|
-
conversation
|
|
1949
|
-
|
|
2177
|
+
conversation,
|
|
2178
|
+
event.type
|
|
2179
|
+
).then((eventID) => {
|
|
2180
|
+
questionPublications.succeed(identity.externalId);
|
|
2181
|
+
if (eventID) {
|
|
2182
|
+
permissionController.registerQuestion(identity, {
|
|
2183
|
+
eventID,
|
|
2184
|
+
sourceEvent: event.type,
|
|
2185
|
+
questions: event.properties.questions
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
}).catch((error) => {
|
|
1950
2189
|
questionPublications.fail(identity.externalId);
|
|
1951
2190
|
reporter.error("Question could not be sent", error);
|
|
1952
2191
|
conversation.error(identity.sessionID, "Question could not be sent");
|
|
@@ -1978,6 +2217,16 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1978
2217
|
}
|
|
1979
2218
|
if (config.questions && (event.type === "question.asked" || event.type === "question.v2.asked")) {
|
|
1980
2219
|
await handleQuestionAsked(event);
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
if (config.questions && ["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
|
|
2223
|
+
const requestID = event.properties.requestID ?? event.properties.id;
|
|
2224
|
+
if (typeof requestID === "string") {
|
|
2225
|
+
permissionController.resolveQuestionLocally(
|
|
2226
|
+
requestID,
|
|
2227
|
+
event.type.endsWith("rejected") ? "rejected" : "answered"
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
1981
2230
|
}
|
|
1982
2231
|
},
|
|
1983
2232
|
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.1",
|
|
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(
|
|
@@ -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,86 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
|
28
33
|
await postJsonWithRetry(config, url, acknowledgement)
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
function questionResponseError(result) {
|
|
37
|
+
if (!result?.error) return
|
|
38
|
+
const status = Number(result?.response?.status) || 502
|
|
39
|
+
const detail = result.error?.data?.message
|
|
40
|
+
?? result.error?.message
|
|
41
|
+
?? "OpenCode rejected the question response"
|
|
42
|
+
return new HttpError(String(detail), status)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeQuestionError(error) {
|
|
46
|
+
if (error instanceof HttpError) return error
|
|
47
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.cause?.status)
|
|
48
|
+
if (!Number.isFinite(status)) return error
|
|
49
|
+
return new HttpError(error?.message || "OpenCode rejected the question response", status)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rawQuestionRequest(client, pending, response, isV2) {
|
|
53
|
+
const transport = client?._client
|
|
54
|
+
if (typeof transport?.post !== "function") {
|
|
55
|
+
throw new HttpError("This OpenCode version cannot apply remote question replies", 501)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rejecting = response.action === "reject"
|
|
59
|
+
if (isV2) {
|
|
60
|
+
return transport.post({
|
|
61
|
+
url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
62
|
+
path: { sessionID: pending.sessionID, requestID: pending.requestID },
|
|
63
|
+
...(rejecting ? {} : { body: { answers: response.answers } }),
|
|
64
|
+
...(rejecting ? {} : { headers: { "Content-Type": "application/json" } }),
|
|
65
|
+
throwOnError: true,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return transport.post({
|
|
69
|
+
url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
70
|
+
path: { requestID: pending.requestID },
|
|
71
|
+
query: { directory: pending.directory },
|
|
72
|
+
...(rejecting ? {} : { body: { answers: response.answers } }),
|
|
73
|
+
...(rejecting ? {} : { headers: { "Content-Type": "application/json" } }),
|
|
74
|
+
throwOnError: true,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function replyToQuestion(client, pending, response) {
|
|
79
|
+
const isV2 = pending.sourceEvent === "question.v2.asked"
|
|
80
|
+
const api = isV2 ? client?.v2?.session?.question : client?.question
|
|
81
|
+
const method = response.action === "reject" ? api?.reject : api?.reply
|
|
82
|
+
const input = isV2
|
|
83
|
+
? {
|
|
84
|
+
sessionID: pending.sessionID,
|
|
85
|
+
requestID: pending.requestID,
|
|
86
|
+
directory: pending.directory,
|
|
87
|
+
...(response.action === "answer"
|
|
88
|
+
? { questionV2Reply: { answers: response.answers } }
|
|
89
|
+
: {}),
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
requestID: pending.requestID,
|
|
93
|
+
directory: pending.directory,
|
|
94
|
+
...(response.action === "answer" ? { answers: response.answers } : {}),
|
|
95
|
+
}
|
|
96
|
+
let result
|
|
97
|
+
try {
|
|
98
|
+
result = typeof method === "function"
|
|
99
|
+
? await method.call(api, input, { throwOnError: true })
|
|
100
|
+
: await rawQuestionRequest(client, pending, response, isV2)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw normalizeQuestionError(error)
|
|
103
|
+
}
|
|
104
|
+
const responseError = questionResponseError(result)
|
|
105
|
+
if (responseError) throw responseError
|
|
106
|
+
if (result?.data === false || result === false) {
|
|
107
|
+
throw new HttpError("OpenCode did not apply the question response", 409)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function postQuestionState(config, eventID, path, body = {}) {
|
|
112
|
+
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin)
|
|
113
|
+
await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] })
|
|
114
|
+
}
|
|
115
|
+
|
|
31
116
|
export function createPermissionController({
|
|
32
117
|
config,
|
|
33
118
|
reporter,
|
|
@@ -37,9 +122,13 @@ export function createPermissionController({
|
|
|
37
122
|
}) {
|
|
38
123
|
const pendingByExternalID = new Map()
|
|
39
124
|
const externalIDByRequestID = new Map()
|
|
125
|
+
const questionsByExternalID = new Map()
|
|
126
|
+
const questionExternalIDByRequestID = new Map()
|
|
127
|
+
const remotelyResolvingQuestions = new Set()
|
|
40
128
|
const processingCommands = new Set()
|
|
41
129
|
const completedCommands = new Map()
|
|
42
130
|
let commandStream
|
|
131
|
+
let leaseTimer
|
|
43
132
|
let disposed = false
|
|
44
133
|
|
|
45
134
|
function removePendingByRequestID(requestID) {
|
|
@@ -50,12 +139,40 @@ export function createPermissionController({
|
|
|
50
139
|
}
|
|
51
140
|
|
|
52
141
|
function stopCommandStreamIfIdle() {
|
|
53
|
-
if (pendingByExternalID.size === 0
|
|
142
|
+
if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
|
|
143
|
+
commandStream?.controller.abort()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function removeQuestionByRequestID(requestID) {
|
|
148
|
+
const externalId = questionExternalIDByRequestID.get(requestID)
|
|
149
|
+
if (!externalId) return
|
|
150
|
+
questionExternalIDByRequestID.delete(requestID)
|
|
151
|
+
questionsByExternalID.delete(externalId)
|
|
152
|
+
if (questionsByExternalID.size === 0 && leaseTimer) {
|
|
153
|
+
clearInterval(leaseTimer)
|
|
154
|
+
leaseTimer = undefined
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function renewQuestionLeases() {
|
|
159
|
+
for (const pending of questionsByExternalID.values()) {
|
|
160
|
+
if (!pending.eventID) continue
|
|
161
|
+
await postQuestionState(config, pending.eventID, "lease").catch((error) => {
|
|
162
|
+
reporter.warning("Question lease could not be renewed", error)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ensureLeaseTimer() {
|
|
168
|
+
if (leaseTimer || questionsByExternalID.size === 0) return
|
|
169
|
+
leaseTimer = setInterval(() => void renewQuestionLeases(), 60_000)
|
|
170
|
+
leaseTimer.unref?.()
|
|
54
171
|
}
|
|
55
172
|
|
|
56
173
|
async function handleCommand(command) {
|
|
57
174
|
if (!command || typeof command !== "object") return
|
|
58
|
-
if (command.type
|
|
175
|
+
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return
|
|
59
176
|
if (typeof command.id !== "string" || command.id.length > 200) return
|
|
60
177
|
|
|
61
178
|
const previousAcknowledgement = completedCommands.get(command.id)
|
|
@@ -67,37 +184,67 @@ export function createPermissionController({
|
|
|
67
184
|
if (processingCommands.has(command.id)) return
|
|
68
185
|
if (typeof command.externalId !== "string") return
|
|
69
186
|
|
|
70
|
-
const
|
|
187
|
+
const isQuestion = command.type === "opencode_question_reply"
|
|
188
|
+
const pending = isQuestion
|
|
189
|
+
? questionsByExternalID.get(command.externalId)
|
|
190
|
+
: pendingByExternalID.get(command.externalId)
|
|
71
191
|
if (!pending) return
|
|
72
192
|
|
|
73
193
|
processingCommands.add(command.id)
|
|
74
|
-
let acknowledgement =
|
|
194
|
+
let acknowledgement = isQuestion
|
|
195
|
+
? validateQuestionCommand(command, pending)
|
|
196
|
+
: validatePermissionCommand(command)
|
|
75
197
|
try {
|
|
76
198
|
if (!acknowledgement) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
199
|
+
if (isQuestion) {
|
|
200
|
+
remotelyResolvingQuestions.add(pending.requestID)
|
|
201
|
+
await replyToQuestion(client, pending, command.response)
|
|
202
|
+
acknowledgement = { status: "applied", action: command.response.action }
|
|
203
|
+
removeQuestionByRequestID(pending.requestID)
|
|
204
|
+
const cleanup = setTimeout(
|
|
205
|
+
() => remotelyResolvingQuestions.delete(pending.requestID),
|
|
206
|
+
30_000,
|
|
207
|
+
)
|
|
208
|
+
cleanup.unref?.()
|
|
209
|
+
} else {
|
|
210
|
+
await replyToOpenCode(client, pending, command.decision)
|
|
211
|
+
acknowledgement = { status: "applied", decision: command.decision }
|
|
212
|
+
removePendingByRequestID(pending.requestID)
|
|
213
|
+
}
|
|
80
214
|
}
|
|
81
215
|
} catch (error) {
|
|
82
|
-
|
|
216
|
+
if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID)
|
|
217
|
+
acknowledgement = isQuestion
|
|
218
|
+
? questionFailureAcknowledgement(command, error)
|
|
219
|
+
: failureAcknowledgement(command, error)
|
|
83
220
|
if (acknowledgement.status === "stale") {
|
|
84
|
-
|
|
221
|
+
if (isQuestion) removeQuestionByRequestID(pending.requestID)
|
|
222
|
+
else removePendingByRequestID(pending.requestID)
|
|
85
223
|
}
|
|
86
|
-
|
|
87
|
-
|
|
224
|
+
const message = isQuestion
|
|
225
|
+
? "Question response could not be applied"
|
|
226
|
+
: "Decision could not be applied"
|
|
227
|
+
reporter.error(message, error)
|
|
228
|
+
conversation?.error(pending.sessionID, message)
|
|
88
229
|
} finally {
|
|
89
230
|
processingCommands.delete(command.id)
|
|
90
231
|
}
|
|
91
232
|
|
|
92
233
|
acknowledgement = {
|
|
93
234
|
...acknowledgement,
|
|
94
|
-
type:
|
|
235
|
+
type: isQuestion
|
|
236
|
+
? "opencode_question_reply_ack"
|
|
237
|
+
: "opencode_permission_reply_ack",
|
|
95
238
|
externalId: command.externalId,
|
|
96
239
|
processedAt: new Date().toISOString(),
|
|
97
240
|
}
|
|
98
241
|
rememberBounded(completedCommands, command.id, acknowledgement)
|
|
99
242
|
|
|
100
|
-
|
|
243
|
+
if (isQuestion) {
|
|
244
|
+
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
245
|
+
} else {
|
|
246
|
+
reportAcknowledgement(acknowledgement, pending, reporter, conversation)
|
|
247
|
+
}
|
|
101
248
|
|
|
102
249
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
103
250
|
reporter.warning("Decision receipt could not be sent", error)
|
|
@@ -106,21 +253,22 @@ export function createPermissionController({
|
|
|
106
253
|
}
|
|
107
254
|
|
|
108
255
|
function ensureCommandStream() {
|
|
109
|
-
|
|
256
|
+
const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0
|
|
257
|
+
if (disposed || commandStream || !hasPending()) return
|
|
110
258
|
|
|
111
259
|
const controller = new AbortController()
|
|
112
260
|
const current = {
|
|
113
261
|
controller,
|
|
114
262
|
task: runCommandStream(config, reporter, {
|
|
115
263
|
signal: controller.signal,
|
|
116
|
-
hasPending
|
|
264
|
+
hasPending,
|
|
117
265
|
onCommand: handleCommand,
|
|
118
266
|
}),
|
|
119
267
|
}
|
|
120
268
|
commandStream = current
|
|
121
269
|
void current.task.finally(() => {
|
|
122
270
|
if (commandStream === current) commandStream = undefined
|
|
123
|
-
if (!disposed &&
|
|
271
|
+
if (!disposed && hasPending()) ensureCommandStream()
|
|
124
272
|
})
|
|
125
273
|
}
|
|
126
274
|
|
|
@@ -136,6 +284,34 @@ export function createPermissionController({
|
|
|
136
284
|
stopCommandStreamIfIdle()
|
|
137
285
|
},
|
|
138
286
|
|
|
287
|
+
registerQuestion(identity, details) {
|
|
288
|
+
questionsByExternalID.set(identity.externalId, {
|
|
289
|
+
...identity,
|
|
290
|
+
...details,
|
|
291
|
+
directory,
|
|
292
|
+
})
|
|
293
|
+
questionExternalIDByRequestID.set(identity.requestID, identity.externalId)
|
|
294
|
+
ensureLeaseTimer()
|
|
295
|
+
ensureCommandStream()
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
resolveQuestionLocally(requestID, outcome) {
|
|
299
|
+
if (remotelyResolvingQuestions.delete(requestID)) {
|
|
300
|
+
removeQuestionByRequestID(requestID)
|
|
301
|
+
stopCommandStreamIfIdle()
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const externalId = questionExternalIDByRequestID.get(requestID)
|
|
305
|
+
const pending = externalId ? questionsByExternalID.get(externalId) : undefined
|
|
306
|
+
if (!pending) return
|
|
307
|
+
removeQuestionByRequestID(requestID)
|
|
308
|
+
if (pending.eventID) {
|
|
309
|
+
void postQuestionState(config, pending.eventID, "source-resolution", { outcome })
|
|
310
|
+
.catch((error) => reporter.warning("Local question state could not be sent", error))
|
|
311
|
+
}
|
|
312
|
+
stopCommandStreamIfIdle()
|
|
313
|
+
},
|
|
314
|
+
|
|
139
315
|
refreshCredentials() {
|
|
140
316
|
commandStream?.controller.abort()
|
|
141
317
|
if (!commandStream) ensureCommandStream()
|
|
@@ -144,6 +320,7 @@ export function createPermissionController({
|
|
|
144
320
|
status() {
|
|
145
321
|
return {
|
|
146
322
|
pending: pendingByExternalID.size,
|
|
323
|
+
pendingQuestions: questionsByExternalID.size,
|
|
147
324
|
stream: commandStream ? "active" : "idle",
|
|
148
325
|
}
|
|
149
326
|
},
|
|
@@ -151,11 +328,15 @@ export function createPermissionController({
|
|
|
151
328
|
async dispose() {
|
|
152
329
|
disposed = true
|
|
153
330
|
commandStream?.controller.abort()
|
|
331
|
+
if (leaseTimer) clearInterval(leaseTimer)
|
|
154
332
|
await commandStream?.task.catch(() => {})
|
|
155
333
|
pendingByExternalID.clear()
|
|
156
334
|
externalIDByRequestID.clear()
|
|
157
335
|
processingCommands.clear()
|
|
158
336
|
completedCommands.clear()
|
|
337
|
+
questionsByExternalID.clear()
|
|
338
|
+
questionExternalIDByRequestID.clear()
|
|
339
|
+
remotelyResolvingQuestions.clear()
|
|
159
340
|
},
|
|
160
341
|
}
|
|
161
342
|
}
|
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
|
+
}
|