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.
@@ -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, bytes = 0, active = 0, disposed = false, disposeTask
45
- let lookups = 0
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
- if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"))
52
- lookups++
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
- let finished = false
55
- const finish = (complete, value) => {
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
- complete(value)
62
+ signal?.removeEventListener("abort", aborted)
63
+ if (ok) resolve(value)
64
+ else reject(value)
61
65
  }
62
- const cancel = () => finish(reject, failure(503, "bridge_closed"))
63
- const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1000)
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
- // The caller's deadline does not release this slot: unsupported SDK
66
- // cancellation must not let timed-out, still-running calls accumulate.
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 (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error
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
- let frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
130
- if (Buffer.byteLength(frame) > 32_768) {
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{1,16})$/.exec(value)
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, created: Date.now(), settled: false,
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).finally(() => { command.settled = true })
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
- if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" })
199
- active++
200
- const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15_000)
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.slice(0, 100)) {
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: [], queuedBytes: 0 }
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_-]{1,160})(\/instructions|\/questions(?:\/([A-Za-z0-9_-]{1,160})\/answers)?)?$/.exec(url.pathname)
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() || body.prompt.length > 16_000 ||
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
- active--
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 = 15_000
313
- server.headersTimeout = 10_000
314
- server.keepAliveTimeout = 5_000
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_-]{1,80}:\d{1,16}$/
9
- const SESSION = /^[A-Za-z0-9_-]{1,160}$/
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() || stat.size > 4096 || (process.platform !== "win32" && (stat.mode & 0o077) !== 0) ||
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,256}$/.test(token)) {
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 unbounded history.
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 = 500, maxBufferBytes = 1024 * 1024, reconnectMs = LIVE_RECONNECT_MS, expectedDirectory } = {}) {
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,256}$/.test(token)) throw new Error("Live-control token is invalid")
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
- const timer = setTimeout(abort, timeoutMs)
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 only a bounded JSON code; return exclusively local allowlisted
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 boundedText(response, 4096))
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 boundedText(response, 256 * 1024)
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_-]{8,128}$/.test(requestId) || !/^[a-f0-9]{64}$/.test(revision) ||
141
- !Array.isArray(answers) || answers.length < 1 || answers.length > 16) throw new Error("Invalid question answer")
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_-]{8,128}$/.test(requestId)) throw new Error("Invalid request ID")
150
- if (typeof prompt !== "string" || !prompt.trim() || prompt.length > 16000) {
151
- throw new Error("Instruction must contain 1–16000 characters")
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 response = await this.fetch(`${this.url}/v1/events?after=${encodeURIComponent(this.cursor)}`, {
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) this.status = "disconnected"
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
- this.gap = { reason: String(frame.value.reason), cursor: checkedCursor(frame.value.cursor) }
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 && this.status !== "unauthorized") this.status = "disconnected"
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
- snapshot({ afterCursor = this.readCursor, sessionId, limit = 20, maxChars = 32000 } = {}) {
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.isInteger(limit) || limit < 1 || limit > 100 ||
317
- !Number.isInteger(maxChars) || maxChars < 1024 || maxChars > 64000) throw new Error("Invalid read bounds")
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.isInteger(waitMs) || waitMs < 0 || waitMs > 25000) throw new Error("Wait must be 0–25000ms")
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 boundedText(response, limit) {
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")