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 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 command stream
7
- so an authorized user can answer **Allow once**, **Always allow**, or **Reject**
8
- from the Backeer iOS application.
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` | Notification-only question card |
236
- | `question.v2.asked` | Notification-only question card, including plan-mode questions |
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) commandStream?.controller.abort();
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 !== "opencode_permission_reply") return;
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 pending = pendingByExternalID.get(command.externalId);
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
- await replyToOpenCode(client, pending, command.decision);
1767
- acknowledgement = { status: "applied", decision: command.decision };
1768
- removePendingByRequestID(pending.requestID);
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
- acknowledgement = failureAcknowledgement(command, error);
1962
+ if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
1963
+ acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
1772
1964
  if (acknowledgement.status === "stale") {
1773
- removePendingByRequestID(pending.requestID);
1965
+ if (isQuestion) removeQuestionByRequestID(pending.requestID);
1966
+ else removePendingByRequestID(pending.requestID);
1774
1967
  }
1775
- reporter.error("Decision could not be applied", error);
1776
- conversation?.error(pending.sessionID, "Decision could not be applied");
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
- reportAcknowledgement(acknowledgement, pending, reporter, conversation);
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
- if (disposed || commandStream || pendingByExternalID.size === 0) return;
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: () => pendingByExternalID.size > 0,
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 && pendingByExternalID.size > 0) ensureCommandStream();
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
- ).then(() => questionPublications.succeed(identity.externalId)).catch((error) => {
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.2.5",
4
- "description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
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": {
@@ -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) commandStream?.controller.abort()
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 !== "opencode_permission_reply") return
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 pending = pendingByExternalID.get(command.externalId)
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 = validatePermissionCommand(command)
194
+ let acknowledgement = isQuestion
195
+ ? validateQuestionCommand(command, pending)
196
+ : validatePermissionCommand(command)
75
197
  try {
76
198
  if (!acknowledgement) {
77
- await replyToOpenCode(client, pending, command.decision)
78
- acknowledgement = { status: "applied", decision: command.decision }
79
- removePendingByRequestID(pending.requestID)
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
- acknowledgement = failureAcknowledgement(command, error)
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
- removePendingByRequestID(pending.requestID)
221
+ if (isQuestion) removeQuestionByRequestID(pending.requestID)
222
+ else removePendingByRequestID(pending.requestID)
85
223
  }
86
- reporter.error("Decision could not be applied", error)
87
- conversation?.error(pending.sessionID, "Decision could not be applied")
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: "opencode_permission_reply_ack",
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
- reportAcknowledgement(acknowledgement, pending, reporter, conversation)
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
- if (disposed || commandStream || pendingByExternalID.size === 0) return
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: () => pendingByExternalID.size > 0,
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 && pendingByExternalID.size > 0) ensureCommandStream()
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
- ).then(() => questionPublications.succeed(identity.externalId))
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
+ }