backeer-opencode 0.7.0 → 0.8.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.
@@ -44,7 +44,7 @@ function make7(connect) {
44
44
  if (current === connection)
45
45
  current = void 0;
46
46
  }
47
- async function run(connection) {
47
+ async function run2(connection) {
48
48
  let iterator;
49
49
  let completion = {};
50
50
  try {
@@ -133,7 +133,7 @@ function make7(connect) {
133
133
  if (connection.connected)
134
134
  subscriber.push(connection.connected);
135
135
  if (fresh)
136
- run(connection);
136
+ run2(connection);
137
137
  }
138
138
  return {
139
139
  next() {
@@ -1966,7 +1966,9 @@ function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
1966
1966
  const tokenFile = values.tokenFile ?? process.env.BACKEER_LIVE_TOKEN_FILE ?? join(configDirectory, "secrets", "backeer-live-token");
1967
1967
  const token = readCredential(tokenFile);
1968
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 };
1969
+ const owner = values.owner ?? "server";
1970
+ if (!["server", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui");
1971
+ return { enabled: true, token, tokenFile, port, owner };
1970
1972
  } catch (error) {
1971
1973
  return { enabled: false, error: error.message };
1972
1974
  }
@@ -2395,7 +2397,7 @@ import { randomUUID } from "node:crypto";
2395
2397
  import { statSync } from "node:fs";
2396
2398
 
2397
2399
  // src/updates.js
2398
- var PLUGIN_VERSION = "0.7.0";
2400
+ var PLUGIN_VERSION = "0.8.1";
2399
2401
  var PACKAGE_NAME = "backeer-opencode";
2400
2402
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
2401
2403
  var CHECK_TIMEOUT_MS = 3e3;
@@ -3417,1072 +3419,1275 @@ function createLifecycleController({
3417
3419
  import { createServer } from "node:http";
3418
3420
  import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
3419
3421
 
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;
3422
+ // src/permission-command.js
3423
+ var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
3424
+ var DECISION_LABELS = {
3425
+ once: "Allow once",
3426
+ always: "Always allow",
3427
+ reject: "Reject"
3428
+ };
3429
+ function validatePermissionCommand(command, now = Date.now()) {
3430
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
3431
+ return {
3432
+ status: "rejected",
3433
+ decision: command.decision,
3434
+ errorCode: "invalid_decision"
3435
+ };
3436
+ }
3437
+ const expiresAt = Date.parse(command.expiresAt);
3438
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3439
+ return { status: "expired", decision: command.decision };
3440
+ }
3441
+ if (command.decision === "always" && command.confirmed !== true) {
3442
+ return {
3443
+ status: "rejected",
3444
+ decision: command.decision,
3445
+ errorCode: "confirmation_required"
3446
+ };
3430
3447
  }
3448
+ }
3449
+ function failureAcknowledgement(command, error) {
3450
+ const stale = error instanceof HttpError && error.status === 404;
3431
3451
  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
+ status: stale ? "stale" : "failed",
3453
+ decision: command.decision,
3454
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
3452
3455
  };
3453
3456
  }
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
- }
3457
+ function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
3458
+ const messages = {
3459
+ applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
3460
+ expired: "Decision expired before it could be applied",
3461
+ stale: "Permission was already answered locally",
3462
+ rejected: "Decision was rejected by validation"
3473
3463
  };
3464
+ const message = messages[acknowledgement.status];
3465
+ if (!message) return;
3466
+ if (acknowledgement.status === "applied") {
3467
+ reporter.applied(message);
3468
+ conversation?.applied(pending.sessionID, message);
3469
+ return;
3470
+ }
3471
+ reporter.warning(message);
3472
+ conversation?.warning(pending.sessionID, message);
3474
3473
  }
3475
3474
 
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");
3475
+ // src/question-command.js
3476
+ var QUESTION_ACTIONS = /* @__PURE__ */ new Set(["answer", "reject"]);
3477
+ function validateQuestionCommand(command, pending, now = Date.now()) {
3478
+ const response = command?.response;
3479
+ if (!response || !QUESTION_ACTIONS.has(response.action)) {
3480
+ return { status: "rejected", action: response?.action, errorCode: "invalid_action" };
3481
+ }
3482
+ const expiresAt = Date.parse(command.expiresAt);
3483
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3484
+ return { status: "expired", action: response.action };
3485
+ }
3486
+ if (response.action === "reject") return;
3487
+ const errorCode = validateQuestionAnswers(response.answers, pending.questions);
3488
+ if (errorCode) return { status: "rejected", action: response.action, errorCode };
3489
+ }
3490
+ function validateQuestionAnswers(values, questions) {
3491
+ if (!Array.isArray(values) || values.length !== questions.length) {
3492
+ return "invalid_answers";
3493
+ }
3494
+ for (let index = 0; index < questions.length; index++) {
3495
+ const question = questions[index];
3496
+ const answers = values[index];
3497
+ if (!Array.isArray(answers) || !question.multiple && answers.length > 1) {
3498
+ return "invalid_answers";
3499
+ }
3500
+ for (const answer of answers) {
3501
+ if (typeof answer !== "string" || answer.trim().length === 0) {
3502
+ return "invalid_answers";
3503
+ }
3504
+ const known = question.options.some((option) => option.label === answer);
3505
+ if (!known && !question.custom) {
3506
+ return "custom_not_allowed";
3492
3507
  }
3493
3508
  }
3494
3509
  }
3510
+ }
3511
+ function questionFailureAcknowledgement(command, error) {
3512
+ const stale = error instanceof HttpError && error.status === 404;
3495
3513
  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
- }
3514
+ status: stale ? "stale" : "failed",
3515
+ action: command?.response?.action,
3516
+ errorCode: stale ? "question_not_found" : "opencode_question_reply_failed"
3521
3517
  };
3522
3518
  }
3519
+ function reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation) {
3520
+ const messages = {
3521
+ applied: acknowledgement.action === "reject" ? "Question rejected from Backeer" : "Question answered from Backeer",
3522
+ expired: "Question response expired before it could be applied",
3523
+ stale: "Question was already answered locally",
3524
+ rejected: "Question response was rejected by validation"
3525
+ };
3526
+ const message = messages[acknowledgement.status];
3527
+ if (!message) return;
3528
+ if (acknowledgement.status === "applied") {
3529
+ reporter.applied(message);
3530
+ conversation?.applied(pending.sessionID, message);
3531
+ } else {
3532
+ reporter.warning(message);
3533
+ conversation?.warning(pending.sessionID, message);
3534
+ }
3535
+ }
3523
3536
 
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 };
3537
+ // src/sse.js
3538
+ function parseSseFrame(frame) {
3539
+ const data = [];
3540
+ let event = "message";
3541
+ let id;
3542
+ for (const line of frame.split(/\r?\n/)) {
3543
+ if (!line || line.startsWith(":")) continue;
3544
+ const separator = line.indexOf(":");
3545
+ const field = separator === -1 ? line : line.slice(0, separator);
3546
+ let value = separator === -1 ? "" : line.slice(separator + 1);
3547
+ if (value.startsWith(" ")) value = value.slice(1);
3548
+ if (field === "event") event = value;
3549
+ if (field === "id") id = value;
3550
+ if (field === "data") data.push(value);
3536
3551
  }
3537
- function text(value, max) {
3538
- return clipped(value, max).value;
3552
+ if (data.length === 0) return;
3553
+ return { event, id, data: data.join("\n") };
3554
+ }
3555
+ function nextSseFrame(buffer) {
3556
+ const lfIndex = buffer.indexOf("\n\n");
3557
+ const crlfIndex = buffer.indexOf("\r\n\r\n");
3558
+ if (lfIndex === -1 && crlfIndex === -1) return;
3559
+ if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
3560
+ return {
3561
+ frame: buffer.slice(0, crlfIndex),
3562
+ rest: buffer.slice(crlfIndex + 4)
3563
+ };
3539
3564
  }
3540
- function content(key, value) {
3541
- const result = clipped(value);
3542
- return { [key]: result.value, ...result.truncated ? { truncated: true } : {} };
3565
+ return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) };
3566
+ }
3567
+ async function consumeSse(response, state, onCommand, signal2, reporter) {
3568
+ if (!response.body) throw new Error("Backeer command stream has no body");
3569
+ const reader = response.body.getReader();
3570
+ const decoder = new TextDecoder();
3571
+ let buffer = "";
3572
+ try {
3573
+ while (!signal2.aborted) {
3574
+ const item = await reader.read();
3575
+ if (item.done) break;
3576
+ buffer += decoder.decode(item.value, { stream: true });
3577
+ for (; ; ) {
3578
+ const next = nextSseFrame(buffer);
3579
+ if (!next) break;
3580
+ buffer = next.rest;
3581
+ state.framesReceived = (state.framesReceived ?? 0) + 1;
3582
+ const message = parseSseFrame(next.frame);
3583
+ if (!message) continue;
3584
+ if (message.id) state.lastEventID = message.id;
3585
+ if (message.event !== "message" && message.event !== "command") continue;
3586
+ try {
3587
+ await onCommand(JSON.parse(message.data));
3588
+ } catch (error) {
3589
+ if (error instanceof SyntaxError) {
3590
+ reporter.error("Invalid command received", "SSE data is not valid JSON");
3591
+ continue;
3592
+ }
3593
+ throw error;
3594
+ }
3595
+ }
3596
+ }
3597
+ } finally {
3598
+ await reader.cancel().catch(() => {
3599
+ });
3543
3600
  }
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
3601
  }
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));
3602
+ async function openCommandStream(config, state, signal2, fetchImplementation) {
3603
+ const headers = {
3604
+ Accept: "text/event-stream",
3605
+ Authorization: `Bearer ${config.token}`
3606
+ };
3607
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
3608
+ const response = await fetchImplementation(config.commandStreamEndpoint, {
3609
+ headers,
3610
+ signal: signal2
3611
+ });
3612
+ if (!response.ok) {
3613
+ const responseBody = limit(await response.text(), 500);
3614
+ throw new HttpError(
3615
+ `Backeer command stream failed with status ${response.status}`,
3616
+ response.status,
3617
+ responseBody
3618
+ );
3617
3619
  }
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);
3620
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
3621
+ throw new Error("Backeer command stream returned an invalid content type");
3626
3622
  }
3623
+ return response;
3624
+ }
3625
+ async function consumeConnection({
3626
+ response,
3627
+ state,
3628
+ onCommand,
3629
+ signal: signal2,
3630
+ reporter,
3631
+ now,
3632
+ stableConnectionMs,
3633
+ onStable
3634
+ }) {
3635
+ const connectedAt = now();
3636
+ const framesBeforeConnection = state.framesReceived;
3627
3637
  try {
3628
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3629
- } catch {
3630
- throw failure(400, "invalid_json");
3638
+ await consumeSse(response, state, onCommand, signal2, reporter);
3639
+ } finally {
3640
+ const receivedFrame = state.framesReceived > framesBeforeConnection;
3641
+ if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
3631
3642
  }
3632
3643
  }
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
- });
3644
+ function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
3645
+ const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
3646
+ if (message !== lastLoggedError) {
3647
+ const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
3648
+ reporter.error(
3649
+ authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
3650
+ message
3651
+ );
3678
3652
  }
