backeer-opencode 0.7.0 → 0.8.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 +12 -0
- package/README.md +10 -7
- package/dist/backeer-permission.js +1306 -996
- package/docs/live-control.md +15 -1
- package/package.json +3 -2
- package/src/live-bridge-client.js +34 -6
- package/src/live-bridge-config.js +3 -1
- package/src/live-bridge-events.js +3 -1
- package/src/live-bridge.js +83 -26
- package/src/live-control-client.js +24 -3
- package/src/live-control-mcp.js +8 -2
- package/src/live-errors.js +19 -0
- package/src/live-managed-mcp.js +9 -3
- package/src/live-question-tools.js +13 -0
- package/src/live-questions.js +49 -0
- package/src/live-session-manager.js +10 -0
- package/src/live-session-scope.js +42 -0
- package/src/opencode-v2-client.js +40 -20
- package/src/opencode-v2-endpoint.js +54 -0
- package/src/opencode-v2-forms.js +18 -7
- package/src/opencode-v2-tui.js +49 -0
- package/src/plugin.js +3 -1
- package/src/question-command.js +13 -8
- package/src/updates.js +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HttpError } from "./http.js"
|
|
2
2
|
import { getFormApi } from "./opencode-v2-forms.js"
|
|
3
3
|
import { createV2LiveClient } from "./live-bridge-client.js"
|
|
4
|
+
import { createHash } from "node:crypto"
|
|
4
5
|
|
|
5
6
|
async function nativeReply(operation) {
|
|
6
7
|
try {
|
|
@@ -28,25 +29,28 @@ function questionForm(form) {
|
|
|
28
29
|
)
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function questionProperties(form) {
|
|
33
|
+
return { id: form.id, sessionID: form.sessionID,
|
|
34
|
+
nativeRevision: createHash("sha256").update(JSON.stringify({ id: form.id,
|
|
35
|
+
sessionID: form.sessionID, title: form.title, fields: form.fields })).digest("hex"),
|
|
36
|
+
questions: form.fields.map((field) => ({
|
|
37
|
+
header: field.title ?? form.title,
|
|
38
|
+
question: field.description ?? field.title ?? form.title,
|
|
39
|
+
options: (field.options ?? []).map((option) => ({
|
|
40
|
+
label: option.label, description: option.description ?? "",
|
|
41
|
+
})),
|
|
42
|
+
multiple: field.type === "multiselect",
|
|
43
|
+
custom: field.custom === true || !field.options?.length,
|
|
44
|
+
})) }
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export function normalizeV2Event(event) {
|
|
32
48
|
const properties = event.data ?? {}
|
|
33
49
|
const normalized = { ...event, properties }
|
|
34
50
|
if (event.type === "form.created") {
|
|
35
51
|
const form = properties.form
|
|
36
52
|
if (!questionForm(form)) return []
|
|
37
|
-
return [{ ...normalized, type: "question.v2.asked", properties:
|
|
38
|
-
id: form.id,
|
|
39
|
-
sessionID: form.sessionID,
|
|
40
|
-
questions: form.fields.map((field) => ({
|
|
41
|
-
header: field.title ?? form.title,
|
|
42
|
-
question: field.description ?? field.title ?? form.title,
|
|
43
|
-
options: (field.options ?? []).map((option) => ({
|
|
44
|
-
label: option.label, description: option.description ?? "",
|
|
45
|
-
})),
|
|
46
|
-
multiple: field.type === "multiselect",
|
|
47
|
-
custom: field.custom === true || !field.options?.length,
|
|
48
|
-
})),
|
|
49
|
-
} }]
|
|
53
|
+
return [{ ...normalized, type: "question.v2.asked", properties: questionProperties(form) }]
|
|
50
54
|
}
|
|
51
55
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
52
56
|
return [{ ...normalized, type: event.type === "form.replied"
|
|
@@ -78,9 +82,11 @@ export function normalizeV2Event(event) {
|
|
|
78
82
|
return [normalized]
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
export function createV2Client(ctx, resolveFormApi
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
export function createV2Client(ctx, resolveFormApi) {
|
|
86
|
+
const liveBridge = createV2LiveClient(ctx)
|
|
87
|
+
resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession })
|
|
88
|
+
const client = {
|
|
89
|
+
liveBridge,
|
|
84
90
|
app: {
|
|
85
91
|
async log({ body }) {
|
|
86
92
|
// Server stderr is captured by OpenCode's log, never the TUI input.
|
|
@@ -101,10 +107,13 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
|
|
|
101
107
|
decision: body.response,
|
|
102
108
|
})),
|
|
103
109
|
v2: { session: { question: {
|
|
104
|
-
async reply({ sessionID, requestID, questionV2Reply }) {
|
|
110
|
+
async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
|
|
105
111
|
const api = await resolveFormApi(sessionID)
|
|
106
112
|
const form = await nativeReply(() => api.get({ sessionID, formID: requestID }))
|
|
107
113
|
if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422)
|
|
114
|
+
if (expectedNativeRevision && questionProperties(form).nativeRevision !== expectedNativeRevision) {
|
|
115
|
+
throw Object.assign(new HttpError("The native question changed; read it again", 409), { code: "question_changed_read_again" })
|
|
116
|
+
}
|
|
108
117
|
const answers = questionV2Reply.answers
|
|
109
118
|
if (!Array.isArray(answers) || answers.length !== form.fields.length) {
|
|
110
119
|
throw new HttpError("Question answer count does not match the form", 422)
|
|
@@ -132,16 +141,27 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
|
|
|
132
141
|
},
|
|
133
142
|
} } },
|
|
134
143
|
async ownsEvent(event) {
|
|
135
|
-
if (event.location?.directory
|
|
144
|
+
if (event.location?.directory === ctx.location.directory) return true
|
|
136
145
|
const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID
|
|
137
146
|
if (!sessionID) return event.type === "installation.update-available"
|
|
138
147
|
if (sessionID === "global") return false
|
|
139
148
|
try {
|
|
140
|
-
const session = await
|
|
141
|
-
return session
|
|
149
|
+
const session = await liveBridge.get(sessionID)
|
|
150
|
+
return liveBridge.ownsSession(session)
|
|
142
151
|
} catch {
|
|
143
152
|
return false
|
|
144
153
|
}
|
|
145
154
|
},
|
|
146
155
|
}
|
|
156
|
+
client.liveBridge.questions = async (sessionID) => {
|
|
157
|
+
const api = await resolveFormApi(sessionID)
|
|
158
|
+
const forms = await nativeReply(() => api.list({ sessionID }))
|
|
159
|
+
if (!Array.isArray(forms)) throw new HttpError("Invalid question list", 502)
|
|
160
|
+
return forms.filter(form => form.sessionID === sessionID && questionForm(form) &&
|
|
161
|
+
(!form.state || form.state.status === "pending")).map(questionProperties)
|
|
162
|
+
}
|
|
163
|
+
client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
|
|
164
|
+
sessionID: pending.sessionID, requestID: pending.id, questionV2Reply: { answers }, expectedNativeRevision: pending.nativeRevision,
|
|
165
|
+
})
|
|
166
|
+
return client
|
|
147
167
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdir, readFile, readlink } from "node:fs/promises"
|
|
2
|
+
import { Service } from "@opencode/client/service"
|
|
3
|
+
|
|
4
|
+
import { HttpError } from "./http.js"
|
|
5
|
+
|
|
6
|
+
// Inspect only sockets already owned by this server; never scan other ports.
|
|
7
|
+
export async function ownLoopbackUrls({ platform = process.platform, fs = { readdir, readFile, readlink } } = {}) {
|
|
8
|
+
if (platform !== "linux") return []
|
|
9
|
+
const inodes = new Set((await Promise.all((await fs.readdir("/proc/self/fd")).map(async fd => {
|
|
10
|
+
try { return /^socket:\[(\d+)\]$/.exec(await fs.readlink(`/proc/self/fd/${fd}`))?.[1] }
|
|
11
|
+
catch { return undefined } // Descriptors can close while being inspected.
|
|
12
|
+
}))).filter(Boolean))
|
|
13
|
+
const tables = await Promise.all(["tcp", "tcp6"].map(async name => {
|
|
14
|
+
try { return await fs.readFile(`/proc/self/net/${name}`, "utf8") }
|
|
15
|
+
catch (error) { if (error.code === "ENOENT") return ""; throw error }
|
|
16
|
+
}))
|
|
17
|
+
const urls = new Set()
|
|
18
|
+
for (const table of tables) for (const row of table.trim().split("\n").slice(1)) {
|
|
19
|
+
const fields = row.trim().split(/\s+/)
|
|
20
|
+
if (fields[3] !== "0A" || !inodes.has(fields[9])) continue
|
|
21
|
+
const [address, portHex] = fields[1].split(":")
|
|
22
|
+
const host = address === "0100007F" ? "127.0.0.1"
|
|
23
|
+
: address === "00000000000000000000000001000000" ? "[::1]" : undefined
|
|
24
|
+
const port = Number.parseInt(portHex, 16)
|
|
25
|
+
if (host && port > 0 && port <= 65535) urls.add(`http://${host}:${port}`)
|
|
26
|
+
}
|
|
27
|
+
return [...urls]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function privateEndpoint({ argv = process.argv, env = process.env, pid = process.pid,
|
|
31
|
+
urls = ownLoopbackUrls, request = fetch } = {}) {
|
|
32
|
+
if (!argv.includes("--stdio")) return undefined
|
|
33
|
+
const flag = argv.indexOf("--port")
|
|
34
|
+
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
|
|
35
|
+
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
|
|
36
|
+
const password = env.OPENCODE_PASSWORD
|
|
37
|
+
try {
|
|
38
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error()
|
|
39
|
+
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
|
|
40
|
+
for (const url of candidates) {
|
|
41
|
+
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
|
42
|
+
let response
|
|
43
|
+
try {
|
|
44
|
+
response = await request(`${url}/api/info`, {
|
|
45
|
+
headers: Service.headers(endpoint), signal: AbortSignal.timeout(2000), redirect: "error",
|
|
46
|
+
})
|
|
47
|
+
if (response.ok && (await response.json()).pid === pid) return endpoint
|
|
48
|
+
} catch { /* Another listener owned by this process is not its native API. */ }
|
|
49
|
+
finally { await response?.body?.cancel().catch(() => {}) }
|
|
50
|
+
}
|
|
51
|
+
} catch { /* Report only a safe diagnostic, never upstream credentials or output. */ }
|
|
52
|
+
throw Object.assign(new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
|
|
53
|
+
{ code: "question_private_server_unavailable" })
|
|
54
|
+
}
|
package/src/opencode-v2-forms.js
CHANGED
|
@@ -2,23 +2,34 @@ import { OpenCode } from "@opencode/client"
|
|
|
2
2
|
import { Service } from "@opencode/client/service"
|
|
3
3
|
|
|
4
4
|
import { HttpError } from "./http.js"
|
|
5
|
+
import { privateEndpoint } from "./opencode-v2-endpoint.js"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
function unavailable(code, message, status = 501) {
|
|
8
|
+
return Object.assign(new HttpError(message, status), { code })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getFormApi(ctx, sessionID, { makeClient = OpenCode.make, ownsSession, resolvePrivate = privateEndpoint } = {}) {
|
|
7
12
|
// 2.0.26 exposes forms on the client API, but not on the plugin context.
|
|
8
|
-
//
|
|
9
|
-
|
|
13
|
+
// Prefer the current private server; never start a second service or change it.
|
|
14
|
+
// discover() itself checks the registered service's protocol compatibility.
|
|
15
|
+
// An app patch-version difference must not hide a compatible owning service.
|
|
16
|
+
const endpoint = await resolvePrivate() ?? await Service.discover({ version: ctx.app.version }) ?? await Service.discover()
|
|
10
17
|
if (!endpoint) {
|
|
11
|
-
throw
|
|
18
|
+
throw unavailable("question_shared_service_unavailable", "Remote questions require a compatible running OpenCode V2 shared service")
|
|
12
19
|
}
|
|
13
20
|
const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
|
14
21
|
let session
|
|
15
22
|
try {
|
|
16
23
|
session = await client.session.get({ sessionID })
|
|
17
24
|
} catch {
|
|
18
|
-
throw
|
|
25
|
+
throw unavailable("question_session_not_in_shared_service", "The question session is not owned by the shared OpenCode service")
|
|
26
|
+
}
|
|
27
|
+
if (session.id !== undefined && session.id !== sessionID) {
|
|
28
|
+
throw unavailable("question_session_not_in_shared_service", "The shared service returned another question session")
|
|
19
29
|
}
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
const owned = ownsSession ? await ownsSession(session) : session.location?.directory === ctx.location.directory
|
|
31
|
+
if (!owned) {
|
|
32
|
+
throw unavailable("question_session_location_mismatch", "The question session belongs to another OpenCode location", 403)
|
|
22
33
|
}
|
|
23
34
|
return client.session.form
|
|
24
35
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js"
|
|
2
|
+
import { createLiveBridge } from "./live-bridge.js"
|
|
3
|
+
import { createV2Client, normalizeV2Event } from "./opencode-v2-client.js"
|
|
4
|
+
import { HttpError } from "./http.js"
|
|
5
|
+
|
|
6
|
+
// The public TUI plugin context injects the client of the actual connected
|
|
7
|
+
// OpenCode owner, including standalone processes. Never discover another server.
|
|
8
|
+
export async function setup(ctx) {
|
|
9
|
+
if (ctx.options?.liveBridge?.owner !== "tui" || !ctx.location?.directory) return
|
|
10
|
+
const native = ctx.client
|
|
11
|
+
if (!native?.session?.form?.list || !native.session.form.get || !native.session.form.reply ||
|
|
12
|
+
!native.session.form.cancel || !native.session.get || !native.session.prompt) {
|
|
13
|
+
throw new HttpError("The injected OpenCode client does not expose native question forms", 501)
|
|
14
|
+
}
|
|
15
|
+
const config = loadConfig(ctx.options)
|
|
16
|
+
if (!config.liveBridge.enabled) return
|
|
17
|
+
const ownerContext = { app: ctx.app, options: ctx.options, location: ctx.location, session: native.session }
|
|
18
|
+
let client
|
|
19
|
+
client = createV2Client(ownerContext, async sessionID => {
|
|
20
|
+
const info = await native.session.get({ sessionID })
|
|
21
|
+
if (info.id !== sessionID || !await client.liveBridge.ownsSession(info)) {
|
|
22
|
+
throw Object.assign(new HttpError("The native question session is outside the verified project", 403),
|
|
23
|
+
{ code: "question_session_location_mismatch" })
|
|
24
|
+
}
|
|
25
|
+
return native.session.form
|
|
26
|
+
})
|
|
27
|
+
client.liveBridge.questionTransport = "injected_native_client"
|
|
28
|
+
const bridge = await createLiveBridge({ config, client, directory: ctx.location.directory })
|
|
29
|
+
let disposed = false
|
|
30
|
+
// Public native data events, not terminal content or UI automation. New
|
|
31
|
+
// subscribers read authoritative pending forms directly from the same owner.
|
|
32
|
+
const unlisten = ctx.data.listen(({ details }) => {
|
|
33
|
+
void (async () => {
|
|
34
|
+
if (disposed || !await client.ownsEvent(details)) return
|
|
35
|
+
for (const event of normalizeV2Event(details)) {
|
|
36
|
+
if (disposed) return
|
|
37
|
+
await bridge.handle(event)
|
|
38
|
+
}
|
|
39
|
+
})().catch(() => { /* A failed event must not affect the native TUI or active work. */ })
|
|
40
|
+
})
|
|
41
|
+
let cleanup
|
|
42
|
+
return () => cleanup ??= (async () => {
|
|
43
|
+
disposed = true
|
|
44
|
+
unlisten()
|
|
45
|
+
await bridge.dispose()
|
|
46
|
+
})()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default { id: "backeer-opencode", setup }
|
package/src/plugin.js
CHANGED
|
@@ -48,7 +48,9 @@ export default async function BackeerPlugin({ client, directory, feedback, v2 =
|
|
|
48
48
|
const reporter = createOpenCodeReporter(client, config.logLevel)
|
|
49
49
|
const conversation = feedback ?? createConversationFeedback({ client, reporter })
|
|
50
50
|
if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error)
|
|
51
|
-
const
|
|
51
|
+
const bridgeConfig = config.liveBridge.owner === "tui"
|
|
52
|
+
? { ...config, liveBridge: { enabled: false } } : config
|
|
53
|
+
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
52
54
|
reporter.error("Live control unavailable", error)
|
|
53
55
|
return { async handle() {}, async dispose() {} }
|
|
54
56
|
})
|
package/src/question-command.js
CHANGED
|
@@ -14,22 +14,27 @@ export function validateQuestionCommand(command, pending, now = Date.now()) {
|
|
|
14
14
|
}
|
|
15
15
|
if (response.action === "reject") return
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const errorCode = validateQuestionAnswers(response.answers, pending.questions)
|
|
18
|
+
if (errorCode) return { status: "rejected", action: response.action, errorCode }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateQuestionAnswers(values, questions) {
|
|
22
|
+
if (!Array.isArray(values) || values.length !== questions.length) {
|
|
23
|
+
return "invalid_answers"
|
|
19
24
|
}
|
|
20
|
-
for (let index = 0; index <
|
|
21
|
-
const question =
|
|
22
|
-
const answers =
|
|
25
|
+
for (let index = 0; index < questions.length; index++) {
|
|
26
|
+
const question = questions[index]
|
|
27
|
+
const answers = values[index]
|
|
23
28
|
if (!Array.isArray(answers) || (!question.multiple && answers.length > 1)) {
|
|
24
|
-
return
|
|
29
|
+
return "invalid_answers"
|
|
25
30
|
}
|
|
26
31
|
for (const answer of answers) {
|
|
27
32
|
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
28
|
-
return
|
|
33
|
+
return "invalid_answers"
|
|
29
34
|
}
|
|
30
35
|
const known = question.options.some((option) => option.label === answer)
|
|
31
36
|
if (!known && !question.custom) {
|
|
32
|
-
return
|
|
37
|
+
return "custom_not_allowed"
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
}
|
package/src/updates.js
CHANGED