backeer-opencode 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/backeer-permission.js +1122 -984
- package/docs/live-control.md +28 -1
- package/package.json +7 -4
- package/scripts/check-package.mjs +28 -0
- package/src/live-bridge-client.js +16 -2
- package/src/live-bridge-events.js +3 -1
- package/src/live-bridge.js +67 -24
- package/src/live-control-client.js +10 -0
- package/src/live-control-mcp.js +8 -1
- package/src/live-discovery.js +58 -0
- package/src/live-managed-mcp.js +20 -8
- package/src/live-question-tools.js +13 -0
- package/src/live-questions.js +48 -0
- package/src/live-session-manager.js +83 -5
- package/src/opencode-v2-client.js +26 -14
- package/src/question-command.js +13 -8
- package/src/updates.js +1 -1
|
@@ -2395,7 +2395,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2395
2395
|
import { statSync } from "node:fs";
|
|
2396
2396
|
|
|
2397
2397
|
// src/updates.js
|
|
2398
|
-
var PLUGIN_VERSION = "0.
|
|
2398
|
+
var PLUGIN_VERSION = "0.8.0";
|
|
2399
2399
|
var PACKAGE_NAME = "backeer-opencode";
|
|
2400
2400
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
2401
2401
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -3417,1072 +3417,1195 @@ function createLifecycleController({
|
|
|
3417
3417
|
import { createServer } from "node:http";
|
|
3418
3418
|
import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
|
|
3419
3419
|
|
|
3420
|
-
// src/
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
return
|
|
3420
|
+
// src/permission-command.js
|
|
3421
|
+
var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
|
|
3422
|
+
var DECISION_LABELS = {
|
|
3423
|
+
once: "Allow once",
|
|
3424
|
+
always: "Always allow",
|
|
3425
|
+
reject: "Reject"
|
|
3426
|
+
};
|
|
3427
|
+
function validatePermissionCommand(command, now = Date.now()) {
|
|
3428
|
+
if (!ALLOWED_DECISIONS.has(command.decision)) {
|
|
3429
|
+
return {
|
|
3430
|
+
status: "rejected",
|
|
3431
|
+
decision: command.decision,
|
|
3432
|
+
errorCode: "invalid_decision"
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
const expiresAt = Date.parse(command.expiresAt);
|
|
3436
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
3437
|
+
return { status: "expired", decision: command.decision };
|
|
3438
|
+
}
|
|
3439
|
+
if (command.decision === "always" && command.confirmed !== true) {
|
|
3440
|
+
return {
|
|
3441
|
+
status: "rejected",
|
|
3442
|
+
decision: command.decision,
|
|
3443
|
+
errorCode: "confirmation_required"
|
|
3444
|
+
};
|
|
3430
3445
|
}
|
|
3431
|
-
return {
|
|
3432
|
-
async list() {
|
|
3433
|
-
if (typeof ctx.session.list === "function") {
|
|
3434
|
-
return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
|
|
3435
|
-
}
|
|
3436
|
-
const refreshed = [];
|
|
3437
|
-
for (const id of known.keys()) {
|
|
3438
|
-
try {
|
|
3439
|
-
refreshed.push(await get(id));
|
|
3440
|
-
} catch {
|
|
3441
|
-
known.delete(id);
|
|
3442
|
-
}
|
|
3443
|
-
}
|
|
3444
|
-
return refreshed;
|
|
3445
|
-
},
|
|
3446
|
-
get,
|
|
3447
|
-
prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
|
|
3448
|
-
dispose: () => {
|
|
3449
|
-
disposed = true;
|
|
3450
|
-
known.clear();
|
|
3451
|
-
}
|
|
3452
|
-
};
|
|
3453
|
-
}
|
|
3454
|
-
function unwrap(value) {
|
|
3455
|
-
if (value?.error) throw new Error("OpenCode request failed");
|
|
3456
|
-
return value?.data ?? value;
|
|
3457
3446
|
}
|
|
3458
|
-
function
|
|
3447
|
+
function failureAcknowledgement(command, error) {
|
|
3448
|
+
const stale = error instanceof HttpError && error.status === 404;
|
|
3459
3449
|
return {
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
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
|
-
}
|
|
3450
|
+
status: stale ? "stale" : "failed",
|
|
3451
|
+
decision: command.decision,
|
|
3452
|
+
errorCode: stale ? "permission_not_found" : "opencode_reply_failed"
|
|
3473
3453
|
};
|
|
3474
3454
|
}
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
const epoch = randomUUID2();
|
|
3482
|
-
const controller = new AbortController();
|
|
3483
|
-
let last, sequence = 0, pending, task;
|
|
3484
|
-
async function drain() {
|
|
3485
|
-
while (pending && !controller.signal.aborted) {
|
|
3486
|
-
const event = pending;
|
|
3487
|
-
pending = void 0;
|
|
3488
|
-
try {
|
|
3489
|
-
await postJsonWithRetry(config, config.eventsEndpoint, event, { signal: controller.signal });
|
|
3490
|
-
} catch {
|
|
3491
|
-
if (!controller.signal.aborted) reporter?.error("Live connection notification could not be sent");
|
|
3492
|
-
}
|
|
3493
|
-
}
|
|
3494
|
-
}
|
|
3495
|
-
return {
|
|
3496
|
-
update(connected) {
|
|
3497
|
-
if (controller.signal.aborted || connected === last) return;
|
|
3498
|
-
const initial = last === void 0;
|
|
3499
|
-
last = connected;
|
|
3500
|
-
if (initial || !config.token || !config.eventsEndpoint) return;
|
|
3501
|
-
pending = {
|
|
3502
|
-
type: "application",
|
|
3503
|
-
severity: connected ? "info" : "warning",
|
|
3504
|
-
title: connected ? "OpenCode ba\u011Flant\u0131s\u0131 yeniden kuruldu" : "OpenCode ba\u011Flant\u0131s\u0131 kesildi",
|
|
3505
|
-
body: connected ? "Canl\u0131 takip yeniden ba\u011Fland\u0131. Sunucudaki g\xF6rev otomatik olarak yeniden ba\u015Flat\u0131lmad\u0131." : "Canl\u0131 takip ba\u011Flant\u0131s\u0131 kesildi. Yeniden ba\u011Flanma her 3 saniyede denenecek; sunucudaki g\xF6rev durdurulmad\u0131.",
|
|
3506
|
-
payload: [{ key: "Connection", value: connected ? "reconnected" : "disconnected" }],
|
|
3507
|
-
externalId: `opencode:live:${epoch}:${++sequence}`,
|
|
3508
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3509
|
-
notify: true,
|
|
3510
|
-
direction: "in"
|
|
3511
|
-
};
|
|
3512
|
-
task ??= drain().finally(() => {
|
|
3513
|
-
task = void 0;
|
|
3514
|
-
});
|
|
3515
|
-
},
|
|
3516
|
-
async dispose() {
|
|
3517
|
-
controller.abort();
|
|
3518
|
-
pending = void 0;
|
|
3519
|
-
await task;
|
|
3520
|
-
}
|
|
3455
|
+
function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
|
|
3456
|
+
const messages = {
|
|
3457
|
+
applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
|
|
3458
|
+
expired: "Decision expired before it could be applied",
|
|
3459
|
+
stale: "Permission was already answered locally",
|
|
3460
|
+
rejected: "Decision was rejected by validation"
|
|
3521
3461
|
};
|
|
3462
|
+
const message = messages[acknowledgement.status];
|
|
3463
|
+
if (!message) return;
|
|
3464
|
+
if (acknowledgement.status === "applied") {
|
|
3465
|
+
reporter.applied(message);
|
|
3466
|
+
conversation?.applied(pending.sessionID, message);
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
reporter.warning(message);
|
|
3470
|
+
conversation?.warning(pending.sessionID, message);
|
|
3522
3471
|
}
|
|
3523
3472
|
|
|
3524
|
-
// src/
|
|
3525
|
-
var
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
function clipped(value, max = 12e3) {
|
|
3531
|
-
if (typeof value !== "string") return { value: "", truncated: false };
|
|
3532
|
-
let result = value.slice(0, 64e3);
|
|
3533
|
-
for (const secret of known) result = result.split(secret).join("[REDACTED]");
|
|
3534
|
-
result = result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]").replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]").replace(/\b(?:sk|bgi)_[A-Za-z0-9_-]{12,}|\bsk-[A-Za-z0-9_-]{12,}/g, "[REDACTED]").replace(/((?:api[_-]?key|access[_-]?token|secret|password|authorization)\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]");
|
|
3535
|
-
return { value: result.slice(0, max), truncated: value.length > 64e3 || result.length > max };
|
|
3473
|
+
// src/question-command.js
|
|
3474
|
+
var QUESTION_ACTIONS = /* @__PURE__ */ new Set(["answer", "reject"]);
|
|
3475
|
+
function validateQuestionCommand(command, pending, now = Date.now()) {
|
|
3476
|
+
const response = command?.response;
|
|
3477
|
+
if (!response || !QUESTION_ACTIONS.has(response.action)) {
|
|
3478
|
+
return { status: "rejected", action: response?.action, errorCode: "invalid_action" };
|
|
3536
3479
|
}
|
|
3537
|
-
|
|
3538
|
-
|
|
3480
|
+
const expiresAt = Date.parse(command.expiresAt);
|
|
3481
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
3482
|
+
return { status: "expired", action: response.action };
|
|
3539
3483
|
}
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3484
|
+
if (response.action === "reject") return;
|
|
3485
|
+
const errorCode = validateQuestionAnswers(response.answers, pending.questions);
|
|
3486
|
+
if (errorCode) return { status: "rejected", action: response.action, errorCode };
|
|
3487
|
+
}
|
|
3488
|
+
function validateQuestionAnswers(values, questions) {
|
|
3489
|
+
if (!Array.isArray(values) || values.length !== questions.length) {
|
|
3490
|
+
return "invalid_answers";
|
|
3543
3491
|
}
|
|
3544
|
-
|
|
3545
|
-
const
|
|
3546
|
-
const
|
|
3547
|
-
if (!
|
|
3548
|
-
|
|
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] } };
|
|
3492
|
+
for (let index = 0; index < questions.length; index++) {
|
|
3493
|
+
const question = questions[index];
|
|
3494
|
+
const answers = values[index];
|
|
3495
|
+
if (!Array.isArray(answers) || !question.multiple && answers.length > 1) {
|
|
3496
|
+
return "invalid_answers";
|
|
3595
3497
|
}
|
|
3596
|
-
|
|
3597
|
-
|
|
3498
|
+
for (const answer of answers) {
|
|
3499
|
+
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
3500
|
+
return "invalid_answers";
|
|
3501
|
+
}
|
|
3502
|
+
const known = question.options.some((option) => option.label === answer);
|
|
3503
|
+
if (!known && !question.custom) {
|
|
3504
|
+
return "custom_not_allowed";
|
|
3505
|
+
}
|
|
3598
3506
|
}
|
|
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
3507
|
}
|
|
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
3508
|
}
|
|
3613
|
-
function
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3509
|
+
function questionFailureAcknowledgement(command, error) {
|
|
3510
|
+
const stale = error instanceof HttpError && error.status === 404;
|
|
3511
|
+
return {
|
|
3512
|
+
status: stale ? "stale" : "failed",
|
|
3513
|
+
action: command?.response?.action,
|
|
3514
|
+
errorCode: stale ? "question_not_found" : "opencode_question_reply_failed"
|
|
3515
|
+
};
|
|
3618
3516
|
}
|
|
3619
|
-
|
|
3620
|
-
const
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3517
|
+
function reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation) {
|
|
3518
|
+
const messages = {
|
|
3519
|
+
applied: acknowledgement.action === "reject" ? "Question rejected from Backeer" : "Question answered from Backeer",
|
|
3520
|
+
expired: "Question response expired before it could be applied",
|
|
3521
|
+
stale: "Question was already answered locally",
|
|
3522
|
+
rejected: "Question response was rejected by validation"
|
|
3523
|
+
};
|
|
3524
|
+
const message = messages[acknowledgement.status];
|
|
3525
|
+
if (!message) return;
|
|
3526
|
+
if (acknowledgement.status === "applied") {
|
|
3527
|
+
reporter.applied(message);
|
|
3528
|
+
conversation?.applied(pending.sessionID, message);
|
|
3529
|
+
} else {
|
|
3530
|
+
reporter.warning(message);
|
|
3531
|
+
conversation?.warning(pending.sessionID, message);
|
|
3626
3532
|
}
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
// src/sse.js
|
|
3536
|
+
function parseSseFrame(frame) {
|
|
3537
|
+
const data = [];
|
|
3538
|
+
let event = "message";
|
|
3539
|
+
let id;
|
|
3540
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
3541
|
+
if (!line || line.startsWith(":")) continue;
|
|
3542
|
+
const separator = line.indexOf(":");
|
|
3543
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
3544
|
+
let value = separator === -1 ? "" : line.slice(separator + 1);
|
|
3545
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
3546
|
+
if (field === "event") event = value;
|
|
3547
|
+
if (field === "id") id = value;
|
|
3548
|
+
if (field === "data") data.push(value);
|
|
3631
3549
|
}
|
|
3550
|
+
if (data.length === 0) return;
|
|
3551
|
+
return { event, id, data: data.join("\n") };
|
|
3632
3552
|
}
|
|
3633
|
-
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3553
|
+
function nextSseFrame(buffer) {
|
|
3554
|
+
const lfIndex = buffer.indexOf("\n\n");
|
|
3555
|
+
const crlfIndex = buffer.indexOf("\r\n\r\n");
|
|
3556
|
+
if (lfIndex === -1 && crlfIndex === -1) return;
|
|
3557
|
+
if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
|
|
3558
|
+
return {
|
|
3559
|
+
frame: buffer.slice(0, crlfIndex),
|
|
3560
|
+
rest: buffer.slice(crlfIndex + 4)
|
|
3561
|
+
};
|
|
3641
3562
|
}
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
const
|
|
3647
|
-
|
|
3648
|
-
let
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
},
|
|
3672
|
-
(error) => {
|
|
3673
|
-
lookups--;
|
|
3674
|
-
finish(reject, error);
|
|
3563
|
+
return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) };
|
|
3564
|
+
}
|
|
3565
|
+
async function consumeSse(response, state, onCommand, signal2, reporter) {
|
|
3566
|
+
if (!response.body) throw new Error("Backeer command stream has no body");
|
|
3567
|
+
const reader = response.body.getReader();
|
|
3568
|
+
const decoder = new TextDecoder();
|
|
3569
|
+
let buffer = "";
|
|
3570
|
+
try {
|
|
3571
|
+
while (!signal2.aborted) {
|
|
3572
|
+
const item = await reader.read();
|
|
3573
|
+
if (item.done) break;
|
|
3574
|
+
buffer += decoder.decode(item.value, { stream: true });
|
|
3575
|
+
for (; ; ) {
|
|
3576
|
+
const next = nextSseFrame(buffer);
|
|
3577
|
+
if (!next) break;
|
|
3578
|
+
buffer = next.rest;
|
|
3579
|
+
state.framesReceived = (state.framesReceived ?? 0) + 1;
|
|
3580
|
+
const message = parseSseFrame(next.frame);
|
|
3581
|
+
if (!message) continue;
|
|
3582
|
+
if (message.id) state.lastEventID = message.id;
|
|
3583
|
+
if (message.event !== "message" && message.event !== "command") continue;
|
|
3584
|
+
try {
|
|
3585
|
+
await onCommand(JSON.parse(message.data));
|
|
3586
|
+
} catch (error) {
|
|
3587
|
+
if (error instanceof SyntaxError) {
|
|
3588
|
+
reporter.error("Invalid command received", "SSE data is not valid JSON");
|
|
3589
|
+
continue;
|
|
3590
|
+
}
|
|
3591
|
+
throw error;
|
|
3675
3592
|
}
|
|
3676
|
-
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
} finally {
|
|
3596
|
+
await reader.cancel().catch(() => {
|
|
3677
3597
|
});
|
|
3678
3598
|
}
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3599
|
+
}
|
|
3600
|
+
async function openCommandStream(config, state, signal2, fetchImplementation) {
|
|
3601
|
+
const headers = {
|
|
3602
|
+
Accept: "text/event-stream",
|
|
3603
|
+
Authorization: `Bearer ${config.token}`
|
|
3604
|
+
};
|
|
3605
|
+
if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID;
|
|
3606
|
+
const response = await fetchImplementation(config.commandStreamEndpoint, {
|
|
3607
|
+
headers,
|
|
3608
|
+
signal: signal2
|
|
3609
|
+
});
|
|
3610
|
+
if (!response.ok) {
|
|
3611
|
+
const responseBody = limit(await response.text(), 500);
|
|
3612
|
+
throw new HttpError(
|
|
3613
|
+
`Backeer command stream failed with status ${response.status}`,
|
|
3614
|
+
response.status,
|
|
3615
|
+
responseBody
|
|
3616
|
+
);
|
|
3617
|
+
}
|
|
3618
|
+
if (!response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
3619
|
+
throw new Error("Backeer command stream returned an invalid content type");
|
|
3620
|
+
}
|
|
3621
|
+
return response;
|
|
3622
|
+
}
|
|
3623
|
+
async function consumeConnection({
|
|
3624
|
+
response,
|
|
3625
|
+
state,
|
|
3626
|
+
onCommand,
|
|
3627
|
+
signal: signal2,
|
|
3628
|
+
reporter,
|
|
3629
|
+
now,
|
|
3630
|
+
stableConnectionMs,
|
|
3631
|
+
onStable
|
|
3632
|
+
}) {
|
|
3633
|
+
const connectedAt = now();
|
|
3634
|
+
const framesBeforeConnection = state.framesReceived;
|
|
3635
|
+
try {
|
|
3636
|
+
await consumeSse(response, state, onCommand, signal2, reporter);
|
|
3637
|
+
} finally {
|
|
3638
|
+
const receivedFrame = state.framesReceived > framesBeforeConnection;
|
|
3639
|
+
if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
|
|
3643
|
+
const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
|
|
3644
|
+
if (message !== lastLoggedError) {
|
|
3645
|
+
const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
|
|
3646
|
+
reporter.error(
|
|
3647
|
+
authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
|
|
3648
|
+
message
|
|
3649
|
+
);
|
|
3650
|
+
}
|
|
3651
|
+
return {
|
|
3652
|
+
lastLoggedError: message,
|
|
3653
|
+
reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status) ? 6e4 : reconnectDelay
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
async function runCommandStream(config, reporter, {
|
|
3657
|
+
signal: signal2,
|
|
3658
|
+
hasPending,
|
|
3659
|
+
onCommand,
|
|
3660
|
+
fetchImplementation = fetch,
|
|
3661
|
+
sleepImplementation = sleep,
|
|
3662
|
+
now = Date.now,
|
|
3663
|
+
stableConnectionMs = 5e3
|
|
3664
|
+
}) {
|
|
3665
|
+
const state = { lastEventID: "", framesReceived: 0 };
|
|
3666
|
+
let reconnectDelay = 1e3;
|
|
3667
|
+
let lastLoggedError = "";
|
|
3668
|
+
while (!signal2.aborted && hasPending()) {
|
|
3682
3669
|
try {
|
|
3683
|
-
|
|
3670
|
+
const response = await openCommandStream(
|
|
3671
|
+
config,
|
|
3672
|
+
state,
|
|
3673
|
+
signal2,
|
|
3674
|
+
fetchImplementation
|
|
3675
|
+
);
|
|
3676
|
+
await consumeConnection({
|
|
3677
|
+
response,
|
|
3678
|
+
state,
|
|
3679
|
+
onCommand,
|
|
3680
|
+
signal: signal2,
|
|
3681
|
+
reporter,
|
|
3682
|
+
now,
|
|
3683
|
+
stableConnectionMs,
|
|
3684
|
+
onStable: () => {
|
|
3685
|
+
reconnectDelay = 1e3;
|
|
3686
|
+
lastLoggedError = "";
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3684
3689
|
} catch (error) {
|
|
3685
|
-
if (
|
|
3686
|
-
|
|
3690
|
+
if (signal2.aborted || isAbortError(error)) return;
|
|
3691
|
+
const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
|
|
3692
|
+
reconnectDelay = next.reconnectDelay;
|
|
3693
|
+
lastLoggedError = next.lastLoggedError;
|
|
3687
3694
|
}
|
|
3688
|
-
if (
|
|
3689
|
-
|
|
3695
|
+
if (!signal2.aborted && hasPending()) {
|
|
3696
|
+
await sleepImplementation(reconnectDelay, signal2);
|
|
3697
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 6e4);
|
|
3690
3698
|
}
|
|
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
3699
|
}
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
// src/permission-controller.js
|
|
3703
|
+
async function replyToOpenCode(client, pending, decision) {
|
|
3704
|
+
const result = await client.postSessionIdPermissionsPermissionId({
|
|
3705
|
+
path: { id: pending.sessionID, permissionID: pending.requestID },
|
|
3706
|
+
query: { directory: pending.directory },
|
|
3707
|
+
body: { response: decision },
|
|
3708
|
+
throwOnError: true
|
|
3709
|
+
});
|
|
3710
|
+
if (result?.data === false || result === false) {
|
|
3711
|
+
throw new HttpError("OpenCode did not apply the permission reply", 409);
|
|
3705
3712
|
}
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3713
|
+
}
|
|
3714
|
+
async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
3715
|
+
const url = new URL(
|
|
3716
|
+
`/v1/commands/${encodeURIComponent(commandID)}/ack`,
|
|
3717
|
+
config.origin
|
|
3718
|
+
);
|
|
3719
|
+
await postJsonWithRetry(config, url, acknowledgement);
|
|
3720
|
+
}
|
|
3721
|
+
function questionResponseError(result) {
|
|
3722
|
+
if (!result?.error) return;
|
|
3723
|
+
const status = Number(result?.response?.status) || 502;
|
|
3724
|
+
const detail = result.error?.data?.message ?? result.error?.message ?? "OpenCode rejected the question response";
|
|
3725
|
+
return new HttpError(String(detail), status);
|
|
3726
|
+
}
|
|
3727
|
+
function normalizeQuestionError(error) {
|
|
3728
|
+
if (error instanceof HttpError) return error;
|
|
3729
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.cause?.status);
|
|
3730
|
+
if (!Number.isFinite(status)) return error;
|
|
3731
|
+
return new HttpError(error?.message || "OpenCode rejected the question response", status);
|
|
3732
|
+
}
|
|
3733
|
+
function rawQuestionRequest(client, pending, response, isV2) {
|
|
3734
|
+
const transport = client?._client;
|
|
3735
|
+
if (typeof transport?.post !== "function") {
|
|
3736
|
+
throw new HttpError("This OpenCode version cannot apply remote question replies", 501);
|
|
3720
3737
|
}
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3738
|
+
const rejecting = response.action === "reject";
|
|
3739
|
+
if (isV2) {
|
|
3740
|
+
return transport.post({
|
|
3741
|
+
url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
3742
|
+
path: { sessionID: pending.sessionID, requestID: pending.requestID },
|
|
3743
|
+
...rejecting ? {} : { body: { answers: response.answers } },
|
|
3744
|
+
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
3745
|
+
throwOnError: true
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
3748
|
+
return transport.post({
|
|
3749
|
+
url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
3750
|
+
path: { requestID: pending.requestID },
|
|
3751
|
+
query: { directory: pending.directory },
|
|
3752
|
+
...rejecting ? {} : { body: { answers: response.answers } },
|
|
3753
|
+
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
3754
|
+
throwOnError: true
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
async function replyToQuestion(client, pending, response) {
|
|
3758
|
+
const isV2 = pending.sourceEvent === "question.v2.asked";
|
|
3759
|
+
const api = isV2 ? client?.v2?.session?.question : client?.question;
|
|
3760
|
+
const method = response.action === "reject" ? api?.reject : api?.reply;
|
|
3761
|
+
const input = isV2 ? {
|
|
3762
|
+
sessionID: pending.sessionID,
|
|
3763
|
+
requestID: pending.requestID,
|
|
3764
|
+
directory: pending.directory,
|
|
3765
|
+
...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
|
|
3766
|
+
} : {
|
|
3767
|
+
requestID: pending.requestID,
|
|
3768
|
+
directory: pending.directory,
|
|
3769
|
+
...response.action === "answer" ? { answers: response.answers } : {}
|
|
3770
|
+
};
|
|
3771
|
+
let result;
|
|
3772
|
+
try {
|
|
3773
|
+
result = typeof method === "function" ? await method.call(api, input, { throwOnError: true }) : await rawQuestionRequest(client, pending, response, isV2);
|
|
3774
|
+
} catch (error) {
|
|
3775
|
+
throw normalizeQuestionError(error);
|
|
3776
|
+
}
|
|
3777
|
+
const responseError = questionResponseError(result);
|
|
3778
|
+
if (responseError) throw responseError;
|
|
3779
|
+
if (result?.data === false || result === false) {
|
|
3780
|
+
throw new HttpError("OpenCode did not apply the question response", 409);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
async function postQuestionState(config, eventID, path, body = {}) {
|
|
3784
|
+
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin);
|
|
3785
|
+
await postJsonWithRetry(config, url, body, { retryDelays: [0, 500] });
|
|
3786
|
+
}
|
|
3787
|
+
function createPermissionController({
|
|
3788
|
+
config,
|
|
3789
|
+
reporter,
|
|
3790
|
+
client,
|
|
3791
|
+
directory,
|
|
3792
|
+
conversation
|
|
3793
|
+
}) {
|
|
3794
|
+
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
3795
|
+
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
3796
|
+
const questionsByExternalID = /* @__PURE__ */ new Map();
|
|
3797
|
+
const questionExternalIDByRequestID = /* @__PURE__ */ new Map();
|
|
3798
|
+
const remotelyResolvingQuestions = /* @__PURE__ */ new Set();
|
|
3799
|
+
const processingCommands = /* @__PURE__ */ new Set();
|
|
3800
|
+
const completedCommands = /* @__PURE__ */ new Map();
|
|
3801
|
+
let commandStream;
|
|
3802
|
+
let leaseTimer;
|
|
3803
|
+
let disposed = false;
|
|
3804
|
+
function removePendingByRequestID(requestID) {
|
|
3805
|
+
const externalId = externalIDByRequestID.get(requestID);
|
|
3806
|
+
if (!externalId) return;
|
|
3807
|
+
externalIDByRequestID.delete(requestID);
|
|
3808
|
+
pendingByExternalID.delete(externalId);
|
|
3809
|
+
}
|
|
3810
|
+
function stopCommandStreamIfIdle() {
|
|
3811
|
+
if (pendingByExternalID.size === 0 && questionsByExternalID.size === 0) {
|
|
3812
|
+
commandStream?.controller.abort();
|
|
3729
3813
|
}
|
|
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
|
-
`;
|
|
3814
|
+
}
|
|
3815
|
+
function removeQuestionByRequestID(requestID) {
|
|
3816
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
3817
|
+
if (!externalId) return;
|
|
3818
|
+
questionExternalIDByRequestID.delete(requestID);
|
|
3819
|
+
questionsByExternalID.delete(externalId);
|
|
3820
|
+
if (questionsByExternalID.size === 0 && leaseTimer) {
|
|
3821
|
+
clearInterval(leaseTimer);
|
|
3822
|
+
leaseTimer = void 0;
|
|
3748
3823
|
}
|
|
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
3824
|
}
|
|
3755
|
-
function
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3825
|
+
async function renewQuestionLeases() {
|
|
3826
|
+
for (const pending of questionsByExternalID.values()) {
|
|
3827
|
+
if (!pending.eventID) continue;
|
|
3828
|
+
await postQuestionState(config, pending.eventID, "lease").catch((error) => {
|
|
3829
|
+
reporter.warning("Question lease could not be renewed", error);
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3760
3832
|
}
|
|
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 };
|
|
3833
|
+
function ensureLeaseTimer() {
|
|
3834
|
+
if (leaseTimer || questionsByExternalID.size === 0) return;
|
|
3835
|
+
leaseTimer = setInterval(() => void renewQuestionLeases(), 6e4);
|
|
3836
|
+
leaseTimer.unref?.();
|
|
3770
3837
|
}
|
|
3771
|
-
async function
|
|
3772
|
-
if (!
|
|
3773
|
-
if (
|
|
3774
|
-
|
|
3838
|
+
async function handleCommand(command) {
|
|
3839
|
+
if (!command || typeof command !== "object") return;
|
|
3840
|
+
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return;
|
|
3841
|
+
if (typeof command.id !== "string" || command.id.length > 200) return;
|
|
3842
|
+
const previousAcknowledgement = completedCommands.get(command.id);
|
|
3843
|
+
if (previousAcknowledgement) {
|
|
3844
|
+
await acknowledgeCommand(config, command.id, previousAcknowledgement).catch((error) => reporter.warning("Decision receipt retry failed", error));
|
|
3845
|
+
return;
|
|
3775
3846
|
}
|
|
3776
|
-
if (
|
|
3777
|
-
|
|
3778
|
-
const
|
|
3779
|
-
|
|
3847
|
+
if (processingCommands.has(command.id)) return;
|
|
3848
|
+
if (typeof command.externalId !== "string") return;
|
|
3849
|
+
const isQuestion = command.type === "opencode_question_reply";
|
|
3850
|
+
const pending = isQuestion ? questionsByExternalID.get(command.externalId) : pendingByExternalID.get(command.externalId);
|
|
3851
|
+
if (!pending) return;
|
|
3852
|
+
processingCommands.add(command.id);
|
|
3853
|
+
let acknowledgement = isQuestion ? validateQuestionCommand(command, pending) : validatePermissionCommand(command);
|
|
3780
3854
|
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();
|
|
3855
|
+
if (!acknowledgement) {
|
|
3856
|
+
if (isQuestion) {
|
|
3857
|
+
remotelyResolvingQuestions.add(pending.requestID);
|
|
3858
|
+
await replyToQuestion(client, pending, command.response);
|
|
3859
|
+
acknowledgement = { status: "applied", action: command.response.action };
|
|
3860
|
+
removeQuestionByRequestID(pending.requestID);
|
|
3861
|
+
const cleanup = setTimeout(
|
|
3862
|
+
() => remotelyResolvingQuestions.delete(pending.requestID),
|
|
3863
|
+
3e4
|
|
3864
|
+
);
|
|
3865
|
+
cleanup.unref?.();
|
|
3866
|
+
} else {
|
|
3867
|
+
await replyToOpenCode(client, pending, command.decision);
|
|
3868
|
+
acknowledgement = { status: "applied", decision: command.decision };
|
|
3869
|
+
removePendingByRequestID(pending.requestID);
|
|
3847
3870
|
}
|
|
3848
|
-
return;
|
|
3849
|
-
}
|
|
3850
|
-
const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions)?$/.exec(url.pathname);
|
|
3851
|
-
if (!match) throw failure(404, "route_not_found");
|
|
3852
|
-
if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]));
|
|
3853
|
-
if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed");
|
|
3854
|
-
if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required");
|
|
3855
|
-
const body = await readBody(request);
|
|
3856
|
-
if (!body || !validBridgeID(body.requestId) || typeof body.prompt !== "string" || !body.prompt.trim() || body.prompt.length > 16e3 || Object.keys(body).some((key) => !["requestId", "prompt"].includes(key))) throw failure(400, "invalid_instruction");
|
|
3857
|
-
await session(match[1]);
|
|
3858
|
-
if (response.headersSent || disposed) return;
|
|
3859
|
-
for (const [id, command2] of commands) {
|
|
3860
|
-
if (command2.settled && Date.now() - command2.created >= LIMITS.commandTTL) commands.delete(id);
|
|
3861
|
-
}
|
|
3862
|
-
const existing = commands.get(body.requestId);
|
|
3863
|
-
if (existing) {
|
|
3864
|
-
if (existing.sessionID !== match[1] || existing.prompt !== body.prompt) throw failure(409, "request_id_conflict");
|
|
3865
|
-
await existing.task;
|
|
3866
|
-
return json(response, 200, existing.result);
|
|
3867
3871
|
}
|
|
3868
|
-
if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full");
|
|
3869
|
-
const command = {
|
|
3870
|
-
sessionID: match[1],
|
|
3871
|
-
prompt: body.prompt,
|
|
3872
|
-
created: Date.now(),
|
|
3873
|
-
settled: false,
|
|
3874
|
-
result: { requestId: body.requestId, sessionID: match[1], status: "accepted" },
|
|
3875
|
-
task: void 0
|
|
3876
|
-
};
|
|
3877
|
-
commands.set(body.requestId, command);
|
|
3878
|
-
command.task = Promise.resolve().then(() => api.prompt(match[1], body.prompt)).finally(() => {
|
|
3879
|
-
command.settled = true;
|
|
3880
|
-
});
|
|
3881
|
-
await command.task;
|
|
3882
|
-
return json(response, 202, command.result);
|
|
3883
3872
|
} catch (error) {
|
|
3884
|
-
|
|
3873
|
+
if (isQuestion) remotelyResolvingQuestions.delete(pending.requestID);
|
|
3874
|
+
acknowledgement = isQuestion ? questionFailureAcknowledgement(command, error) : failureAcknowledgement(command, error);
|
|
3875
|
+
if (acknowledgement.status === "stale") {
|
|
3876
|
+
if (isQuestion) removeQuestionByRequestID(pending.requestID);
|
|
3877
|
+
else removePendingByRequestID(pending.requestID);
|
|
3878
|
+
}
|
|
3879
|
+
const message = isQuestion ? "Question response could not be applied" : "Decision could not be applied";
|
|
3880
|
+
reporter.error(message, error);
|
|
3881
|
+
conversation?.error(pending.sessionID, message);
|
|
3885
3882
|
} finally {
|
|
3886
|
-
|
|
3887
|
-
clearTimeout(timeout);
|
|
3888
|
-
}
|
|
3889
|
-
}
|
|
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
|
-
return {
|
|
3906
|
-
handle,
|
|
3907
|
-
address: server.address(),
|
|
3908
|
-
dispose() {
|
|
3909
|
-
disposeTask ??= new Promise((resolve2) => {
|
|
3910
|
-
disposed = true;
|
|
3911
|
-
for (const cancel of lookupWaiters) cancel();
|
|
3912
|
-
for (const stream of streams) {
|
|
3913
|
-
removeStream(stream);
|
|
3914
|
-
stream.response.destroy();
|
|
3915
|
-
}
|
|
3916
|
-
server.close(() => {
|
|
3917
|
-
void connectionNotifications.dispose().then(resolve2);
|
|
3918
|
-
});
|
|
3919
|
-
server.closeAllConnections?.();
|
|
3920
|
-
events.length = 0;
|
|
3921
|
-
states.clear();
|
|
3922
|
-
commands.clear();
|
|
3923
|
-
sanitizer.clear();
|
|
3924
|
-
api.dispose?.();
|
|
3925
|
-
});
|
|
3926
|
-
return disposeTask;
|
|
3883
|
+
processingCommands.delete(command.id);
|
|
3927
3884
|
}
|
|
3928
|
-
|
|
3929
|
-
|
|
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"
|
|
3885
|
+
acknowledgement = {
|
|
3886
|
+
...acknowledgement,
|
|
3887
|
+
type: isQuestion ? "opencode_question_reply_ack" : "opencode_permission_reply_ack",
|
|
3888
|
+
externalId: command.externalId,
|
|
3889
|
+
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3944
3890
|
};
|
|
3891
|
+
rememberBounded(completedCommands, command.id, acknowledgement);
|
|
3892
|
+
if (isQuestion) {
|
|
3893
|
+
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
3894
|
+
} else {
|
|
3895
|
+
reportAcknowledgement(acknowledgement, pending, reporter, conversation);
|
|
3896
|
+
}
|
|
3897
|
+
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
3898
|
+
reporter.warning("Decision receipt could not be sent", error);
|
|
3899
|
+
});
|
|
3900
|
+
stopCommandStreamIfIdle();
|
|
3945
3901
|
}
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3902
|
+
function ensureCommandStream() {
|
|
3903
|
+
const hasPending = () => pendingByExternalID.size > 0 || questionsByExternalID.size > 0;
|
|
3904
|
+
if (disposed || commandStream || !hasPending()) return;
|
|
3905
|
+
const controller = new AbortController();
|
|
3906
|
+
const current = {
|
|
3907
|
+
controller,
|
|
3908
|
+
task: runCommandStream(config, reporter, {
|
|
3909
|
+
signal: controller.signal,
|
|
3910
|
+
hasPending,
|
|
3911
|
+
onCommand: handleCommand
|
|
3912
|
+
})
|
|
3955
3913
|
};
|
|
3914
|
+
commandStream = current;
|
|
3915
|
+
void current.task.finally(() => {
|
|
3916
|
+
if (commandStream === current) commandStream = void 0;
|
|
3917
|
+
if (!disposed && hasPending()) ensureCommandStream();
|
|
3918
|
+
});
|
|
3956
3919
|
}
|
|
3957
|
-
}
|
|
3958
|
-
function failureAcknowledgement(command, error) {
|
|
3959
|
-
const stale = error instanceof HttpError && error.status === 404;
|
|
3960
3920
|
return {
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
}
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3921
|
+
register(identity) {
|
|
3922
|
+
pendingByExternalID.set(identity.externalId, { ...identity, directory });
|
|
3923
|
+
externalIDByRequestID.set(identity.requestID, identity.externalId);
|
|
3924
|
+
ensureCommandStream();
|
|
3925
|
+
},
|
|
3926
|
+
resolveLocally(requestID) {
|
|
3927
|
+
removePendingByRequestID(requestID);
|
|
3928
|
+
stopCommandStreamIfIdle();
|
|
3929
|
+
},
|
|
3930
|
+
registerQuestion(identity, details) {
|
|
3931
|
+
questionsByExternalID.set(identity.externalId, {
|
|
3932
|
+
...identity,
|
|
3933
|
+
...details,
|
|
3934
|
+
directory
|
|
3935
|
+
});
|
|
3936
|
+
questionExternalIDByRequestID.set(identity.requestID, identity.externalId);
|
|
3937
|
+
ensureLeaseTimer();
|
|
3938
|
+
ensureCommandStream();
|
|
3939
|
+
},
|
|
3940
|
+
resolveQuestionLocally(requestID, outcome) {
|
|
3941
|
+
if (remotelyResolvingQuestions.delete(requestID)) {
|
|
3942
|
+
removeQuestionByRequestID(requestID);
|
|
3943
|
+
stopCommandStreamIfIdle();
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
const externalId = questionExternalIDByRequestID.get(requestID);
|
|
3947
|
+
const pending = externalId ? questionsByExternalID.get(externalId) : void 0;
|
|
3948
|
+
if (!pending) return;
|
|
3949
|
+
removeQuestionByRequestID(requestID);
|
|
3950
|
+
if (pending.eventID) {
|
|
3951
|
+
void postQuestionState(config, pending.eventID, "source-resolution", { outcome }).catch((error) => reporter.warning("Local question state could not be sent", error));
|
|
3952
|
+
}
|
|
3953
|
+
stopCommandStreamIfIdle();
|
|
3954
|
+
},
|
|
3955
|
+
refreshCredentials() {
|
|
3956
|
+
commandStream?.controller.abort();
|
|
3957
|
+
if (!commandStream) ensureCommandStream();
|
|
3958
|
+
},
|
|
3959
|
+
status() {
|
|
3960
|
+
return {
|
|
3961
|
+
pending: pendingByExternalID.size,
|
|
3962
|
+
pendingQuestions: questionsByExternalID.size,
|
|
3963
|
+
stream: commandStream ? "active" : "idle"
|
|
3964
|
+
};
|
|
3965
|
+
},
|
|
3966
|
+
async dispose() {
|
|
3967
|
+
disposed = true;
|
|
3968
|
+
commandStream?.controller.abort();
|
|
3969
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
3970
|
+
await commandStream?.task.catch(() => {
|
|
3971
|
+
});
|
|
3972
|
+
pendingByExternalID.clear();
|
|
3973
|
+
externalIDByRequestID.clear();
|
|
3974
|
+
processingCommands.clear();
|
|
3975
|
+
completedCommands.clear();
|
|
3976
|
+
questionsByExternalID.clear();
|
|
3977
|
+
questionExternalIDByRequestID.clear();
|
|
3978
|
+
remotelyResolvingQuestions.clear();
|
|
3979
|
+
}
|
|
3972
3980
|
};
|
|
3973
|
-
const message = messages[acknowledgement.status];
|
|
3974
|
-
if (!message) return;
|
|
3975
|
-
if (acknowledgement.status === "applied") {
|
|
3976
|
-
reporter.applied(message);
|
|
3977
|
-
conversation?.applied(pending.sessionID, message);
|
|
3978
|
-
return;
|
|
3979
|
-
}
|
|
3980
|
-
reporter.warning(message);
|
|
3981
|
-
conversation?.warning(pending.sessionID, message);
|
|
3982
3981
|
}
|
|
3983
3982
|
|
|
3984
|
-
// src/
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
return
|
|
3994
|
-
}
|
|
3995
|
-
if (response.action === "reject") return;
|
|
3996
|
-
if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
|
|
3997
|
-
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
3983
|
+
// src/live-bridge-client.js
|
|
3984
|
+
function createV2LiveClient(ctx) {
|
|
3985
|
+
const known = /* @__PURE__ */ new Map();
|
|
3986
|
+
let disposed = false;
|
|
3987
|
+
async function get(id) {
|
|
3988
|
+
const info = await ctx.session.get({ sessionID: id });
|
|
3989
|
+
if (disposed) return info;
|
|
3990
|
+
known.set(id, info);
|
|
3991
|
+
if (known.size > 100) known.delete(known.keys().next().value);
|
|
3992
|
+
return info;
|
|
3998
3993
|
}
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
4004
|
-
}
|
|
4005
|
-
for (const answer of answers) {
|
|
4006
|
-
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
4007
|
-
return { status: "rejected", action: response.action, errorCode: "invalid_answers" };
|
|
3994
|
+
return {
|
|
3995
|
+
async list() {
|
|
3996
|
+
if (typeof ctx.session.list === "function") {
|
|
3997
|
+
return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
|
|
4008
3998
|
}
|
|
4009
|
-
const
|
|
4010
|
-
|
|
4011
|
-
|
|
3999
|
+
const refreshed = [];
|
|
4000
|
+
for (const id of known.keys()) {
|
|
4001
|
+
try {
|
|
4002
|
+
refreshed.push(await get(id));
|
|
4003
|
+
} catch {
|
|
4004
|
+
known.delete(id);
|
|
4005
|
+
}
|
|
4012
4006
|
}
|
|
4007
|
+
return refreshed;
|
|
4008
|
+
},
|
|
4009
|
+
get,
|
|
4010
|
+
prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
|
|
4011
|
+
dispose: () => {
|
|
4012
|
+
disposed = true;
|
|
4013
|
+
known.clear();
|
|
4013
4014
|
}
|
|
4014
|
-
}
|
|
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
4015
|
};
|
|
4023
4016
|
}
|
|
4024
|
-
function
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4017
|
+
function unwrap(value) {
|
|
4018
|
+
if (value?.error) throw new Error("OpenCode request failed");
|
|
4019
|
+
return value?.data ?? value;
|
|
4020
|
+
}
|
|
4021
|
+
function createV1LiveClient(client, directory) {
|
|
4022
|
+
return {
|
|
4023
|
+
async questions(sessionID) {
|
|
4024
|
+
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 }) : (() => {
|
|
4025
|
+
throw Object.assign(new Error("Question listing unavailable"), { status: 501 });
|
|
4026
|
+
})();
|
|
4027
|
+
const values = unwrap(result);
|
|
4028
|
+
if (!Array.isArray(values)) throw new Error("Invalid question list");
|
|
4029
|
+
return values.filter((question) => question.sessionID === sessionID);
|
|
4030
|
+
},
|
|
4031
|
+
answerQuestion: (pending, answers) => replyToQuestion(
|
|
4032
|
+
client,
|
|
4033
|
+
{ ...pending, requestID: pending.id, directory, sourceEvent: "question.asked" },
|
|
4034
|
+
{ action: "answer", answers }
|
|
4035
|
+
),
|
|
4036
|
+
list: async () => unwrap(await client.session.list({ query: { directory }, throwOnError: true })),
|
|
4037
|
+
get: async (id) => unwrap(await client.session.get({ path: { id }, query: { directory }, throwOnError: true })),
|
|
4038
|
+
async prompt(id, text) {
|
|
4039
|
+
if (typeof client.session.promptAsync !== "function") {
|
|
4040
|
+
throw Object.assign(new Error("OpenCode asynchronous prompts are unavailable"), { status: 501 });
|
|
4041
|
+
}
|
|
4042
|
+
return unwrap(await client.session.promptAsync({
|
|
4043
|
+
path: { id },
|
|
4044
|
+
query: { directory },
|
|
4045
|
+
body: { parts: [{ type: "text", text }] },
|
|
4046
|
+
throwOnError: true
|
|
4047
|
+
}));
|
|
4048
|
+
}
|
|
4030
4049
|
};
|
|
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
|
-
}
|
|
4040
4050
|
}
|
|
4041
4051
|
|
|
4042
|
-
// src/
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
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)) {
|
|
4065
|
-
return {
|
|
4066
|
-
frame: buffer.slice(0, crlfIndex),
|
|
4067
|
-
rest: buffer.slice(crlfIndex + 4)
|
|
4068
|
-
};
|
|
4069
|
-
}
|
|
4070
|
-
return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) };
|
|
4071
|
-
}
|
|
4072
|
-
async function consumeSse(response, state, onCommand, signal2, reporter) {
|
|
4073
|
-
if (!response.body) throw new Error("Backeer command stream has no body");
|
|
4074
|
-
const reader = response.body.getReader();
|
|
4075
|
-
const decoder = new TextDecoder();
|
|
4076
|
-
let buffer = "";
|
|
4077
|
-
try {
|
|
4078
|
-
while (!signal2.aborted) {
|
|
4079
|
-
const item = await reader.read();
|
|
4080
|
-
if (item.done) break;
|
|
4081
|
-
buffer += decoder.decode(item.value, { stream: true });
|
|
4082
|
-
for (; ; ) {
|
|
4083
|
-
const next = nextSseFrame(buffer);
|
|
4084
|
-
if (!next) break;
|
|
4085
|
-
buffer = next.rest;
|
|
4086
|
-
state.framesReceived = (state.framesReceived ?? 0) + 1;
|
|
4087
|
-
const message = parseSseFrame(next.frame);
|
|
4088
|
-
if (!message) continue;
|
|
4089
|
-
if (message.id) state.lastEventID = message.id;
|
|
4090
|
-
if (message.event !== "message" && message.event !== "command") continue;
|
|
4091
|
-
try {
|
|
4092
|
-
await onCommand(JSON.parse(message.data));
|
|
4093
|
-
} catch (error) {
|
|
4094
|
-
if (error instanceof SyntaxError) {
|
|
4095
|
-
reporter.error("Invalid command received", "SSE data is not valid JSON");
|
|
4096
|
-
continue;
|
|
4097
|
-
}
|
|
4098
|
-
throw error;
|
|
4099
|
-
}
|
|
4052
|
+
// src/live-connection.js
|
|
4053
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
4054
|
+
var LIVE_RECONNECT_MS = 3e3;
|
|
4055
|
+
var LIVE_HEARTBEAT_MS = 15e3;
|
|
4056
|
+
function createLiveConnectionNotifier(config, reporter) {
|
|
4057
|
+
const epoch = randomUUID2();
|
|
4058
|
+
const controller = new AbortController();
|
|
4059
|
+
let last, sequence = 0, pending, task;
|
|
4060
|
+
async function drain() {
|
|
4061
|
+
while (pending && !controller.signal.aborted) {
|
|
4062
|
+
const event = pending;
|
|
4063
|
+
pending = void 0;
|
|
4064
|
+
try {
|
|
4065
|
+
await postJsonWithRetry(config, config.eventsEndpoint, event, { signal: controller.signal });
|
|
4066
|
+
} catch {
|
|
4067
|
+
if (!controller.signal.aborted) reporter?.error("Live connection notification could not be sent");
|
|
4100
4068
|
}
|
|
4101
4069
|
}
|
|
4102
|
-
} finally {
|
|
4103
|
-
await reader.cancel().catch(() => {
|
|
4104
|
-
});
|
|
4105
4070
|
}
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4071
|
+
return {
|
|
4072
|
+
update(connected) {
|
|
4073
|
+
if (controller.signal.aborted || connected === last) return;
|
|
4074
|
+
const initial = last === void 0;
|
|
4075
|
+
last = connected;
|
|
4076
|
+
if (initial || !config.token || !config.eventsEndpoint) return;
|
|
4077
|
+
pending = {
|
|
4078
|
+
type: "application",
|
|
4079
|
+
severity: connected ? "info" : "warning",
|
|
4080
|
+
title: connected ? "OpenCode ba\u011Flant\u0131s\u0131 yeniden kuruldu" : "OpenCode ba\u011Flant\u0131s\u0131 kesildi",
|
|
4081
|
+
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.",
|
|
4082
|
+
payload: [{ key: "Connection", value: connected ? "reconnected" : "disconnected" }],
|
|
4083
|
+
externalId: `opencode:live:${epoch}:${++sequence}`,
|
|
4084
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4085
|
+
notify: true,
|
|
4086
|
+
direction: "in"
|
|
4087
|
+
};
|
|
4088
|
+
task ??= drain().finally(() => {
|
|
4089
|
+
task = void 0;
|
|
4090
|
+
});
|
|
4091
|
+
},
|
|
4092
|
+
async dispose() {
|
|
4093
|
+
controller.abort();
|
|
4094
|
+
pending = void 0;
|
|
4095
|
+
await task;
|
|
4096
|
+
}
|
|
4111
4097
|
};
|
|
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");
|
|
4127
|
-
}
|
|
4128
|
-
return response;
|
|
4129
4098
|
}
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
await consumeSse(response, state, onCommand, signal2, reporter);
|
|
4144
|
-
} finally {
|
|
4145
|
-
const receivedFrame = state.framesReceived > framesBeforeConnection;
|
|
4146
|
-
if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable();
|
|
4099
|
+
|
|
4100
|
+
// src/live-bridge-events.js
|
|
4101
|
+
var ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
4102
|
+
var validBridgeID = (value) => typeof value === "string" && ID.test(value);
|
|
4103
|
+
function createBridgeSanitizer(secrets = []) {
|
|
4104
|
+
const known = secrets.filter((value) => typeof value === "string" && value.length > 0);
|
|
4105
|
+
const assistants = /* @__PURE__ */ new Map();
|
|
4106
|
+
function clipped(value, max = 12e3) {
|
|
4107
|
+
if (typeof value !== "string") return { value: "", truncated: false };
|
|
4108
|
+
let result = value.slice(0, 64e3);
|
|
4109
|
+
for (const secret of known) result = result.split(secret).join("[REDACTED]");
|
|
4110
|
+
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]");
|
|
4111
|
+
return { value: result.slice(0, max), truncated: value.length > 64e3 || result.length > max };
|
|
4147
4112
|
}
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
const message = error instanceof HttpError ? `${error.message}: ${error.responseBody}` : limit(error, 500);
|
|
4151
|
-
if (message !== lastLoggedError) {
|
|
4152
|
-
const authenticationFailure = error instanceof HttpError && [401, 403].includes(error.status);
|
|
4153
|
-
reporter.error(
|
|
4154
|
-
authenticationFailure ? "Authentication failed \u2014 run: npx backeer-opencode@latest configure" : "Connection interrupted \u2014 retrying",
|
|
4155
|
-
message
|
|
4156
|
-
);
|
|
4113
|
+
function text(value, max) {
|
|
4114
|
+
return clipped(value, max).value;
|
|
4157
4115
|
}
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
}
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
state,
|
|
4180
|
-
signal2,
|
|
4181
|
-
fetchImplementation
|
|
4182
|
-
);
|
|
4183
|
-
await consumeConnection({
|
|
4184
|
-
response,
|
|
4185
|
-
state,
|
|
4186
|
-
onCommand,
|
|
4187
|
-
signal: signal2,
|
|
4188
|
-
reporter,
|
|
4189
|
-
now,
|
|
4190
|
-
stableConnectionMs,
|
|
4191
|
-
onStable: () => {
|
|
4192
|
-
reconnectDelay = 1e3;
|
|
4193
|
-
lastLoggedError = "";
|
|
4194
|
-
}
|
|
4195
|
-
});
|
|
4196
|
-
} catch (error) {
|
|
4197
|
-
if (signal2.aborted || isAbortError(error)) return;
|
|
4198
|
-
const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter);
|
|
4199
|
-
reconnectDelay = next.reconnectDelay;
|
|
4200
|
-
lastLoggedError = next.lastLoggedError;
|
|
4116
|
+
function content(key, value) {
|
|
4117
|
+
const result = clipped(value);
|
|
4118
|
+
return { [key]: result.value, ...result.truncated ? { truncated: true } : {} };
|
|
4119
|
+
}
|
|
4120
|
+
function sanitize(event) {
|
|
4121
|
+
const p = event?.properties ?? event?.data ?? {};
|
|
4122
|
+
const sessionID = p.sessionID ?? p.info?.sessionID ?? p.part?.sessionID;
|
|
4123
|
+
if (!validBridgeID(sessionID) || sessionID === "global") return void 0;
|
|
4124
|
+
const base = { type: event.type, sessionID, data: {} };
|
|
4125
|
+
if (event.type === "message.updated") {
|
|
4126
|
+
const info = p.info;
|
|
4127
|
+
if (!validBridgeID(info?.id)) return void 0;
|
|
4128
|
+
if (info.role === "assistant") {
|
|
4129
|
+
assistants.set(info.id, sessionID);
|
|
4130
|
+
if (assistants.size > 256) assistants.delete(assistants.keys().next().value);
|
|
4131
|
+
}
|
|
4132
|
+
return info.role === "assistant" ? { ...base, data: {
|
|
4133
|
+
messageID: info.id,
|
|
4134
|
+
completed: !!info.time?.completed,
|
|
4135
|
+
failed: !!info.error
|
|
4136
|
+
} } : void 0;
|
|
4201
4137
|
}
|
|
4202
|
-
if (
|
|
4203
|
-
|
|
4204
|
-
|
|
4138
|
+
if (event.type === "message.part.updated") {
|
|
4139
|
+
const part = p.part;
|
|
4140
|
+
if (assistants.get(part?.messageID) !== sessionID) return void 0;
|
|
4141
|
+
if (part.type === "text") return { ...base, type: "assistant.text", data: {
|
|
4142
|
+
messageID: part.messageID,
|
|
4143
|
+
...content("text", part.text),
|
|
4144
|
+
replace: true
|
|
4145
|
+
} };
|
|
4146
|
+
if (part.type !== "tool") return void 0;
|
|
4147
|
+
const state = part.state ?? {};
|
|
4148
|
+
return { ...base, type: "tool.status", data: {
|
|
4149
|
+
tool: text(part.tool, 80),
|
|
4150
|
+
status: text(state.status, 40),
|
|
4151
|
+
...state.status === "completed" ? content("output", state.output) : {},
|
|
4152
|
+
...state.status === "error" ? { failed: true } : {}
|
|
4153
|
+
} };
|
|
4154
|
+
}
|
|
4155
|
+
if (event.type === "session.text.ended") return { ...base, type: "assistant.text", data: {
|
|
4156
|
+
messageID: validBridgeID(p.assistantMessageID) ? p.assistantMessageID : void 0,
|
|
4157
|
+
...content("text", p.text),
|
|
4158
|
+
replace: true
|
|
4159
|
+
} };
|
|
4160
|
+
if (["session.tool.called", "session.tool.success", "session.tool.failed"].includes(event.type)) {
|
|
4161
|
+
return { ...base, type: "tool.status", data: {
|
|
4162
|
+
status: event.type.split(".").at(-1),
|
|
4163
|
+
...event.type === "session.tool.success" ? content(
|
|
4164
|
+
"output",
|
|
4165
|
+
(p.content ?? []).filter((item) => item?.type === "text").map((item) => item.text).join("\n")
|
|
4166
|
+
) : {}
|
|
4167
|
+
} };
|
|
4168
|
+
}
|
|
4169
|
+
if (["permission.asked", "question.asked", "question.v2.asked"].includes(event.type)) {
|
|
4170
|
+
return { ...base, type: "approval.waiting", data: {
|
|
4171
|
+
kind: event.type.split(".")[0],
|
|
4172
|
+
...event.type !== "permission.asked" && validBridgeID(p.id ?? p.requestID) ? { questionId: p.id ?? p.requestID } : {}
|
|
4173
|
+
} };
|
|
4174
|
+
}
|
|
4175
|
+
if (["permission.replied", "question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
|
|
4176
|
+
return { ...base, type: "approval.settled", data: { kind: event.type.split(".")[0] } };
|
|
4205
4177
|
}
|
|
4178
|
+
if (event.type === "session.status") return { ...base, data: {
|
|
4179
|
+
status: ["idle", "busy", "retry"].includes(p.status?.type) ? p.status.type : "unknown"
|
|
4180
|
+
} };
|
|
4181
|
+
if (["session.idle", "session.error", "session.deleted"].includes(event.type)) return base;
|
|
4182
|
+
return void 0;
|
|
4206
4183
|
}
|
|
4184
|
+
return { sanitize, text, clear: () => assistants.clear() };
|
|
4207
4185
|
}
|
|
4208
4186
|
|
|
4209
|
-
// src/
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4187
|
+
// src/live-questions.js
|
|
4188
|
+
import { createHash } from "node:crypto";
|
|
4189
|
+
var invalid = (code = "invalid_question_answer", status = 422) => Object.assign(new Error(code), { code, status });
|
|
4190
|
+
function questionSnapshot(pending, text) {
|
|
4191
|
+
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");
|
|
4192
|
+
const questions = pending.questions.map((question, index) => {
|
|
4193
|
+
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) {
|
|
4194
|
+
throw invalid("unsupported_question");
|
|
4195
|
+
}
|
|
4196
|
+
return {
|
|
4197
|
+
index,
|
|
4198
|
+
header: text(question.header, 240),
|
|
4199
|
+
question: text(question.question, 2e3),
|
|
4200
|
+
multiple: question.multiple === true,
|
|
4201
|
+
custom: question.custom === true,
|
|
4202
|
+
options: question.options.map((option, index2) => ({
|
|
4203
|
+
index: index2,
|
|
4204
|
+
label: text(option.label, 500),
|
|
4205
|
+
description: text(option.description, 1e3)
|
|
4206
|
+
}))
|
|
4207
|
+
};
|
|
4216
4208
|
});
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
const
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4209
|
+
const revision = createHash("sha256").update(JSON.stringify({
|
|
4210
|
+
id: pending.id,
|
|
4211
|
+
sessionID: pending.sessionID,
|
|
4212
|
+
questions: pending.questions
|
|
4213
|
+
})).digest("hex");
|
|
4214
|
+
const result = { questionId: pending.id, sessionID: pending.sessionID, revision, questions };
|
|
4215
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 3e4) throw invalid("question_too_large");
|
|
4216
|
+
return result;
|
|
4217
|
+
}
|
|
4218
|
+
function resolveQuestionAnswers(pending, answers) {
|
|
4219
|
+
if (!Array.isArray(answers) || answers.length !== pending.questions.length) throw invalid();
|
|
4220
|
+
const values = answers.map((answer, index) => {
|
|
4221
|
+
const question = pending.questions[index];
|
|
4222
|
+
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();
|
|
4223
|
+
if (answer.text !== void 0 && question.custom !== true) throw invalid("custom_not_allowed");
|
|
4224
|
+
return [
|
|
4225
|
+
...answer.choices.map((choice) => question.options[choice].label),
|
|
4226
|
+
...answer.text === void 0 ? [] : [answer.text]
|
|
4227
|
+
];
|
|
4228
|
+
});
|
|
4229
|
+
const errorCode = validateQuestionAnswers(values, pending.questions);
|
|
4230
|
+
if (errorCode) throw invalid(errorCode);
|
|
4231
|
+
return values;
|
|
4233
4232
|
}
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
return new
|
|
4233
|
+
|
|
4234
|
+
// src/live-bridge.js
|
|
4235
|
+
var LIMITS = { events: 256, bytes: 2e6, clients: 8, commands: 1024, commandTTL: 864e5, body: 32768, requests: 16 };
|
|
4236
|
+
function failure(status, code) {
|
|
4237
|
+
return Object.assign(new Error(code), { status, code });
|
|
4239
4238
|
}
|
|
4240
|
-
function
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
}
|
|
4245
|
-
const rejecting = response.action === "reject";
|
|
4246
|
-
if (isV2) {
|
|
4247
|
-
return transport.post({
|
|
4248
|
-
url: `/api/session/{sessionID}/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
4249
|
-
path: { sessionID: pending.sessionID, requestID: pending.requestID },
|
|
4250
|
-
...rejecting ? {} : { body: { answers: response.answers } },
|
|
4251
|
-
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
4252
|
-
throwOnError: true
|
|
4253
|
-
});
|
|
4239
|
+
function json(response, status, value) {
|
|
4240
|
+
if (!response.destroyed && !response.headersSent) {
|
|
4241
|
+
response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
4242
|
+
response.end(JSON.stringify(value));
|
|
4254
4243
|
}
|
|
4255
|
-
return transport.post({
|
|
4256
|
-
url: `/question/{requestID}/${rejecting ? "reject" : "reply"}`,
|
|
4257
|
-
path: { requestID: pending.requestID },
|
|
4258
|
-
query: { directory: pending.directory },
|
|
4259
|
-
...rejecting ? {} : { body: { answers: response.answers } },
|
|
4260
|
-
...rejecting ? {} : { headers: { "Content-Type": "application/json" } },
|
|
4261
|
-
throwOnError: true
|
|
4262
|
-
});
|
|
4263
4244
|
}
|
|
4264
|
-
async function
|
|
4265
|
-
const
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
directory: pending.directory,
|
|
4272
|
-
...response.action === "answer" ? { questionV2Reply: { answers: response.answers } } : {}
|
|
4273
|
-
} : {
|
|
4274
|
-
requestID: pending.requestID,
|
|
4275
|
-
directory: pending.directory,
|
|
4276
|
-
...response.action === "answer" ? { answers: response.answers } : {}
|
|
4277
|
-
};
|
|
4278
|
-
let result;
|
|
4279
|
-
try {
|
|
4280
|
-
result = typeof method === "function" ? await method.call(api, input, { throwOnError: true }) : await rawQuestionRequest(client, pending, response, isV2);
|
|
4281
|
-
} catch (error) {
|
|
4282
|
-
throw normalizeQuestionError(error);
|
|
4245
|
+
async function readBody(request) {
|
|
4246
|
+
const chunks = [];
|
|
4247
|
+
let bytes = 0;
|
|
4248
|
+
for await (const chunk of request) {
|
|
4249
|
+
bytes += chunk.length;
|
|
4250
|
+
if (bytes > LIMITS.body) throw failure(413, "body_too_large");
|
|
4251
|
+
chunks.push(chunk);
|
|
4283
4252
|
}
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
throw
|
|
4253
|
+
try {
|
|
4254
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
4255
|
+
} catch {
|
|
4256
|
+
throw failure(400, "invalid_json");
|
|
4288
4257
|
}
|
|
4289
4258
|
}
|
|
4290
|
-
async function
|
|
4291
|
-
const
|
|
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);
|
|
4259
|
+
async function createLiveBridge({ config, client, directory, reporter }) {
|
|
4260
|
+
const options = config.liveBridge;
|
|
4261
|
+
if (!options?.enabled) return { handle() {
|
|
4262
|
+
}, async dispose() {
|
|
4263
|
+
}, address: void 0 };
|
|
4264
|
+
const token = options.token;
|
|
4265
|
+
if (typeof token !== "string" || token.length < 32 || token === config.token) {
|
|
4266
|
+
throw new Error("Live bridge requires a separate credential of at least 32 characters");
|
|
4316
4267
|
}
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4268
|
+
const api = client.liveBridge ?? createV1LiveClient(client, directory);
|
|
4269
|
+
const connectionNotifications = createLiveConnectionNotifier(config, reporter);
|
|
4270
|
+
const sanitizer = createBridgeSanitizer([token, config.token]);
|
|
4271
|
+
const epoch = randomUUID3();
|
|
4272
|
+
const events = [], streams = /* @__PURE__ */ new Set(), commands = /* @__PURE__ */ new Map(), states = /* @__PURE__ */ new Map();
|
|
4273
|
+
let sequence = 0, bytes = 0, active = 0, disposed = false, disposeTask;
|
|
4274
|
+
let lookups = 0;
|
|
4275
|
+
const lookupWaiters = /* @__PURE__ */ new Set();
|
|
4276
|
+
const cursor = () => `${epoch}:${sequence}`;
|
|
4277
|
+
function lookup(id) {
|
|
4278
|
+
if (disposed) return Promise.reject(failure(503, "bridge_closed"));
|
|
4279
|
+
if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
|
|
4280
|
+
lookups++;
|
|
4281
|
+
return new Promise((resolve2, reject) => {
|
|
4282
|
+
let finished = false;
|
|
4283
|
+
const finish = (complete, value) => {
|
|
4284
|
+
if (finished) return;
|
|
4285
|
+
finished = true;
|
|
4286
|
+
clearTimeout(timer);
|
|
4287
|
+
lookupWaiters.delete(cancel);
|
|
4288
|
+
complete(value);
|
|
4289
|
+
};
|
|
4290
|
+
const cancel = () => finish(reject, failure(503, "bridge_closed"));
|
|
4291
|
+
const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1e3);
|
|
4292
|
+
lookupWaiters.add(cancel);
|
|
4293
|
+
Promise.resolve().then(() => api.get(id)).then(
|
|
4294
|
+
(value) => {
|
|
4295
|
+
lookups--;
|
|
4296
|
+
finish(resolve2, value);
|
|
4297
|
+
},
|
|
4298
|
+
(error) => {
|
|
4299
|
+
lookups--;
|
|
4300
|
+
finish(reject, error);
|
|
4301
|
+
}
|
|
4302
|
+
);
|
|
4303
|
+
});
|
|
4304
|
+
}
|
|
4305
|
+
async function session(id) {
|
|
4306
|
+
if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session");
|
|
4307
|
+
let info;
|
|
4308
|
+
try {
|
|
4309
|
+
info = await lookup(id);
|
|
4310
|
+
} catch (error) {
|
|
4311
|
+
if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error;
|
|
4312
|
+
throw failure(404, "session_not_found");
|
|
4320
4313
|
}
|
|
4314
|
+
if (info?.id !== id || typeof info.time?.archived === "number" || (info.location?.directory ?? info.directory) !== directory) {
|
|
4315
|
+
throw failure(404, "session_not_found");
|
|
4316
|
+
}
|
|
4317
|
+
return {
|
|
4318
|
+
id,
|
|
4319
|
+
directory,
|
|
4320
|
+
title: sanitizer.text(info.title, 240),
|
|
4321
|
+
...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
|
|
4322
|
+
status: states.get(id) ?? "unknown"
|
|
4323
|
+
};
|
|
4321
4324
|
}
|
|
4322
|
-
function
|
|
4323
|
-
const
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
if (
|
|
4328
|
-
|
|
4329
|
-
|
|
4325
|
+
function removeStream(stream) {
|
|
4326
|
+
const removed = streams.delete(stream);
|
|
4327
|
+
clearInterval(stream.heartbeat);
|
|
4328
|
+
stream.queue.length = 0;
|
|
4329
|
+
stream.queuedBytes = 0;
|
|
4330
|
+
if (removed && !streams.size && !disposed) connectionNotifications.update(false);
|
|
4331
|
+
}
|
|
4332
|
+
function write(stream, value) {
|
|
4333
|
+
if (!streams.has(stream)) return;
|
|
4334
|
+
if (stream.blocked) {
|
|
4335
|
+
stream.queue.push(value);
|
|
4336
|
+
stream.queuedBytes += Buffer.byteLength(value);
|
|
4337
|
+
if (stream.queuedBytes > LIMITS.bytes) {
|
|
4338
|
+
removeStream(stream);
|
|
4339
|
+
stream.response.destroy();
|
|
4340
|
+
}
|
|
4341
|
+
return;
|
|
4342
|
+
}
|
|
4343
|
+
if (!stream.response.write(value)) {
|
|
4344
|
+
stream.blocked = true;
|
|
4330
4345
|
}
|
|
4331
4346
|
}
|
|
4332
|
-
async function
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4347
|
+
async function handle(event) {
|
|
4348
|
+
if (disposed) return;
|
|
4349
|
+
const safe = sanitizer.sanitize(event);
|
|
4350
|
+
if (!safe) return;
|
|
4351
|
+
try {
|
|
4352
|
+
await session(safe.sessionID);
|
|
4353
|
+
} catch {
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
if (disposed) return;
|
|
4357
|
+
if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status);
|
|
4358
|
+
if (safe.type === "session.idle") states.set(safe.sessionID, "idle");
|
|
4359
|
+
if (safe.type === "session.deleted") states.delete(safe.sessionID);
|
|
4360
|
+
if (states.size > 256) states.delete(states.keys().next().value);
|
|
4361
|
+
const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() };
|
|
4362
|
+
let frame = `id: ${record.id}
|
|
4363
|
+
event: backeer.event
|
|
4364
|
+
data: ${JSON.stringify(record)}
|
|
4365
|
+
|
|
4366
|
+
`;
|
|
4367
|
+
if (Buffer.byteLength(frame) > 32768) {
|
|
4368
|
+
record.data = { truncated: true };
|
|
4369
|
+
frame = `id: ${record.id}
|
|
4370
|
+
event: backeer.event
|
|
4371
|
+
data: ${JSON.stringify(record)}
|
|
4372
|
+
|
|
4373
|
+
`;
|
|
4338
4374
|
}
|
|
4375
|
+
const size = Buffer.byteLength(frame);
|
|
4376
|
+
events.push({ sequence, sessionID: record.sessionID, frame, size });
|
|
4377
|
+
bytes += size;
|
|
4378
|
+
while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size;
|
|
4379
|
+
for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame);
|
|
4339
4380
|
}
|
|
4340
|
-
function
|
|
4341
|
-
if (
|
|
4342
|
-
|
|
4343
|
-
|
|
4381
|
+
function authorized(request) {
|
|
4382
|
+
if (request.headers.origin || request.headers["sec-fetch-site"]) return false;
|
|
4383
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
4384
|
+
const actual = Buffer.from(request.headers.authorization ?? "");
|
|
4385
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
4344
4386
|
}
|
|
4345
|
-
|
|
4346
|
-
if (!
|
|
4347
|
-
|
|
4348
|
-
if (
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4387
|
+
function parseCursor(value) {
|
|
4388
|
+
if (!value) return { after: 0 };
|
|
4389
|
+
const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value);
|
|
4390
|
+
if (!match) throw failure(400, "invalid_cursor");
|
|
4391
|
+
if (match[1] !== epoch) return { gap: "stream_restarted" };
|
|
4392
|
+
const after = Number(match[2]);
|
|
4393
|
+
if (!Number.isSafeInteger(after) || after > sequence) throw failure(400, "invalid_cursor");
|
|
4394
|
+
if (events.length && after < events[0].sequence - 1) return { gap: "cursor_expired" };
|
|
4395
|
+
return { after };
|
|
4396
|
+
}
|
|
4397
|
+
function cachedCommand(requestId, sessionID, signature) {
|
|
4398
|
+
for (const [id, command] of commands) {
|
|
4399
|
+
if (command.settled && Date.now() - command.created >= LIMITS.commandTTL) commands.delete(id);
|
|
4400
|
+
}
|
|
4401
|
+
const existing = commands.get(requestId);
|
|
4402
|
+
if (existing && (existing.sessionID !== sessionID || existing.signature !== signature)) {
|
|
4403
|
+
throw failure(409, "request_id_conflict");
|
|
4404
|
+
}
|
|
4405
|
+
return existing;
|
|
4406
|
+
}
|
|
4407
|
+
async function execute(requestId, sessionID, signature, operation, questionId) {
|
|
4408
|
+
let command = cachedCommand(requestId, sessionID, signature);
|
|
4409
|
+
if (!command) {
|
|
4410
|
+
if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full");
|
|
4411
|
+
if (questionId && [...commands.values()].some((item) => item.sessionID === sessionID && item.questionId === questionId)) {
|
|
4412
|
+
throw failure(409, "question_reply_already_submitted");
|
|
4413
|
+
}
|
|
4414
|
+
command = {
|
|
4415
|
+
sessionID,
|
|
4416
|
+
signature,
|
|
4417
|
+
questionId,
|
|
4418
|
+
created: Date.now(),
|
|
4419
|
+
settled: false,
|
|
4420
|
+
result: {
|
|
4421
|
+
requestId,
|
|
4422
|
+
sessionID,
|
|
4423
|
+
...questionId ? { questionId } : {},
|
|
4424
|
+
status: questionId ? "answered" : "accepted"
|
|
4425
|
+
}
|
|
4426
|
+
};
|
|
4427
|
+
commands.set(requestId, command);
|
|
4428
|
+
command.task = Promise.resolve().then(operation).finally(() => {
|
|
4429
|
+
command.settled = true;
|
|
4430
|
+
});
|
|
4353
4431
|
}
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
if (
|
|
4359
|
-
|
|
4360
|
-
|
|
4432
|
+
await command.task;
|
|
4433
|
+
return command.result;
|
|
4434
|
+
}
|
|
4435
|
+
async function questions(sessionID) {
|
|
4436
|
+
if (typeof api.questions !== "function") throw failure(501, "questions_unavailable_update_bridge");
|
|
4437
|
+
const pending = await api.questions(sessionID);
|
|
4438
|
+
if (!Array.isArray(pending)) throw failure(502, "invalid_question_list");
|
|
4439
|
+
const own = pending.filter((item) => item.sessionID === sessionID);
|
|
4440
|
+
if (own.length > 16) throw failure(413, "too_many_questions");
|
|
4441
|
+
return own;
|
|
4442
|
+
}
|
|
4443
|
+
async function route(request, response) {
|
|
4444
|
+
if (!authorized(request)) return json(response, 401, { error: "unauthorized" });
|
|
4445
|
+
if (request.headers["x-backeer-project"] !== void 0 && request.headers["x-backeer-project"] !== encodeURIComponent(directory)) {
|
|
4446
|
+
return json(response, 409, { error: "project_mismatch" });
|
|
4447
|
+
}
|
|
4448
|
+
if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" });
|
|
4449
|
+
active++;
|
|
4450
|
+
const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15e3);
|
|
4451
|
+
timeout.unref();
|
|
4361
4452
|
try {
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
)
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4453
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
4454
|
+
if (url.username || url.password) throw failure(400, "invalid_url");
|
|
4455
|
+
if (request.method === "GET" && url.pathname === "/v1/sessions") {
|
|
4456
|
+
const result2 = await api.list();
|
|
4457
|
+
const values = Array.isArray(result2) ? result2 : result2?.data;
|
|
4458
|
+
if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
|
|
4459
|
+
const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) && typeof info.time?.archived !== "number" && (info.location?.directory ?? info.directory) === directory);
|
|
4460
|
+
return json(response, 200, { sessions: own.map((info) => ({
|
|
4461
|
+
id: info.id,
|
|
4462
|
+
title: sanitizer.text(info.title, 240),
|
|
4463
|
+
status: states.get(info.id) ?? "unknown",
|
|
4464
|
+
...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
|
|
4465
|
+
})), directory, cursor: cursor() });
|
|
4466
|
+
}
|
|
4467
|
+
if (request.method === "GET" && url.pathname === "/v1/events") {
|
|
4468
|
+
const selected = url.searchParams.get("sessionID") || void 0;
|
|
4469
|
+
if (selected) await session(selected);
|
|
4470
|
+
const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"]);
|
|
4471
|
+
if (response.headersSent) return;
|
|
4472
|
+
if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams");
|
|
4473
|
+
clearTimeout(timeout);
|
|
4474
|
+
response.writeHead(200, {
|
|
4475
|
+
"content-type": "text/event-stream",
|
|
4476
|
+
"cache-control": "no-cache, no-transform",
|
|
4477
|
+
"x-accel-buffering": "no",
|
|
4478
|
+
connection: "keep-alive"
|
|
4479
|
+
});
|
|
4480
|
+
if (parsed.gap) {
|
|
4481
|
+
response.end(`event: backeer.gap
|
|
4482
|
+
data: ${JSON.stringify({ reason: parsed.gap, cursor: cursor() })}
|
|
4483
|
+
|
|
4484
|
+
`);
|
|
4485
|
+
return;
|
|
4486
|
+
}
|
|
4487
|
+
const stream = {
|
|
4488
|
+
response,
|
|
4489
|
+
sessionID: selected,
|
|
4490
|
+
heartbeat: void 0,
|
|
4491
|
+
blocked: false,
|
|
4492
|
+
queue: [],
|
|
4493
|
+
queuedBytes: 0
|
|
4494
|
+
};
|
|
4495
|
+
streams.add(stream);
|
|
4496
|
+
connectionNotifications.update(true);
|
|
4497
|
+
request.socket.setTimeout(0);
|
|
4498
|
+
request.socket.setKeepAlive(true, LIVE_HEARTBEAT_MS);
|
|
4499
|
+
response.on("close", () => removeStream(stream));
|
|
4500
|
+
response.on("drain", () => {
|
|
4501
|
+
stream.blocked = false;
|
|
4502
|
+
while (streams.has(stream) && !stream.blocked && stream.queue.length) {
|
|
4503
|
+
const next = stream.queue.shift();
|
|
4504
|
+
stream.queuedBytes -= Buffer.byteLength(next);
|
|
4505
|
+
write(stream, next);
|
|
4506
|
+
}
|
|
4507
|
+
});
|
|
4508
|
+
write(stream, `: connected
|
|
4509
|
+
retry: ${LIVE_RECONNECT_MS}
|
|
4510
|
+
|
|
4511
|
+
`);
|
|
4512
|
+
for (const item of events) {
|
|
4513
|
+
if (!streams.has(stream)) break;
|
|
4514
|
+
if (item.sequence > parsed.after && (!selected || selected === item.sessionID)) write(stream, item.frame);
|
|
4515
|
+
}
|
|
4516
|
+
if (streams.has(stream)) {
|
|
4517
|
+
stream.heartbeat = setInterval(() => write(stream, ": heartbeat\n\n"), LIVE_HEARTBEAT_MS);
|
|
4518
|
+
stream.heartbeat.unref();
|
|
4377
4519
|
}
|
|
4520
|
+
return;
|
|
4378
4521
|
}
|
|
4379
|
-
|
|
4380
|
-
if (
|
|
4381
|
-
|
|
4382
|
-
if (
|
|
4383
|
-
|
|
4384
|
-
|
|
4522
|
+
const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions|\/questions(?:\/([A-Za-z0-9_-]{1,160})\/answers)?)?$/.exec(url.pathname);
|
|
4523
|
+
if (!match) throw failure(404, "route_not_found");
|
|
4524
|
+
if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]));
|
|
4525
|
+
if (request.method === "GET" && match[2] === "/questions") {
|
|
4526
|
+
await session(match[1]);
|
|
4527
|
+
const result2 = { questions: (await questions(match[1])).map((item) => questionSnapshot(item, sanitizer.text)) };
|
|
4528
|
+
if (Buffer.byteLength(JSON.stringify(result2)) > 24e4) throw failure(413, "questions_too_large");
|
|
4529
|
+
return json(response, 200, result2);
|
|
4385
4530
|
}
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4531
|
+
if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed");
|
|
4532
|
+
if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required");
|
|
4533
|
+
const body = await readBody(request);
|
|
4534
|
+
if (match[3]) {
|
|
4535
|
+
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");
|
|
4536
|
+
await session(match[1]);
|
|
4537
|
+
const signature2 = JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers });
|
|
4538
|
+
const existing2 = cachedCommand(body.requestId, match[1], signature2);
|
|
4539
|
+
if (existing2) {
|
|
4540
|
+
await existing2.task;
|
|
4541
|
+
return json(response, 200, existing2.result);
|
|
4542
|
+
}
|
|
4543
|
+
const pending = (await questions(match[1])).find((item) => item.id === match[3]);
|
|
4544
|
+
if (!pending) throw failure(409, "question_already_settled_or_missing");
|
|
4545
|
+
if (questionSnapshot(pending, sanitizer.text).revision !== body.revision) throw failure(409, "question_changed_read_again");
|
|
4546
|
+
const answers = resolveQuestionAnswers(pending, body.answers);
|
|
4547
|
+
if (typeof api.answerQuestion !== "function") throw failure(501, "questions_unavailable_update_bridge");
|
|
4548
|
+
if (response.headersSent || disposed) return;
|
|
4549
|
+
return json(response, 200, await execute(
|
|
4550
|
+
body.requestId,
|
|
4551
|
+
match[1],
|
|
4552
|
+
signature2,
|
|
4553
|
+
() => api.answerQuestion(pending, answers),
|
|
4554
|
+
match[3]
|
|
4555
|
+
));
|
|
4556
|
+
}
|
|
4557
|
+
if (match[2] !== "/instructions") throw failure(405, "method_not_allowed");
|
|
4558
|
+
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");
|
|
4559
|
+
await session(match[1]);
|
|
4560
|
+
if (response.headersSent || disposed) return;
|
|
4561
|
+
const signature = JSON.stringify({ prompt: body.prompt });
|
|
4562
|
+
const existing = cachedCommand(body.requestId, match[1], signature);
|
|
4563
|
+
const result = await execute(body.requestId, match[1], signature, () => api.prompt(match[1], body.prompt));
|
|
4564
|
+
return json(response, existing ? 200 : 202, result);
|
|
4565
|
+
} catch (error) {
|
|
4566
|
+
json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" });
|
|
4389
4567
|
} 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);
|
|
4568
|
+
active--;
|
|
4569
|
+
clearTimeout(timeout);
|
|
4403
4570
|
}
|
|
4404
|
-
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
4405
|
-
reporter.warning("Decision receipt could not be sent", error);
|
|
4406
|
-
});
|
|
4407
|
-
stopCommandStreamIfIdle();
|
|
4408
4571
|
}
|
|
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();
|
|
4572
|
+
const server = createServer((request, response) => {
|
|
4573
|
+
void route(request, response);
|
|
4574
|
+
});
|
|
4575
|
+
server.requestTimeout = 15e3;
|
|
4576
|
+
server.headersTimeout = 1e4;
|
|
4577
|
+
server.keepAliveTimeout = 5e3;
|
|
4578
|
+
await new Promise((resolve2, reject) => {
|
|
4579
|
+
server.once("error", reject);
|
|
4580
|
+
server.listen(options.port ?? 43871, "127.0.0.1", () => {
|
|
4581
|
+
server.off("error", reject);
|
|
4582
|
+
resolve2();
|
|
4425
4583
|
});
|
|
4426
|
-
}
|
|
4584
|
+
});
|
|
4585
|
+
server.on("error", () => {
|
|
4586
|
+
});
|
|
4427
4587
|
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(() => {
|
|
4588
|
+
handle,
|
|
4589
|
+
address: server.address(),
|
|
4590
|
+
dispose() {
|
|
4591
|
+
disposeTask ??= new Promise((resolve2) => {
|
|
4592
|
+
disposed = true;
|
|
4593
|
+
for (const cancel of lookupWaiters) cancel();
|
|
4594
|
+
for (const stream of streams) {
|
|
4595
|
+
removeStream(stream);
|
|
4596
|
+
stream.response.destroy();
|
|
4597
|
+
}
|
|
4598
|
+
server.close(() => {
|
|
4599
|
+
void connectionNotifications.dispose().then(resolve2);
|
|
4600
|
+
});
|
|
4601
|
+
server.closeAllConnections?.();
|
|
4602
|
+
events.length = 0;
|
|
4603
|
+
states.clear();
|
|
4604
|
+
commands.clear();
|
|
4605
|
+
sanitizer.clear();
|
|
4606
|
+
api.dispose?.();
|
|
4478
4607
|
});
|
|
4479
|
-
|
|
4480
|
-
externalIDByRequestID.clear();
|
|
4481
|
-
processingCommands.clear();
|
|
4482
|
-
completedCommands.clear();
|
|
4483
|
-
questionsByExternalID.clear();
|
|
4484
|
-
questionExternalIDByRequestID.clear();
|
|
4485
|
-
remotelyResolvingQuestions.clear();
|
|
4608
|
+
return disposeTask;
|
|
4486
4609
|
}
|
|
4487
4610
|
};
|
|
4488
4611
|
}
|
|
@@ -5183,26 +5306,29 @@ function questionForm(form) {
|
|
|
5183
5306
|
(field) => ["string", "multiselect"].includes(field.type) && !field.hidden && !field.when?.length
|
|
5184
5307
|
);
|
|
5185
5308
|
}
|
|
5309
|
+
function questionProperties(form) {
|
|
5310
|
+
return {
|
|
5311
|
+
id: form.id,
|
|
5312
|
+
sessionID: form.sessionID,
|
|
5313
|
+
questions: form.fields.map((field) => ({
|
|
5314
|
+
header: field.title ?? form.title,
|
|
5315
|
+
question: field.description ?? field.title ?? form.title,
|
|
5316
|
+
options: (field.options ?? []).map((option) => ({
|
|
5317
|
+
label: option.label,
|
|
5318
|
+
description: option.description ?? ""
|
|
5319
|
+
})),
|
|
5320
|
+
multiple: field.type === "multiselect",
|
|
5321
|
+
custom: field.custom === true || !field.options?.length
|
|
5322
|
+
}))
|
|
5323
|
+
};
|
|
5324
|
+
}
|
|
5186
5325
|
function normalizeV2Event(event) {
|
|
5187
5326
|
const properties = event.data ?? {};
|
|
5188
5327
|
const normalized = { ...event, properties };
|
|
5189
5328
|
if (event.type === "form.created") {
|
|
5190
5329
|
const form = properties.form;
|
|
5191
5330
|
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
|
-
} }];
|
|
5331
|
+
return [{ ...normalized, type: "question.v2.asked", properties: questionProperties(form) }];
|
|
5206
5332
|
}
|
|
5207
5333
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
5208
5334
|
return [{ ...normalized, type: event.type === "form.replied" ? "question.v2.replied" : "question.v2.rejected" }];
|
|
@@ -5234,7 +5360,7 @@ function normalizeV2Event(event) {
|
|
|
5234
5360
|
return [normalized];
|
|
5235
5361
|
}
|
|
5236
5362
|
function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
|
|
5237
|
-
|
|
5363
|
+
const client = {
|
|
5238
5364
|
liveBridge: createV2LiveClient(ctx),
|
|
5239
5365
|
app: {
|
|
5240
5366
|
async log({ body }) {
|
|
@@ -5298,6 +5424,18 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
|
|
|
5298
5424
|
}
|
|
5299
5425
|
}
|
|
5300
5426
|
};
|
|
5427
|
+
client.liveBridge.questions = async (sessionID) => {
|
|
5428
|
+
const api = await resolveFormApi(sessionID);
|
|
5429
|
+
const forms = await nativeReply(() => api.list({ sessionID }));
|
|
5430
|
+
if (!Array.isArray(forms)) throw new HttpError("Invalid question list", 502);
|
|
5431
|
+
return forms.filter((form) => form.sessionID === sessionID && questionForm(form) && (!form.state || form.state.status === "pending")).map(questionProperties);
|
|
5432
|
+
};
|
|
5433
|
+
client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
|
|
5434
|
+
sessionID: pending.sessionID,
|
|
5435
|
+
requestID: pending.id,
|
|
5436
|
+
questionV2Reply: { answers }
|
|
5437
|
+
});
|
|
5438
|
+
return client;
|
|
5301
5439
|
}
|
|
5302
5440
|
|
|
5303
5441
|
// src/opencode-v2.js
|