3679
- async function session(id) {
3680
- if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session");
3681
- let info;
3653
+ return {
3654
+ lastLoggedError: message,
3655
+ reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status) ? 6e4 : reconnectDelay
3656
+ };
3657
+ }
3658
+ async function runCommandStream(config, reporter, {
3659
+ signal: signal2,
3660
+ hasPending,
3661
+ onCommand,
3662
+ fetchImplementation = fetch,
3663
+ sleepImplementation = sleep,
3664
+ now = Date.now,
3665
+ stableConnectionMs = 5e3
3666
+ }) {
3667
+ const state = { lastEventID: "", framesReceived: 0 };
3668
+ let reconnectDelay = 1e3;
3669
+ let lastLoggedError = "";
3670
+ while (!signal2.aborted && hasPending()) {
3682
3671
  try {
3683
- info = await lookup(id);
3672
+ const response = await openCommandStream(
3673
+ config,
3674
+ state,
3675
+ signal2,
3676
+ fetchImplementation
3677
+ );
3678
+ await consumeConnection({
3679
+ response,
3680
+ state,
3681
+ onCommand,
3682
+ signal: signal2,
3683
+ reporter,
3684
+ now,
3685
+ stableConnectionMs,
3686
+ onStable: () => {
3687
+ reconnectDelay = 1e3;
3688
+ lastLoggedError = "";
3689
+ }
3690
+ });
3684
3691
  } 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");
3692
+ if (signal2.aborted || isAbortError(error)) return;
3693
+ const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
3694
+ reconnectDelay = next.reconnectDelay;
3695
+ lastLoggedError = next.lastLoggedError;
3687
3696
  }
3688
- if (info?.id !== id || typeof info.time?.archived === "number" || (info.location?.directory ?? info.directory) !== directory) {
3689
- throw failure(404, "session_not_found");
3697
+ if (!signal2.aborted && hasPending()) {
3698
+ await sleepImplementation(reconnectDelay, signal2);
3699
+ reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
3690
3700
  }
3691
- return {
3692
- id,
3693
- directory,
3694
- title: sanitizer.text(info.title, 240),
3695
- ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
3696
- status: states.get(id) ?? "unknown"
3697
- };
3698
3701
  }
3699
- function removeStream(stream) {
3700
- const removed = streams.delete(stream);
3701
- clearInterval(stream.heartbeat);
3702
- stream.queue.length = 0;
3703
- stream.queuedBytes = 0;
3704
- if (removed && !streams.size && !disposed) connectionNotifications.update(false);
3702
+ }
3703
+
3704
+ // src/permission-controller.js
3705
+ async function replyToOpenCode(client, pending, decision) {
3706
+ const result = await client.postSessionIdPermissionsPermissionId({
3707
+ path: { id: pending.sessionID, permissionID: pending.requestID },
3708
+ query: { directory: pending.directory },
3709
+ body: { response: decision },
3710
+ throwOnError: true
3711
+ });
3712
+ if (result?.data === false || result === false) {
3713
+ throw new HttpError("OpenCode did not apply the permission reply", 409);
3705
3714
  }
3706
- function write(stream, value) {
3707
- if (!streams.has(stream)) return;
3708
- if (stream.blocked) {
3709
- stream.queue.push(value);
3710
- stream.queuedBytes += Buffer.byteLength(value);
3711
- if (stream.queuedBytes > LIMITS.bytes) {
3712
- removeStream(stream);
3713
- stream.response.destroy();
3714
- }
3715
- return;
3716
- }
3717
- if (!stream.response.write(value)) {
3718
- stream.blocked = true;
3719
- }
3715
+ }
3716
+ async function acknowledgeCommand(config, commandID, acknowledgement) {
3717
+ const url = new URL(
3718
+ `/v1/commands/${encodeURIComponent(commandID)}/ack`,
3719
+ config.origin
3720
+ );
3721
+ await postJsonWithRetry(config, url, acknowledgement);
3722
+ }
3723
+ function questionResponseError(result) {
3724
+ if (!result?.error) return;
3725
+ const status = Number(result?.response?.status) || 502;
3726
+ const detail = result.error?.data?.message ?? result.error?.message ?? "OpenCode rejected the question response";
3727
+ return new HttpError(String(detail), status);
3728
+ }
3729
+ function normalizeQuestionError(error) {
3730
+ if (error instanceof HttpError) return error;
3731
+ const status = Number(error?.status ?? error?.statusCode ?? error?.cause?.status);
3732
+ if (!Number.isFinite(status)) return error;
3733
+ return new HttpError(error?.message || "OpenCode rejected the question response", status);
3734
+ }
3735
+ function rawQuestionRequest(client, pending, response, isV2) {
3736
+ const transport = client?._client;
3737
+ if (typeof transport?.post !== "function") {
3738
+ throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
3720
3739
  }
3721
- async function handle(event) {
3722
- if (disposed) return;
3723
- const safe = sanitizer.sanitize(event);
3724
- if (!safe) return;
3725
- try {
3726
- await session(safe.sessionID);
3727
- } catch {
3728
- return;
3740
+ const rejecting = response.action === "reject";
3741
+ if (isV2) {
3742
+ return transport.post({
3743
+ url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
3744
+ path: { sessionID: pending.sessionID, requestID: pending.requestID },
3745
+ ...rejecting ? {} : { body: { answers: response.answers } },
3746
+ ...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
3747
+ throwOnError: true
3748
+ });
3749
+ }
3750
+ return transport.post({
3751
+ url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
3752
+ path: { requestID: pending.requestID },
3753
+ query: { directory: pending.directory },
3754
+ ...rejecting ? {} : { body: { answers: response.answers } },
3755
+ ...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
3756
+ throwOnError: true
3757
+ });
3758
+ }
3759
+ async function replyToQuestion(client, pending, response) {
3760
+ const isV2 = pending.sourceEvent === "question.v2.asked";
3761
+ const api = isV2 ? client?.v2?.session?.question : client?.question;
3762
+ const method = response.action === "reject" ? api?.reject : api?.reply;
3763
+ const input = isV2 ? {
3764
+ sessionID: pending.sessionID,
3765
+ requestID: pending.requestID,
3766
+ directory: pending.directory,
3767
+ ...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
3768
+ } : {
3769
+ requestID: pending.requestID,
3770
+ directory: pending.directory,
3771
+ ...response.action === "answer" ? { answers: response.answers } : {}
3772
+ };
3773
+ let result;
3774
+ try {
3775
+ result = typeof method === "function" ? await method.call(api, input, { throwOnError: true }) : await rawQuestionRequest(client, pending, response, isV2);
3776
+ } catch (error) {
3777
+ throw normalizeQuestionError(error);
3778
+ }
3779
+ const responseError = questionResponseError(result);
3780
+ if (responseError) throw responseError;
3781
+ if (result?.data === false || result === false) {
3782
+ throw new HttpError("OpenCode did not apply the question response", 409);
3783
+ }
3784
+ }
3785
+ async function postQuestionState(config, eventID, path, body = {}) {
3786
+ const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
3787
+ await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
3788
+ }
3789
+ function createPermissionController({
3790
+ config,
3791
+ reporter,
3792
+ client,
3793
+ directory,
3794
+ conversation
3795
+ }) {
3796
+ const pendingByExternalID = /* @__PURE__ */ new Map();
3797
+ const externalIDByRequestID = /* @__PURE__ */ new Map();
3798
+ const questionsByExternalID = /* @__PURE__ */ new Map();
3799
+ const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
3800
+ const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
3801
+ const processingCommands = /* @__PURE__ */ new Set();
3802
+ const completedCommands = /* @__PURE__ */ new Map();
3803
+ let commandStream;
3804
+ let leaseTimer;
3805
+ let disposed = false;
3806
+ function removePendingByRequestID(requestID) {
3807
+ const externalId = externalIDByRequestID.get(requestID);
3808
+ if (!externalId) return;
3809
+ externalIDByRequestID.delete(requestID);
3810
+ pendingByExternalID.delete(externalId);
3811
+ }
3812
+ function stopCommandStreamIfIdle() {
3813
+ if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
3814
+ commandStream?.controller.abort();
3729
3815
  }
3730
- if (disposed) return;
3731
- if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status);
3732
- if (safe.type === "session.idle") states.set(safe.sessionID, "idle");
3733
- if (safe.type === "session.deleted") states.delete(safe.sessionID);
3734
- if (states.size > 256) states.delete(states.keys().next().value);
3735
- const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() };
3736
- let frame = `id: ${record.id}
3737
- event: backeer.event
3738
- data: ${JSON.stringify(record)}
3739
-
3740
- `;
3741
- if (Buffer.byteLength(frame) > 32768) {
3742
- record.data = { truncated: true };
3743
- frame = `id: ${record.id}
3744
- event: backeer.event
3745
- data: ${JSON.stringify(record)}
3746
-
3747
- `;
3816
+ }
3817
+ function removeQuestionByRequestID(requestID) {
3818
+ const externalId = questionExternalIDByRequestID.get(requestID);
3819
+ if (!externalId) return;
3820
+ questionExternalIDByRequestID.delete(requestID);
3821
+ questionsByExternalID.delete(externalId);
3822
+ if (questionsByExternalID.size === 0 && leaseTimer) {
3823
+ clearInterval(leaseTimer);
3824
+ leaseTimer = void 0;
3748
3825
  }
3749
- const size = Buffer.byteLength(frame);
3750
- events.push({ sequence, sessionID: record.sessionID, frame, size });
3751
- bytes += size;
3752
- while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size;
3753
- for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame);
3754
3826
  }
3755
- function authorized(request) {
3756
- if (request.headers.origin || request.headers["sec-fetch-site"]) return false;
3757
- const expected = Buffer.from(`Bearer ${token}`);
3758
- const actual = Buffer.from(request.headers.authorization ?? "");
3759
- return actual.length === expected.length && timingSafeEqual(actual, expected);
3827
+ async function renewQuestionLeases() {
3828
+ for (const pending of questionsByExternalID.values()) {
3829
+ if (!pending.eventID) continue;
3830
+ await postQuestionState(config, pending.eventID, "lease").catch((error) => {
3831
+ reporter.warning("Question lease could not be renewed", error);
3832
+ });
3833
+ }
3760
3834
  }
3761
- function parseCursor(value) {
3762
- if (!value) return { after: 0 };
3763
- const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value);
3764
- if (!match) throw failure(400, "invalid_cursor");
3765
- if (match[1] !== epoch) return { gap: "stream_restarted" };
3766
- const after = Number(match[2]);
3767
- if (!Number.isSafeInteger(after) || after > sequence) throw failure(400, "invalid_cursor");
3768
- if (events.length && after < events[0].sequence - 1) return { gap: "cursor_expired" };
3769
- return { after };
3835
+ function ensureLeaseTimer() {
3836
+ if (leaseTimer || questionsByExternalID.size === 0) return;
3837
+ leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
3838
+ leaseTimer.unref?.();
3770
3839
  }
3771
- async function route(request, response) {
3772
- if (!authorized(request)) return json(response, 401, { error: "unauthorized" });
3773
- if (request.headers["x-backeer-project"] !== void 0 && request.headers["x-backeer-project"] !== encodeURIComponent(directory)) {
3774
- return json(response, 409, { error: "project_mismatch" });
3840
+ async function handleCommand(command) {
3841
+ if (!command || typeof command !== "object") return;
3842
+ if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
3843
+ if (typeof command.id !== "string" || command.id.length > 200) return;
3844
+ const previousAcknowledgement = completedCommands.get(command.id);
3845
+ if (previousAcknowledgement) {
3846
+ await acknowledgeCommand(config, command.id, previousAcknowledgement).catch((error) => reporter.warning("Decision receipt retry failed", error));
3847
+ return;
3775
3848
  }
3776
- if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" });
3777
- active++;
3778
- const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15e3);
3779
- timeout.unref();
3849
+ if (processingCommands.has(command.id)) return;
3850
+ if (typeof command.externalId !== "string") return;
3851
+ const isQuestion = command.type === "opencode_question_reply";
3852
+ const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
3853
+ if (!pending) return;
3854
+ processingCommands.add(command.id);
3855
+ let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
3780
3856
  try {
3781
- const url = new URL(request.url, "http://127.0.0.1");
3782
- if (url.username || url.password) throw failure(400, "invalid_url");
3783
- if (request.method === "GET" && url.pathname === "/v1/sessions") {
3784
- const result = await api.list();
3785
- const values = Array.isArray(result) ? result : result?.data;
3786
- if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
3787
- const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) && typeof info.time?.archived !== "number" && (info.location?.directory ?? info.directory) === directory);
3788
- return json(response, 200, { sessions: own.map((info) => ({
3789
- id: info.id,
3790
- title: sanitizer.text(info.title, 240),
3791
- status: states.get(info.id) ?? "unknown",
3792
- ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
3793
- })), directory, cursor: cursor() });
3794
- }
3795
- if (request.method === "GET" && url.pathname === "/v1/events") {
3796
- const selected = url.searchParams.get("sessionID") || void 0;
3797
- if (selected) await session(selected);
3798
- const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"]);
3799
- if (response.headersSent) return;
3800
- if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams");
3801
- clearTimeout(timeout);
3802
- response.writeHead(200, {
3803
- "content-type": "text/event-stream",
3804
- "cache-control": "no-cache, no-transform",
3805
- "x-accel-buffering": "no",
3806
- connection: "keep-alive"
3807
- });
3808
- if (parsed.gap) {
3809
- response.end(`event: backeer.gap
3810
- data: ${JSON.stringify({ reason: parsed.gap, cursor: cursor() })}
3811
-
3812
- `);
3813
- return;
3814
- }
3815
- const stream = {
3816
- response,
3817
- sessionID: selected,
3818
- heartbeat: void 0,
3819
- blocked: false,
3820
- queue: [],
3821
- queuedBytes: 0
3822
- };
3823
- streams.add(stream);
3824
- connectionNotifications.update(true);
3825
- request.socket.setTimeout(0);
3826
- request.socket.setKeepAlive(true, LIVE_HEARTBEAT_MS);
3827
- response.on("close", () => removeStream(stream));
3828
- response.on("drain", () => {
3829
- stream.blocked = false;
3830
- while (streams.has(stream) && !stream.blocked && stream.queue.length) {
3831
- const next = stream.queue.shift();
3832
- stream.queuedBytes -= Buffer.byteLength(next);
3833
- write(stream, next);
3834
- }
3835
- });
3836
- write(stream, `: connected
3837
- retry: ${LIVE_RECONNECT_MS}
3838
-
3839
- `);
3840
- for (const item of events) {
3841
- if (!streams.has(stream)) break;
3842
- if (item.sequence > parsed.after && (!selected || selected === item.sessionID)) write(stream, item.frame);
3843
- }
3844
- if (streams.has(stream)) {
3845
- stream.heartbeat = setInterval(() => write(stream, ": heartbeat\n\n"), LIVE_HEARTBEAT_MS);
3846
- stream.heartbeat.unref();
3857
+ if (!acknowledgement) {
3858
+ if (isQuestion) {
3859
+ remotelyResolvingQuestions.add(pending.requestID);
3860
+ await replyToQuestion(client, pending, command.response);
3861
+ acknowledgement = { status: "applied", action: command.response.action };
3862
+ removeQuestionByRequestID(pending.requestID);
3863
+ const cleanup = setTimeout(
3864
+ () => remotelyResolvingQuestions.delete(pending.requestID),
3865
+ 3e4
3866
+ );
3867
+ cleanup.unref?.();
3868
+ } else {
3869
+ await replyToOpenCode(client, pending, command.decision);
3870
+ acknowledgement = { status: "applied", decision: command.decision };
3871
+ removePendingByRequestID(pending.requestID);
3847
3872
  }
3848
- return;
3849
3873
  }
3850
- const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions)?$/.exec(url.pathname);
3851
- if (!match) throw failure(404, "route_not_found");
3852
- if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]));
3853
- if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed");
3854
- if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required");
3855
- const body = await readBody(request);
3856
- 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");
3857
- await session(match[1]);
3858
- if (response.headersSent || disposed) return;
3859
- for (const [id, command2] of commands) {
3860
- if (command2.settled && Date.now() - command2.created >= LIMITS.commandTTL) commands.delete(id);
3874
+ } catch (error) {
3875
+ if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
3876
+ acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
3877
+ if (acknowledgement.status === "stale") {
3878
+ if (isQuestion) removeQuestionByRequestID(pending.requestID);
3879
+ else removePendingByRequestID(pending.requestID);
3880
+ }
3881
+ const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
3882
+ reporter.error(message, error);
3883
+ conversation?.error(pending.sessionID, message);
3884
+ } finally {
3885
+ processingCommands.delete(command.id);
3886
+ }
3887
+ acknowledgement = {
3888
+ ...acknowledgement,
3889
+ type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
3890
+ externalId: command.externalId,
3891
+ processedAt: (/* @__PURE__ */ new Date()).toISOString()
3892
+ };
3893
+ rememberBounded(completedCommands, command.id, acknowledgement);
3894
+ if (isQuestion) {
3895
+ reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
3896
+ } else {
3897
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation);
3898
+ }
3899
+ await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
3900
+ reporter.warning("Decision receipt could not be sent", error);
3901
+ });
3902
+ stopCommandStreamIfIdle();
3903
+ }
3904
+ function ensureCommandStream() {
3905
+ const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
3906
+ if (disposed || commandStream || !hasPending()) return;
3907
+ const controller = new AbortController();
3908
+ const current = {
3909
+ controller,
3910
+ task: runCommandStream(config, reporter, {
3911
+ signal: controller.signal,
3912
+ hasPending,
3913
+ onCommand: handleCommand
3914
+ })
3915
+ };
3916
+ commandStream = current;
3917
+ void current.task.finally(() => {
3918
+ if (commandStream === current) commandStream = void 0;
3919
+ if (!disposed && hasPending()) ensureCommandStream();
3920
+ });
3921
+ }
3922
+ return {
3923
+ register(identity) {
3924
+ pendingByExternalID.set(identity.externalId, { ...identity, directory });
3925
+ externalIDByRequestID.set(identity.requestID, identity.externalId);
3926
+ ensureCommandStream();
3927
+ },
3928
+ resolveLocally(requestID) {
3929
+ removePendingByRequestID(requestID);
3930
+ stopCommandStreamIfIdle();
3931
+ },
3932
+ registerQuestion(identity, details) {
3933
+ questionsByExternalID.set(identity.externalId, {
3934
+ ...identity,
3935
+ ...details,
3936
+ directory
3937
+ });
3938
+ questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
3939
+ ensureLeaseTimer();
3940
+ ensureCommandStream();
3941
+ },
3942
+ resolveQuestionLocally(requestID, outcome) {
3943
+ if (remotelyResolvingQuestions.delete(requestID)) {
3944
+ removeQuestionByRequestID(requestID);
3945
+ stopCommandStreamIfIdle();
3946
+ return;
3861
3947
  }
3862
- const existing = commands.get(body.requestId);
3863
- if (existing) {
3864
- if (existing.sessionID !== match[1] || existing.prompt !== body.prompt) throw failure(409, "request_id_conflict");
3865
- await existing.task;
3866
- return json(response, 200, existing.result);
3948
+ const externalId = questionExternalIDByRequestID.get(requestID);
3949
+ const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
3950
+ if (!pending) return;
3951
+ removeQuestionByRequestID(requestID);
3952
+ if (pending.eventID) {
3953
+ void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
3867
3954
  }
3868
- if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full");
3869
- const command = {
3870
- sessionID: match[1],
3871
- prompt: body.prompt,
3872
- created: Date.now(),
3873
- settled: false,
3874
- result: { requestId: body.requestId, sessionID: match[1], status: "accepted" },
3875
- task: void 0
3955
+ stopCommandStreamIfIdle();
3956
+ },
3957
+ refreshCredentials() {
3958
+ commandStream?.controller.abort();
3959
+ if (!commandStream) ensureCommandStream();
3960
+ },
3961
+ status() {
3962
+ return {
3963
+ pending: pendingByExternalID.size,
3964
+ pendingQuestions: questionsByExternalID.size,
3965
+ stream: commandStream ? "active" : "idle"
3876
3966
  };
3877
- commands.set(body.requestId, command);
3878
- command.task = Promise.resolve().then(() => api.prompt(match[1], body.prompt)).finally(() => {
3879
- command.settled = true;
3967
+ },
3968
+ async dispose() {
3969
+ disposed = true;
3970
+ commandStream?.controller.abort();
3971
+ if (leaseTimer) clearInterval(leaseTimer);
3972
+ await commandStream?.task.catch(() => {
3880
3973
  });
3881
- await command.task;
3882
- return json(response, 202, command.result);
3883
- } catch (error) {
3884
- json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" });
3885
- } finally {
3886
- active--;
3887
- clearTimeout(timeout);
3974
+ pendingByExternalID.clear();
3975
+ externalIDByRequestID.clear();
3976
+ processingCommands.clear();
3977
+ completedCommands.clear();
3978
+ questionsByExternalID.clear();
3979
+ questionExternalIDByRequestID.clear();
3980
+ remotelyResolvingQuestions.clear();
3981
+ }
3982
+ };
3983
+ }
3984
+
3985
+ // src/live-session-scope.js
3986
+ import { execFile } from "node:child_process";
3987
+ import { promisify } from "node:util";
3988
+ import { realpath } from "node:fs/promises";
3989
+ import { isAbsolute as isAbsolute2 } from "node:path";
3990
+ var run = promisify(execFile);
3991
+ async function commonDirectory(directory) {
3992
+ const { stdout } = await run(
3993
+ "git",
3994
+ ["-C", directory, "rev-parse", "--path-format=absolute", "--git-common-dir"],
3995
+ { timeout: 1e3, maxBuffer: 8192, windowsHide: true }
3996
+ );
3997
+ return realpath(stdout.trim());
3998
+ }
3999
+ function approvedWorktreeSessions(value) {
4000
+ if (!Array.isArray(value) || value.length > 16) return [];
4001
+ return value.filter((item) => /^[A-Za-z0-9_-]{1,160}$/.test(item?.sessionID) && item.sessionID !== "global" && typeof item.directory === "string" && isAbsolute2(item.directory) && item.directory.length <= 4096);
4002
+ }
4003
+ function createSessionScope(directory, { resolveCommon = commonDirectory, approvedSessions = [] } = {}) {
4004
+ const observed = /* @__PURE__ */ new Map();
4005
+ const approved = approvedWorktreeSessions(approvedSessions);
4006
+ return async function owns(info) {
4007
+ const location = info?.location?.directory ?? info?.directory;
4008
+ if (typeof location !== "string" || typeof directory !== "string") return false;
4009
+ if (location === directory) {
4010
+ if (info.id) {
4011
+ observed.set(info.id, info.projectID);
4012
+ if (observed.size > 256) observed.delete(observed.keys().next().value);
4013
+ }
4014
+ return true;
4015
+ }
4016
+ const explicitlyApproved = approved.some((item) => item.sessionID === info.id && item.directory === location);
4017
+ if (!explicitlyApproved && (!observed.has(info.id) || !info.projectID || observed.get(info.id) !== info.projectID)) return false;
4018
+ try {
4019
+ const [base, current] = await Promise.all([resolveCommon(directory), resolveCommon(location)]);
4020
+ return base === current;
4021
+ } catch {
4022
+ return false;
3888
4023
  }
4024
+ };
4025
+ }
4026
+
4027
+ // src/live-bridge-client.js
4028
+ function createV2LiveClient(ctx) {
4029
+ const approvedSessions = approvedWorktreeSessions(ctx.options?.liveBridge?.sessionWorktrees);
4030
+ const known = new Map(approvedSessions.map((item) => [item.sessionID, void 0]));
4031
+ const ownsSession = createSessionScope(ctx.location?.directory, { approvedSessions });
4032
+ let disposed = false;
4033
+ async function get(id) {
4034
+ const info = await ctx.session.get({ sessionID: id });
4035
+ if (disposed) return info;
4036
+ await ownsSession(info);
4037
+ known.set(id, info);
4038
+ if (known.size > 100) known.delete(known.keys().next().value);
4039
+ return info;
3889
4040
  }
3890
- const server = createServer((request, response) => {
3891
- void route(request, response);
3892
- });
3893
- server.requestTimeout = 15e3;
3894
- server.headersTimeout = 1e4;
3895
- server.keepAliveTimeout = 5e3;
3896
- await new Promise((resolve2, reject) => {
3897
- server.once("error", reject);
3898
- server.listen(options.port ?? 43871, "127.0.0.1", () => {
3899
- server.off("error", reject);
3900
- resolve2();
3901
- });
3902
- });
3903
- server.on("error", () => {
3904
- });
3905
4041
  return {
3906
- handle,
3907
- address: server.address(),
3908
- dispose() {
3909
- disposeTask ??= new Promise((resolve2) => {
3910
- disposed = true;
3911
- for (const cancel of lookupWaiters) cancel();
3912
- for (const stream of streams) {
3913
- removeStream(stream);
3914
- stream.response.destroy();
4042
+ async list() {
4043
+ const refreshed = [];
4044
+ if (typeof ctx.session.list === "function") {
4045
+ const result = await ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
4046
+ const values = Array.isArray(result) ? result : result?.data;
4047
+ if (!Array.isArray(values)) throw new Error("Invalid native session list");
4048
+ for (const info of values.slice(0, 100)) {
4049
+ if (await ownsSession(info)) {
4050
+ refreshed.push(info);
4051
+ known.set(info.id, info);
4052
+ }
3915
4053
  }
3916
- server.close(() => {
3917
- void connectionNotifications.dispose().then(resolve2);
3918
- });
3919
- server.closeAllConnections?.();
3920
- events.length = 0;
3921
- states.clear();
3922
- commands.clear();
3923
- sanitizer.clear();
3924
- api.dispose?.();
3925
- });
3926
- return disposeTask;
4054
+ }
4055
+ for (const id of [...known.keys()].slice(0, 100)) {
4056
+ if (refreshed.some((info) => info.id === id)) continue;
4057
+ try {
4058
+ refreshed.push(await get(id));
4059
+ } catch {
4060
+ known.delete(id);
4061
+ }
4062
+ }
4063
+ return refreshed;
4064
+ },
4065
+ get,
4066
+ ownsSession,
4067
+ prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
4068
+ dispose: () => {
4069
+ disposed = true;
4070
+ known.clear();
3927
4071
  }
3928
4072
  };
3929
4073
  }
3930
-
3931
- // src/permission-command.js
3932
- var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
3933
- var DECISION_LABELS = {
3934
- once: "Allow once",
3935
- always: "Always allow",
3936
- reject: "Reject"
3937
- };
3938
- function validatePermissionCommand(command, now = Date.now()) {
3939
- if (!ALLOWED_DECISIONS.has(command.decision)) {
3940
- return {
3941
- status: "rejected",
3942
- decision: command.decision,
3943
- errorCode: "invalid_decision"
3944
- };
3945
- }
3946
- const expiresAt = Date.parse(command.expiresAt);
3947
- if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3948
- return { status: "expired", decision: command.decision };
3949
- }
3950
- if (command.decision === "always" && command.confirmed !== true) {
3951
- return {
3952
- status: "rejected",
3953
- decision: command.decision,
3954
- errorCode: "confirmation_required"
3955
- };
3956
- }
4074
+ function unwrap(value) {
4075
+ if (value?.error) throw new Error("OpenCode request failed");
4076
+ return value?.data ?? value;
3957
4077
  }
3958
- function failureAcknowledgement(command, error) {
3959
- const stale = error instanceof HttpError && error.status === 404;
4078
+ function createV1LiveClient(client, directory) {
3960
4079
  return {
3961
- status: stale ? "stale" : "failed",
3962
- decision: command.decision,
3963
- errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
4080
+ async questions(sessionID) {
4081
+ const result = typeof client.question?.list === "function" ? await client.question.list({ directory }, { throwOnError: true }) : typeof client._client?.get === "function" ? await client._client.get({ url: "/question", query: { directory }, throwOnError: true }) : (() => {
4082
+ throw Object.assign(new Error("Question listing unavailable"), { status: 501 });
4083
+ })();
4084
+ const values = unwrap(result);
4085
+ if (!Array.isArray(values)) throw new Error("Invalid question list");
4086
+ return values.filter((question) => question.sessionID === sessionID);
4087
+ },
4088
+ answerQuestion: (pending, answers) => replyToQuestion(
4089
+ client,
4090
+ { ...pending, requestID: pending.id, directory, sourceEvent: "question.asked" },
4091
+ { action: "answer", answers }
4092
+ ),
4093
+ list: async () => unwrap(await client.session.list({ query: { directory }, throwOnError: true })),
4094
+ get: async (id) => unwrap(await client.session.get({ path: { id }, query: { directory }, throwOnError: true })),
4095
+ async prompt(id, text) {
4096
+ if (typeof client.session.promptAsync !== "function") {
4097
+ throw Object.assign(new Error("OpenCode asynchronous prompts are unavailable"), { status: 501 });
4098
+ }
4099
+ return unwrap(await client.session.promptAsync({
4100
+ path: { id },
4101
+ query: { directory },
4102
+ body: { parts: [{ type: "text", text }] },
4103
+ throwOnError: true
4104
+ }));
4105
+ }
3964
4106
  };
3965
4107
  }
3966
- function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
3967
- const messages = {
3968
- applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
3969
- expired: "Decision expired before it could be applied",
3970
- stale: "Permission was already answered locally",
3971
- rejected: "Decision was rejected by validation"
3972
- };
3973
- const message = messages[acknowledgement.status];
3974
- if (!message) return;
3975
- if (acknowledgement.status === "applied") {
3976
- reporter.applied(message);
3977
- conversation?.applied(pending.sessionID, message);
3978
- return;
4108
+
4109
+ // src/live-connection.js
4110
+ import { randomUUID as randomUUID2 } from "node:crypto";
4111
+ var LIVE_RECONNECT_MS = 3e3;
4112
+ var LIVE_HEARTBEAT_MS = 15e3;
4113
+ function createLiveConnectionNotifier(config, reporter) {
4114
+ const epoch = randomUUID2();
4115
+ const controller = new AbortController();
4116
+ let last, sequence = 0, pending, task;
4117
+ async function drain() {
4118
+ while (pending && !controller.signal.aborted) {
4119
+ const event = pending;
4120
+ pending = void 0;
4121
+ try {
4122
+ await postJsonWithRetry(config, config.eventsEndpoint, event, { signal: controller.signal });
4123
+ } catch {
4124
+ if (!controller.signal.aborted) reporter?.error("Live connection notification could not be sent");
4125
+ }
4126
+ }
3979
4127
  }
3980
- reporter.warning(message);
3981
- conversation?.warning(pending.sessionID, message);
4128
+ return {
4129
+ update(connected) {
4130
+ if (controller.signal.aborted || connected === last) return;
4131
+ const initial = last === void 0;
4132
+ last = connected;
4133
+ if (initial || !config.token || !config.eventsEndpoint) return;
4134
+ pending = {
4135
+ type: "application",
4136
+ severity: connected ? "info" : "warning",
4137
+ title: connected ? "OpenCode ba\u011Flant\u0131s\u0131 yeniden kuruldu" : "OpenCode ba\u011Flant\u0131s\u0131 kesildi",
4138
+ 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.",
4139
+ payload: [{ key: "Connection", value: connected ? "reconnected" : "disconnected" }],
4140
+ externalId: `opencode:live:${epoch}:${++sequence}`,
4141
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4142
+ notify: true,
4143
+ direction: "in"
4144
+ };
4145
+ task ??= drain().finally(() => {
4146
+ task = void 0;
4147
+ });
4148
+ },
4149
+ async dispose() {
4150
+ controller.abort();
4151
+ pending = void 0;
4152
+ await task;
4153
+ }
4154
+ };
3982
4155
  }
3983
4156
 
3984
- // src/question-command.js
3985
- var QUESTION_ACTIONS = /* @__PURE__ */ new Set(["answer", "reject"]);
3986
- function validateQuestionCommand(command, pending, now = Date.now()) {
3987
- const response = command?.response;
3988
- if (!response || !QUESTION_ACTIONS.has(response.action)) {
3989
- return { status: "rejected", action: response?.action, errorCode: "invalid_action" };
4157
+ // src/live-bridge-events.js
4158
+ var ID = /^[A-Za-z0-9_-]{1,160}$/;
4159
+ var validBridgeID = (value) => typeof value === "string" && ID.test(value);
4160
+ function createBridgeSanitizer(secrets = []) {
4161
+ const known = secrets.filter((value) => typeof value === "string" && value.length > 0);
4162
+ const assistants = /* @__PURE__ */ new Map();
4163
+ function clipped(value, max = 12e3) {
4164
+ if (typeof value !== "string") return { value: "", truncated: false };
4165
+ let result = value.slice(0, 64e3);
4166
+ for (const secret of known) result = result.split(secret).join("[REDACTED]");
4167
+ 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]");
4168
+ return { value: result.slice(0, max), truncated: value.length > 64e3 || result.length > max };
3990
4169
  }
3991
- const expiresAt = Date.parse(command.expiresAt);
3992
- if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3993
- return { status: "expired", action: response.action };
4170
+ function text(value, max) {
4171
+ return clipped(value, max).value;
3994
4172
  }
3995
- if (response.action === "reject") return;
3996
- if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
3997
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
4173
+ function content(key, value) {
4174
+ const result = clipped(value);
4175
+ return { [key]: result.value, ...result.truncated ? { truncated: true } : {} };
3998
4176
  }
3999
- for (let index = 0; index < pending.questions.length; index++) {
4000
- const question = pending.questions[index];
4001
- const answers = response.answers[index];
4002
- if (!Array.isArray(answers) || !question.multiple && answers.length > 1) {
4003
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
4177
+ function sanitize(event) {
4178
+ const p = event?.properties ?? event?.data ?? {};
4179
+ const sessionID = p.sessionID ?? p.info?.sessionID ?? p.part?.sessionID;
4180
+ if (!validBridgeID(sessionID) || sessionID === "global") return void 0;
4181
+ const base = { type: event.type, sessionID, data: {} };
4182
+ if (event.type === "message.updated") {
4183
+ const info = p.info;
4184
+ if (!validBridgeID(info?.id)) return void 0;
4185
+ if (info.role === "assistant") {
4186
+ assistants.set(info.id, sessionID);
4187
+ if (assistants.size > 256) assistants.delete(assistants.keys().next().value);
4188
+ }
4189
+ return info.role === "assistant" ? { ...base, data: {
4190
+ messageID: info.id,
4191
+ completed: !!info.time?.completed,
4192
+ failed: !!info.error
4193
+ } } : void 0;
4194
+ }
4195
+ if (event.type === "message.part.updated") {
4196
+ const part = p.part;
4197
+ if (assistants.get(part?.messageID) !== sessionID) return void 0;
4198
+ if (part.type === "text") return { ...base, type: "assistant.text", data: {
4199
+ messageID: part.messageID,
4200
+ ...content("text", part.text),
4201
+ replace: true
4202
+ } };
4203
+ if (part.type !== "tool") return void 0;
4204
+ const state = part.state ?? {};
4205
+ return { ...base, type: "tool.status", data: {
4206
+ tool: text(part.tool, 80),
4207
+ status: text(state.status, 40),
4208
+ ...state.status === "completed" ? content("output", state.output) : {},
4209
+ ...state.status === "error" ? { failed: true } : {}
4210
+ } };
4211
+ }
4212
+ if (event.type === "session.text.ended") return { ...base, type: "assistant.text", data: {
4213
+ messageID: validBridgeID(p.assistantMessageID) ? p.assistantMessageID : void 0,
4214
+ ...content("text", p.text),
4215
+ replace: true
4216
+ } };
4217
+ if (["session.tool.called", "session.tool.success", "session.tool.failed"].includes(event.type)) {
4218
+ return { ...base, type: "tool.status", data: {
4219
+ status: event.type.split(".").at(-1),
4220
+ ...event.type === "session.tool.success" ? content(
4221
+ "output",
4222
+ (p.content ?? []).filter((item) => item?.type === "text").map((item) => item.text).join("\n")
4223
+ ) : {}
4224
+ } };
4004
4225
  }
4005
- for (const answer of answers) {
4006
- if (typeof answer !== "string" || answer.trim().length === 0) {
4007
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
4008
- }
4009
- const known = question.options.some((option) => option.label === answer);
4010
- if (!known && !question.custom) {
4011
- return { status: "rejected", action: response.action, errorCode: "custom_not_allowed" };
4012
- }
4226
+ if (["permission.asked", "question.asked", "question.v2.asked"].includes(event.type)) {
4227
+ return { ...base, type: "approval.waiting", data: {
4228
+ kind: event.type.split(".")[0],
4229
+ ...event.type !== "permission.asked" && validBridgeID(p.id ?? p.requestID) ? { questionId: p.id ?? p.requestID } : {}
4230
+ } };
4013
4231
  }
4232
+ if (["permission.replied", "question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
4233
+ return { ...base, type: "approval.settled", data: { kind: event.type.split(".")[0] } };
4234
+ }
4235
+ if (event.type === "session.status") return { ...base, data: {
4236
+ status: ["idle", "busy", "retry"].includes(p.status?.type) ? p.status.type : "unknown"
4237
+ } };
4238
+ if (["session.idle", "session.error", "session.deleted"].includes(event.type)) return base;
4239
+ return void 0;
4014
4240
  }
4015
- }
4016
- function questionFailureAcknowledgement(command, error) {
4017
- const stale = error instanceof HttpError && error.status === 404;
4018
- return {
4019
- status: stale ? "stale" : "failed",
4020
- action: command?.response?.action,
4021
- errorCode: stale ? "question_not_found" : "opencode_question_reply_failed"
4022
- };
4023
- }
4024
- function reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation) {
4025
- const messages = {
4026
- applied: acknowledgement.action === "reject" ? "Question rejected from Backeer" : "Question answered from Backeer",
4027
- expired: "Question response expired before it could be applied",
4028
- stale: "Question was already answered locally",
4029
- rejected: "Question response was rejected by validation"
4030
- };
4031
- const message = messages[acknowledgement.status];
4032
- if (!message) return;
4033
- if (acknowledgement.status === "applied") {
4034
- reporter.applied(message);
4035
- conversation?.applied(pending.sessionID, message);
4036
- } else {
4037
- reporter.warning(message);
4038
- conversation?.warning(pending.sessionID, message);
4039
- }
4241
+ return { sanitize, text, clear: () => assistants.clear() };
4040
4242
  }
4041
4243
 
4042
- // src/sse.js
4043
- function parseSseFrame(frame) {
4044
- const data = [];
4045
- let event = "message";
4046
- let id;
4047
- for (const line of frame.split(/\r?\n/)) {
4048
- if (!line || line.startsWith(":")) continue;
4049
- const separator = line.indexOf(":");
4050
- const field = separator === -1 ? line : line.slice(0, separator);
4051
- let value = separator === -1 ? "" : line.slice(separator + 1);
4052
- if (value.startsWith(" ")) value = value.slice(1);
4053
- if (field === "event") event = value;
4054
- if (field === "id") id = value;
4055
- if (field === "data") data.push(value);
4056
- }
4057
- if (data.length === 0) return;
4058
- return { event, id, data: data.join("\n") };
4059
- }
4060
- function nextSseFrame(buffer) {
4061
- const lfIndex = buffer.indexOf("\n\n");
4062
- const crlfIndex = buffer.indexOf("\r\n\r\n");
4063
- if (lfIndex === -1 && crlfIndex === -1) return;
4064
- if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
4244
+ // src/live-questions.js
4245
+ import { createHash } from "node:crypto";
4246
+ var invalid = (code = "invalid_question_answer", status = 422) => Object.assign(new Error(code), { code, status });
4247
+ function questionSnapshot(pending, text) {
4248
+ if (!validBridgeID(pending?.id) || !validBridgeID(pending?.sessionID) || pending.sessionID === "global" || !Array.isArray(pending.questions) || !pending.questions.length || pending.questions.length > 16) throw invalid("unsupported_question");
4249
+ const questions = pending.questions.map((question, index) => {
4250
+ if (typeof question.question !== "string" || !Array.isArray(question.options) || question.options.length > 32 || question.options.some((option) => typeof option?.label !== "string") || new Set(question.options.map((option) => option.label)).size !== question.options.length) {
4251
+ throw invalid("unsupported_question");
4252
+ }
4065
4253
  return {
4066
- frame: buffer.slice(0, crlfIndex),
4067
- rest: buffer.slice(crlfIndex + 4)
4254
+ index,
4255
+ header: text(question.header, 240),
4256
+ question: text(question.question, 2e3),
4257
+ multiple: question.multiple === true,
4258
+ custom: question.custom === true,
4259
+ options: question.options.map((option, index2) => ({
4260
+ index: index2,
4261
+ label: text(option.label, 500),
4262
+ description: text(option.description, 1e3)
4263
+ }))
4068
4264
  };
4069
- }
4070
- return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) };
4265
+ });
4266
+ const revision = createHash("sha256").update(JSON.stringify({
4267
+ id: pending.id,
4268
+ sessionID: pending.sessionID,
4269
+ questions: pending.questions,
4270
+ ...pending.nativeRevision ? { nativeRevision: pending.nativeRevision } : {}
4271
+ })).digest("hex");
4272
+ const result = { questionId: pending.id, sessionID: pending.sessionID, revision, questions };
4273
+ if (Buffer.byteLength(JSON.stringify(result)) > 3e4) throw invalid("question_too_large");
4274
+ return result;
4275
+ }
4276
+ function resolveQuestionAnswers(pending, answers) {
4277
+ if (!Array.isArray(answers) || answers.length !== pending.questions.length) throw invalid();
4278
+ const values = answers.map((answer, index) => {
4279
+ const question = pending.questions[index];
4280
+ if (!answer || !Array.isArray(answer.choices) || answer.choices.length > 32 || Object.keys(answer).some((key) => !["choices", "text"].includes(key)) || new Set(answer.choices).size !== answer.choices.length || answer.choices.some((choice) => !Number.isInteger(choice) || choice < 0 || choice >= question.options.length) || answer.text !== void 0 && (typeof answer.text !== "string" || !answer.text.trim() || answer.text.length > 8e3)) throw invalid();
4281
+ if (answer.text !== void 0 && question.custom !== true) throw invalid("custom_not_allowed");
4282
+ return [
4283
+ ...answer.choices.map((choice) => question.options[choice].label),
4284
+ ...answer.text === void 0 ? [] : [answer.text]
4285
+ ];
4286
+ });
4287
+ const errorCode = validateQuestionAnswers(values, pending.questions);
4288
+ if (errorCode) throw invalid(errorCode);
4289
+ return values;
4071
4290
  }
4072
- async function consumeSse(response, state, onCommand, signal2, reporter) {
4073
- if (!response.body) throw new Error("Backeer command stream has no body");
4074
- const reader = response.body.getReader();
4075
- const decoder = new TextDecoder();
4076
- let buffer = "";
4077
- try {
4078
- while (!signal2.aborted) {
4079
- const item = await reader.read();
4080
- if (item.done) break;
4081
- buffer += decoder.decode(item.value, { stream: true });
4082
- for (; ; ) {
4083
- const next = nextSseFrame(buffer);
4084
- if (!next) break;
4085
- buffer = next.rest;
4086
- state.framesReceived = (state.framesReceived ?? 0) + 1;
4087
- const message = parseSseFrame(next.frame);
4088
- if (!message) continue;
4089
- if (message.id) state.lastEventID = message.id;
4090
- if (message.event !== "message" && message.event !== "command") continue;
4091
- try {
4092
- await onCommand(JSON.parse(message.data));
4093
- } catch (error) {
4094
- if (error instanceof SyntaxError) {
4095
- reporter.error("Invalid command received", "SSE data is not valid JSON");
4096
- continue;
4097
- }
4098
- throw error;
4099
- }
4100
- }
4101
- }
4102
- } finally {
4103
- await reader.cancel().catch(() => {
4104
- });
4105
- }
4291
+
4292
+ // src/live-bridge.js
4293
+ var LIMITS = { events: 256, bytes: 2e6, clients: 8, commands: 1024, commandTTL: 864e5, body: 32768, requests: 16 };
4294
+ function failure(status, code) {
4295
+ return Object.assign(new Error(code), { status, code });
4106
4296
  }
4107
- async function openCommandStream(config, state, signal2, fetchImplementation) {
4108
- const headers = {
4109
- Accept: "text/event-stream",
4110
- Authorization: `Bearer ${config.token}`
4111
- };
4112
- if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
4113
- const response = await fetchImplementation(config.commandStreamEndpoint, {
4114
- headers,
4115
- signal: signal2
4116
- });
4117
- if (!response.ok) {
4118
- const responseBody = limit(await response.text(), 500);
4119
- throw new HttpError(
4120
- `Backeer command stream failed with status ${response.status}`,
4121
- response.status,
4122
- responseBody
4123
- );
4124
- }
4125
- if (!response.headers.get("content-type")?.includes("text/event-stream")) {
4126
- throw new Error("Backeer command stream returned an invalid content type");
4297
+ function json(response, status, value) {
4298
+ if (!response.destroyed && !response.headersSent) {
4299
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
4300
+ response.end(JSON.stringify(value));
4127
4301
  }
4128
- return response;
4129
4302
  }
4130
- async function consumeConnection({
4131
- response,
4132
- state,
4133
- onCommand,
4134
- signal: signal2,
4135
- reporter,
4136
- now,
4137
- stableConnectionMs,
4138
- onStable
4139
- }) {
4140
- const connectedAt = now();
4141
- const framesBeforeConnection = state.framesReceived;
4303
+ async function readBody(request) {
4304
+ const chunks = [];
4305
+ let bytes = 0;
4306
+ for await (const chunk of request) {
4307
+ bytes += chunk.length;
4308
+ if (bytes > LIMITS.body) throw failure(413, "body_too_large");
4309
+ chunks.push(chunk);
4310
+ }
4142
4311
  try {
4143
- await consumeSse(response, state, onCommand, signal2, reporter);
4144
- } finally {
4145
- const receivedFrame = state.framesReceived > framesBeforeConnection;
4146
- if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
4312
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
4313
+ } catch {
4314
+ throw failure(400, "invalid_json");
4147
4315
  }
4148
4316
  }
4149
- function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
4150
- const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
4151
- if (message !== lastLoggedError) {
4152
- const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
4153
- reporter.error(
4154
- authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
4155
- message
4156
- );
4317
+ async function createLiveBridge({ config, client, directory, reporter }) {
4318
+ const options = config.liveBridge;
4319
+ if (!options?.enabled) return { handle() {
4320
+ }, async dispose() {
4321
+ }, address: void 0 };
4322
+ const token = options.token;
4323
+ if (typeof token !== "string" || token.length < 32 || token === config.token) {
4324
+ throw new Error("Live bridge requires a separate credential of at least 32 characters");
4157
4325
  }
4158
- return {
4159
- lastLoggedError: message,
4160
- reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status) ? 6e4 : reconnectDelay
4161
- };
4162
- }
4163
- async function runCommandStream(config, reporter, {
4164
- signal: signal2,
4165
- hasPending,
4166
- onCommand,
4167
- fetchImplementation = fetch,
4168
- sleepImplementation = sleep,
4169
- now = Date.now,
4170
- stableConnectionMs = 5e3
4171
- }) {
4172
- const state = { lastEventID: "", framesReceived: 0 };
4173
- let reconnectDelay = 1e3;
4174
- let lastLoggedError = "";
4175
- while (!signal2.aborted && hasPending()) {
4176
- try {
4177
- const response = await openCommandStream(
4178
- config,
4179
- state,
4180
- signal2,
4181
- fetchImplementation
4182
- );
4183
- await consumeConnection({
4184
- response,
4185
- state,
4186
- onCommand,
4187
- signal: signal2,
4188
- reporter,
4189
- now,
4190
- stableConnectionMs,
4191
- onStable: () => {
4192
- reconnectDelay = 1e3;
4193
- lastLoggedError = "";
4326
+ const api = client.liveBridge ?? createV1LiveClient(client, directory);
4327
+ const connectionNotifications = createLiveConnectionNotifier(config, reporter);
4328
+ const sanitizer = createBridgeSanitizer([token, config.token]);
4329
+ const epoch = randomUUID3();
4330
+ const events = [], streams = /* @__PURE__ */ new Set(), commands = /* @__PURE__ */ new Map(), states = /* @__PURE__ */ new Map();
4331
+ let sequence = 0, bytes = 0, active = 0, disposed = false, disposeTask;
4332
+ let lookups = 0;
4333
+ const lookupWaiters = /* @__PURE__ */ new Set();
4334
+ const cursor = () => `${epoch}:${sequence}`;
4335
+ function lookup(id) {
4336
+ if (disposed) return Promise.reject(failure(503, "bridge_closed"));
4337
+ if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
4338
+ lookups++;
4339
+ return new Promise((resolve2, reject) => {
4340
+ let finished = false;
4341
+ const finish = (complete, value) => {
4342
+ if (finished) return;
4343
+ finished = true;
4344
+ clearTimeout(timer);
4345
+ lookupWaiters.delete(cancel);
4346
+ complete(value);
4347
+ };
4348
+ const cancel = () => finish(reject, failure(503, "bridge_closed"));
4349
+ const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1e3);
4350
+ lookupWaiters.add(cancel);
4351
+ Promise.resolve().then(() => api.get(id)).then(
4352
+ (value) => {
4353
+ lookups--;
4354
+ finish(resolve2, value);
4355
+ },
4356
+ (error) => {
4357
+ lookups--;
4358
+ finish(reject, error);
4194
4359
  }
4195
- });
4360
+ );
4361
+ });
4362
+ }
4363
+ async function session(id) {
4364
+ if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session");
4365
+ let info;
4366
+ try {
4367
+ info = await lookup(id);
4196
4368
  } catch (error) {
4197
- if (signal2.aborted || isAbortError(error)) return;
4198
- const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
4199
- reconnectDelay = next.reconnectDelay;
4200
- lastLoggedError = next.lastLoggedError;
4369
+ if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error;
4370
+ throw failure(404, "session_not_found");
4201
4371
  }
4202
- if (!signal2.aborted && hasPending()) {
4203
- await sleepImplementation(reconnectDelay, signal2);
4204
- reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
4372
+ if (info?.id !== id || typeof info.time?.archived === "number") {
4373
+ throw failure(404, "session_not_found");
4205
4374
  }
4375
+ const locationDirectory = info.location?.directory ?? info.directory;
4376
+ const owned = api.ownsSession ? await api.ownsSession(info) : locationDirectory === directory;
4377
+ if (!owned) throw failure(404, "session_not_found");
4378
+ return {
4379
+ id,
4380
+ directory,
4381
+ title: sanitizer.text(info.title, 240),
4382
+ ...locationDirectory !== directory ? { locationDirectory: sanitizer.text(locationDirectory, 4096), scope: "verified_git_worktree" } : {},
4383
+ ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
4384
+ status: states.get(id) ?? "unknown"
4385
+ };
4206
4386
  }
4207
- }
4208
-
4209
- // src/permission-controller.js
4210
- async function replyToOpenCode(client, pending, decision) {
4211
- const result = await client.postSessionIdPermissionsPermissionId({
4212
- path: { id: pending.sessionID, permissionID: pending.requestID },
4213
- query: { directory: pending.directory },
4214
- body: { response: decision },
4215
- throwOnError: true
4216
- });
4217
- if (result?.data === false || result === false) {
4218
- throw new HttpError("OpenCode did not apply the permission reply", 409);
4219
- }
4220
- }
4221
- async function acknowledgeCommand(config, commandID, acknowledgement) {
4222
- const url = new URL(
4223
- `/v1/commands/${encodeURIComponent(commandID)}/ack`,
4224
- config.origin
4225
- );
4226
- await postJsonWithRetry(config, url, acknowledgement);
4227
- }
4228
- function questionResponseError(result) {
4229
- if (!result?.error) return;
4230
- const status = Number(result?.response?.status) || 502;
4231
- const detail = result.error?.data?.message ?? result.error?.message ?? "OpenCode rejected the question response";
4232
- return new HttpError(String(detail), status);
4233
- }
4234
- function normalizeQuestionError(error) {
4235
- if (error instanceof HttpError) return error;
4236
- const status = Number(error?.status ?? error?.statusCode ?? error?.cause?.status);
4237
- if (!Number.isFinite(status)) return error;
4238
- return new HttpError(error?.message || "OpenCode rejected the question response", status);
4239
- }
4240
- function rawQuestionRequest(client, pending, response, isV2) {
4241
- const transport = client?._client;
4242
- if (typeof transport?.post !== "function") {
4243
- throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
4387
+ function removeStream(stream) {
4388
+ const removed = streams.delete(stream);
4389
+ clearInterval(stream.heartbeat);
4390
+ stream.queue.length = 0;
4391
+ stream.queuedBytes = 0;
4392
+ if (removed && !streams.size && !disposed) connectionNotifications.update(false);
4244
4393
  }
4245
- const rejecting = response.action === "reject";
4246
- if (isV2) {
4247
- return transport.post({
4248
- url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
4249
- path: { sessionID: pending.sessionID, requestID: pending.requestID },
4250
- ...rejecting ? {} : { body: { answers: response.answers } },
4251
- ...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
4252
- throwOnError: true
4253
- });
4394
+ function write(stream, value) {
4395
+ if (!streams.has(stream)) return;
4396
+ if (stream.blocked) {
4397
+ stream.queue.push(value);
4398
+ stream.queuedBytes += Buffer.byteLength(value);
4399
+ if (stream.queuedBytes > LIMITS.bytes) {
4400
+ removeStream(stream);
4401
+ stream.response.destroy();
4402
+ }
4403
+ return;
4404
+ }
4405
+ if (!stream.response.write(value)) {
4406
+ stream.blocked = true;
4407
+ }
4254
4408
  }
4255
- return transport.post({
4256
- url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
4257
- path: { requestID: pending.requestID },
4258
- query: { directory: pending.directory },
4259
- ...rejecting ? {} : { body: { answers: response.answers } },
4260
- ...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
4261
- throwOnError: true
4262
- });
4263
- }
4264
- async function replyToQuestion(client, pending, response) {
4265
- const isV2 = pending.sourceEvent === "question.v2.asked";
4266
- const api = isV2 ? client?.v2?.session?.question : client?.question;
4267
- const method = response.action === "reject" ? api?.reject : api?.reply;
4268
- const input = isV2 ? {
4269
- sessionID: pending.sessionID,
4270
- requestID: pending.requestID,
4271
- directory: pending.directory,
4272
- ...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
4273
- } : {
4274
- requestID: pending.requestID,
4275
- directory: pending.directory,
4276
- ...response.action === "answer" ? { answers: response.answers } : {}
4277
- };
4278
- let result;
4279
- try {
4280
- result = typeof method === "function" ? await method.call(api, input, { throwOnError: true }) : await rawQuestionRequest(client, pending, response, isV2);
4281
- } catch (error) {
4282
- throw normalizeQuestionError(error);
4409
+ async function handle(event) {
4410
+ if (disposed) return;
4411
+ const safe = sanitizer.sanitize(event);
4412
+ if (!safe) return;
4413
+ try {
4414
+ await session(safe.sessionID);
4415
+ } catch {
4416
+ return;
4417
+ }
4418
+ if (disposed) return;
4419
+ if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status);
4420
+ if (safe.type === "session.idle") states.set(safe.sessionID, "idle");
4421
+ if (safe.type === "session.deleted") states.delete(safe.sessionID);
4422
+ if (states.size > 256) states.delete(states.keys().next().value);
4423
+ const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() };
4424
+ let frame = `id: ${record.id}
4425
+ event: backeer.event
4426
+ data: ${JSON.stringify(record)}
4427
+
4428
+ `;
4429
+ if (Buffer.byteLength(frame) > 32768) {
4430
+ record.data = { truncated: true };
4431
+ frame = `id: ${record.id}
4432
+ event: backeer.event
4433
+ data: ${JSON.stringify(record)}
4434
+
4435
+ `;
4436
+ }
4437
+ const size = Buffer.byteLength(frame);
4438
+ events.push({ sequence, sessionID: record.sessionID, frame, size });
4439
+ bytes += size;
4440
+ while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size;
4441
+ for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame);
4283
4442
  }
4284
- const responseError = questionResponseError(result);
4285
- if (responseError) throw responseError;
4286
- if (result?.data === false || result === false) {
4287
- throw new HttpError("OpenCode did not apply the question response", 409);
4443
+ function authorized(request) {
4444
+ if (request.headers.origin || request.headers["sec-fetch-site"]) return false;
4445
+ const expected = Buffer.from(`Bearer ${token}`);
4446
+ const actual = Buffer.from(request.headers.authorization ?? "");
4447
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
4288
4448
  }
4289
- }
4290
- async function postQuestionState(config, eventID, path, body = {}) {
4291
- const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
4292
- await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
4293
- }
4294
- function createPermissionController({
4295
- config,
4296
- reporter,
4297
- client,
4298
- directory,
4299
- conversation
4300
- }) {
4301
- const pendingByExternalID = /* @__PURE__ */ new Map();
4302
- const externalIDByRequestID = /* @__PURE__ */ new Map();
4303
- const questionsByExternalID = /* @__PURE__ */ new Map();
4304
- const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
4305
- const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
4306
- const processingCommands = /* @__PURE__ */ new Set();
4307
- const completedCommands = /* @__PURE__ */ new Map();
4308
- let commandStream;
4309
- let leaseTimer;
4310
- let disposed = false;
4311
- function removePendingByRequestID(requestID) {
4312
- const externalId = externalIDByRequestID.get(requestID);
4313
- if (!externalId) return;
4314
- externalIDByRequestID.delete(requestID);
4315
- pendingByExternalID.delete(externalId);
4449
+ function parseCursor(value) {
4450
+ if (!value) return { after: 0 };
4451
+ const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value);
4452
+ if (!match) throw failure(400, "invalid_cursor");
4453
+ if (match[1] !== epoch) return { gap: "stream_restarted" };
4454
+ const after = Number(match[2]);
4455
+ if (!Number.isSafeInteger(after) || after > sequence) throw failure(400, "invalid_cursor");
4456
+ if (events.length && after < events[0].sequence - 1) return { gap: "cursor_expired" };
4457
+ return { after };
4316
4458
  }
4317
- function stopCommandStreamIfIdle() {
4318
- if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
4319
- commandStream?.controller.abort();
4459
+ function cachedCommand(requestId, sessionID, signature) {
4460
+ for (const [id, command] of commands) {
4461
+ if (command.settled && Date.now() - command.created >= LIMITS.commandTTL) commands.delete(id);
4320
4462
  }
4321
- }
4322
- function removeQuestionByRequestID(requestID) {
4323
- const externalId = questionExternalIDByRequestID.get(requestID);
4324
- if (!externalId) return;
4325
- questionExternalIDByRequestID.delete(requestID);
4326
- questionsByExternalID.delete(externalId);
4327
- if (questionsByExternalID.size === 0 && leaseTimer) {
4328
- clearInterval(leaseTimer);
4329
- leaseTimer = void 0;
4463
+ const existing = commands.get(requestId);
4464
+ if (existing && (existing.sessionID !== sessionID || existing.signature !== signature)) {
4465
+ throw failure(409, "request_id_conflict");
4330
4466
  }
4467
+ return existing;
4331
4468
  }
4332
- async function renewQuestionLeases() {
4333
- for (const pending of questionsByExternalID.values()) {
4334
- if (!pending.eventID) continue;
4335
- await postQuestionState(config, pending.eventID, "lease").catch((error) => {
4336
- reporter.warning("Question lease could not be renewed", error);
4469
+ async function execute(requestId, sessionID, signature, operation, questionId) {
4470
+ let command = cachedCommand(requestId, sessionID, signature);
4471
+ if (!command) {
4472
+ if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full");
4473
+ if (questionId && [...commands.values()].some((item) => item.sessionID === sessionID && item.questionId === questionId)) {
4474
+ throw failure(409, "question_reply_already_submitted");
4475
+ }
4476
+ command = {
4477
+ sessionID,
4478
+ signature,
4479
+ questionId,
4480
+ created: Date.now(),
4481
+ settled: false,
4482
+ result: {
4483
+ requestId,
4484
+ sessionID,
4485
+ ...questionId ? { questionId } : {},
4486
+ status: questionId ? "answered" : "accepted"
4487
+ }
4488
+ };
4489
+ commands.set(requestId, command);
4490
+ command.task = Promise.resolve().then(operation).finally(() => {
4491
+ command.settled = true;
4337
4492
  });
4338
4493
  }
4494
+ await command.task;
4495
+ return command.result;
4339
4496
  }
4340
- function ensureLeaseTimer() {
4341
- if (leaseTimer || questionsByExternalID.size === 0) return;
4342
- leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
4343
- leaseTimer.unref?.();
4497
+ async function questions(sessionID) {
4498
+ if (typeof api.questions !== "function") throw failure(501, "questions_unavailable_update_bridge");
4499
+ const pending = await api.questions(sessionID);
4500
+ if (!Array.isArray(pending)) throw failure(502, "invalid_question_list");
4501
+ const own = pending.filter((item) => item.sessionID === sessionID);
4502
+ if (own.length > 16) throw failure(413, "too_many_questions");
4503
+ return own;
4344
4504
  }
4345
- async function handleCommand(command) {
4346
- if (!command || typeof command !== "object") return;
4347
- if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
4348
- if (typeof command.id !== "string" || command.id.length > 200) return;
4349
- const previousAcknowledgement = completedCommands.get(command.id);
4350
- if (previousAcknowledgement) {
4351
- await acknowledgeCommand(config, command.id, previousAcknowledgement).catch((error) => reporter.warning("Decision receipt retry failed", error));
4352
- return;
4353
- }
4354
- if (processingCommands.has(command.id)) return;
4355
- if (typeof command.externalId !== "string") return;
4356
- const isQuestion = command.type === "opencode_question_reply";
4357
- const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
4358
- if (!pending) return;
4359
- processingCommands.add(command.id);
4360
- let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
4361
- try {
4362
- if (!acknowledgement) {
4363
- if (isQuestion) {
4364
- remotelyResolvingQuestions.add(pending.requestID);
4365
- await replyToQuestion(client, pending, command.response);
4366
- acknowledgement = { status: "applied", action: command.response.action };
4367
- removeQuestionByRequestID(pending.requestID);
4368
- const cleanup = setTimeout(
4369
- () => remotelyResolvingQuestions.delete(pending.requestID),
4370
- 3e4
4371
- );
4372
- cleanup.unref?.();
4373
- } else {
4374
- await replyToOpenCode(client, pending, command.decision);
4375
- acknowledgement = { status: "applied", decision: command.decision };
4376
- removePendingByRequestID(pending.requestID);
4505
+ async function route(request, response) {
4506
+ if (!authorized(request)) return json(response, 401, { error: "unauthorized" });
4507
+ if (request.headers["x-backeer-project"] !== void 0 && request.headers["x-backeer-project"] !== encodeURIComponent(directory)) {
4508
+ return json(response, 409, { error: "project_mismatch" });
4509
+ }
4510
+ if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" });
4511
+ active++;
4512
+ const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15e3);
4513
+ timeout.unref();
4514
+ try {
4515
+ const url = new URL(request.url, "http://127.0.0.1");
4516
+ if (url.username || url.password) throw failure(400, "invalid_url");
4517
+ if (request.method === "GET" && url.pathname === "/v1/sessions") {
4518
+ const result2 = await api.list();
4519
+ const values = Array.isArray(result2) ? result2 : result2?.data;
4520
+ if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
4521
+ const own = [];
4522
+ for (const info of values.slice(0, 100)) {
4523
+ if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue;
4524
+ if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info);
4377
4525
  }
4526
+ return json(response, 200, {
4527
+ sessions: own.map((info) => ({
4528
+ id: info.id,
4529
+ title: sanitizer.text(info.title, 240),
4530
+ status: states.get(info.id) ?? "unknown",
4531
+ ...(info.location?.directory ?? info.directory) !== directory ? {
4532
+ locationDirectory: sanitizer.text(info.location?.directory ?? info.directory, 4096),
4533
+ scope: "verified_git_worktree"
4534
+ } : {},
4535
+ ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
4536
+ })),
4537
+ directory,
4538
+ cursor: cursor(),
4539
+ protocolVersion: 1,
4540
+ bridgeVersion: PLUGIN_VERSION,
4541
+ capabilities: {
4542
+ questions: typeof api.questions === "function",
4543
+ questionAnswers: typeof api.answerQuestion === "function",
4544
+ verifiedWorktreeSessions: typeof api.ownsSession === "function",
4545
+ questionTransport: api.questionTransport ?? "server"
4546
+ }
4547
+ });
4378
4548
  }
4379
- } catch (error) {
4380
- if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
4381
- acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
4382
- if (acknowledgement.status === "stale") {
4383
- if (isQuestion) removeQuestionByRequestID(pending.requestID);
4384
- else removePendingByRequestID(pending.requestID);
4549
+ if (request.method === "GET" && url.pathname === "/v1/events") {
4550
+ const selected = url.searchParams.get("sessionID") || void 0;
4551
+ if (selected) await session(selected);
4552
+ const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"]);
4553
+ if (response.headersSent) return;
4554
+ if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams");
4555
+ clearTimeout(timeout);
4556
+ response.writeHead(200, {
4557
+ "content-type": "text/event-stream",
4558
+ "cache-control": "no-cache, no-transform",
4559
+ "x-accel-buffering": "no",
4560
+ connection: "keep-alive"
4561
+ });
4562
+ if (parsed.gap) {
4563
+ response.end(`event: backeer.gap
4564
+ data: ${JSON.stringify({ reason: parsed.gap, cursor: cursor() })}
4565
+
4566
+ `);
4567
+ return;
4568
+ }
4569
+ const stream = {
4570
+ response,
4571
+ sessionID: selected,
4572
+ heartbeat: void 0,
4573
+ blocked: false,
4574
+ queue: [],
4575
+ queuedBytes: 0
4576
+ };
4577
+ streams.add(stream);
4578
+ connectionNotifications.update(true);
4579
+ request.socket.setTimeout(0);
4580
+ request.socket.setKeepAlive(true, LIVE_HEARTBEAT_MS);
4581
+ response.on("close", () => removeStream(stream));
4582
+ response.on("drain", () => {
4583
+ stream.blocked = false;
4584
+ while (streams.has(stream) && !stream.blocked && stream.queue.length) {
4585
+ const next = stream.queue.shift();
4586
+ stream.queuedBytes -= Buffer.byteLength(next);
4587
+ write(stream, next);
4588
+ }
4589
+ });
4590
+ write(stream, `: connected
4591
+ retry: ${LIVE_RECONNECT_MS}
4592
+
4593
+ `);
4594
+ for (const item of events) {
4595
+ if (!streams.has(stream)) break;
4596
+ if (item.sequence > parsed.after && (!selected || selected === item.sessionID)) write(stream, item.frame);
4597
+ }
4598
+ if (streams.has(stream)) {
4599
+ stream.heartbeat = setInterval(() => write(stream, ": heartbeat\n\n"), LIVE_HEARTBEAT_MS);
4600
+ stream.heartbeat.unref();
4601
+ }
4602
+ return;
4385
4603
  }
4386
- const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
4387
- reporter.error(message, error);
4388
- conversation?.error(pending.sessionID, message);
4604
+ const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions|\/questions(?:\/([A-Za-z0-9_-]{1,160})\/answers)?)?$/.exec(url.pathname);
4605
+ if (!match) throw failure(404, "route_not_found");
4606
+ if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]));
4607
+ if (request.method === "GET" && match[2] === "/questions") {
4608
+ await session(match[1]);
4609
+ const result2 = { questions: (await questions(match[1])).map((item) => questionSnapshot(item, sanitizer.text)) };
4610
+ if (Buffer.byteLength(JSON.stringify(result2)) > 24e4) throw failure(413, "questions_too_large");
4611
+ return json(response, 200, result2);
4612
+ }
4613
+ if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed");
4614
+ if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required");
4615
+ const body = await readBody(request);
4616
+ if (match[3]) {
4617
+ if (!body || !validBridgeID(body.requestId) || !/^[a-f0-9]{64}$/.test(body.revision) || Object.keys(body).some((key) => !["requestId", "revision", "answers"].includes(key))) throw failure(400, "invalid_question_answer");
4618
+ await session(match[1]);
4619
+ const signature2 = JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers });
4620
+ const existing2 = cachedCommand(body.requestId, match[1], signature2);
4621
+ if (existing2) {
4622
+ await existing2.task;
4623
+ return json(response, 200, existing2.result);
4624
+ }
4625
+ const pending = (await questions(match[1])).find((item) => item.id === match[3]);
4626
+ if (!pending) throw failure(409, "question_already_settled_or_missing");
4627
+ if (questionSnapshot(pending, sanitizer.text).revision !== body.revision) throw failure(409, "question_changed_read_again");
4628
+ const answers = resolveQuestionAnswers(pending, body.answers);
4629
+ if (typeof api.answerQuestion !== "function") throw failure(501, "questions_unavailable_update_bridge");
4630
+ if (response.headersSent || disposed) return;
4631
+ return json(response, 200, await execute(
4632
+ body.requestId,
4633
+ match[1],
4634
+ signature2,
4635
+ () => api.answerQuestion(pending, answers),
4636
+ match[3]
4637
+ ));
4638
+ }
4639
+ if (match[2] !== "/instructions") throw failure(405, "method_not_allowed");
4640
+ 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");
4641
+ await session(match[1]);
4642
+ if (response.headersSent || disposed) return;
4643
+ const signature = JSON.stringify({ prompt: body.prompt });
4644
+ const existing = cachedCommand(body.requestId, match[1], signature);
4645
+ const result = await execute(body.requestId, match[1], signature, () => api.prompt(match[1], body.prompt));
4646
+ return json(response, existing ? 200 : 202, result);
4647
+ } catch (error) {
4648
+ json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" });
4389
4649
  } finally {
4390
- processingCommands.delete(command.id);
4391
- }
4392
- acknowledgement = {
4393
- ...acknowledgement,
4394
- type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
4395
- externalId: command.externalId,
4396
- processedAt: (/* @__PURE__ */ new Date()).toISOString()
4397
- };
4398
- rememberBounded(completedCommands, command.id, acknowledgement);
4399
- if (isQuestion) {
4400
- reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
4401
- } else {
4402
- reportAcknowledgement(acknowledgement, pending, reporter, conversation);
4650
+ active--;
4651
+ clearTimeout(timeout);
4403
4652
  }
4404
- await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
4405
- reporter.warning("Decision receipt could not be sent", error);
4406
- });
4407
- stopCommandStreamIfIdle();
4408
4653
  }
4409
- function ensureCommandStream() {
4410
- const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
4411
- if (disposed || commandStream || !hasPending()) return;
4412
- const controller = new AbortController();
4413
- const current = {
4414
- controller,
4415
- task: runCommandStream(config, reporter, {
4416
- signal: controller.signal,
4417
- hasPending,
4418
- onCommand: handleCommand
4419
- })
4420
- };
4421
- commandStream = current;
4422
- void current.task.finally(() => {
4423
- if (commandStream === current) commandStream = void 0;
4424
- if (!disposed && hasPending()) ensureCommandStream();
4654
+ const server = createServer((request, response) => {
4655
+ void route(request, response);
4656
+ });
4657
+ server.requestTimeout = 15e3;
4658
+ server.headersTimeout = 1e4;
4659
+ server.keepAliveTimeout = 5e3;
4660
+ await new Promise((resolve2, reject) => {
4661
+ server.once("error", reject);
4662
+ server.listen(options.port ?? 43871, "127.0.0.1", () => {
4663
+ server.off("error", reject);
4664
+ resolve2();
4425
4665
  });
4426
- }
4666
+ });
4667
+ server.on("error", () => {
4668
+ });
4427
4669
  return {
4428
- register(identity) {
4429
- pendingByExternalID.set(identity.externalId, { ...identity, directory });
4430
- externalIDByRequestID.set(identity.requestID, identity.externalId);
4431
- ensureCommandStream();
4432
- },
4433
- resolveLocally(requestID) {
4434
- removePendingByRequestID(requestID);
4435
- stopCommandStreamIfIdle();
4436
- },
4437
- registerQuestion(identity, details) {
4438
- questionsByExternalID.set(identity.externalId, {
4439
- ...identity,
4440
- ...details,
4441
- directory
4442
- });
4443
- questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
4444
- ensureLeaseTimer();
4445
- ensureCommandStream();
4446
- },
4447
- resolveQuestionLocally(requestID, outcome) {
4448
- if (remotelyResolvingQuestions.delete(requestID)) {
4449
- removeQuestionByRequestID(requestID);
4450
- stopCommandStreamIfIdle();
4451
- return;
4452
- }
4453
- const externalId = questionExternalIDByRequestID.get(requestID);
4454
- const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
4455
- if (!pending) return;
4456
- removeQuestionByRequestID(requestID);
4457
- if (pending.eventID) {
4458
- void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
4459
- }
4460
- stopCommandStreamIfIdle();
4461
- },
4462
- refreshCredentials() {
4463
- commandStream?.controller.abort();
4464
- if (!commandStream) ensureCommandStream();
4465
- },
4466
- status() {
4467
- return {
4468
- pending: pendingByExternalID.size,
4469
- pendingQuestions: questionsByExternalID.size,
4470
- stream: commandStream ? "active" : "idle"
4471
- };
4472
- },
4473
- async dispose() {
4474
- disposed = true;
4475
- commandStream?.controller.abort();
4476
- if (leaseTimer) clearInterval(leaseTimer);
4477
- await commandStream?.task.catch(() => {
4670
+ handle,
4671
+ address: server.address(),
4672
+ dispose() {
4673
+ disposeTask ??= new Promise((resolve2) => {
4674
+ disposed = true;
4675
+ for (const cancel of lookupWaiters) cancel();
4676
+ for (const stream of streams) {
4677
+ removeStream(stream);
4678
+ stream.response.destroy();
4679
+ }
4680
+ server.close(() => {
4681
+ void connectionNotifications.dispose().then(resolve2);
4682
+ });
4683
+ server.closeAllConnections?.();
4684
+ events.length = 0;
4685
+ states.clear();
4686
+ commands.clear();
4687
+ sanitizer.clear();
4688
+ api.dispose?.();
4478
4689
  });
4479
- pendingByExternalID.clear();
4480
- externalIDByRequestID.clear();
4481
- processingCommands.clear();
4482
- completedCommands.clear();
4483
- questionsByExternalID.clear();
4484
- questionExternalIDByRequestID.clear();
4485
- remotelyResolvingQuestions.clear();
4690
+ return disposeTask;
4486
4691
  }
4487
4692
  };
4488
4693
  }
@@ -4503,7 +4708,8 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
4503
4708
  const reporter = createOpenCodeReporter(client, config.logLevel);
4504
4709
  const conversation = feedback ?? createConversationFeedback({ client, reporter });
4505
4710
  if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
4506
- const liveBridge = await createLiveBridge({ config, client, directory, reporter }).catch((error) => {
4711
+ const bridgeConfig = config.liveBridge.owner === "tui" ? { ...config, liveBridge: { enabled: false } } : config;
4712
+ const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
4507
4713
  reporter.error("Live control unavailable", error);
4508
4714
  return { async handle() {
4509
4715
  }, async dispose() {
@@ -5142,26 +5348,103 @@ function delay(milliseconds) {
5142
5348
  }
5143
5349
  var Service = { discover, ensure, stop, headers: headers2 };
5144
5350
 
5351
+ // src/opencode-v2-endpoint.js
5352
+ import { readdir, readFile as readFile3, readlink } from "node:fs/promises";
5353
+ async function ownLoopbackUrls({ platform = process.platform, fs = { readdir, readFile: readFile3, readlink } } = {}) {
5354
+ if (platform !== "linux") return [];
5355
+ const inodes = new Set((await Promise.all((await fs.readdir("/proc/self/fd")).map(async (fd) => {
5356
+ try {
5357
+ return /^socket:\[(\d+)\]$/.exec(await fs.readlink(`/proc/self/fd/${fd}`))?.[1];
5358
+ } catch {
5359
+ return void 0;
5360
+ }
5361
+ }))).filter(Boolean));
5362
+ const tables = await Promise.all(["tcp", "tcp6"].map(async (name) => {
5363
+ try {
5364
+ return await fs.readFile(`/proc/self/net/${name}`, "utf8");
5365
+ } catch (error) {
5366
+ if (error.code === "ENOENT") return "";
5367
+ throw error;
5368
+ }
5369
+ }));
5370
+ const urls = /* @__PURE__ */ new Set();
5371
+ for (const table of tables) for (const row of table.trim().split("\n").slice(1)) {
5372
+ const fields = row.trim().split(/\s+/);
5373
+ if (fields[3] !== "0A" || !inodes.has(fields[9])) continue;
5374
+ const [address, portHex] = fields[1].split(":");
5375
+ const host = address === "0100007F" ? "127.0.0.1" : address === "00000000000000000000000001000000" ? "[::1]" : void 0;
5376
+ const port = Number.parseInt(portHex, 16);
5377
+ if (host && port > 0 && port <= 65535) urls.add(`http://${host}:${port}`);
5378
+ }
5379
+ return [...urls];
5380
+ }
5381
+ async function privateEndpoint({
5382
+ argv = process.argv,
5383
+ env = process.env,
5384
+ pid = process.pid,
5385
+ urls = ownLoopbackUrls,
5386
+ request = fetch
5387
+ } = {}) {
5388
+ if (!argv.includes("--stdio")) return void 0;
5389
+ const flag = argv.indexOf("--port");
5390
+ const rawPort = flag >= 0 ? argv[flag + 1] : argv.find((arg) => arg.startsWith("--port="))?.slice(7);
5391
+ const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
5392
+ const password = env.OPENCODE_PASSWORD;
5393
+ try {
5394
+ if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error();
5395
+ const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`];
5396
+ for (const url of candidates) {
5397
+ const endpoint = { url, auth: { type: "basic", username: "opencode", password } };
5398
+ let response;
5399
+ try {
5400
+ response = await request(`${url}/api/info`, {
5401
+ headers: Service.headers(endpoint),
5402
+ signal: AbortSignal.timeout(2e3),
5403
+ redirect: "error"
5404
+ });
5405
+ if (response.ok && (await response.json()).pid === pid) return endpoint;
5406
+ } catch {
5407
+ } finally {
5408
+ await response?.body?.cancel().catch(() => {
5409
+ });
5410
+ }
5411
+ }
5412
+ } catch {
5413
+ }
5414
+ throw Object.assign(
5415
+ new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
5416
+ { code: "question_private_server_unavailable" }
5417
+ );
5418
+ }
5419
+
5145
5420
  // src/opencode-v2-forms.js
5146
- async function getFormApi(ctx, sessionID, { makeClient = exports_client.make } = {}) {
5147
- const endpoint = await Service.discover({ version: ctx.app.version });
5421
+ function unavailable(code, message, status = 501) {
5422
+ return Object.assign(new HttpError(message, status), { code });
5423
+ }
5424
+ async function getFormApi(ctx, sessionID, { makeClient = exports_client.make, ownsSession, resolvePrivate = privateEndpoint } = {}) {
5425
+ const endpoint = await resolvePrivate() ?? await Service.discover({ version: ctx.app.version }) ?? await Service.discover();
5148
5426
  if (!endpoint) {
5149
- throw new HttpError("Remote question replies require the OpenCode V2 shared service", 501);
5427
+ throw unavailable("question_shared_service_unavailable", "Remote questions require a compatible running OpenCode V2 shared service");
5150
5428
  }
5151
5429
  const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) });
5152
5430
  let session;
5153
5431
  try {
5154
5432
  session = await client.session.get({ sessionID });
5155
5433
  } catch {
5156
- throw new HttpError("The question session is not owned by the shared OpenCode service", 501);
5434
+ throw unavailable("question_session_not_in_shared_service", "The question session is not owned by the shared OpenCode service");
5435
+ }
5436
+ if (session.id !== void 0 && session.id !== sessionID) {
5437
+ throw unavailable("question_session_not_in_shared_service", "The shared service returned another question session");
5157
5438
  }
5158
- if (session.location.directory !== ctx.location.directory) {
5159
- throw new HttpError("The question session belongs to another OpenCode location", 403);
5439
+ const owned = ownsSession ? await ownsSession(session) : session.location?.directory === ctx.location.directory;
5440
+ if (!owned) {
5441
+ throw unavailable("question_session_location_mismatch", "The question session belongs to another OpenCode location", 403);
5160
5442
  }
5161
5443
  return client.session.form;
5162
5444
  }
5163
5445
 
5164
5446
  // src/opencode-v2-client.js
5447
+ import { createHash as createHash2 } from "node:crypto";
5165
5448
  async function nativeReply(operation) {
5166
5449
  try {
5167
5450
  return await operation();
@@ -5183,26 +5466,35 @@ function questionForm(form) {
5183
5466
  (field) => ["string", "multiselect"].includes(field.type) && !field.hidden && !field.when?.length
5184
5467
  );
5185
5468
  }
5469
+ function questionProperties(form) {
5470
+ return {
5471
+ id: form.id,
5472
+ sessionID: form.sessionID,
5473
+ nativeRevision: createHash2("sha256").update(JSON.stringify({
5474
+ id: form.id,
5475
+ sessionID: form.sessionID,
5476
+ title: form.title,
5477
+ fields: form.fields
5478
+ })).digest("hex"),
5479
+ questions: form.fields.map((field) => ({
5480
+ header: field.title ?? form.title,
5481
+ question: field.description ?? field.title ?? form.title,
5482
+ options: (field.options ?? []).map((option) => ({
5483
+ label: option.label,
5484
+ description: option.description ?? ""
5485
+ })),
5486
+ multiple: field.type === "multiselect",
5487
+ custom: field.custom === true || !field.options?.length
5488
+ }))
5489
+ };
5490
+ }
5186
5491
  function normalizeV2Event(event) {
5187
5492
  const properties = event.data ?? {};
5188
5493
  const normalized = { ...event, properties };
5189
5494
  if (event.type === "form.created") {
5190
5495
  const form = properties.form;
5191
5496
  if (!questionForm(form)) return [];
5192
- return [{ ...normalized, type: "question.v2.asked", properties: {
5193
- id: form.id,
5194
- sessionID: form.sessionID,
5195
- questions: form.fields.map((field) => ({
5196
- header: field.title ?? form.title,
5197
- question: field.description ?? field.title ?? form.title,
5198
- options: (field.options ?? []).map((option) => ({
5199
- label: option.label,
5200
- description: option.description ?? ""
5201
- })),
5202
- multiple: field.type === "multiselect",
5203
- custom: field.custom === true || !field.options?.length
5204
- }))
5205
- } }];
5497
+ return [{ ...normalized, type: "question.v2.asked", properties: questionProperties(form) }];
5206
5498
  }
5207
5499
  if (event.type === "form.replied" || event.type === "form.cancelled") {
5208
5500
  return [{ ...normalized, type: event.type === "form.replied" ? "question.v2.replied" : "question.v2.rejected" }];
@@ -5233,9 +5525,11 @@ function normalizeV2Event(event) {
5233
5525
  }
5234
5526
  return [normalized];
5235
5527
  }
5236
- function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
5237
- return {
5238
- liveBridge: createV2LiveClient(ctx),
5528
+ function createV2Client(ctx, resolveFormApi) {
5529
+ const liveBridge = createV2LiveClient(ctx);
5530
+ resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession });
5531
+ const client = {
5532
+ liveBridge,
5239
5533
  app: {
5240
5534
  async log({ body }) {
5241
5535
  console.error(JSON.stringify(body));
@@ -5255,10 +5549,13 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
5255
5549
  decision: body.response
5256
5550
  })),
5257
5551
  v2: { session: { question: {
5258
- async reply({ sessionID, requestID, questionV2Reply }) {
5552
+ async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
5259
5553
  const api = await resolveFormApi(sessionID);
5260
5554
  const form = await nativeReply(() => api.get({ sessionID, formID: requestID }));
5261
5555
  if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422);
5556
+ if (expectedNativeRevision && questionProperties(form).nativeRevision !== expectedNativeRevision) {
5557
+ throw Object.assign(new HttpError("The native question changed; read it again", 409), { code: "question_changed_read_again" });
5558
+ }
5262
5559
  const answers = questionV2Reply.answers;
5263
5560
  if (!Array.isArray(answers) || answers.length !== form.fields.length) {
5264
5561
  throw new HttpError("Question answer count does not match the form", 422);
@@ -5286,18 +5583,31 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
5286
5583
  }
5287
5584
  } } },
5288
5585
  async ownsEvent(event) {
5289
- if (event.location?.directory) return event.location.directory === ctx.location.directory;
5586
+ if (event.location?.directory === ctx.location.directory) return true;
5290
5587
  const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID;
5291
5588
  if (!sessionID) return event.type === "installation.update-available";
5292
5589
  if (sessionID === "global") return false;
5293
5590
  try {
5294
- const session = await ctx.session.get({ sessionID });
5295
- return session.location?.directory === ctx.location.directory;
5591
+ const session = await liveBridge.get(sessionID);
5592
+ return liveBridge.ownsSession(session);
5296
5593
  } catch {
5297
5594
  return false;
5298
5595
  }
5299
5596
  }
5300
5597
  };
5598
+ client.liveBridge.questions = async (sessionID) => {
5599
+ const api = await resolveFormApi(sessionID);
5600
+ const forms = await nativeReply(() => api.list({ sessionID }));
5601
+ if (!Array.isArray(forms)) throw new HttpError("Invalid question list", 502);
5602
+ return forms.filter((form) => form.sessionID === sessionID && questionForm(form) && (!form.state || form.state.status === "pending")).map(questionProperties);
5603
+ };
5604
+ client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
5605
+ sessionID: pending.sessionID,
5606
+ requestID: pending.id,
5607
+ questionV2Reply: { answers },
5608
+ expectedNativeRevision: pending.nativeRevision
5609
+ });
5610
+ return client;
5301
5611
  }
5302
5612
 
5303
5613
  // src/opencode-v2.js