backeer-opencode 0.3.2 → 0.5.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 +42 -0
- package/README.md +91 -17
- package/bin/backeer-opencode-mcp.js +9 -0
- package/dist/backeer-permission.js +3251 -88
- package/docs/live-control.md +80 -0
- package/package.json +12 -5
- package/src/config.js +4 -0
- package/src/index.js +6 -1
- package/src/live-bridge-client.js +52 -0
- package/src/live-bridge-config.js +48 -0
- package/src/live-bridge-events.js +80 -0
- package/src/live-bridge.js +273 -0
- package/src/live-connection.js +50 -0
- package/src/live-control-client.js +361 -0
- package/src/live-control-mcp.js +79 -0
- package/src/opencode-v2-client.js +147 -0
- package/src/opencode-v2-forms.js +24 -0
- package/src/opencode-v2.js +105 -0
- package/src/plugin.js +57 -49
- package/src/updates.js +4 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import server from "./plugin.js"
|
|
2
|
+
import { createOpenCodeReporter } from "./reporter.js"
|
|
3
|
+
import { createV2Client, normalizeV2Event } from "./opencode-v2-client.js"
|
|
4
|
+
import { LIVE_RECONNECT_MS } from "./live-connection.js"
|
|
5
|
+
|
|
6
|
+
function waitForReconnect(signal, delay) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const finish = () => {
|
|
9
|
+
clearTimeout(timer)
|
|
10
|
+
signal.removeEventListener("abort", finish)
|
|
11
|
+
resolve()
|
|
12
|
+
}
|
|
13
|
+
const timer = setTimeout(finish, delay)
|
|
14
|
+
signal.addEventListener("abort", finish, { once: true })
|
|
15
|
+
if (signal.aborted) finish()
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function setup(ctx) {
|
|
20
|
+
const client = createV2Client(ctx)
|
|
21
|
+
const reporter = createOpenCodeReporter(client, ctx.options?.logLevel ?? process.env.BACKEER_LOG_LEVEL)
|
|
22
|
+
// V2 has no ignored/noReply conversation entry. Do not put delivery feedback
|
|
23
|
+
// into model context or start an agent turn just to display a notification.
|
|
24
|
+
const feedback = Object.fromEntries(
|
|
25
|
+
["info", "success", "applied", "warning", "error"].map((kind) => [
|
|
26
|
+
kind, () => {},
|
|
27
|
+
]),
|
|
28
|
+
)
|
|
29
|
+
feedback.handle = () => {}
|
|
30
|
+
feedback.dispose = () => {}
|
|
31
|
+
const hooks = await server({
|
|
32
|
+
client,
|
|
33
|
+
directory: ctx.location.directory,
|
|
34
|
+
feedback,
|
|
35
|
+
v2: true,
|
|
36
|
+
}, { ...ctx.options, todos: false })
|
|
37
|
+
if (!hooks.event) return
|
|
38
|
+
|
|
39
|
+
const controller = new AbortController()
|
|
40
|
+
let streamTask
|
|
41
|
+
let cleanupTask
|
|
42
|
+
const registrations = []
|
|
43
|
+
function cleanup() {
|
|
44
|
+
cleanupTask ??= (async () => {
|
|
45
|
+
controller.abort()
|
|
46
|
+
await streamTask?.catch(() => {})
|
|
47
|
+
await hooks.dispose()
|
|
48
|
+
await Promise.all(registrations.map((registration) => registration.dispose()))
|
|
49
|
+
})()
|
|
50
|
+
return cleanupTask
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await ctx.command.transform((editor) => {
|
|
55
|
+
editor.add({
|
|
56
|
+
name: "backeer",
|
|
57
|
+
description: "Check, test, update, or manage Backeer",
|
|
58
|
+
async execute({ sessionID, prompt, delivery }) {
|
|
59
|
+
const output = { parts: [] }
|
|
60
|
+
await hooks["command.execute.before"]({
|
|
61
|
+
command: "backeer", sessionID, arguments: prompt.text,
|
|
62
|
+
}, output)
|
|
63
|
+
if (output.parts.length) {
|
|
64
|
+
await ctx.session.prompt({
|
|
65
|
+
sessionID,
|
|
66
|
+
text: output.parts.map((part) => part.text).join("\n"),
|
|
67
|
+
delivery,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}).then((registration) => registrations.push(registration))
|
|
73
|
+
|
|
74
|
+
registrations.push(await ctx.session.hook("prompt", async (event) => {
|
|
75
|
+
const output = { message: { id: event.messageID }, parts: [] }
|
|
76
|
+
await hooks["chat.message"]({ sessionID: event.sessionID }, output)
|
|
77
|
+
if (output.parts.length) {
|
|
78
|
+
event.prompt.text += `\n\n${output.parts.map((part) => part.text).join("\n")}`
|
|
79
|
+
}
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
streamTask = (async () => {
|
|
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
|
+
for (const normalized of normalizeV2Event(event)) {
|
|
89
|
+
await hooks.event({ event: normalized })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!controller.signal.aborted) reporter.error("OpenCode event stream disconnected", error)
|
|
94
|
+
}
|
|
95
|
+
if (!controller.signal.aborted) {
|
|
96
|
+
await waitForReconnect(controller.signal, LIVE_RECONNECT_MS)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})()
|
|
100
|
+
return cleanup
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await cleanup()
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
registerBackeerCommand,
|
|
16
16
|
} from "./management.js"
|
|
17
17
|
import { createLifecycleController } from "./lifecycle-controller.js"
|
|
18
|
+
import { createLiveBridge } from "./live-bridge.js"
|
|
18
19
|
import { createPermissionController } from "./permission-controller.js"
|
|
19
20
|
import { createOpenCodeReporter } from "./reporter.js"
|
|
20
21
|
import { createConversationFeedback } from "./session-messages.js"
|
|
@@ -27,11 +28,11 @@ import {
|
|
|
27
28
|
* OpenCode server plugin entrypoint.
|
|
28
29
|
*
|
|
29
30
|
* Supported options: origin, tokenFile, permissions, questions, todos,
|
|
30
|
-
* lifecycle, logLevel.
|
|
31
|
+
* lifecycle, liveBridge, logLevel.
|
|
31
32
|
* The ingest token is intentionally accepted only through the environment or a
|
|
32
33
|
* protected file so it cannot accidentally be committed in opencode.json.
|
|
33
34
|
*/
|
|
34
|
-
export default async function BackeerPlugin({ client, directory }, options = {}) {
|
|
35
|
+
export default async function BackeerPlugin({ client, directory, feedback, v2 = false }, options = {}) {
|
|
35
36
|
const bootstrapReporter = createOpenCodeReporter(
|
|
36
37
|
client,
|
|
37
38
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
@@ -45,7 +46,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const reporter = createOpenCodeReporter(client, config.logLevel)
|
|
48
|
-
const conversation = createConversationFeedback({ client, reporter })
|
|
49
|
+
const conversation = feedback ?? createConversationFeedback({ client, reporter })
|
|
50
|
+
if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error)
|
|
51
|
+
const liveBridge = await createLiveBridge({ config, client, directory, reporter }).catch((error) => {
|
|
52
|
+
reporter.error("Live control unavailable", error)
|
|
53
|
+
return { async handle() {}, async dispose() {} }
|
|
54
|
+
})
|
|
49
55
|
|
|
50
56
|
const permissionController = createPermissionController({
|
|
51
57
|
config,
|
|
@@ -64,7 +70,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
64
70
|
const permissionPublications = createDeliveryTracker()
|
|
65
71
|
const questionPublications = createDeliveryTracker()
|
|
66
72
|
const todoCompletions = createCompletionTracker()
|
|
67
|
-
const updateChecker = createUpdateChecker()
|
|
73
|
+
const updateChecker = createUpdateChecker({ v2 })
|
|
68
74
|
const handleBackeerCommand = createBackeerCommandHandler({
|
|
69
75
|
client,
|
|
70
76
|
config,
|
|
@@ -167,62 +173,64 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
167
173
|
})
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
registerBackeerCommand(opencodeConfig)
|
|
173
|
-
},
|
|
176
|
+
async function handleNotificationEvent(event) {
|
|
177
|
+
if (!config.token) return
|
|
174
178
|
|
|
175
|
-
|
|
179
|
+
conversation.handle(event)
|
|
180
|
+
lifecycleController.handle(event)
|
|
176
181
|
|
|
177
|
-
"
|
|
182
|
+
if (config.permissions && event.type === "permission.asked") {
|
|
183
|
+
await handlePermissionAsked(event)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
if (config.permissions && event.type === "permission.replied") {
|
|
188
|
+
permissionController.resolveLocally(
|
|
189
|
+
event.properties.requestID ?? event.properties.id,
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
181
193
|
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
if (config.todos && event.type === "todo.updated") {
|
|
195
|
+
await handleTodoUpdated(event)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
184
198
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
if (
|
|
200
|
+
config.questions &&
|
|
201
|
+
(event.type === "question.asked" || event.type === "question.v2.asked")
|
|
202
|
+
) {
|
|
203
|
+
await handleQuestionAsked(event)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
189
206
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
207
|
+
if (
|
|
208
|
+
config.questions &&
|
|
209
|
+
["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"]
|
|
210
|
+
.includes(event.type)
|
|
211
|
+
) {
|
|
212
|
+
const requestID = event.properties.requestID ?? event.properties.id
|
|
213
|
+
if (typeof requestID === "string") {
|
|
214
|
+
permissionController.resolveQuestionLocally(
|
|
215
|
+
requestID,
|
|
216
|
+
event.type.endsWith("rejected") ? "rejected" : "answered",
|
|
193
217
|
)
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (config.todos && event.type === "todo.updated") {
|
|
198
|
-
await handleTodoUpdated(event)
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
config.questions &&
|
|
204
|
-
(event.type === "question.asked" || event.type === "question.v2.asked")
|
|
205
|
-
) {
|
|
206
|
-
await handleQuestionAsked(event)
|
|
207
|
-
return
|
|
208
218
|
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
209
221
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
event.type.endsWith("rejected") ? "rejected" : "answered",
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
}
|
|
222
|
+
return {
|
|
223
|
+
config: async (opencodeConfig) => {
|
|
224
|
+
registerBackeerCommand(opencodeConfig)
|
|
225
|
+
},
|
|
226
|
+
"command.execute.before": handleBackeerCommand,
|
|
227
|
+
"chat.message": handleUpdateNotice,
|
|
228
|
+
event: async ({ event }) => {
|
|
229
|
+
// Start existing mobile handling first; live lookups must not gate approvals.
|
|
230
|
+
await Promise.all([handleNotificationEvent(event), liveBridge.handle(event)])
|
|
223
231
|
},
|
|
224
|
-
|
|
225
232
|
dispose: async () => {
|
|
233
|
+
await liveBridge.dispose()
|
|
226
234
|
await permissionController.dispose()
|
|
227
235
|
lifecycleController.dispose()
|
|
228
236
|
permissionPublications.clear()
|
package/src/updates.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PLUGIN_VERSION = "0.
|
|
1
|
+
export const PLUGIN_VERSION = "0.5.1"
|
|
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}
|
|
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 }
|