backeer-opencode 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -235,7 +235,7 @@ function make3(options) {
235
235
  };
236
236
  const responseError = async (response, descriptor) => {
237
237
  if (descriptor.declaredStatuses.includes(response.status))
238
- throw declared(await json(response));
238
+ throw declared(await json2(response));
239
239
  try {
240
240
  await response.body?.cancel();
241
241
  } catch {
@@ -255,7 +255,7 @@ function make3(options) {
255
255
  }
256
256
  return;
257
257
  }
258
- return await json(response);
258
+ return await json2(response);
259
259
  };
260
260
  const sse = (descriptor, requestOptions) => ({
261
261
  async *[Symbol.asyncIterator]() {
@@ -1615,7 +1615,7 @@ function appendQuery(params, key, value) {
1615
1615
  }
1616
1616
  params.append(key, String(value));
1617
1617
  }
1618
- async function json(response) {
1618
+ async function json2(response) {
1619
1619
  if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
1620
1620
  try {
1621
1621
  await response.body?.cancel();
@@ -1855,9 +1855,9 @@ var init_promise = __esm({
1855
1855
  });
1856
1856
 
1857
1857
  // src/config.js
1858
- import { readFileSync } from "node:fs";
1858
+ import { readFileSync as readFileSync2 } from "node:fs";
1859
1859
  import { homedir } from "node:os";
1860
- import { join } from "node:path";
1860
+ import { join as join2 } from "node:path";
1861
1861
 
1862
1862
  // src/utilities.js
1863
1863
  var MAX_TRACKED_ITEMS = 500;
@@ -1930,6 +1930,48 @@ function rememberRecent(map, key, value) {
1930
1930
  while (map.size > 100) map.delete(map.keys().next().value);
1931
1931
  }
1932
1932
 
1933
+ // src/live-bridge-config.js
1934
+ import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
1935
+ import { isAbsolute, join } from "node:path";
1936
+ function readCredential(file) {
1937
+ if (typeof file !== "string" || !isAbsolute(file)) {
1938
+ throw new Error("Live control requires an absolute credential-file path");
1939
+ }
1940
+ let descriptor;
1941
+ try {
1942
+ descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
1943
+ const stat = fstatSync(descriptor);
1944
+ if (!stat.isFile() || stat.size > 4096 || process.platform !== "win32" && ((stat.mode & 63) !== 0 || stat.uid !== process.getuid())) {
1945
+ throw new Error("invalid_file");
1946
+ }
1947
+ const token = readFileSync(descriptor, "utf8").trim();
1948
+ if (!/^[A-Za-z0-9_-]{32,256}$/.test(token)) throw new Error("invalid_token");
1949
+ return token;
1950
+ } catch {
1951
+ throw new Error("Live control credential must be an owner-only regular file containing a 32\u2013256 character token");
1952
+ } finally {
1953
+ if (descriptor !== void 0) closeSync(descriptor);
1954
+ }
1955
+ }
1956
+ function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
1957
+ const values = option && typeof option === "object" ? option : {};
1958
+ const enabled2 = (typeof option === "boolean" ? option : values.enabled) ?? process.env.BACKEER_LIVE_ENABLED;
1959
+ if (enabled2 !== true && enabled2 !== "true" && enabled2 !== "1") return { enabled: false };
1960
+ try {
1961
+ const rawPort = values.port ?? process.env.BACKEER_LIVE_PORT ?? 43871;
1962
+ const port = Number(rawPort);
1963
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1964
+ throw new Error("Live control port must be an integer between 1 and 65535");
1965
+ }
1966
+ const tokenFile = values.tokenFile ?? process.env.BACKEER_LIVE_TOKEN_FILE ?? join(configDirectory, "secrets", "backeer-live-token");
1967
+ const token = readCredential(tokenFile);
1968
+ if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token");
1969
+ return { enabled: true, token, tokenFile, port };
1970
+ } catch (error) {
1971
+ return { enabled: false, error: error.message };
1972
+ }
1973
+ }
1974
+
1933
1975
  // src/config.js
1934
1976
  var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
1935
1977
  function integer(value, fallback, { minimum = 0, maximum = 86400 } = {}) {
@@ -1971,13 +2013,13 @@ function loadLifecycleConfig(option) {
1971
2013
  }
1972
2014
  function defaultConfigDirectory() {
1973
2015
  const base = process.env.XDG_CONFIG_HOME?.trim();
1974
- return base ? join(base, "opencode") : join(homedir(), ".config", "opencode");
2016
+ return base ? join2(base, "opencode") : join2(homedir(), ".config", "opencode");
1975
2017
  }
1976
2018
  function loadToken(tokenFile) {
1977
2019
  const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim();
1978
2020
  if (environmentToken) return { token: environmentToken, source: "environment" };
1979
2021
  try {
1980
- const token = readFileSync(tokenFile, "utf8").trim();
2022
+ const token = readFileSync2(tokenFile, "utf8").trim();
1981
2023
  return { token, source: token ? "file" : "missing" };
1982
2024
  } catch {
1983
2025
  return { token: "", source: "missing" };
@@ -1990,7 +2032,7 @@ function loadConfig(options = {}) {
1990
2032
  if (origin.protocol !== "https:") {
1991
2033
  throw new Error("Backeer origin must use HTTPS");
1992
2034
  }
1993
- const tokenFile = options.tokenFile ?? process.env.BACKEER_TOKEN_FILE ?? join(defaultConfigDirectory(), "secrets", "backeer-ingest-token");
2035
+ const tokenFile = options.tokenFile ?? process.env.BACKEER_TOKEN_FILE ?? join2(defaultConfigDirectory(), "secrets", "backeer-ingest-token");
1994
2036
  const loadedToken = loadToken(tokenFile);
1995
2037
  return {
1996
2038
  origin,
@@ -2000,6 +2042,10 @@ function loadConfig(options = {}) {
2000
2042
  token: loadedToken.token,
2001
2043
  tokenSource: loadedToken.source,
2002
2044
  tokenFile,
2045
+ liveBridge: loadLiveBridgeConfig(options.liveBridge, {
2046
+ configDirectory: defaultConfigDirectory(),
2047
+ ingestToken: loadedToken.token
2048
+ }),
2003
2049
  permissions: enabled(
2004
2050
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS
2005
2051
  ),
@@ -2337,7 +2383,7 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
2337
2383
  import {
2338
2384
  chmodSync,
2339
2385
  mkdirSync,
2340
- readFileSync as readFileSync2,
2386
+ readFileSync as readFileSync3,
2341
2387
  renameSync,
2342
2388
  statSync as statSync2,
2343
2389
  writeFileSync
@@ -2349,7 +2395,7 @@ import { randomUUID } from "node:crypto";
2349
2395
  import { statSync } from "node:fs";
2350
2396
 
2351
2397
  // src/updates.js
2352
- var PLUGIN_VERSION = "0.4.0";
2398
+ var PLUGIN_VERSION = "0.5.1";
2353
2399
  var PACKAGE_NAME = "backeer-opencode";
2354
2400
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
2355
2401
  var CHECK_TIMEOUT_MS = 3e3;
@@ -2840,7 +2886,7 @@ function rotateTokenFromFile(config, sourcePathValue) {
2840
2886
  if ((sourceStats.mode & 63) !== 0) {
2841
2887
  throw new Error("The token source must not be readable by group or other users");
2842
2888
  }
2843
- const token = readFileSync2(sourcePath, "utf8").trim();
2889
+ const token = readFileSync3(sourcePath, "utf8").trim();
2844
2890
  if (!token || /\s/.test(token)) {
2845
2891
  throw new Error("The token source must contain exactly one non-empty token");
2846
2892
  }
@@ -3367,6 +3413,517 @@ function createLifecycleController({
3367
3413
  };
3368
3414
  }
3369
3415
 
3416
+ // src/live-bridge.js
3417
+ import { createServer } from "node:http";
3418
+ import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
3419
+
3420
+ // src/live-bridge-client.js
3421
+ function createV2LiveClient(ctx) {
3422
+ const known = /* @__PURE__ */ new Map();
3423
+ let disposed = false;
3424
+ async function get(id) {
3425
+ const info = await ctx.session.get({ sessionID: id });
3426
+ if (disposed) return info;
3427
+ known.set(id, info);
3428
+ if (known.size > 100) known.delete(known.keys().next().value);
3429
+ return info;
3430
+ }
3431
+ return {
3432
+ async list() {
3433
+ if (typeof ctx.session.list === "function") {
3434
+ return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
3435
+ }
3436
+ const refreshed = [];
3437
+ for (const id of known.keys()) {
3438
+ try {
3439
+ refreshed.push(await get(id));
3440
+ } catch {
3441
+ known.delete(id);
3442
+ }
3443
+ }
3444
+ return refreshed;
3445
+ },
3446
+ get,
3447
+ prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
3448
+ dispose: () => {
3449
+ disposed = true;
3450
+ known.clear();
3451
+ }
3452
+ };
3453
+ }
3454
+ function unwrap(value) {
3455
+ if (value?.error) throw new Error("OpenCode request failed");
3456
+ return value?.data ?? value;
3457
+ }
3458
+ function createV1LiveClient(client, directory) {
3459
+ return {
3460
+ list: async () => unwrap(await client.session.list({ query: { directory }, throwOnError: true })),
3461
+ get: async (id) => unwrap(await client.session.get({ path: { id }, query: { directory }, throwOnError: true })),
3462
+ async prompt(id, text) {
3463
+ if (typeof client.session.promptAsync !== "function") {
3464
+ throw Object.assign(new Error("OpenCode asynchronous prompts are unavailable"), { status: 501 });
3465
+ }
3466
+ return unwrap(await client.session.promptAsync({
3467
+ path: { id },
3468
+ query: { directory },
3469
+ body: { parts: [{ type: "text", text }] },
3470
+ throwOnError: true
3471
+ }));
3472
+ }
3473
+ };
3474
+ }
3475
+
3476
+ // src/live-connection.js
3477
+ import { randomUUID as randomUUID2 } from "node:crypto";
3478
+ var LIVE_RECONNECT_MS = 3e3;
3479
+ var LIVE_HEARTBEAT_MS = 15e3;
3480
+ function createLiveConnectionNotifier(config, reporter) {
3481
+ const epoch = randomUUID2();
3482
+ const controller = new AbortController();
3483
+ let last, sequence = 0, pending, task;
3484
+ async function drain() {
3485
+ while (pending && !controller.signal.aborted) {
3486
+ const event = pending;
3487
+ pending = void 0;
3488
+ try {
3489
+ await postJsonWithRetry(config, config.eventsEndpoint, event, { signal: controller.signal });
3490
+ } catch {
3491
+ if (!controller.signal.aborted) reporter?.error("Live connection notification could not be sent");
3492
+ }
3493
+ }
3494
+ }
3495
+ return {
3496
+ update(connected) {
3497
+ if (controller.signal.aborted || connected === last) return;
3498
+ const initial = last === void 0;
3499
+ last = connected;
3500
+ if (initial || !config.token || !config.eventsEndpoint) return;
3501
+ pending = {
3502
+ type: "application",
3503
+ severity: connected ? "info" : "warning",
3504
+ title: connected ? "OpenCode ba\u011Flant\u0131s\u0131 yeniden kuruldu" : "OpenCode ba\u011Flant\u0131s\u0131 kesildi",
3505
+ body: connected ? "Canl\u0131 takip yeniden ba\u011Fland\u0131. Sunucudaki g\xF6rev otomatik olarak yeniden ba\u015Flat\u0131lmad\u0131." : "Canl\u0131 takip ba\u011Flant\u0131s\u0131 kesildi. Yeniden ba\u011Flanma her 3 saniyede denenecek; sunucudaki g\xF6rev durdurulmad\u0131.",
3506
+ payload: [{ key: "Connection", value: connected ? "reconnected" : "disconnected" }],
3507
+ externalId: `opencode:live:${epoch}:${++sequence}`,
3508
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3509
+ notify: true,
3510
+ direction: "in"
3511
+ };
3512
+ task ??= drain().finally(() => {
3513
+ task = void 0;
3514
+ });
3515
+ },
3516
+ async dispose() {
3517
+ controller.abort();
3518
+ pending = void 0;
3519
+ await task;
3520
+ }
3521
+ };
3522
+ }
3523
+
3524
+ // src/live-bridge-events.js
3525
+ var ID = /^[A-Za-z0-9_-]{1,160}$/;
3526
+ var validBridgeID = (value) => typeof value === "string" && ID.test(value);
3527
+ function createBridgeSanitizer(secrets = []) {
3528
+ const known = secrets.filter((value) => typeof value === "string" && value.length > 0);
3529
+ const assistants = /* @__PURE__ */ new Map();
3530
+ function clipped(value, max = 12e3) {
3531
+ if (typeof value !== "string") return { value: "", truncated: false };
3532
+ let result = value.slice(0, 64e3);
3533
+ for (const secret of known) result = result.split(secret).join("[REDACTED]");
3534
+ result = result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]").replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]").replace(/\b(?:sk|bgi)_[A-Za-z0-9_-]{12,}|\bsk-[A-Za-z0-9_-]{12,}/g, "[REDACTED]").replace(/((?:api[_-]?key|access[_-]?token|secret|password|authorization)\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]");
3535
+ return { value: result.slice(0, max), truncated: value.length > 64e3 || result.length > max };
3536
+ }
3537
+ function text(value, max) {
3538
+ return clipped(value, max).value;
3539
+ }
3540
+ function content(key, value) {
3541
+ const result = clipped(value);
3542
+ return { [key]: result.value, ...result.truncated ? { truncated: true } : {} };
3543
+ }
3544
+ function sanitize(event) {
3545
+ const p = event?.properties ?? event?.data ?? {};
3546
+ const sessionID = p.sessionID ?? p.info?.sessionID ?? p.part?.sessionID;
3547
+ if (!validBridgeID(sessionID) || sessionID === "global") return void 0;
3548
+ const base = { type: event.type, sessionID, data: {} };
3549
+ if (event.type === "message.updated") {
3550
+ const info = p.info;
3551
+ if (!validBridgeID(info?.id)) return void 0;
3552
+ if (info.role === "assistant") {
3553
+ assistants.set(info.id, sessionID);
3554
+ if (assistants.size > 256) assistants.delete(assistants.keys().next().value);
3555
+ }
3556
+ return info.role === "assistant" ? { ...base, data: {
3557
+ messageID: info.id,
3558
+ completed: !!info.time?.completed,
3559
+ failed: !!info.error
3560
+ } } : void 0;
3561
+ }
3562
+ if (event.type === "message.part.updated") {
3563
+ const part = p.part;
3564
+ if (assistants.get(part?.messageID) !== sessionID) return void 0;
3565
+ if (part.type === "text") return { ...base, type: "assistant.text", data: {
3566
+ messageID: part.messageID,
3567
+ ...content("text", part.text),
3568
+ replace: true
3569
+ } };
3570
+ if (part.type !== "tool") return void 0;
3571
+ const state = part.state ?? {};
3572
+ return { ...base, type: "tool.status", data: {
3573
+ tool: text(part.tool, 80),
3574
+ status: text(state.status, 40),
3575
+ ...state.status === "completed" ? content("output", state.output) : {},
3576
+ ...state.status === "error" ? { failed: true } : {}
3577
+ } };
3578
+ }
3579
+ if (event.type === "session.text.ended") return { ...base, type: "assistant.text", data: {
3580
+ messageID: validBridgeID(p.assistantMessageID) ? p.assistantMessageID : void 0,
3581
+ ...content("text", p.text),
3582
+ replace: true
3583
+ } };
3584
+ if (["session.tool.called", "session.tool.success", "session.tool.failed"].includes(event.type)) {
3585
+ return { ...base, type: "tool.status", data: {
3586
+ status: event.type.split(".").at(-1),
3587
+ ...event.type === "session.tool.success" ? content(
3588
+ "output",
3589
+ (p.content ?? []).filter((item) => item?.type === "text").map((item) => item.text).join("\n")
3590
+ ) : {}
3591
+ } };
3592
+ }
3593
+ if (["permission.asked", "question.asked", "question.v2.asked"].includes(event.type)) {
3594
+ return { ...base, type: "approval.waiting", data: { kind: event.type.split(".")[0] } };
3595
+ }
3596
+ if (["permission.replied", "question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
3597
+ return { ...base, type: "approval.settled", data: { kind: event.type.split(".")[0] } };
3598
+ }
3599
+ if (event.type === "session.status") return { ...base, data: {
3600
+ status: ["idle", "busy", "retry"].includes(p.status?.type) ? p.status.type : "unknown"
3601
+ } };
3602
+ if (["session.idle", "session.error", "session.deleted"].includes(event.type)) return base;
3603
+ return void 0;
3604
+ }
3605
+ return { sanitize, text, clear: () => assistants.clear() };
3606
+ }
3607
+
3608
+ // src/live-bridge.js
3609
+ var LIMITS = { events: 256, bytes: 2e6, clients: 8, commands: 1024, commandTTL: 864e5, body: 32768, requests: 16 };
3610
+ function failure(status, code) {
3611
+ return Object.assign(new Error(code), { status, code });
3612
+ }
3613
+ function json(response, status, value) {
3614
+ if (!response.destroyed && !response.headersSent) {
3615
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
3616
+ response.end(JSON.stringify(value));
3617
+ }
3618
+ }
3619
+ async function readBody(request) {
3620
+ const chunks = [];
3621
+ let bytes = 0;
3622
+ for await (const chunk of request) {
3623
+ bytes += chunk.length;
3624
+ if (bytes > LIMITS.body) throw failure(413, "body_too_large");
3625
+ chunks.push(chunk);
3626
+ }
3627
+ try {
3628
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3629
+ } catch {
3630
+ throw failure(400, "invalid_json");
3631
+ }
3632
+ }
3633
+ async function createLiveBridge({ config, client, directory, reporter }) {
3634
+ const options = config.liveBridge;
3635
+ if (!options?.enabled) return { handle() {
3636
+ }, async dispose() {
3637
+ }, address: void 0 };
3638
+ const token = options.token;
3639
+ if (typeof token !== "string" || token.length < 32 || token === config.token) {
3640
+ throw new Error("Live bridge requires a separate credential of at least 32 characters");
3641
+ }
3642
+ const api = client.liveBridge ?? createV1LiveClient(client, directory);
3643
+ const connectionNotifications = createLiveConnectionNotifier(config, reporter);
3644
+ const sanitizer = createBridgeSanitizer([token, config.token]);
3645
+ const epoch = randomUUID3();
3646
+ const events = [], streams = /* @__PURE__ */ new Set(), commands = /* @__PURE__ */ new Map(), states = /* @__PURE__ */ new Map();
3647
+ let sequence = 0, bytes = 0, active = 0, disposed = false, disposeTask;
3648
+ let lookups = 0;
3649
+ const lookupWaiters = /* @__PURE__ */ new Set();
3650
+ const cursor = () => `${epoch}:${sequence}`;
3651
+ function lookup(id) {
3652
+ if (disposed) return Promise.reject(failure(503, "bridge_closed"));
3653
+ if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
3654
+ lookups++;
3655
+ return new Promise((resolve2, reject) => {
3656
+ let finished = false;
3657
+ const finish = (complete, value) => {
3658
+ if (finished) return;
3659
+ finished = true;
3660
+ clearTimeout(timer);
3661
+ lookupWaiters.delete(cancel);
3662
+ complete(value);
3663
+ };
3664
+ const cancel = () => finish(reject, failure(503, "bridge_closed"));
3665
+ const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1e3);
3666
+ lookupWaiters.add(cancel);
3667
+ Promise.resolve().then(() => api.get(id)).then(
3668
+ (value) => {
3669
+ lookups--;
3670
+ finish(resolve2, value);
3671
+ },
3672
+ (error) => {
3673
+ lookups--;
3674
+ finish(reject, error);
3675
+ }
3676
+ );
3677
+ });
3678
+ }
3679
+ async function session(id) {
3680
+ if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session");
3681
+ let info;
3682
+ try {
3683
+ info = await lookup(id);
3684
+ } catch (error) {
3685
+ if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error;
3686
+ throw failure(404, "session_not_found");
3687
+ }
3688
+ if (info?.id !== id || (info.location?.directory ?? info.directory) !== directory) {
3689
+ throw failure(404, "session_not_found");
3690
+ }
3691
+ return {
3692
+ id,
3693
+ title: sanitizer.text(info.title, 240),
3694
+ ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
3695
+ status: states.get(id) ?? "unknown"
3696
+ };
3697
+ }
3698
+ function removeStream(stream) {
3699
+ const removed = streams.delete(stream);
3700
+ clearInterval(stream.heartbeat);
3701
+ stream.queue.length = 0;
3702
+ stream.queuedBytes = 0;
3703
+ if (removed && !streams.size && !disposed) connectionNotifications.update(false);
3704
+ }
3705
+ function write(stream, value) {
3706
+ if (!streams.has(stream)) return;
3707
+ if (stream.blocked) {
3708
+ stream.queue.push(value);
3709
+ stream.queuedBytes += Buffer.byteLength(value);
3710
+ if (stream.queuedBytes > LIMITS.bytes) {
3711
+ removeStream(stream);
3712
+ stream.response.destroy();
3713
+ }
3714
+ return;
3715
+ }
3716
+ if (!stream.response.write(value)) {
3717
+ stream.blocked = true;
3718
+ }
3719
+ }
3720
+ async function handle(event) {
3721
+ if (disposed) return;
3722
+ const safe = sanitizer.sanitize(event);
3723
+ if (!safe) return;
3724
+ try {
3725
+ await session(safe.sessionID);
3726
+ } catch {
3727
+ return;
3728
+ }
3729
+ if (disposed) return;
3730
+ if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status);
3731
+ if (safe.type === "session.idle") states.set(safe.sessionID, "idle");
3732
+ if (safe.type === "session.deleted") states.delete(safe.sessionID);
3733
+ if (states.size > 256) states.delete(states.keys().next().value);
3734
+ const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() };
3735
+ let frame = `id: ${record.id}
3736
+ event: backeer.event
3737
+ data: ${JSON.stringify(record)}
3738
+
3739
+ `;
3740
+ if (Buffer.byteLength(frame) > 32768) {
3741
+ record.data = { truncated: true };
3742
+ frame = `id: ${record.id}
3743
+ event: backeer.event
3744
+ data: ${JSON.stringify(record)}
3745
+
3746
+ `;
3747
+ }
3748
+ const size = Buffer.byteLength(frame);
3749
+ events.push({ sequence, sessionID: record.sessionID, frame, size });
3750
+ bytes += size;
3751
+ while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size;
3752
+ for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame);
3753
+ }
3754
+ function authorized(request) {
3755
+ if (request.headers.origin || request.headers["sec-fetch-site"]) return false;
3756
+ const expected = Buffer.from(`Bearer ${token}`);
3757
+ const actual = Buffer.from(request.headers.authorization ?? "");
3758
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
3759
+ }
3760
+ function parseCursor(value) {
3761
+ if (!value) return { after: 0 };
3762
+ const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value);
3763
+ if (!match) throw failure(400, "invalid_cursor");
3764
+ if (match[1] !== epoch) return { gap: "stream_restarted" };
3765
+ const after = Number(match[2]);
3766
+ if (!Number.isSafeInteger(after) || after > sequence) throw failure(400, "invalid_cursor");
3767
+ if (events.length && after < events[0].sequence - 1) return { gap: "cursor_expired" };
3768
+ return { after };
3769
+ }
3770
+ async function route(request, response) {
3771
+ if (!authorized(request)) return json(response, 401, { error: "unauthorized" });
3772
+ if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" });
3773
+ active++;
3774
+ const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15e3);
3775
+ timeout.unref();
3776
+ try {
3777
+ const url = new URL(request.url, "http://127.0.0.1");
3778
+ if (url.username || url.password) throw failure(400, "invalid_url");
3779
+ if (request.method === "GET" && url.pathname === "/v1/sessions") {
3780
+ const result = await api.list();
3781
+ const values = Array.isArray(result) ? result : result?.data;
3782
+ if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
3783
+ const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) && (info.location?.directory ?? info.directory) === directory);
3784
+ return json(response, 200, { sessions: own.map((info) => ({
3785
+ id: info.id,
3786
+ title: sanitizer.text(info.title, 240),
3787
+ status: states.get(info.id) ?? "unknown",
3788
+ ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
3789
+ })), cursor: cursor() });
3790
+ }
3791
+ if (request.method === "GET" && url.pathname === "/v1/events") {
3792
+ const selected = url.searchParams.get("sessionID") || void 0;
3793
+ if (selected) await session(selected);
3794
+ const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"]);
3795
+ if (response.headersSent) return;
3796
+ if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams");
3797
+ clearTimeout(timeout);
3798
+ response.writeHead(200, {
3799
+ "content-type": "text/event-stream",
3800
+ "cache-control": "no-cache, no-transform",
3801
+ "x-accel-buffering": "no",
3802
+ connection: "keep-alive"
3803
+ });
3804
+ if (parsed.gap) {
3805
+ response.end(`event: backeer.gap
3806
+ data: ${JSON.stringify({ reason: parsed.gap, cursor: cursor() })}
3807
+
3808
+ `);
3809
+ return;
3810
+ }
3811
+ const stream = {
3812
+ response,
3813
+ sessionID: selected,
3814
+ heartbeat: void 0,
3815
+ blocked: false,
3816
+ queue: [],
3817
+ queuedBytes: 0
3818
+ };
3819
+ streams.add(stream);
3820
+ connectionNotifications.update(true);
3821
+ request.socket.setTimeout(0);
3822
+ request.socket.setKeepAlive(true, LIVE_HEARTBEAT_MS);
3823
+ response.on("close", () => removeStream(stream));
3824
+ response.on("drain", () => {
3825
+ stream.blocked = false;
3826
+ while (streams.has(stream) && !stream.blocked && stream.queue.length) {
3827
+ const next = stream.queue.shift();
3828
+ stream.queuedBytes -= Buffer.byteLength(next);
3829
+ write(stream, next);
3830
+ }
3831
+ });
3832
+ write(stream, `: connected
3833
+ retry: ${LIVE_RECONNECT_MS}
3834
+
3835
+ `);
3836
+ for (const item of events) {
3837
+ if (!streams.has(stream)) break;
3838
+ if (item.sequence > parsed.after && (!selected || selected === item.sessionID)) write(stream, item.frame);
3839
+ }
3840
+ if (streams.has(stream)) {
3841
+ stream.heartbeat = setInterval(() => write(stream, ": heartbeat\n\n"), LIVE_HEARTBEAT_MS);
3842
+ stream.heartbeat.unref();
3843
+ }
3844
+ return;
3845
+ }
3846
+ const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions)?$/.exec(url.pathname);
3847
+ if (!match) throw failure(404, "route_not_found");
3848
+ if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]));
3849
+ if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed");
3850
+ if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required");
3851
+ const body = await readBody(request);
3852
+ if (!body || !validBridgeID(body.requestId) || typeof body.prompt !== "string" || !body.prompt.trim() || body.prompt.length > 16e3 || Object.keys(body).some((key) => !["requestId", "prompt"].includes(key))) throw failure(400, "invalid_instruction");
3853
+ await session(match[1]);
3854
+ if (response.headersSent || disposed) return;
3855
+ for (const [id, command2] of commands) {
3856
+ if (command2.settled && Date.now() - command2.created >= LIMITS.commandTTL) commands.delete(id);
3857
+ }
3858
+ const existing = commands.get(body.requestId);
3859
+ if (existing) {
3860
+ if (existing.sessionID !== match[1] || existing.prompt !== body.prompt) throw failure(409, "request_id_conflict");
3861
+ await existing.task;
3862
+ return json(response, 200, existing.result);
3863
+ }
3864
+ if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full");
3865
+ const command = {
3866
+ sessionID: match[1],
3867
+ prompt: body.prompt,
3868
+ created: Date.now(),
3869
+ settled: false,
3870
+ result: { requestId: body.requestId, sessionID: match[1], status: "accepted" },
3871
+ task: void 0
3872
+ };
3873
+ commands.set(body.requestId, command);
3874
+ command.task = Promise.resolve().then(() => api.prompt(match[1], body.prompt)).finally(() => {
3875
+ command.settled = true;
3876
+ });
3877
+ await command.task;
3878
+ return json(response, 202, command.result);
3879
+ } catch (error) {
3880
+ json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" });
3881
+ } finally {
3882
+ active--;
3883
+ clearTimeout(timeout);
3884
+ }
3885
+ }
3886
+ const server = createServer((request, response) => {
3887
+ void route(request, response);
3888
+ });
3889
+ server.requestTimeout = 15e3;
3890
+ server.headersTimeout = 1e4;
3891
+ server.keepAliveTimeout = 5e3;
3892
+ await new Promise((resolve2, reject) => {
3893
+ server.once("error", reject);
3894
+ server.listen(options.port ?? 43871, "127.0.0.1", () => {
3895
+ server.off("error", reject);
3896
+ resolve2();
3897
+ });
3898
+ });
3899
+ server.on("error", () => {
3900
+ });
3901
+ return {
3902
+ handle,
3903
+ address: server.address(),
3904
+ dispose() {
3905
+ disposeTask ??= new Promise((resolve2) => {
3906
+ disposed = true;
3907
+ for (const cancel of lookupWaiters) cancel();
3908
+ for (const stream of streams) {
3909
+ removeStream(stream);
3910
+ stream.response.destroy();
3911
+ }
3912
+ server.close(() => {
3913
+ void connectionNotifications.dispose().then(resolve2);
3914
+ });
3915
+ server.closeAllConnections?.();
3916
+ events.length = 0;
3917
+ states.clear();
3918
+ commands.clear();
3919
+ sanitizer.clear();
3920
+ api.dispose?.();
3921
+ });
3922
+ return disposeTask;
3923
+ }
3924
+ };
3925
+ }
3926
+
3370
3927
  // src/permission-command.js
