backeer-opencode 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Backeer lifecycle notifications and remote permission and question replies for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -50,6 +50,9 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
+ "dependencies": {
54
+ "@opencode/client": "2.0.26"
55
+ },
53
56
  "devDependencies": {
54
57
  "@eslint/js": "^9.39.5",
55
58
  "esbuild": "^0.28.2",
package/src/index.js CHANGED
@@ -1 +1,6 @@
1
- export { default } from "./plugin.js"
1
+ import server from "./plugin.js"
2
+ import { setup } from "./opencode-v2.js"
3
+
4
+ // A plain definition avoids an authoring-SDK dependency in the installer bundle.
5
+ // OpenCode >=1.18.29 calls server(); OpenCode V2 calls setup().
6
+ export default { id: "backeer-opencode", setup, server }
@@ -0,0 +1,145 @@
1
+ import { HttpError } from "./http.js"
2
+ import { getFormApi } from "./opencode-v2-forms.js"
3
+
4
+ async function nativeReply(operation) {
5
+ try {
6
+ return await operation()
7
+ } catch (error) {
8
+ const statuses = {
9
+ PermissionNotFoundError: 404,
10
+ SessionNotFoundError: 404,
11
+ FormNotFoundError: 404,
12
+ FormAlreadySettledError: 409,
13
+ FormInvalidAnswerError: 422,
14
+ }
15
+ const status = statuses[error?._tag]
16
+ if (status) throw new HttpError(error.message || error._tag, status)
17
+ throw error
18
+ }
19
+ }
20
+
21
+ function questionForm(form) {
22
+ return form?.metadata?.kind === "question" &&
23
+ typeof form.sessionID === "string" && form.sessionID !== "global" &&
24
+ Array.isArray(form.fields) && form.fields.length > 0 &&
25
+ form.fields.every((field) =>
26
+ ["string", "multiselect"].includes(field.type) && !field.hidden && !field.when?.length,
27
+ )
28
+ }
29
+
30
+ export function normalizeV2Event(event) {
31
+ const properties = event.data ?? {}
32
+ const normalized = { ...event, properties }
33
+ if (event.type === "form.created") {
34
+ const form = properties.form
35
+ if (!questionForm(form)) return []
36
+ return [{ ...normalized, type: "question.v2.asked", properties: {
37
+ id: form.id,
38
+ sessionID: form.sessionID,
39
+ questions: form.fields.map((field) => ({
40
+ header: field.title ?? form.title,
41
+ question: field.description ?? field.title ?? form.title,
42
+ options: (field.options ?? []).map((option) => ({
43
+ label: option.label, description: option.description ?? "",
44
+ })),
45
+ multiple: field.type === "multiselect",
46
+ custom: field.custom === true || !field.options?.length,
47
+ })),
48
+ } }]
49
+ }
50
+ if (event.type === "form.replied" || event.type === "form.cancelled") {
51
+ return [{ ...normalized, type: event.type === "form.replied"
52
+ ? "question.v2.replied" : "question.v2.rejected" }]
53
+ }
54
+ if (event.type === "session.execution.started") {
55
+ return [{ ...normalized, type: "session.status", properties: {
56
+ ...properties, status: { type: "busy" },
57
+ } }]
58
+ }
59
+ if (["session.execution.succeeded", "session.execution.interrupted"].includes(event.type)) {
60
+ return [
61
+ { ...normalized, type: "message.updated", properties: { info: {
62
+ role: "assistant", sessionID: properties.sessionID, id: event.id,
63
+ time: { completed: event.created },
64
+ error: event.type === "session.execution.interrupted",
65
+ } } },
66
+ { ...normalized, type: "session.idle" },
67
+ ]
68
+ }
69
+ if (event.type === "session.execution.failed") {
70
+ return [{ ...normalized, type: "session.error" }, { ...normalized, type: "session.idle" }]
71
+ }
72
+ // Intermediate UI-idle transitions are not execution completion boundaries.
73
+ if (event.type === "session.idle" ||
74
+ (event.type === "session.status" && properties.status?.type === "idle")) {
75
+ return []
76
+ }
77
+ return [normalized]
78
+ }
79
+
80
+ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
81
+ return {
82
+ app: {
83
+ async log({ body }) {
84
+ // Server stderr is captured by OpenCode's log, never the TUI input.
85
+ console.error(JSON.stringify(body))
86
+ },
87
+ },
88
+ session: {
89
+ get: ({ path }) => ctx.session.get({ sessionID: path.id }),
90
+ prompt: ({ path, body }) => ctx.session.synthetic({
91
+ sessionID: path.id,
92
+ text: body.parts.map((part) => part.text).join("\n"),
93
+ resume: false,
94
+ }),
95
+ },
96
+ postSessionIdPermissionsPermissionId: ({ path, body }) => nativeReply(() => ctx.permission.reply({
97
+ sessionID: path.id,
98
+ requestID: path.permissionID,
99
+ decision: body.response,
100
+ })),
101
+ v2: { session: { question: {
102
+ async reply({ sessionID, requestID, questionV2Reply }) {
103
+ const api = await resolveFormApi(sessionID)
104
+ const form = await nativeReply(() => api.get({ sessionID, formID: requestID }))
105
+ if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422)
106
+ const answers = questionV2Reply.answers
107
+ if (!Array.isArray(answers) || answers.length !== form.fields.length) {
108
+ throw new HttpError("Question answer count does not match the form", 422)
109
+ }
110
+ const answer = {}
111
+ form.fields.forEach((field, index) => {
112
+ const selections = answers[index]
113
+ if (!Array.isArray(selections) || selections.some((value) => typeof value !== "string")) {
114
+ throw new HttpError("Invalid question answer", 422)
115
+ }
116
+ const values = selections.map((label) =>
117
+ field.options?.find((option) => option.label === label)?.value ?? label,
118
+ )
119
+ if (field.type === "multiselect") answer[field.key] = values
120
+ else if (values.length === 1) answer[field.key] = values[0]
121
+ else if (values.length > 1) throw new HttpError("Single-choice question has multiple answers", 422)
122
+ })
123
+ return nativeReply(() => api.reply({ sessionID, formID: requestID, answer }))
124
+ },
125
+ async reject({ sessionID, requestID }) {
126
+ const api = await resolveFormApi(sessionID)
127
+ const form = await nativeReply(() => api.get({ sessionID, formID: requestID }))
128
+ if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422)
129
+ return nativeReply(() => api.cancel({ sessionID, formID: requestID }))
130
+ },
131
+ } } },
132
+ async ownsEvent(event) {
133
+ if (event.location?.directory) return event.location.directory === ctx.location.directory
134
+ const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID
135
+ if (!sessionID) return event.type === "installation.update-available"
136
+ if (sessionID === "global") return false
137
+ try {
138
+ const session = await ctx.session.get({ sessionID })
139
+ return session.location?.directory === ctx.location.directory
140
+ } catch {
141
+ return false
142
+ }
143
+ },
144
+ }
145
+ }
@@ -0,0 +1,24 @@
1
+ import { OpenCode } from "@opencode/client"
2
+ import { Service } from "@opencode/client/service"
3
+
4
+ import { HttpError } from "./http.js"
5
+
6
+ export async function getFormApi(ctx, sessionID, { makeClient = OpenCode.make } = {}) {
7
+ // 2.0.26 exposes forms on the client API, but not on the plugin context.
8
+ // Discover only: never start a second service or change its configuration.
9
+ const endpoint = await Service.discover({ version: ctx.app.version })
10
+ if (!endpoint) {
11
+ throw new HttpError("Remote question replies require the OpenCode V2 shared service", 501)
12
+ }
13
+ const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
14
+ let session
15
+ try {
16
+ session = await client.session.get({ sessionID })
17
+ } catch {
18
+ throw new HttpError("The question session is not owned by the shared OpenCode service", 501)
19
+ }
20
+ if (session.location.directory !== ctx.location.directory) {
21
+ throw new HttpError("The question session belongs to another OpenCode location", 403)
22
+ }
23
+ return client.session.form
24
+ }
@@ -0,0 +1,107 @@
1
+ import server from "./plugin.js"
2
+ import { createOpenCodeReporter } from "./reporter.js"
3
+ import { createV2Client, normalizeV2Event } from "./opencode-v2-client.js"
4
+
5
+ function waitForReconnect(signal, delay) {
6
+ return new Promise((resolve) => {
7
+ const finish = () => {
8
+ clearTimeout(timer)
9
+ signal.removeEventListener("abort", finish)
10
+ resolve()
11
+ }
12
+ const timer = setTimeout(finish, delay)
13
+ signal.addEventListener("abort", finish, { once: true })
14
+ if (signal.aborted) finish()
15
+ })
16
+ }
17
+
18
+ export async function setup(ctx) {
19
+ const client = createV2Client(ctx)
20
+ const reporter = createOpenCodeReporter(client, ctx.options?.logLevel ?? process.env.BACKEER_LOG_LEVEL)
21
+ // V2 has no ignored/noReply conversation entry. Do not put delivery feedback
22
+ // into model context or start an agent turn just to display a notification.
23
+ const feedback = Object.fromEntries(
24
+ ["info", "success", "applied", "warning", "error"].map((kind) => [
25
+ kind, () => {},
26
+ ]),
27
+ )
28
+ feedback.handle = () => {}
29
+ feedback.dispose = () => {}
30
+ const hooks = await server({
31
+ client,
32
+ directory: ctx.location.directory,
33
+ feedback,
34
+ v2: true,
35
+ }, { ...ctx.options, todos: false })
36
+ if (!hooks.event) return
37
+
38
+ const controller = new AbortController()
39
+ let streamTask
40
+ let cleanupTask
41
+ const registrations = []
42
+ function cleanup() {
43
+ cleanupTask ??= (async () => {
44
+ controller.abort()
45
+ await streamTask?.catch(() => {})
46
+ await hooks.dispose()
47
+ await Promise.all(registrations.map((registration) => registration.dispose()))
48
+ })()
49
+ return cleanupTask
50
+ }
51
+
52
+ try {
53
+ await ctx.command.transform((editor) => {
54
+ editor.add({
55
+ name: "backeer",
56
+ description: "Check, test, update, or manage Backeer",
57
+ async execute({ sessionID, prompt, delivery }) {
58
+ const output = { parts: [] }
59
+ await hooks["command.execute.before"]({
60
+ command: "backeer", sessionID, arguments: prompt.text,
61
+ }, output)
62
+ if (output.parts.length) {
63
+ await ctx.session.prompt({
64
+ sessionID,
65
+ text: output.parts.map((part) => part.text).join("\n"),
66
+ delivery,
67
+ })
68
+ }
69
+ },
70
+ })
71
+ }).then((registration) => registrations.push(registration))
72
+
73
+ registrations.push(await ctx.session.hook("prompt", async (event) => {
74
+ const output = { message: { id: event.messageID }, parts: [] }
75
+ await hooks["chat.message"]({ sessionID: event.sessionID }, output)
76
+ if (output.parts.length) {
77
+ event.prompt.text += `\n\n${output.parts.map((part) => part.text).join("\n")}`
78
+ }
79
+ }))
80
+
81
+ streamTask = (async () => {
82
+ let delay = 1000
83
+ while (!controller.signal.aborted) {
84
+ try {
85
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
86
+ if (controller.signal.aborted) break
87
+ if (!await client.ownsEvent(event)) continue
88
+ delay = 1000
89
+ for (const normalized of normalizeV2Event(event)) {
90
+ await hooks.event({ event: normalized })
91
+ }
92
+ }
93
+ } catch (error) {
94
+ if (!controller.signal.aborted) reporter.error("OpenCode event stream disconnected", error)
95
+ }
96
+ if (!controller.signal.aborted) {
97
+ await waitForReconnect(controller.signal, delay)
98
+ delay = Math.min(delay * 2, 30_000)
99
+ }
100
+ }
101
+ })()
102
+ return cleanup
103
+ } catch (error) {
104
+ await cleanup()
105
+ throw error
106
+ }
107
+ }
package/src/plugin.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  * The ingest token is intentionally accepted only through the environment or a
32
32
  * protected file so it cannot accidentally be committed in opencode.json.
