backeer-opencode 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.3.0 - 2026-09-01
6
+
7
+ - Publish structured `question.asked` and `question.v2.asked` action metadata.
8
+ - Apply single-choice, multi-choice, custom, skipped, and rejected question
9
+ responses from the Backeer iOS application through OpenCode's native APIs.
10
+ - Share the source-authenticated SSE connection with permission commands and
11
+ use stable, idempotent question acknowledgements.
12
+ - Renew five-minute question leases while OpenCode is waiting and resolve the
13
+ Backeer event when a question is answered locally.
14
+
5
15
  ## 0.2.5 - 2026-09-01
6
16
 
7
17
  - 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,32 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
1724
1807
  );
1725
1808
  await postJsonWithRetry(config, url, acknowledgement);
1726
1809
  }
1810
+ async function replyToQuestion(client, pending, response) {
1811
+ const isV2 = pending.sourceEvent === "question.v2.asked";
1812
+ const api = isV2 ? client?.v2?.session?.question : client?.question;
1813
+ const method = response.action === "reject" ? api?.reject : api?.reply;
1814
+ if (typeof method !== "function") {
1815
+ throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
1816
+ }
1817
+ const input = isV2 ? {
1818
+ sessionID: pending.sessionID,
1819
+ requestID: pending.requestID,
1820
+ directory: pending.directory,
1821
+ ...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
1822
+ } : {
1823
+ requestID: pending.requestID,
1824
+ directory: pending.directory,
1825
+ ...response.action === "answer" ? { answers: response.answers } : {}
1826
+ };
1827
+ const result = await method.call(api, input);
1828
+ if (result?.data === false || result === false) {
1829
+ throw new HttpError("OpenCode did not apply the question response", 409);
1830
+ }
1831
+ }
1832
+ async function postQuestionState(config, eventID, path, body = {}) {
1833
+ const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
1834
+ await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
1835
+ }
1727
1836
  function createPermissionController({
1728
1837
  config,
1729
1838
  reporter,
@@ -1733,9 +1842,13 @@ function createPermissionController({
1733
1842
  }) {
1734
1843
  const pendingByExternalID = /* @__PURE__ */ new Map();
1735
1844
  const externalIDByRequestID = /* @__PURE__ */ new Map();
1845
+ const questionsByExternalID = /* @__PURE__ */ new Map();
1846
+ const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
1847
+ const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
1736
1848
  const processingCommands = /* @__PURE__ */ new Set();
1737
1849
  const completedCommands = /* @__PURE__ */ new Map();
1738
1850
  let commandStream;
1851
+ let leaseTimer;
1739
1852
  let disposed = false;
1740
1853
  function removePendingByRequestID(requestID) {
1741
1854
  const externalId = externalIDByRequestID.get(requestID);
@@ -1744,11 +1857,36 @@ function createPermissionController({
1744
1857
  pendingByExternalID.delete(externalId);
1745
1858
  }
1746
1859
  function stopCommandStreamIfIdle() {
1747
- if (pendingByExternalID.size === 0) commandStream?.controller.abort();
1860
+ if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
1861
+ commandStream?.controller.abort();
1862
+ }
1863
+ }
1864
+ function removeQuestionByRequestID(requestID) {
1865
+ const externalId = questionExternalIDByRequestID.get(requestID);
1866
+ if (!externalId) return;
1867
+ questionExternalIDByRequestID.delete(requestID);
1868
+ questionsByExternalID.delete(externalId);
1869
+ if (questionsByExternalID.size === 0 && leaseTimer) {
1870
+ clearInterval(leaseTimer);
1871
+ leaseTimer = void 0;
1872
+ }
1873
+ }
1874
+ async function renewQuestionLeases() {
1875
+ for (const pending of questionsByExternalID.values()) {
1876
+ if (!pending.eventID) continue;
1877
+ await postQuestionState(config, pending.eventID, "lease").catch((error) => {
1878
+ reporter.warning("Question lease could not be renewed", error);
1879
+ });
1880
+ }
1881
+ }
1882
+ function ensureLeaseTimer() {
1883
+ if (leaseTimer || questionsByExternalID.size === 0) return;
1884
+ leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
1885
+ leaseTimer.unref?.();
1748
1886
  }
1749
1887
  async function handleCommand(command) {
1750
1888
  if (!command || typeof command !== "object") return;
1751
- if (command.type !== "opencode_permission_reply") return;
1889
+ if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
1752
1890
  if (typeof command.id !== "string" || command.id.length > 200) return;
1753
1891
  const previousAcknowledgement = completedCommands.get(command.id);
1754
1892
  if (previousAcknowledgement) {
@@ -1757,54 +1895,75 @@ function createPermissionController({
1757
1895
  }
1758
1896
  if (processingCommands.has(command.id)) return;
1759
1897
  if (typeof command.externalId !== "string") return;
1760
- const pending = pendingByExternalID.get(command.externalId);
1898
+ const isQuestion = command.type === "opencode_question_reply";
1899
+ const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
1761
1900
  if (!pending) return;
1762
1901
  processingCommands.add(command.id);
1763
- let acknowledgement = validatePermissionCommand(command);
1902
+ let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
1764
1903
  try {
1765
1904
  if (!acknowledgement) {
1766
- await replyToOpenCode(client, pending, command.decision);
1767
- acknowledgement = { status: "applied", decision: command.decision };
1768
- removePendingByRequestID(pending.requestID);
1905
+ if (isQuestion) {
1906
+ remotelyResolvingQuestions.add(pending.requestID);
1907
+ await replyToQuestion(client, pending, command.response);
1908
+ acknowledgement = { status: "applied", action: command.response.action };
1909
+ removeQuestionByRequestID(pending.requestID);
1910
+ const cleanup = setTimeout(
1911
+ () => remotelyResolvingQuestions.delete(pending.requestID),
1912
+ 3e4
1913
+ );
1914
+ cleanup.unref?.();
1915
+ } else {
1916
+ await replyToOpenCode(client, pending, command.decision);
1917
+ acknowledgement = { status: "applied", decision: command.decision };
1918
+ removePendingByRequestID(pending.requestID);
1919
+ }
1769
1920
  }
1770
1921
  } catch (error) {
1771
- acknowledgement = failureAcknowledgement(command, error);
1922
+ if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
1923
+ acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
1772
1924
  if (acknowledgement.status === "stale") {
1773
- removePendingByRequestID(pending.requestID);
1925
+ if (isQuestion) removeQuestionByRequestID(pending.requestID);
1926
+ else removePendingByRequestID(pending.requestID);
1774
1927
  }
1775
- reporter.error("Decision could not be applied", error);
1776
- conversation?.error(pending.sessionID, "Decision could not be applied");
1928
+ const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
1929
+ reporter.error(message, error);
1930
+ conversation?.error(pending.sessionID, message);
1777
1931
  } finally {
1778
1932
  processingCommands.delete(command.id);
1779
1933
  }
1780
1934
  acknowledgement = {
1781
1935
  ...acknowledgement,
1782
- type: "opencode_permission_reply_ack",
1936
+ type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
1783
1937
  externalId: command.externalId,
1784
1938
  processedAt: (/* @__PURE__ */ new Date()).toISOString()
1785
1939
  };
1786
1940
  rememberBounded(completedCommands, command.id, acknowledgement);
1787
- reportAcknowledgement(acknowledgement, pending, reporter, conversation);
1941
+ if (isQuestion) {
1942
+ reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
1943
+ } else {
1944
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation);
1945
+ }
1788
1946
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
1789
1947
  reporter.warning("Decision receipt could not be sent", error);
1790
1948
  });
1791
1949
  stopCommandStreamIfIdle();
1792
1950
  }
1793
1951
  function ensureCommandStream() {
1794
- if (disposed || commandStream || pendingByExternalID.size === 0) return;
1952
+ const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
1953
+ if (disposed || commandStream || !hasPending()) return;
1795
1954
  const controller = new AbortController();
1796
1955
  const current = {
1797
1956
  controller,
1798
1957
  task: runCommandStream(config, reporter, {
1799
1958
  signal: controller.signal,
1800
- hasPending: () => pendingByExternalID.size > 0,
1959
+ hasPending,
1801
1960
  onCommand: handleCommand
1802
1961
  })
1803
1962
  };
1804
1963
  commandStream = current;
1805
1964
  void current.task.finally(() => {
1806
1965
  if (commandStream === current) commandStream = void 0;
1807
- if (!disposed && pendingByExternalID.size > 0) ensureCommandStream();
1966
+ if (!disposed && hasPending()) ensureCommandStream();
1808
1967
  });
1809
1968
  }
1810
1969
  return {
@@ -1817,6 +1976,31 @@ function createPermissionController({
1817
1976
  removePendingByRequestID(requestID);
1818
1977
  stopCommandStreamIfIdle();
1819
1978
  },
1979
+ registerQuestion(identity, details) {
1980
+ questionsByExternalID.set(identity.externalId, {
1981
+ ...identity,
1982
+ ...details,
1983
+ directory
1984
+ });
1985
+ questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
1986
+ ensureLeaseTimer();
1987
+ ensureCommandStream();
1988
+ },
1989
+ resolveQuestionLocally(requestID, outcome) {
1990
+ if (remotelyResolvingQuestions.delete(requestID)) {
1991
+ removeQuestionByRequestID(requestID);
1992
+ stopCommandStreamIfIdle();
1993
+ return;
1994
+ }
1995
+ const externalId = questionExternalIDByRequestID.get(requestID);
1996
+ const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
1997
+ if (!pending) return;
1998
+ removeQuestionByRequestID(requestID);
1999
+ if (pending.eventID) {
2000
+ void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
2001
+ }
2002
+ stopCommandStreamIfIdle();
2003
+ },
1820
2004
  refreshCredentials() {
1821
2005
  commandStream?.controller.abort();
1822
2006
  if (!commandStream) ensureCommandStream();
@@ -1824,18 +2008,23 @@ function createPermissionController({
1824
2008
  status() {
1825
2009
  return {
1826
2010
  pending: pendingByExternalID.size,
2011
+ pendingQuestions: questionsByExternalID.size,
1827
2012
  stream: commandStream ? "active" : "idle"
1828
2013
  };
1829
2014
  },
1830
2015
  async dispose() {
1831
2016
  disposed = true;
1832
2017
  commandStream?.controller.abort();
2018
+ if (leaseTimer) clearInterval(leaseTimer);
1833
2019
  await commandStream?.task.catch(() => {
1834
2020
  });
1835
2021
  pendingByExternalID.clear();
1836
2022
  externalIDByRequestID.clear();
1837
2023
  processingCommands.clear();
1838
2024
  completedCommands.clear();
2025
+ questionsByExternalID.clear();
2026
+ questionExternalIDByRequestID.clear();
2027
+ remotelyResolvingQuestions.clear();
1839
2028
  }
1840
2029
  };
1841
2030
  }
@@ -1945,8 +2134,18 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1945
2134
  identity,
1946
2135
  client,
1947
2136
  directory,
1948
- conversation
1949
- ).then(() => questionPublications.succeed(identity.externalId)).catch((error) => {
2137
+ conversation,
2138
+ event.type
2139
+ ).then((eventID) => {
2140
+ questionPublications.succeed(identity.externalId);
2141
+ if (eventID) {
2142
+ permissionController.registerQuestion(identity, {
2143
+ eventID,
2144
+ sourceEvent: event.type,
2145
+ questions: event.properties.questions
2146
+ });
2147
+ }
2148
+ }).catch((error) => {
1950
2149
  questionPublications.fail(identity.externalId);
1951
2150
  reporter.error("Question could not be sent", error);
1952
2151
  conversation.error(identity.sessionID, "Question could not be sent");
@@ -1978,6 +2177,16 @@ async function BackeerPlugin({ client, directory }, options = {}) {
1978
2177
  }
1979
2178
  if (config.questions && (event.type === "question.asked" || event.type === "question.v2.asked")) {
1980
2179
  await handleQuestionAsked(event);
2180
+ return;
2181
+ }
2182
+ if (config.questions && ["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
2183
+ const requestID = event.properties.requestID ?? event.properties.id;
2184
+ if (typeof requestID === "string") {
2185
+ permissionController.resolveQuestionLocally(
2186
+ requestID,
2187
+ event.type.endsWith("rejected") ? "rejected" : "answered"
2188
+ );
2189
+ }
1981
2190
  }
1982
2191
  },
1983
2192
  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.0",
4
+ "description": "Backeer lifecycle notifications and remote permission and question replies for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -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,38 @@ async function acknowledgeCommand(config, commandID, acknowledgement) {
28
33
  await postJsonWithRetry(config, url, acknowledgement)
29
34
  }
30
35
 
36
+ async function replyToQuestion(client, pending, response) {
37
+ const isV2 = pending.sourceEvent === "question.v2.asked"
38
+ const api = isV2 ? client?.v2?.session?.question : client?.question
39
+ const method = response.action === "reject" ? api?.reject : api?.reply
40
+ if (typeof method !== "function") {
41
+ throw new HttpError("This OpenCode version cannot apply remote question replies", 501)
42
+ }
43
+ const input = isV2
44
+ ? {
45
+ sessionID: pending.sessionID,
46
+ requestID: pending.requestID,
47
+ directory: pending.directory,
48
+ ...(response.action === "answer"
49
+ ? { questionV2Reply: { answers: response.answers } }
50
+ : {}),
51
+ }
52
+ : {
53
+ requestID: pending.requestID,
54
+ directory: pending.directory,
55
+ ...(response.action === "answer" ? { answers: response.answers } : {}),
56
+ }
57
+ const result = await method.call(api, input)
58
+ if (result?.data === false || result === false) {
59
+ throw new HttpError("OpenCode did not apply the question response", 409)
60
+ }
61
+ }
62
+
63
+ async function postQuestionState(config, eventID, path, body = {}) {
64
+ const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin)
65
+ await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] })
66
+ }
67
+
31
68
  export function createPermissionController({
32
69
  config,
33
70
  reporter,
@@ -37,9 +74,13 @@ export function createPermissionController({
37
74
  }) {
38
75
  const pendingByExternalID = new Map()
39
76
  const externalIDByRequestID = new Map()
77
+ const questionsByExternalID = new Map()
78
+ const questionExternalIDByRequestID = new Map()
79
+ const remotelyResolvingQuestions = new Set()
40
80
  const processingCommands = new Set()
41
81
  const completedCommands = new Map()
42
82
  let commandStream
83
+ let leaseTimer
43
84
  let disposed = false
44
85
 
45
86
  function removePendingByRequestID(requestID) {
@@ -50,12 +91,40 @@ export function createPermissionController({
50
91
  }
51
92
 
52
93
  function stopCommandStreamIfIdle() {
53
- if (pendingByExternalID.size === 0) commandStream?.controller.abort()
94
+ if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
95
+ commandStream?.controller.abort()
96
+ }
97
+ }
98
+
99
+ function removeQuestionByRequestID(requestID) {
100
+ const externalId = questionExternalIDByRequestID.get(requestID)
101
+ if (!externalId) return
102
+ questionExternalIDByRequestID.delete(requestID)
103
+ questionsByExternalID.delete(externalId)
104
+ if (questionsByExternalID.size === 0 && leaseTimer) {
105
+ clearInterval(leaseTimer)
106
+ leaseTimer = undefined
107
+ }
108
+ }
109
+
110
+ async function renewQuestionLeases() {
111
+ for (const pending of questionsByExternalID.values()) {
112
+ if (!pending.eventID) continue
113
+ await postQuestionState(config, pending.eventID, "lease").catch((error) => {
114
+ reporter.warning("Question lease could not be renewed", error)
115
+ })
116
+ }
117
+ }
118
+
119
+ function ensureLeaseTimer() {
120
+ if (leaseTimer || questionsByExternalID.size === 0) return
121
+ leaseTimer = setInterval(() => void renewQuestionLeases(), 60_000)
122
+ leaseTimer.unref?.()
54
123
  }
55
124
 
56
125
  async function handleCommand(command) {
57
126
  if (!command || typeof command !== "object") return
58
- if (command.type !== "opencode_permission_reply") return
127
+ if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return
59
128
  if (typeof command.id !== "string" || command.id.length > 200) return
60
129
 
61
130
  const previousAcknowledgement = completedCommands.get(command.id)
@@ -67,37 +136,67 @@ export function createPermissionController({
67
136
  if (processingCommands.has(command.id)) return
68
137
  if (typeof command.externalId !== "string") return
69
138
 
70
- const pending = pendingByExternalID.get(command.externalId)
139
+ const isQuestion = command.type === "opencode_question_reply"
140
+ const pending = isQuestion
141
+ ? questionsByExternalID.get(command.externalId)
142
+ : pendingByExternalID.get(command.externalId)
71
143
  if (!pending) return
72
144
 
73
145
  processingCommands.add(command.id)
74
- let acknowledgement = validatePermissionCommand(command)
146
+ let acknowledgement = isQuestion
147
+ ? validateQuestionCommand(command, pending)
148
+ : validatePermissionCommand(command)
75
149
  try {
76
150
  if (!acknowledgement) {
77
- await replyToOpenCode(client, pending, command.decision)
78
- acknowledgement = { status: "applied", decision: command.decision }
79
- removePendingByRequestID(pending.requestID)
151
+ if (isQuestion) {
152
+ remotelyResolvingQuestions.add(pending.requestID)
153
+ await replyToQuestion(client, pending, command.response)
154
+ acknowledgement = { status: "applied", action: command.response.action }
155
+ removeQuestionByRequestID(pending.requestID)
156
+ const cleanup = setTimeout(
157
+ () => remotelyResolvingQuestions.delete(pending.requestID),
158
+ 30_000,
159
+ )
160
+ cleanup.unref?.()
161
+ } else {
162
+ await replyToOpenCode(client, pending, command.decision)
163
+ acknowledgement = { status: "applied", decision: command.decision }
164
+ removePendingByRequestID(pending.requestID)
165
+ }
80
166
  }
81
167
  } catch (error) {
82
- acknowledgement = failureAcknowledgement(command, error)
168
+ if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID)
169
+ acknowledgement = isQuestion
170
+ ? questionFailureAcknowledgement(command, error)
171
+ : failureAcknowledgement(command, error)
83
172
  if (acknowledgement.status === "stale") {
84
- removePendingByRequestID(pending.requestID)
173
+ if (isQuestion) removeQuestionByRequestID(pending.requestID)
174
+ else removePendingByRequestID(pending.requestID)
85
175
  }
86
- reporter.error("Decision could not be applied", error)
87
- conversation?.error(pending.sessionID, "Decision could not be applied")
176
+ const message = isQuestion
177
+ ? "Question response could not be applied"
178
+ : "Decision could not be applied"
179
+ reporter.error(message, error)
180
+ conversation?.error(pending.sessionID, message)
88
181
  } finally {
89
182
  processingCommands.delete(command.id)
90
183
  }
91
184
 
92
185
  acknowledgement = {
93
186
  ...acknowledgement,
94
- type: "opencode_permission_reply_ack",
187
+ type: isQuestion
188
+ ? "opencode_question_reply_ack"
189
+ : "opencode_permission_reply_ack",
95
190
  externalId: command.externalId,
96
191
  processedAt: new Date().toISOString(),
97
192
  }
98
193
  rememberBounded(completedCommands, command.id, acknowledgement)
99
194
 
100
- reportAcknowledgement(acknowledgement, pending, reporter, conversation)
195
+ if (isQuestion) {
196
+ reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation)
197
+ } else {
198
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation)
199
+ }
101
200
 
102
201
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
103
202
  reporter.warning("Decision receipt could not be sent", error)
@@ -106,21 +205,22 @@ export function createPermissionController({
106
205
  }
107
206
 
108
207
  function ensureCommandStream() {
109
- if (disposed || commandStream || pendingByExternalID.size === 0) return
208
+ const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0
209
+ if (disposed || commandStream || !hasPending()) return
110
210
 
111
211
  const controller = new AbortController()
112
212
  const current = {
113
213
  controller,
114
214
  task: runCommandStream(config, reporter, {
115
215
  signal: controller.signal,
116
- hasPending: () => pendingByExternalID.size > 0,
216
+ hasPending,
117
217
  onCommand: handleCommand,
118
218
  }),
119
219
  }
120
220
  commandStream = current
121
221
  void current.task.finally(() => {
122
222
  if (commandStream === current) commandStream = undefined
123
- if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
223
+ if (!disposed && hasPending()) ensureCommandStream()
124
224
  })
125
225
  }
126
226
 
@@ -136,6 +236,34 @@ export function createPermissionController({
136
236
  stopCommandStreamIfIdle()
137
237
  },
138
238
 
239
+ registerQuestion(identity, details) {
240
+ questionsByExternalID.set(identity.externalId, {
241
+ ...identity,
242
+ ...details,
243
+ directory,
244
+ })
245
+ questionExternalIDByRequestID.set(identity.requestID, identity.externalId)
246
+ ensureLeaseTimer()
247
+ ensureCommandStream()
248
+ },
249
+
250
+ resolveQuestionLocally(requestID, outcome) {
251
+ if (remotelyResolvingQuestions.delete(requestID)) {
252
+ removeQuestionByRequestID(requestID)
253
+ stopCommandStreamIfIdle()
254
+ return
255
+ }
256
+ const externalId = questionExternalIDByRequestID.get(requestID)
257
+ const pending = externalId ? questionsByExternalID.get(externalId) : undefined
258
+ if (!pending) return
259
+ removeQuestionByRequestID(requestID)
260
+ if (pending.eventID) {
261
+ void postQuestionState(config, pending.eventID, "source-resolution", { outcome })
262
+ .catch((error) => reporter.warning("Local question state could not be sent", error))
263
+ }
264
+ stopCommandStreamIfIdle()
265
+ },
266
+
139
267
  refreshCredentials() {
140
268
  commandStream?.controller.abort()
141
269
  if (!commandStream) ensureCommandStream()
@@ -144,6 +272,7 @@ export function createPermissionController({
144
272
  status() {
145
273
  return {
146
274
  pending: pendingByExternalID.size,
275
+ pendingQuestions: questionsByExternalID.size,
147
276
  stream: commandStream ? "active" : "idle",
148
277
  }
149
278
  },
@@ -151,11 +280,15 @@ export function createPermissionController({
151
280
  async dispose() {
152
281
  disposed = true
153
282
  commandStream?.controller.abort()
283
+ if (leaseTimer) clearInterval(leaseTimer)
154
284
  await commandStream?.task.catch(() => {})
155
285
  pendingByExternalID.clear()
156
286
  externalIDByRequestID.clear()
157
287
  processingCommands.clear()
158
288
  completedCommands.clear()
289
+ questionsByExternalID.clear()
290
+ questionExternalIDByRequestID.clear()
291
+ remotelyResolvingQuestions.clear()
159
292
  },
160
293
  }
161
294
  }
package/src/plugin.js CHANGED
@@ -149,7 +149,17 @@ export default async function BackeerPlugin({ client, directory }, options = {})
149
149
  client,
150
150
  directory,
151
151
  conversation,
152
- ).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
+ }