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.
- package/CHANGELOG.md +12 -0
- package/README.md +10 -7
- package/dist/backeer-permission.js +1306 -996
- package/docs/live-control.md +15 -1
- package/package.json +3 -2
- package/src/live-bridge-client.js +34 -6
- package/src/live-bridge-config.js +3 -1
- package/src/live-bridge-events.js +3 -1
- package/src/live-bridge.js +83 -26
- package/src/live-control-client.js +24 -3
- package/src/live-control-mcp.js +8 -2
- package/src/live-errors.js +19 -0
- package/src/live-managed-mcp.js +9 -3
- package/src/live-question-tools.js +13 -0
- package/src/live-questions.js +49 -0
- package/src/live-session-manager.js +10 -0
- package/src/live-session-scope.js +42 -0
- package/src/opencode-v2-client.js +40 -20
- package/src/opencode-v2-endpoint.js +54 -0
- package/src/opencode-v2-forms.js +18 -7
- package/src/opencode-v2-tui.js +49 -0
- package/src/plugin.js +3 -1
- package/src/question-command.js +13 -8
- package/src/updates.js +1 -1
|
@@ -44,7 +44,7 @@ function make7(connect) {
|
|
|
44
44
|
if (current === connection)
|
|
45
45
|
current = void 0;
|
|
46
46
|
}
|
|
47
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
return
|
|
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
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
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
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
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/
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
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/
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
const
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
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
|
-
|
|
3538
|
-
|
|
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
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
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
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3629
|
-
}
|
|
3630
|
-
|
|
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
|
-
|
|
3634
|
-
const
|
|
3635
|
-
if (
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
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
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
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
|
-
|
|
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 (
|
|
3686
|
-
|
|
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 (
|
|
3689
|
-
|
|
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
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
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
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
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
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
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
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
if (
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
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
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
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
|
|
3762
|
-
if (
|
|
3763
|
-
|
|
3764
|
-
|
|
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
|
|
3772
|
-
if (!
|
|
3773
|
-
if (
|
|
3774
|
-
|
|
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 (
|
|
3777
|
-
|
|
3778
|
-
const
|
|
3779
|
-
|
|
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
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
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
|
-
|
|
3851
|
-
if (
|
|
3852
|
-
|
|
3853
|
-
if (
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
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
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
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
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
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
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3967
|
+
},
|
|
3968
|
+
async dispose() {
|
|
3969
|
+
disposed = true;
|
|
3970
|
+
commandStream?.controller.abort();
|
|
3971
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
3972
|
+
await commandStream?.task.catch(() => {
|
|
3880
3973
|
});
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
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
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
for (const
|
|
3913
|
-
|
|
3914
|
-
|
|
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
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
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
|
-
|
|
3932
|
-
|
|
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
|
|
3959
|
-
const stale = error instanceof HttpError && error.status === 404;
|
|
4078
|
+
function createV1LiveClient(client, directory) {
|
|
3960
4079
|
return {
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
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
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
const
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
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
|
-
|
|
3981
|
-
|
|
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/
|
|
3985
|
-
var
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
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
|
-
|
|
3992
|
-
|
|
3993
|
-
return { status: "expired", action: response.action };
|
|
4170
|
+
function text(value, max) {
|
|
4171
|
+
return clipped(value, max).value;
|
|
3994
4172
|
}
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
return {
|
|
4173
|
+
function content(key, value) {
|
|
4174
|
+
const result = clipped(value);
|
|
4175
|
+
return { [key]: result.value, ...result.truncated ? { truncated: true } : {} };
|
|
3998
4176
|
}
|
|
3999
|
-
|
|
4000
|
-
const
|
|
4001
|
-
const
|
|
4002
|
-
if (!
|
|
4003
|
-
|
|
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
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
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/
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
if (!
|
|
4049
|
-
|
|
4050
|
-
|
|
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
|
-
|
|
4067
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
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
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
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
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
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
|
-
|
|
4144
|
-
}
|
|
4145
|
-
|
|
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
|
|
4150
|
-
const
|
|
4151
|
-
if (
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
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
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
);
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
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 (
|
|
4198
|
-
|
|
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 (
|
|
4203
|
-
|
|
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
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
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
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
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
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
}
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
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
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
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
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
}
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
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
|
|
4318
|
-
|
|
4319
|
-
|
|
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
|
-
|
|
4323
|
-
|
|
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
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
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
|
|
4341
|
-
if (
|
|
4342
|
-
|
|
4343
|
-
|
|
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
|
|
4346
|
-
if (!
|
|
4347
|
-
if (
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
if (
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
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
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
if (
|
|
4384
|
-
|
|
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
|
|
4387
|
-
|
|
4388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
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
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5147
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5159
|
-
|
|
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
|
|
5237
|
-
|
|
5238
|
-
|
|
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
|
|
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
|
|
5295
|
-
return session
|
|
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
|