33
33
  */
34
- export default async function BackeerPlugin({ client, directory }, options = {}) {
34
+ export default async function BackeerPlugin({ client, directory, feedback, v2 = false }, options = {}) {
35
35
  const bootstrapReporter = createOpenCodeReporter(
36
36
  client,
37
37
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
@@ -45,7 +45,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
45
45
  }
46
46
 
47
47
  const reporter = createOpenCodeReporter(client, config.logLevel)
48
- const conversation = createConversationFeedback({ client, reporter })
48
+ const conversation = feedback ?? createConversationFeedback({ client, reporter })
49
49
 
50
50
  const permissionController = createPermissionController({
51
51
  config,
@@ -64,7 +64,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
64
64
  const permissionPublications = createDeliveryTracker()
65
65
  const questionPublications = createDeliveryTracker()
66
66
  const todoCompletions = createCompletionTracker()
67
- const updateChecker = createUpdateChecker()
67
+ const updateChecker = createUpdateChecker({ v2 })
68
68
  const handleBackeerCommand = createBackeerCommandHandler({
69
69
  client,
70
70
  config,
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.2.5"
1
+ export const PLUGIN_VERSION = "0.4.0"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"
@@ -37,6 +37,7 @@ export function isVersionNewer(latestValue, currentValue) {
37
37
  export function createUpdateChecker({
38
38
  fetchImplementation = globalThis.fetch,
39
39
  now = () => Date.now(),
40
+ v2 = false,
40
41
  } = {}) {
41
42
  let checkedAt = 0
42
43
  let cachedResult
@@ -70,7 +71,8 @@ export function createUpdateChecker({
70
71
  currentVersion: PLUGIN_VERSION,
71
72
  latestVersion,
72
73
  updateCommand:
73
- `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`,
74
+ v2 ? `opencode plugin add ${PACKAGE_NAME}@${latestVersion}`
75
+ : `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`,
74
76
  }
75
77
  } catch {
76
78
  return { state: "unavailable", currentVersion: PLUGIN_VERSION }