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.
@@ -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
- return {
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
  }
@@ -14,22 +14,27 @@ export function validateQuestionCommand(command, pending, now = Date.now()) {
14
14
  }
15
15
  if (response.action === "reject") return
16
16
 
17
- if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
18
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
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 < pending.questions.length; index++) {
21
- const question = pending.questions[index]
22
- const answers = response.answers[index]
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 { status: "rejected", action: response.action, errorCode: "invalid_answers" }
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 { status: "rejected", action: response.action, errorCode: "invalid_answers" }
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 { status: "rejected", action: response.action, errorCode: "custom_not_allowed" }
37
+ return "custom_not_allowed"
33
38
  }
34
39
  }
35
40
  }
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.6.0"
1
+ export const PLUGIN_VERSION = "0.8.0"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"