backeer-opencode 0.8.2 → 0.10.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 +19 -0
- package/dist/backeer-permission.js +227 -284
- package/docs/live-control.md +25 -12
- package/package.json +1 -1
- package/src/delivery-tracker.js +4 -4
- package/src/diagnostics.js +3 -3
- package/src/event-publishers.js +33 -34
- package/src/feedback-session-store.js +3 -14
- package/src/http.js +14 -16
- package/src/lifecycle-controller.js +5 -5
- package/src/lifecycle-publishers.js +8 -9
- package/src/live-bridge-client.js +3 -4
- package/src/live-bridge-config.js +3 -4
- package/src/live-bridge-events.js +6 -9
- package/src/live-bridge.js +53 -76
- package/src/live-control-client.js +84 -38
- package/src/live-control-mcp.js +10 -10
- package/src/live-diagnostics.js +42 -0
- package/src/live-discovery.js +11 -3
- package/src/live-errors.js +4 -0
- package/src/live-managed-mcp.js +16 -10
- package/src/live-profiles.js +6 -6
- package/src/live-question-tools.js +5 -5
- package/src/live-questions.js +4 -5
- package/src/live-session-manager.js +101 -15
- package/src/live-session-scope.js +3 -4
- package/src/permission-controller.js +4 -4
- package/src/plugin.js +3 -0
- package/src/reporter.js +4 -8
- package/src/session-messages.js +3 -3
- package/src/sse.js +7 -7
- package/src/updates.js +1 -1
- package/src/utilities.js +3 -6
package/src/live-bridge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
|
-
import { randomUUID, timingSafeEqual } from "node:crypto"
|
|
2
|
+
import { randomUUID, timingSafeEqual, createHash } from "node:crypto"
|
|
3
3
|
import { resolve } from "node:path"
|
|
4
4
|
import { createV1LiveClient } from "./live-bridge-client.js"
|
|
5
5
|
import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
|
|
@@ -7,7 +7,6 @@ import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
|
|
|
7
7
|
import { questionSnapshot, resolveQuestionAnswers } from "./live-questions.js"
|
|
8
8
|
import { PLUGIN_VERSION } from "./updates.js"
|
|
9
9
|
|
|
10
|
-
const LIMITS = { events: 256, bytes: 2_000_000, clients: 8, commands: 1024, commandTTL: 86_400_000, body: 32_768, requests: 16 }
|
|
11
10
|
function failure(status, code) { return Object.assign(new Error(code), { status, code }) }
|
|
12
11
|
function json(response, status, value) {
|
|
13
12
|
if (!response.destroyed && !response.headersSent) {
|
|
@@ -17,10 +16,7 @@ function json(response, status, value) {
|
|
|
17
16
|
}
|
|
18
17
|
async function readBody(request) {
|
|
19
18
|
const chunks = []
|
|
20
|
-
let bytes = 0
|
|
21
19
|
for await (const chunk of request) {
|
|
22
|
-
bytes += chunk.length
|
|
23
|
-
if (bytes > LIMITS.body) throw failure(413, "body_too_large")
|
|
24
20
|
chunks.push(chunk)
|
|
25
21
|
}
|
|
26
22
|
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")) } catch { throw failure(400, "invalid_json") }
|
|
@@ -41,41 +37,46 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
41
37
|
const sanitizer = createBridgeSanitizer([token, config.token])
|
|
42
38
|
const epoch = randomUUID()
|
|
43
39
|
const events = [], streams = new Set(), commands = new Map(), states = new Map()
|
|
44
|
-
let sequence = 0,
|
|
45
|
-
|
|
46
|
-
const lookupWaiters = new Set()
|
|
40
|
+
let sequence = 0, disposed = false, disposeTask
|
|
41
|
+
const lookupWaiters = new Set(), lookups = new Map()
|
|
47
42
|
const cursor = () => `${epoch}:${sequence}`
|
|
48
43
|
|
|
49
|
-
function lookup(id) {
|
|
44
|
+
function lookup(id, signal) {
|
|
50
45
|
if (disposed) return Promise.reject(failure(503, "bridge_closed"))
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
let entry = lookups.get(id)
|
|
47
|
+
if (!entry) {
|
|
48
|
+
entry = { waiters: new Set() }
|
|
49
|
+
lookups.set(id, entry)
|
|
50
|
+
const settled = (ok, value) => {
|
|
51
|
+
lookups.delete(id)
|
|
52
|
+
for (const finish of [...entry.waiters]) finish(ok, value)
|
|
53
|
+
}
|
|
54
|
+
// Retrying readers share one in-flight native lookup for the exact ID.
|
|
55
|
+
// Aborted HTTP readers are removable subscribers, not retained Promise callbacks.
|
|
56
|
+
Promise.resolve().then(() => api.get(id)).then(value => settled(true, value), error => settled(false, error))
|
|
57
|
+
}
|
|
53
58
|
return new Promise((resolve, reject) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (finished) return
|
|
57
|
-
finished = true
|
|
58
|
-
clearTimeout(timer)
|
|
59
|
+
const finish = (ok, value) => {
|
|
60
|
+
if (!entry.waiters.delete(finish)) return
|
|
59
61
|
lookupWaiters.delete(cancel)
|
|
60
|
-
|
|
62
|
+
signal?.removeEventListener("abort", aborted)
|
|
63
|
+
if (ok) resolve(value)
|
|
64
|
+
else reject(value)
|
|
61
65
|
}
|
|
62
|
-
const cancel = () => finish(
|
|
63
|
-
const
|
|
66
|
+
const cancel = () => finish(false, failure(503, "bridge_closed"))
|
|
67
|
+
const aborted = () => finish(false, failure(499, "request_cancelled"))
|
|
68
|
+
entry.waiters.add(finish)
|
|
64
69
|
lookupWaiters.add(cancel)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Promise.resolve().then(() => api.get(id)).then(
|
|
68
|
-
(value) => { lookups--; finish(resolve, value) },
|
|
69
|
-
(error) => { lookups--; finish(reject, error) },
|
|
70
|
-
)
|
|
70
|
+
signal?.addEventListener("abort", aborted, { once: true })
|
|
71
|
+
if (signal?.aborted) aborted()
|
|
71
72
|
})
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
async function session(id) {
|
|
75
|
+
async function session(id, signal) {
|
|
75
76
|
if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session")
|
|
76
77
|
let info
|
|
77
|
-
try { info = await lookup(id) } catch (error) {
|
|
78
|
-
if (["
|
|
78
|
+
try { info = await lookup(id, signal) } catch (error) {
|
|
79
|
+
if (["bridge_closed", "request_cancelled"].includes(error.code)) throw error
|
|
79
80
|
throw failure(404, "session_not_found")
|
|
80
81
|
}
|
|
81
82
|
if (info?.id !== id || typeof info.time?.archived === "number") {
|
|
@@ -95,18 +96,12 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
95
96
|
const removed = streams.delete(stream)
|
|
96
97
|
clearInterval(stream.heartbeat)
|
|
97
98
|
stream.queue.length = 0
|
|
98
|
-
stream.queuedBytes = 0
|
|
99
99
|
if (removed && !streams.size && !disposed) connectionNotifications.update(false)
|
|
100
100
|
}
|
|
101
101
|
function write(stream, value) {
|
|
102
102
|
if (!streams.has(stream)) return
|
|
103
103
|
if (stream.blocked) {
|
|
104
104
|
stream.queue.push(value)
|
|
105
|
-
stream.queuedBytes += Buffer.byteLength(value)
|
|
106
|
-
if (stream.queuedBytes > LIMITS.bytes) {
|
|
107
|
-
removeStream(stream)
|
|
108
|
-
stream.response.destroy()
|
|
109
|
-
}
|
|
110
105
|
return
|
|
111
106
|
}
|
|
112
107
|
if (!stream.response.write(value)) {
|
|
@@ -124,17 +119,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
124
119
|
if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status)
|
|
125
120
|
if (safe.type === "session.idle") states.set(safe.sessionID, "idle")
|
|
126
121
|
if (safe.type === "session.deleted") states.delete(safe.sessionID)
|
|
127
|
-
if (states.size > 256) states.delete(states.keys().next().value)
|
|
128
122
|
const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() }
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
record.data = { truncated: true }
|
|
132
|
-
frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
|
|
133
|
-
}
|
|
134
|
-
const size = Buffer.byteLength(frame)
|
|
135
|
-
events.push({ sequence, sessionID: record.sessionID, frame, size })
|
|
136
|
-
bytes += size
|
|
137
|
-
while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size
|
|
123
|
+
const frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
|
|
124
|
+
events.push({ sequence, sessionID: record.sessionID, frame })
|
|
138
125
|
for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame)
|
|
139
126
|
}
|
|
140
127
|
function authorized(request) {
|
|
@@ -145,7 +132,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
145
132
|
}
|
|
146
133
|
function parseCursor(value) {
|
|
147
134
|
if (!value) return { after: 0 }
|
|
148
|
-
const match = /^([a-f0-9-]{36}):(\d
|
|
135
|
+
const match = /^([a-f0-9-]{36}):(\d+)$/.exec(value)
|
|
149
136
|
if (!match) throw failure(400, "invalid_cursor")
|
|
150
137
|
if (match[1] !== epoch) return { gap: "stream_restarted" }
|
|
151
138
|
const after = Number(match[2])
|
|
@@ -154,9 +141,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
154
141
|
return { after }
|
|
155
142
|
}
|
|
156
143
|
function cachedCommand(requestId, sessionID, signature) {
|
|
157
|
-
for (const [id, command] of commands) {
|
|
158
|
-
if (command.settled && Date.now() - command.created >= LIMITS.commandTTL) commands.delete(id)
|
|
159
|
-
}
|
|
160
144
|
const existing = commands.get(requestId)
|
|
161
145
|
if (existing && (existing.sessionID !== sessionID || existing.signature !== signature)) {
|
|
162
146
|
throw failure(409, "request_id_conflict")
|
|
@@ -166,17 +150,16 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
166
150
|
async function execute(requestId, sessionID, signature, operation, questionId) {
|
|
167
151
|
let command = cachedCommand(requestId, sessionID, signature)
|
|
168
152
|
if (!command) {
|
|
169
|
-
if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full")
|
|
170
153
|
// Another chat/mobile/native UI may answer too: OpenCode is authoritative.
|
|
171
154
|
// Reserve locally before calling it so concurrent request IDs cannot submit twice.
|
|
172
155
|
if (questionId && [...commands.values()].some(item => item.sessionID === sessionID && item.questionId === questionId)) {
|
|
173
156
|
throw failure(409, "question_reply_already_submitted")
|
|
174
157
|
}
|
|
175
|
-
command = { sessionID, signature, questionId,
|
|
158
|
+
command = { sessionID, signature, questionId,
|
|
176
159
|
result: { requestId, sessionID, ...(questionId ? { questionId } : {}),
|
|
177
160
|
status: questionId ? "answered" : "accepted" } }
|
|
178
161
|
commands.set(requestId, command)
|
|
179
|
-
command.task = Promise.resolve().then(operation).
|
|
162
|
+
command.task = Promise.resolve().then(operation).then(() => undefined)
|
|
180
163
|
}
|
|
181
164
|
await command.task
|
|
182
165
|
return command.result
|
|
@@ -186,7 +169,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
186
169
|
const pending = await api.questions(sessionID)
|
|
187
170
|
if (!Array.isArray(pending)) throw failure(502, "invalid_question_list")
|
|
188
171
|
const own = pending.filter(item => item.sessionID === sessionID)
|
|
189
|
-
if (own.length > 16) throw failure(413, "too_many_questions")
|
|
190
172
|
return own
|
|
191
173
|
}
|
|
192
174
|
async function route(request, response) {
|
|
@@ -195,10 +177,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
195
177
|
request.headers["x-backeer-project"] !== encodeURIComponent(directory)) {
|
|
196
178
|
return json(response, 409, { error: "project_mismatch" })
|
|
197
179
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
timeout.unref()
|
|
180
|
+
const controller = new AbortController()
|
|
181
|
+
const closed = () => controller.abort()
|
|
182
|
+
response.once("close", closed)
|
|
202
183
|
try {
|
|
203
184
|
const url = new URL(request.url, "http://127.0.0.1")
|
|
204
185
|
if (url.username || url.password) throw failure(400, "invalid_url")
|
|
@@ -207,7 +188,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
207
188
|
const values = Array.isArray(result) ? result : result?.data
|
|
208
189
|
if (!Array.isArray(values)) throw failure(502, "invalid_session_list")
|
|
209
190
|
const own = []
|
|
210
|
-
for (const info of values
|
|
191
|
+
for (const info of values) {
|
|
211
192
|
if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue
|
|
212
193
|
if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info)
|
|
213
194
|
}
|
|
@@ -224,11 +205,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
224
205
|
}
|
|
225
206
|
if (request.method === "GET" && url.pathname === "/v1/events") {
|
|
226
207
|
const selected = url.searchParams.get("sessionID") || undefined
|
|
227
|
-
if (selected) await session(selected)
|
|
208
|
+
if (selected) await session(selected, controller.signal)
|
|
228
209
|
const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"])
|
|
229
210
|
if (response.headersSent) return
|
|
230
|
-
if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams")
|
|
231
|
-
clearTimeout(timeout)
|
|
232
211
|
response.writeHead(200, {
|
|
233
212
|
"content-type": "text/event-stream", "cache-control": "no-cache, no-transform",
|
|
234
213
|
"x-accel-buffering": "no", connection: "keep-alive",
|
|
@@ -238,7 +217,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
238
217
|
return
|
|
239
218
|
}
|
|
240
219
|
const stream = { response, sessionID: selected, heartbeat: undefined,
|
|
241
|
-
blocked: false, queue: []
|
|
220
|
+
blocked: false, queue: [] }
|
|
242
221
|
streams.add(stream)
|
|
243
222
|
connectionNotifications.update(true)
|
|
244
223
|
request.socket.setTimeout(0)
|
|
@@ -248,7 +227,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
248
227
|
stream.blocked = false
|
|
249
228
|
while (streams.has(stream) && !stream.blocked && stream.queue.length) {
|
|
250
229
|
const next = stream.queue.shift()
|
|
251
|
-
stream.queuedBytes -= Buffer.byteLength(next)
|
|
252
230
|
write(stream, next)
|
|
253
231
|
}
|
|
254
232
|
})
|
|
@@ -263,13 +241,12 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
263
241
|
}
|
|
264
242
|
return
|
|
265
243
|
}
|
|
266
|
-
const match = /^\/v1\/sessions\/([A-Za-z0-9_-]
|
|
244
|
+
const match = /^\/v1\/sessions\/([A-Za-z0-9_-]+)(\/instructions|\/questions(?:\/([A-Za-z0-9_-]+)\/answers)?)?$/.exec(url.pathname)
|
|
267
245
|
if (!match) throw failure(404, "route_not_found")
|
|
268
|
-
if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]))
|
|
246
|
+
if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1], controller.signal))
|
|
269
247
|
if (request.method === "GET" && match[2] === "/questions") {
|
|
270
|
-
await session(match[1])
|
|
248
|
+
await session(match[1], controller.signal)
|
|
271
249
|
const result = { questions: (await questions(match[1])).map(item => questionSnapshot(item, sanitizer.text)) }
|
|
272
|
-
if (Buffer.byteLength(JSON.stringify(result)) > 240_000) throw failure(413, "questions_too_large")
|
|
273
250
|
return json(response, 200, result)
|
|
274
251
|
}
|
|
275
252
|
if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed")
|
|
@@ -278,8 +255,8 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
278
255
|
if (match[3]) {
|
|
279
256
|
if (!body || !validBridgeID(body.requestId) || !/^[a-f0-9]{64}$/.test(body.revision) ||
|
|
280
257
|
Object.keys(body).some(key => !["requestId", "revision", "answers"].includes(key))) throw failure(400, "invalid_question_answer")
|
|
281
|
-
await session(match[1])
|
|
282
|
-
const signature = JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers })
|
|
258
|
+
await session(match[1], controller.signal)
|
|
259
|
+
const signature = createHash("sha256").update(JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers })).digest("hex")
|
|
283
260
|
const existing = cachedCommand(body.requestId, match[1], signature)
|
|
284
261
|
if (existing) { await existing.task; return json(response, 200, existing.result) }
|
|
285
262
|
const pending = (await questions(match[1])).find(item => item.id === match[3])
|
|
@@ -293,25 +270,24 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
293
270
|
}
|
|
294
271
|
if (match[2] !== "/instructions") throw failure(405, "method_not_allowed")
|
|
295
272
|
if (!body || !validBridgeID(body.requestId) || typeof body.prompt !== "string" ||
|
|
296
|
-
!body.prompt.trim() ||
|
|
273
|
+
!body.prompt.trim() ||
|
|
297
274
|
Object.keys(body).some((key) => !["requestId", "prompt"].includes(key))) throw failure(400, "invalid_instruction")
|
|
298
|
-
await session(match[1])
|
|
275
|
+
await session(match[1], controller.signal)
|
|
299
276
|
if (response.headersSent || disposed) return
|
|
300
|
-
const signature = JSON.stringify({ prompt: body.prompt })
|
|
277
|
+
const signature = createHash("sha256").update(JSON.stringify({ prompt: body.prompt })).digest("hex")
|
|
301
278
|
const existing = cachedCommand(body.requestId, match[1], signature)
|
|
302
279
|
const result = await execute(body.requestId, match[1], signature, () => api.prompt(match[1], body.prompt))
|
|
303
280
|
return json(response, existing ? 200 : 202, result)
|
|
304
281
|
} catch (error) {
|
|
305
282
|
json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" })
|
|
306
283
|
} finally {
|
|
307
|
-
|
|
308
|
-
clearTimeout(timeout)
|
|
284
|
+
response.removeListener("close", closed)
|
|
309
285
|
}
|
|
310
286
|
}
|
|
311
287
|
const server = createServer((request, response) => { void route(request, response) })
|
|
312
|
-
server.requestTimeout =
|
|
313
|
-
server.headersTimeout =
|
|
314
|
-
server.keepAliveTimeout =
|
|
288
|
+
server.requestTimeout = 0
|
|
289
|
+
server.headersTimeout = 0
|
|
290
|
+
server.keepAliveTimeout = 0
|
|
315
291
|
await new Promise((resolve, reject) => {
|
|
316
292
|
server.once("error", reject)
|
|
317
293
|
server.listen(options.port ?? 43871, "127.0.0.1", () => { server.off("error", reject); resolve() })
|
|
@@ -329,6 +305,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
329
305
|
events.length = 0
|
|
330
306
|
states.clear()
|
|
331
307
|
commands.clear()
|
|
308
|
+
lookups.clear()
|
|
332
309
|
sanitizer.clear()
|
|
333
310
|
api.dispose?.()
|
|
334
311
|
})
|
|
@@ -4,9 +4,10 @@ import { isAbsolute } from "node:path"
|
|
|
4
4
|
import { LIVE_RECONNECT_MS, LIVE_STALE_MS } from "./live-connection.js"
|
|
5
5
|
import { checkWindowsTokenAcl } from "./live-token-permissions.js"
|
|
6
6
|
import { liveErrorMessages } from "./live-errors.js"
|
|
7
|
+
import { connectionDiagnostic } from "./live-diagnostics.js"
|
|
7
8
|
|
|
8
|
-
const CURSOR = /^[A-Za-z0-9_-]
|
|
9
|
-
const SESSION = /^[A-Za-z0-9_-]
|
|
9
|
+
const CURSOR = /^[A-Za-z0-9_-]+:\d+$/
|
|
10
|
+
const SESSION = /^[A-Za-z0-9_-]+$/
|
|
10
11
|
|
|
11
12
|
export function liveControlOrigin(url) {
|
|
12
13
|
const parsed = new URL(url)
|
|
@@ -27,12 +28,12 @@ export async function readLiveControlToken(path) {
|
|
|
27
28
|
(constants.O_NONBLOCK ?? 0))
|
|
28
29
|
try {
|
|
29
30
|
const stat = await file.stat()
|
|
30
|
-
if (!stat.isFile() ||
|
|
31
|
+
if (!stat.isFile() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0) ||
|
|
31
32
|
(typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
32
33
|
throw new Error("Live-control token must be an owner-only regular file")
|
|
33
34
|
}
|
|
34
35
|
const token = (await file.readFile("utf8")).trim()
|
|
35
|
-
if (!/^[A-Za-z0-9_-]{32,
|
|
36
|
+
if (!/^[A-Za-z0-9_-]{32,}$/.test(token)) {
|
|
36
37
|
throw new Error("Live-control token file is invalid")
|
|
37
38
|
}
|
|
38
39
|
return token
|
|
@@ -68,14 +69,16 @@ function parseFrame(frame) {
|
|
|
68
69
|
return { type, id, value: JSON.parse(data.join("\n")) }
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
// One persistent SSE connection per MCP process; no interval polling or
|
|
72
|
+
// One persistent SSE connection per MCP process; no interval polling or imposed history quota.
|
|
72
73
|
export class LiveControlClient {
|
|
73
74
|
constructor({ url = "http://127.0.0.1:43871", token, fetchImpl = fetch,
|
|
74
|
-
maxEvents =
|
|
75
|
+
maxEvents = Infinity, maxBufferBytes = Infinity, reconnectMs = LIVE_RECONNECT_MS, expectedDirectory, sessionId } = {}) {
|
|
75
76
|
const origin = liveControlOrigin(url)
|
|
76
|
-
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{32,
|
|
77
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{32,}$/.test(token)) throw new Error("Live-control token is invalid")
|
|
77
78
|
this.url = origin
|
|
78
79
|
this.token = token
|
|
80
|
+
if (sessionId !== undefined) checkedSession(sessionId)
|
|
81
|
+
this.sessionId = sessionId
|
|
79
82
|
this.projectHeaders = expectedDirectory === undefined ? {} : { "X-Backeer-Project": encodeURIComponent(expectedDirectory) }
|
|
80
83
|
this.fetch = fetchImpl
|
|
81
84
|
this.maxEvents = maxEvents
|
|
@@ -92,6 +95,12 @@ export class LiveControlClient {
|
|
|
92
95
|
this.floor = undefined
|
|
93
96
|
this.task = null
|
|
94
97
|
this.starting = null
|
|
98
|
+
this.reconnectAttempts = 0
|
|
99
|
+
this.successfulConnections = 0
|
|
100
|
+
this.lastConnectedAt = null
|
|
101
|
+
this.lastDisconnectedAt = null
|
|
102
|
+
this.nextRetryAt = null
|
|
103
|
+
this.lastDiagnostic = null
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
async request(path, { method = "GET", body, signal, timeoutMs = 10000 } = {}) {
|
|
@@ -100,7 +109,8 @@ export class LiveControlClient {
|
|
|
100
109
|
this.controller.signal.addEventListener("abort", abort, { once: true })
|
|
101
110
|
signal?.addEventListener("abort", abort, { once: true })
|
|
102
111
|
if (this.controller.signal.aborted || signal?.aborted) controller.abort()
|
|
103
|
-
|
|
112
|
+
let timedOut = false
|
|
113
|
+
const timer = setTimeout(() => { timedOut = true; abort() }, timeoutMs)
|
|
104
114
|
try {
|
|
105
115
|
const response = await this.fetch(this.url + path, {
|
|
106
116
|
method, redirect: "error", signal: controller.signal,
|
|
@@ -109,22 +119,29 @@ export class LiveControlClient {
|
|
|
109
119
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
110
120
|
})
|
|
111
121
|
if (!response.ok) {
|
|
112
|
-
// Read
|
|
122
|
+
// Read the JSON code but return exclusively local allowlisted
|
|
113
123
|
// messages, never original upstream error text or arbitrary JSON data.
|
|
114
|
-
let code
|
|
124
|
+
let code, connectionCode
|
|
115
125
|
try {
|
|
116
126
|
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
117
|
-
const value = JSON.parse(await
|
|
127
|
+
const value = JSON.parse(await responseText(response))
|
|
118
128
|
if (Object.hasOwn(liveErrorMessages, value?.error)) code = value.error
|
|
129
|
+
if (value?.error === "project_mismatch") connectionCode = "project_mismatch"
|
|
119
130
|
}
|
|
120
131
|
} catch { /* Retain the generic status without exposing remote content. */ }
|
|
121
132
|
finally { await response.body?.cancel().catch(() => {}) }
|
|
122
133
|
const diagnostic = code ? `: ${liveErrorMessages[code]}` : ""
|
|
123
134
|
throw Object.assign(new Error(`Live-control request failed (HTTP ${response.status})${diagnostic}`),
|
|
124
|
-
{ status: response.status, ...(code ? { code } : {}) })
|
|
135
|
+
{ status: response.status, ...(code ? { code } : {}), ...(connectionCode ? { connectionCode } : {}) })
|
|
125
136
|
}
|
|
126
|
-
const text = await
|
|
137
|
+
const text = await responseText(response)
|
|
127
138
|
return JSON.parse(text)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const diagnostic = connectionDiagnostic(error, { session: /^\/v1\/sessions\//.test(path), write: method !== "GET", timeout: timedOut,
|
|
141
|
+
cancelled: this.controller.signal.aborted || signal?.aborted })
|
|
142
|
+
// Leave the native status/code available for exact-session checks. Only the
|
|
143
|
+
// allowlisted connection code is exposed by MCP, never the fetch message.
|
|
144
|
+
throw Object.assign(error, { connectionCode: diagnostic.code })
|
|
128
145
|
} finally {
|
|
129
146
|
clearTimeout(timer)
|
|
130
147
|
this.controller.signal.removeEventListener("abort", abort)
|
|
@@ -137,8 +154,8 @@ export class LiveControlClient {
|
|
|
137
154
|
listQuestions(id) { return this.request(`/v1/sessions/${checkedSession(id)}/questions`) }
|
|
138
155
|
|
|
139
156
|
answerQuestion({ sessionId, questionId, revision, answers, requestId, signal }) {
|
|
140
|
-
if (!/^[A-Za-z0-9_-]
|
|
141
|
-
!Array.isArray(answers) || answers.length < 1
|
|
157
|
+
if (!/^[A-Za-z0-9_-]+$/.test(requestId) || !/^[a-f0-9]{64}$/.test(revision) ||
|
|
158
|
+
!Array.isArray(answers) || answers.length < 1) throw new Error("Invalid question answer")
|
|
142
159
|
// No automatic POST retry. Reuse the exact requestId/payload on uncertainty.
|
|
143
160
|
return this.request(`/v1/sessions/${checkedSession(sessionId)}/questions/${checkedSession(questionId)}/answers`, {
|
|
144
161
|
method: "POST", body: { requestId, revision, answers }, signal,
|
|
@@ -146,9 +163,9 @@ export class LiveControlClient {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
async sendInstruction({ sessionId, requestId, prompt, signal }) {
|
|
149
|
-
if (!/^[A-Za-z0-9_-]
|
|
150
|
-
if (typeof prompt !== "string" || !prompt.trim()
|
|
151
|
-
throw new Error("Instruction must contain
|
|
166
|
+
if (!/^[A-Za-z0-9_-]+$/.test(requestId)) throw new Error("Invalid request ID")
|
|
167
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
168
|
+
throw new Error("Instruction must contain nonempty text")
|
|
152
169
|
}
|
|
153
170
|
const encodedSession = checkedSession(sessionId)
|
|
154
171
|
// Capture the pre-command cursor before POST: a fast agent may finish before the first read.
|
|
@@ -182,9 +199,11 @@ export class LiveControlClient {
|
|
|
182
199
|
this.floor = initial
|
|
183
200
|
this.readCursor = initial
|
|
184
201
|
this.task = this.runStream()
|
|
185
|
-
} catch {
|
|
202
|
+
} catch (error) {
|
|
186
203
|
if (this.controller.signal.aborted) return
|
|
187
204
|
this.status = "disconnected"
|
|
205
|
+
this.recordFailure(error)
|
|
206
|
+
this.nextRetryAt = Date.now() + this.reconnectMs
|
|
188
207
|
this.task = (async () => { await this.delay(this.reconnectMs); await this.runStream() })()
|
|
189
208
|
}
|
|
190
209
|
})().finally(() => { this.starting = null })
|
|
@@ -194,16 +213,19 @@ export class LiveControlClient {
|
|
|
194
213
|
|
|
195
214
|
async runStream() {
|
|
196
215
|
while (!this.controller.signal.aborted) {
|
|
216
|
+
if (this.lastDisconnectedAt !== null) this.reconnectAttempts++
|
|
217
|
+
this.nextRetryAt = null
|
|
197
218
|
this.status = "connecting"
|
|
198
219
|
let reader
|
|
199
220
|
let watchdog
|
|
200
221
|
let resync = false
|
|
222
|
+
let timedOut = false
|
|
201
223
|
const connection = new AbortController()
|
|
202
224
|
const abort = () => connection.abort()
|
|
203
225
|
this.controller.signal.addEventListener("abort", abort, { once: true })
|
|
204
226
|
const armWatchdog = (ms = LIVE_STALE_MS) => {
|
|
205
227
|
clearTimeout(watchdog)
|
|
206
|
-
watchdog = setTimeout(abort, ms)
|
|
228
|
+
watchdog = setTimeout(() => { timedOut = true; abort() }, ms)
|
|
207
229
|
}
|
|
208
230
|
armWatchdog(this.reconnectMs)
|
|
209
231
|
try {
|
|
@@ -217,7 +239,8 @@ export class LiveControlClient {
|
|
|
217
239
|
this.floor = initial
|
|
218
240
|
this.readCursor = initial
|
|
219
241
|
}
|
|
220
|
-
const
|
|
242
|
+
const selected = this.sessionId === undefined ? "" : `&sessionID=${encodeURIComponent(this.sessionId)}`
|
|
243
|
+
const response = await this.fetch(`${this.url}/v1/events?after=${encodeURIComponent(this.cursor)}${selected}`, {
|
|
221
244
|
redirect: "error", signal: connection.signal,
|
|
222
245
|
headers: { Authorization: `Bearer ${this.token}`, Accept: "text/event-stream", ...this.projectHeaders },
|
|
223
246
|
})
|
|
@@ -229,10 +252,14 @@ export class LiveControlClient {
|
|
|
229
252
|
this.status = response.status === 401 || response.status === 403 ? "unauthorized" : "disconnected"
|
|
230
253
|
this.notify()
|
|
231
254
|
await response.body?.cancel().catch(() => {})
|
|
232
|
-
throw new Error("SSE connection unavailable")
|
|
255
|
+
throw Object.assign(new Error("SSE connection unavailable"), { status: response.status,
|
|
256
|
+
...(response.ok ? { connectionCode: "invalid_bridge_response" } : {}) })
|
|
233
257
|
}
|
|
234
258
|
if (!response.body) throw new Error("SSE body missing")
|
|
235
259
|
this.status = "connected"
|
|
260
|
+
this.successfulConnections++
|
|
261
|
+
this.lastConnectedAt = Date.now()
|
|
262
|
+
this.lastDiagnostic = null
|
|
236
263
|
armWatchdog()
|
|
237
264
|
this.notify()
|
|
238
265
|
reader = response.body.getReader()
|
|
@@ -241,7 +268,10 @@ export class LiveControlClient {
|
|
|
241
268
|
while (!this.controller.signal.aborted && !resync) {
|
|
242
269
|
const item = await reader.read()
|
|
243
270
|
if (item.done) {
|
|
244
|
-
if (!this.controller.signal.aborted)
|
|
271
|
+
if (!this.controller.signal.aborted) {
|
|
272
|
+
this.status = "disconnected"
|
|
273
|
+
this.recordFailure({ connectionCode: "connection_interrupted" })
|
|
274
|
+
}
|
|
245
275
|
break
|
|
246
276
|
}
|
|
247
277
|
armWatchdog()
|
|
@@ -249,13 +279,14 @@ export class LiveControlClient {
|
|
|
249
279
|
buffer = buffer.replace(/\r\n|\r(?!$)/g, "\n")
|
|
250
280
|
let at
|
|
251
281
|
while ((at = buffer.indexOf("\n\n")) !== -1) {
|
|
252
|
-
if (at > 64 * 1024) throw new Error("SSE frame exceeds limit")
|
|
253
282
|
const frame = parseFrame(buffer.slice(0, at))
|
|
254
283
|
buffer = buffer.slice(at + 2)
|
|
255
284
|
if (!frame) continue
|
|
256
285
|
if (frame.type === "backeer.gap") {
|
|
257
286
|
if (!frame.value.cursor) throw new Error("SSE gap cursor missing")
|
|
258
|
-
|
|
287
|
+
const reason = ["stream_restarted", "cursor_expired", "history_expired", "invalid_cursor"].includes(frame.value.reason)
|
|
288
|
+
? frame.value.reason : "remote_replay_unavailable"
|
|
289
|
+
this.gap = { reason, cursor: checkedCursor(frame.value.cursor) }
|
|
259
290
|
resync = true
|
|
260
291
|
this.cursor = this.gap.cursor
|
|
261
292
|
this.floor = this.cursor
|
|
@@ -282,16 +313,19 @@ export class LiveControlClient {
|
|
|
282
313
|
}
|
|
283
314
|
this.notify()
|
|
284
315
|
}
|
|
285
|
-
if (buffer.length > 64 * 1024) throw new Error("SSE frame exceeds limit")
|
|
286
316
|
}
|
|
287
|
-
} catch {
|
|
288
|
-
if (!this.controller.signal.aborted
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (!this.controller.signal.aborted) {
|
|
319
|
+
if (this.status !== "unauthorized") this.status = "disconnected"
|
|
320
|
+
this.recordFailure(error, { timeout: timedOut })
|
|
321
|
+
}
|
|
289
322
|
} finally {
|
|
290
323
|
clearTimeout(watchdog)
|
|
291
324
|
this.controller.signal.removeEventListener("abort", abort)
|
|
292
325
|
await reader?.cancel().catch(() => {})
|
|
293
326
|
reader?.releaseLock()
|
|
294
327
|
}
|
|
328
|
+
if (!this.controller.signal.aborted) this.nextRetryAt = Date.now() + this.reconnectMs
|
|
295
329
|
this.notify()
|
|
296
330
|
if (!this.controller.signal.aborted) await this.delay(this.reconnectMs)
|
|
297
331
|
}
|
|
@@ -310,11 +344,24 @@ export class LiveControlClient {
|
|
|
310
344
|
})
|
|
311
345
|
}
|
|
312
346
|
|
|
313
|
-
|
|
347
|
+
recordFailure(error, context) {
|
|
348
|
+
this.lastDisconnectedAt = Date.now()
|
|
349
|
+
this.lastDiagnostic = connectionDiagnostic(error, context)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
connectionState() {
|
|
353
|
+
return { status: this.status, reconnectEveryMs: this.reconnectMs, reconnectAttempts: this.reconnectAttempts,
|
|
354
|
+
successfulConnections: this.successfulConnections, lastConnectedAt: this.lastConnectedAt,
|
|
355
|
+
lastDisconnectedAt: this.lastDisconnectedAt, nextRetryAt: this.nextRetryAt,
|
|
356
|
+
diagnostic: this.lastDiagnostic, bufferedEvents: this.events.length, cursor: this.cursor,
|
|
357
|
+
...(this.gap ? { gap: this.gap } : {}) }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
snapshot({ afterCursor = this.readCursor, sessionId, limit = Infinity, maxChars = Infinity } = {}) {
|
|
314
361
|
checkedCursor(afterCursor)
|
|
315
362
|
if (sessionId !== undefined) checkedSession(sessionId)
|
|
316
|
-
if (!Number.
|
|
317
|
-
!Number.
|
|
363
|
+
if ((limit !== Infinity && (!Number.isSafeInteger(limit) || limit < 1)) ||
|
|
364
|
+
(maxChars !== Infinity && (!Number.isSafeInteger(maxChars) || maxChars < 1))) throw new Error("Invalid read bounds")
|
|
318
365
|
let gap = this.gap
|
|
319
366
|
let start = 0
|
|
320
367
|
if (!gap && afterCursor !== this.floor) {
|
|
@@ -341,11 +388,12 @@ export class LiveControlClient {
|
|
|
341
388
|
cursor = event.id
|
|
342
389
|
}
|
|
343
390
|
this.readCursor = cursor
|
|
344
|
-
return { events: result, cursor, connection: this.status, hasMore: index < this.events.length
|
|
391
|
+
return { events: result, cursor, connection: this.status, hasMore: index < this.events.length,
|
|
392
|
+
transport: this.connectionState() }
|
|
345
393
|
}
|
|
346
394
|
|
|
347
395
|
async readEvents(options = {}, { waitMs = 0, signal } = {}) {
|
|
348
|
-
if (!Number.
|
|
396
|
+
if (waitMs !== Infinity && (!Number.isSafeInteger(waitMs) || waitMs < 0)) throw new Error("Wait must be a nonnegative safe integer or unlimited")
|
|
349
397
|
await this.start(options.afterCursor)
|
|
350
398
|
let result = this.snapshot(options)
|
|
351
399
|
if (!waitMs || result.events.length || result.gap || result.hasMore || this.status === "unauthorized") return result
|
|
@@ -359,7 +407,7 @@ export class LiveControlClient {
|
|
|
359
407
|
this.controller.signal.removeEventListener("abort", done)
|
|
360
408
|
resolve()
|
|
361
409
|
}
|
|
362
|
-
const timer = setTimeout(done, Math.max(0, deadline - Date.now()))
|
|
410
|
+
const timer = Number.isFinite(deadline) ? setTimeout(done, Math.min(2 ** 31 - 1, Math.max(0, deadline - Date.now()))) : undefined
|
|
363
411
|
this.listeners.add(done)
|
|
364
412
|
signal?.addEventListener("abort", done, { once: true })
|
|
365
413
|
this.controller.signal.addEventListener("abort", done, { once: true })
|
|
@@ -374,6 +422,7 @@ export class LiveControlClient {
|
|
|
374
422
|
async close() {
|
|
375
423
|
this.controller.abort()
|
|
376
424
|
this.status = "closed"
|
|
425
|
+
this.nextRetryAt = null
|
|
377
426
|
this.notify()
|
|
378
427
|
await this.starting
|
|
379
428
|
await this.task
|
|
@@ -383,17 +432,14 @@ export class LiveControlClient {
|
|
|
383
432
|
}
|
|
384
433
|
}
|
|
385
434
|
|
|
386
|
-
async function
|
|
435
|
+
async function responseText(response) {
|
|
387
436
|
if (!response.body) throw new Error("Live-control response is empty")
|
|
388
437
|
const reader = response.body.getReader()
|
|
389
438
|
const chunks = []
|
|
390
|
-
let size = 0
|
|
391
439
|
try {
|
|
392
440
|
while (true) {
|
|
393
441
|
const { done, value } = await reader.read()
|
|
394
442
|
if (done) break
|
|
395
|
-
size += value.byteLength
|
|
396
|
-
if (size > limit) throw new Error("Live-control response exceeds limit")
|
|
397
443
|
chunks.push(Buffer.from(value))
|
|
398
444
|
}
|
|
399
445
|
return Buffer.concat(chunks).toString("utf8")
|