backeer-opencode 0.6.0 → 0.8.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 +13 -0
- package/README.md +1 -1
- package/dist/backeer-permission.js +1122 -984
- package/docs/live-control.md +28 -1
- package/package.json +7 -4
- package/scripts/check-package.mjs +28 -0
- package/src/live-bridge-client.js +16 -2
- package/src/live-bridge-events.js +3 -1
- package/src/live-bridge.js +67 -24
- package/src/live-control-client.js +10 -0
- package/src/live-control-mcp.js +8 -1
- package/src/live-discovery.js +58 -0
- package/src/live-managed-mcp.js +20 -8
- package/src/live-question-tools.js +13 -0
- package/src/live-questions.js +48 -0
- package/src/live-session-manager.js +83 -5
- package/src/opencode-v2-client.js +26 -14
- package/src/question-command.js +13 -8
- package/src/updates.js +1 -1
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
2
|
import { LiveControlClient, readLiveControlToken } from "./live-control-client.js"
|
|
3
|
-
import { ConnectionError, loadConnectionProfiles } from "./live-profiles.js"
|
|
3
|
+
import { ConnectionError, loadConnectionProfiles, saveConnectionProfiles, connectionProfile } from "./live-profiles.js"
|
|
4
|
+
import { discoverLiveConnections, discoveryDefaults } from "./live-discovery.js"
|
|
4
5
|
|
|
5
6
|
// Bindings are immutable capabilities, kept in the calling chat, never a global "current session".
|
|
6
7
|
// Each binding owns its SSE client/cursor, even when two chats target the same session.
|
|
7
8
|
export class LiveSessionManager {
|
|
8
|
-
constructor({ profilesFile, clientFactory = options => new LiveControlClient(options), maxBindings = 16
|
|
9
|
+
constructor({ profilesFile, clientFactory = options => new LiveControlClient(options), maxBindings = 16,
|
|
10
|
+
discovery = discoveryDefaults() }) {
|
|
9
11
|
this.profilesFile = profilesFile
|
|
10
12
|
this.clientFactory = clientFactory
|
|
11
13
|
this.maxBindings = maxBindings
|
|
12
14
|
this.bindings = new Map()
|
|
13
15
|
this.clients = new Set()
|
|
14
16
|
this.closed = false
|
|
17
|
+
this.discovery = discovery
|
|
18
|
+
this.candidates = new Map()
|
|
19
|
+
this.saving = Promise.resolve()
|
|
20
|
+
this.shutdown = new AbortController()
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
async profiles() {
|
|
@@ -39,13 +45,14 @@ export class LiveSessionManager {
|
|
|
39
45
|
finally { this.clients.delete(client) }
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
async listSessions(connection) {
|
|
48
|
+
async listSessions(connection, { includeChildren = false } = {}) {
|
|
43
49
|
const profile = await this.profile(connection)
|
|
44
50
|
const client = await this.client(profile)
|
|
45
51
|
try {
|
|
46
52
|
const result = await client.listSessions()
|
|
47
53
|
this.checkProject(profile, result)
|
|
48
|
-
return { connection, ...result
|
|
54
|
+
return { connection, ...result, sessions: result.sessions.filter(item => includeChildren || !item.parentID),
|
|
55
|
+
subagentCount: result.sessions.filter(item => item.parentID).length }
|
|
49
56
|
} finally { await this.release(client) }
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -55,7 +62,17 @@ export class LiveSessionManager {
|
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
async connect(connection, sessionId) {
|
|
65
|
+
async connect(connection, sessionId, { sessionTitle } = {}) {
|
|
66
|
+
if ((sessionId === undefined) === (sessionTitle === undefined)) {
|
|
67
|
+
throw new ConnectionError("Choose exactly one session ID or exact session title from the current list.")
|
|
68
|
+
}
|
|
69
|
+
if (sessionTitle !== undefined) {
|
|
70
|
+
if (typeof sessionTitle !== "string" || !sessionTitle.trim() || sessionTitle.length > 240) throw new ConnectionError("Invalid session title.")
|
|
71
|
+
const result = await this.listSessions(connection)
|
|
72
|
+
const matches = result.sessions.filter(item => item.title === sessionTitle)
|
|
73
|
+
if (matches.length !== 1) throw new ConnectionError("Session title is missing or ambiguous. Refresh the session list and select an exact session ID.")
|
|
74
|
+
sessionId = matches[0].id
|
|
75
|
+
}
|
|
59
76
|
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || sessionId === "global") {
|
|
60
77
|
throw new ConnectionError("Invalid OpenCode session ID.")
|
|
61
78
|
}
|
|
@@ -104,6 +121,16 @@ export class LiveSessionManager {
|
|
|
104
121
|
return value.client.sendInstruction({ sessionId: value.sessionId, requestId, prompt, signal })
|
|
105
122
|
}
|
|
106
123
|
|
|
124
|
+
async questions(bindingId) {
|
|
125
|
+
const value = await this.verified(bindingId)
|
|
126
|
+
return value.client.listQuestions(value.sessionId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async answer(bindingId, args, signal) {
|
|
130
|
+
const value = await this.verified(bindingId)
|
|
131
|
+
return value.client.answerQuestion({ ...args, sessionId: value.sessionId, signal })
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
async read(bindingId, options, waitMs = 0, signal) {
|
|
108
135
|
const value = await this.verified(bindingId)
|
|
109
136
|
return value.client.readEvents({ ...options, sessionId: value.sessionId }, { waitMs, signal })
|
|
@@ -116,9 +143,60 @@ export class LiveSessionManager {
|
|
|
116
143
|
return { disconnected: true, remoteWorkStopped: false }
|
|
117
144
|
}
|
|
118
145
|
|
|
146
|
+
async discover(options = {}, signal) {
|
|
147
|
+
const controller = new AbortController()
|
|
148
|
+
const abort = () => controller.abort()
|
|
149
|
+
this.shutdown.signal.addEventListener("abort", abort, { once: true })
|
|
150
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
151
|
+
if (signal?.aborted || this.closed) abort()
|
|
152
|
+
let result
|
|
153
|
+
try { result = await discoverLiveConnections({ profiles: await this.profiles(), defaults: this.discovery, ...options, signal: controller.signal }) }
|
|
154
|
+
finally {
|
|
155
|
+
this.shutdown.signal.removeEventListener("abort", abort)
|
|
156
|
+
signal?.removeEventListener("abort", abort)
|
|
157
|
+
}
|
|
158
|
+
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
159
|
+
const candidates = []
|
|
160
|
+
for (const candidate of result.candidates) {
|
|
161
|
+
let id = [...this.candidates].find(([, item]) => JSON.stringify(item.profile) === JSON.stringify(candidate.profile))?.[0]
|
|
162
|
+
if (!id) {
|
|
163
|
+
if (this.candidates.size >= 32) throw new ConnectionError("Discovery candidate limit reached. Restart discovery in a new MCP process.")
|
|
164
|
+
id = randomUUID()
|
|
165
|
+
}
|
|
166
|
+
this.candidates.set(id, candidate)
|
|
167
|
+
candidates.push({ candidateId: id, savedName: candidate.profile.name === "discovered" ? undefined : candidate.profile.name,
|
|
168
|
+
url: candidate.profile.url, directory: candidate.profile.directory, sessions: candidate.sessions, subagentCount: candidate.subagentCount })
|
|
169
|
+
}
|
|
170
|
+
return { candidates, diagnostics: candidates.length ? [] : result.diagnostics,
|
|
171
|
+
nextStep: candidates.length ? "Select the intended connection, save it with a short name, then choose a session by title."
|
|
172
|
+
: "Open the intended SSH tunnel and select an existing protected live-control token file. Do not paste credentials into chat." }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
saveDiscovered(candidateId, name) {
|
|
176
|
+
// Serialize local saves so concurrent chats cannot overwrite each other's connection profiles.
|
|
177
|
+
const save = this.saving.then(async () => {
|
|
178
|
+
const candidate = this.candidates.get(candidateId)
|
|
179
|
+
if (!candidate || this.closed) throw new ConnectionError("Discovery candidate not found. Discover connections again.")
|
|
180
|
+
const profile = connectionProfile({ ...candidate.profile, name })
|
|
181
|
+
const client = await this.client(profile)
|
|
182
|
+
try { this.checkProject(profile, await client.listSessions()) }
|
|
183
|
+
finally { await this.release(client) }
|
|
184
|
+
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
185
|
+
const profiles = await this.profiles()
|
|
186
|
+
const existing = profiles.find(item => item.name === name)
|
|
187
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(profile)) throw new ConnectionError("Connection name already belongs to another target. Choose another name.")
|
|
188
|
+
if (!existing) await saveConnectionProfiles(this.profilesFile, [...profiles, profile])
|
|
189
|
+
return { connection: name, directory: profile.directory, saved: true, sessionStored: false }
|
|
190
|
+
})
|
|
191
|
+
this.saving = save.catch(() => {})
|
|
192
|
+
return save
|
|
193
|
+
}
|
|
194
|
+
|
|
119
195
|
async close() {
|
|
120
196
|
this.closed = true
|
|
197
|
+
this.shutdown.abort()
|
|
121
198
|
this.bindings.clear()
|
|
199
|
+
this.candidates.clear()
|
|
122
200
|
await Promise.allSettled([...this.clients].map(client => this.release(client)))
|
|
123
201
|
}
|
|
124
202
|
}
|
|
@@ -28,25 +28,26 @@ function questionForm(form) {
|
|
|
28
28
|
)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function questionProperties(form) {
|
|
32
|
+
return { id: form.id, sessionID: form.sessionID,
|
|
33
|
+
questions: form.fields.map((field) => ({
|
|
34
|
+
header: field.title ?? form.title,
|
|
35
|
+
question: field.description ?? field.title ?? form.title,
|
|
36
|
+
options: (field.options ?? []).map((option) => ({
|
|
37
|
+
label: option.label, description: option.description ?? "",
|
|
38
|
+
})),
|
|
39
|
+
multiple: field.type === "multiselect",
|
|
40
|
+
custom: field.custom === true || !field.options?.length,
|
|
41
|
+
})) }
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export function normalizeV2Event(event) {
|
|
32
45
|
const properties = event.data ?? {}
|
|
33
46
|
const normalized = { ...event, properties }
|
|
34
47
|
if (event.type === "form.created") {
|
|
35
48
|
const form = properties.form
|
|
36
49
|
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
|
-
} }]
|
|
50
|
+
return [{ ...normalized, type: "question.v2.asked", properties: questionProperties(form) }]
|
|
50
51
|
}
|
|
51
52
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
52
53
|
return [{ ...normalized, type: event.type === "form.replied"
|
|
@@ -79,7 +80,7 @@ export function normalizeV2Event(event) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
|
|
82
|
-
|
|
83
|
+
const client = {
|
|
83
84
|
liveBridge: createV2LiveClient(ctx),
|
|
84
85
|
app: {
|
|
85
86
|
async log({ body }) {
|
|
@@ -144,4 +145,15 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
|
|
|
144
145
|
}
|
|
145
146
|
},
|
|
146
147
|
}
|
|
148
|
+
client.liveBridge.questions = async (sessionID) => {
|
|
149
|
+
const api = await resolveFormApi(sessionID)
|
|
150
|
+
const forms = await nativeReply(() => api.list({ sessionID }))
|
|
151
|
+
if (!Array.isArray(forms)) throw new HttpError("Invalid question list", 502)
|
|
152
|
+
return forms.filter(form => form.sessionID === sessionID && questionForm(form) &&
|
|
153
|
+
(!form.state || form.state.status === "pending")).map(questionProperties)
|
|
154
|
+
}
|
|
155
|
+
client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
|
|
156
|
+
sessionID: pending.sessionID, requestID: pending.id, questionV2Reply: { answers },
|
|
157
|
+
})
|
|
158
|
+
return client
|
|
147
159
|
}
|
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