3371
3928
  var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
3372
3929
  var DECISION_LABELS = {
@@ -3941,6 +4498,13 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
3941
4498
  }
3942
4499
  const reporter = createOpenCodeReporter(client, config.logLevel);
3943
4500
  const conversation = feedback ?? createConversationFeedback({ client, reporter });
4501
+ if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
4502
+ const liveBridge = await createLiveBridge({ config, client, directory, reporter }).catch((error) => {
4503
+ reporter.error("Live control unavailable", error);
4504
+ return { async handle() {
4505
+ }, async dispose() {
4506
+ } };
4507
+ });
3944
4508
  const permissionController = createPermissionController({
3945
4509
  config,
3946
4510
  reporter,
@@ -4048,6 +4612,38 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
4048
4612
  conversation.error(identity.sessionID, "Question could not be sent");
4049
4613
  });
4050
4614
  }
4615
+ async function handleNotificationEvent(event) {
4616
+ if (!config.token) return;
4617
+ conversation.handle(event);
4618
+ lifecycleController.handle(event);
4619
+ if (config.permissions && event.type === "permission.asked") {
4620
+ await handlePermissionAsked(event);
4621
+ return;
4622
+ }
4623
+ if (config.permissions && event.type === "permission.replied") {
4624
+ permissionController.resolveLocally(
4625
+ event.properties.requestID ?? event.properties.id
4626
+ );
4627
+ return;
4628
+ }
4629
+ if (config.todos && event.type === "todo.updated") {
4630
+ await handleTodoUpdated(event);
4631
+ return;
4632
+ }
4633
+ if (config.questions && (event.type === "question.asked" || event.type === "question.v2.asked")) {
4634
+ await handleQuestionAsked(event);
4635
+ return;
4636
+ }
4637
+ if (config.questions && ["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
4638
+ const requestID = event.properties.requestID ?? event.properties.id;
4639
+ if (typeof requestID === "string") {
4640
+ permissionController.resolveQuestionLocally(
4641
+ requestID,
4642
+ event.type.endsWith("rejected") ? "rejected" : "answered"
4643
+ );
4644
+ }
4645
+ }
4646
+ }
4051
4647
  return {
4052
4648
  config: async (opencodeConfig) => {
4053
4649
  registerBackeerCommand(opencodeConfig);
@@ -4055,38 +4651,10 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
4055
4651
  "command.execute.before": handleBackeerCommand,
4056
4652
  "chat.message": handleUpdateNotice,
4057
4653
  event: async ({ event }) => {
4058
- if (!config.token) return;
4059
- conversation.handle(event);
4060
- lifecycleController.handle(event);
4061
- if (config.permissions && event.type === "permission.asked") {
4062
- await handlePermissionAsked(event);
4063
- return;
4064
- }
4065
- if (config.permissions && event.type === "permission.replied") {
4066
- permissionController.resolveLocally(
4067
- event.properties.requestID ?? event.properties.id
4068
- );
4069
- return;
4070
- }
4071
- if (config.todos && event.type === "todo.updated") {
4072
- await handleTodoUpdated(event);
4073
- return;
4074
- }
4075
- if (config.questions && (event.type === "question.asked" || event.type === "question.v2.asked")) {
4076
- await handleQuestionAsked(event);
4077
- return;
4078
- }
4079
- if (config.questions && ["question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
4080
- const requestID = event.properties.requestID ?? event.properties.id;
4081
- if (typeof requestID === "string") {
4082
- permissionController.resolveQuestionLocally(
4083
- requestID,
4084
- event.type.endsWith("rejected") ? "rejected" : "answered"
4085
- );
4086
- }
4087
- }
4654
+ await Promise.all([handleNotificationEvent(event), liveBridge.handle(event)]);
4088
4655
  },
4089
4656
  dispose: async () => {
4657
+ await liveBridge.dispose();
4090
4658
  await permissionController.dispose();
4091
4659
  lifecycleController.dispose();
4092
4660
  permissionPublications.clear();
@@ -4141,11 +4709,11 @@ function spawnServiceContender2(command, args, env) {
4141
4709
  }
4142
4710
  function contenderPool2(timing) {
4143
4711
  const contenders = /* @__PURE__ */ new Set();
4144
- let failure;
4712
+ let failure2;
4145
4713
  let spawnDelay = timing.spawnDelay;
4146
4714
  let lastSpawn = 0;
4147
4715
  return {
4148
- failure: () => failure,
4716
+ failure: () => failure2,
4149
4717
  serviceAnswered() {
4150
4718
  spawnDelay = timing.spawnDelay;
4151
4719
  },
@@ -4156,7 +4724,7 @@ function contenderPool2(timing) {
4156
4724
  contenders.delete(item);
4157
4725
  }
4158
4726
  }
4159
- failure = void 0;
4727
+ failure2 = void 0;
4160
4728
  lastSpawn = 0;
4161
4729
  },
4162
4730
  recruitNow() {
@@ -4164,16 +4732,16 @@ function contenderPool2(timing) {
4164
4732
  },
4165
4733
  reap() {
4166
4734
  const finished = [...contenders].filter(contenderFinished2);
4167
- failure ??= finished.map(contenderFailure).find((error) => error !== void 0);
4735
+ failure2 ??= finished.map(contenderFailure).find((error) => error !== void 0);
4168
4736
  if (finished.some((item) => item.child.exitCode === 0))
4169
4737
  spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay);
4170
4738
  finished.forEach((item) => contenders.delete(item));
4171
- return contenders.size === 0 ? failure : void 0;
4739
+ return contenders.size === 0 ? failure2 : void 0;
4172
4740
  },
4173
4741
  shouldRecruit(registered2) {
4174
4742
  if (lastSpawn === 0 && registered2)
4175
4743
  lastSpawn = Date.now();
4176
- return failure === void 0 && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay;
4744
+ return failure2 === void 0 && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay;
4177
4745
  },
4178
4746
  add(contender) {
4179
4747
  contenders.add(contender);
@@ -4332,7 +4900,7 @@ function isHandoff(value) {
4332
4900
 
4333
4901
  // node_modules/@opencode/client/dist/chunks/service-timing-8ye1ye6f.js
4334
4902
  import { homedir as homedir2 } from "node:os";
4335
- import { join as join2 } from "node:path";
4903
+ import { join as join3 } from "node:path";
4336
4904
  function decide2(service, options) {
4337
4905
  if (!matchesVersion2(service.version, options))
4338
4906
  return { _tag: "replace", pty: service.state === "ready" ? "handoff" : "clear" };
@@ -4348,7 +4916,7 @@ function fail(message) {
4348
4916
  return { _tag: "fail", error: new Error(message) };
4349
4917
  }
4350
4918
  function fallback2() {
4351
- return join2(process.env["XDG_STATE_HOME"] ?? join2(homedir2(), ".local", "state"), "opencode", "service.json");
4919
+ return join3(process.env["XDG_STATE_HOME"] ?? join3(homedir2(), ".local", "state"), "opencode", "service.json");
4352
4920
  }
4353
4921
  function same2(left, right) {
4354
4922
  return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
@@ -4663,6 +5231,7 @@ function normalizeV2Event(event) {
4663
5231
  }
4664
5232
  function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
4665
5233
  return {
5234
+ liveBridge: createV2LiveClient(ctx),
4666
5235
  app: {
4667
5236
  async log({ body }) {
4668
5237
  console.error(JSON.stringify(body));
@@ -4807,13 +5376,11 @@ ${output.parts.map((part) => part.text).join("\n")}`;
4807
5376
  }
4808
5377
  }));
4809
5378
  streamTask = (async () => {
4810
- let delay2 = 1e3;
4811
5379
  while (!controller.signal.aborted) {
4812
5380
  try {
4813
5381
  for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
4814
5382
  if (controller.signal.aborted) break;
4815
5383
  if (!await client.ownsEvent(event)) continue;
4816
- delay2 = 1e3;
4817
5384
  for (const normalized of normalizeV2Event(event)) {
4818
5385
  await hooks.event({ event: normalized });
4819
5386
  }
@@ -4822,8 +5389,7 @@ ${output.parts.map((part) => part.text).join("\n")}`;
4822
5389
  if (!controller.signal.aborted) reporter.error("OpenCode event stream disconnected", error);
4823
5390
  }
4824
5391
  if (!controller.signal.aborted) {
4825
- await waitForReconnect(controller.signal, delay2);
4826
- delay2 = Math.min(delay2 * 2, 3e4);
5392
+ await waitForReconnect(controller.signal, LIVE_RECONNECT_MS);
4827
5393
  }
4828
5394
  }
4829
5395
  })();