backeer-opencode 0.2.0 → 0.2.2
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 +17 -0
- package/README.md +34 -18
- package/dist/backeer-permission.js +343 -71
- package/package.json +1 -1
- package/src/config.js +1 -0
- package/src/diagnostics.js +148 -0
- package/src/event-publishers.js +6 -0
- package/src/lifecycle-controller.js +12 -2
- package/src/lifecycle-publishers.js +9 -5
- package/src/management.js +18 -16
- package/src/permission-controller.js +9 -0
- package/src/plugin.js +25 -6
- package/src/reporter.js +50 -0
- package/src/session-messages.js +122 -0
- package/src/updates.js +1 -1
|
@@ -33,37 +33,31 @@ function normalizeLogLevel(value) {
|
|
|
33
33
|
const level = String(value ?? "info").trim().toLowerCase();
|
|
34
34
|
return LOG_LEVELS.has(level) ? level : "info";
|
|
35
35
|
}
|
|
36
|
-
function
|
|
36
|
+
function createOpenCodeReporter(client, levelValue) {
|
|
37
37
|
const level = normalizeLogLevel(levelValue);
|
|
38
|
-
const useColor = Boolean(process.stdout?.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
|
|
39
38
|
const recentlyEmitted = /* @__PURE__ */ new Map();
|
|
40
|
-
const
|
|
41
|
-
const symbols = {
|
|
42
|
-
info: decorate("36", "\u25C6"),
|
|
43
|
-
success: decorate("32", "\u25C6"),
|
|
44
|
-
applied: decorate("32", "\u2713"),
|
|
45
|
-
warning: decorate("33", "!"),
|
|
46
|
-
error: decorate("31", "\xD7"),
|
|
47
|
-
debug: decorate("2", "\xB7")
|
|
48
|
-
};
|
|
49
|
-
const brand = decorate("1", "Backeer");
|
|
39
|
+
const method = client?.app?.log;
|
|
50
40
|
function emit(requiredLevel, kind, message, detail, deduplicate = false) {
|
|
51
|
-
if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return;
|
|
41
|
+
if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return;
|
|
52
42
|
const key = `${kind}:${message}`;
|
|
53
43
|
const now = Date.now();
|
|
54
44
|
if (deduplicate) {
|
|
55
45
|
const previous = recentlyEmitted.get(key);
|
|
56
46
|
if (previous && now - previous < LOG_DEDUPLICATION_MS) return;
|
|
57
|
-
recentlyEmitted
|
|
58
|
-
recentlyEmitted.set(key, now);
|
|
59
|
-
while (recentlyEmitted.size > 100) {
|
|
60
|
-
recentlyEmitted.delete(recentlyEmitted.keys().next().value);
|
|
61
|
-
}
|
|
47
|
+
rememberRecent(recentlyEmitted, key, now);
|
|
62
48
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
const body = {
|
|
50
|
+
service: "backeer-opencode",
|
|
51
|
+
level: kind === "warning" ? "warn" : kind === "applied" || kind === "success" ? "info" : kind,
|
|
52
|
+
message: limit(message, 500)
|
|
53
|
+
};
|
|
54
|
+
if (level === "debug" && detail) {
|
|
55
|
+
body.extra = { detail: limit(detail?.message ?? detail, 500) };
|
|
56
|
+
}
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
void Promise.resolve(method.call(client.app, { body })).catch(() => {
|
|
59
|
+
});
|
|
60
|
+
}, 0);
|
|
67
61
|
}
|
|
68
62
|
return {
|
|
69
63
|
info: (message, detail) => emit("info", "info", message, detail),
|
|
@@ -74,6 +68,11 @@ function createConsoleReporter(levelValue) {
|
|
|
74
68
|
debug: (message, detail) => emit("debug", "debug", message, detail)
|
|
75
69
|
};
|
|
76
70
|
}
|
|
71
|
+
function rememberRecent(map, key, value) {
|
|
72
|
+
map.delete(key);
|
|
73
|
+
map.set(key, value);
|
|
74
|
+
while (map.size > 100) map.delete(map.keys().next().value);
|
|
75
|
+
}
|
|
77
76
|
|
|
78
77
|
// src/config.js
|
|
79
78
|
var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
|
|
@@ -140,6 +139,7 @@ function loadConfig(options = {}) {
|
|
|
140
139
|
return {
|
|
141
140
|
origin,
|
|
142
141
|
eventsEndpoint: new URL("/v1/events", origin),
|
|
142
|
+
sourceHealthEndpoint: new URL("/v1/source/health", origin),
|
|
143
143
|
commandStreamEndpoint: new URL("/v1/commands/stream", origin),
|
|
144
144
|
token: loadedToken.token,
|
|
145
145
|
tokenSource: loadedToken.source,
|
|
@@ -244,7 +244,7 @@ function permissionIdentity(request) {
|
|
|
244
244
|
externalId: `opencode:${sessionID}:${requestID}`
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
-
async function publishPermission(config, reporter, request, directory, identity) {
|
|
247
|
+
async function publishPermission(config, reporter, request, directory, identity, conversation) {
|
|
248
248
|
const permissionType = request.permission ?? request.action ?? "unknown";
|
|
249
249
|
const resources = Array.isArray(request.patterns) ? request.patterns.join(", ") : Array.isArray(request.resources) ? request.resources.join(", ") : "";
|
|
250
250
|
const payload = [
|
|
@@ -268,6 +268,7 @@ async function publishPermission(config, reporter, request, directory, identity)
|
|
|
268
268
|
direction: "in"
|
|
269
269
|
});
|
|
270
270
|
reporter.success("Permission request sent", `HTTP ${result.status}`);
|
|
271
|
+
conversation?.success(identity.sessionID, "Permission request sent");
|
|
271
272
|
}
|
|
272
273
|
function questionIdentity(request) {
|
|
273
274
|
const requestID = request.id ?? request.requestID;
|
|
@@ -302,7 +303,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
302
303
|
return "";
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
|
-
async function publishQuestion(config, reporter, request, identity, client, directory) {
|
|
306
|
+
async function publishQuestion(config, reporter, request, identity, client, directory, conversation) {
|
|
306
307
|
const questions = Array.isArray(request.questions) ? request.questions : [];
|
|
307
308
|
if (questions.length === 0) return;
|
|
308
309
|
const sessionTitle = await resolveSessionTitle(
|
|
@@ -344,8 +345,9 @@ async function publishQuestion(config, reporter, request, identity, client, dire
|
|
|
344
345
|
direction: "in"
|
|
345
346
|
});
|
|
346
347
|
reporter.success("Question sent", `HTTP ${result.status}`);
|
|
348
|
+
conversation?.success(identity.sessionID, "Question sent");
|
|
347
349
|
}
|
|
348
|
-
async function publishTodoCompletion(config, reporter, request, eventID, client, directory) {
|
|
350
|
+
async function publishTodoCompletion(config, reporter, request, eventID, client, directory, conversation) {
|
|
349
351
|
const todos = request.todos;
|
|
350
352
|
const sessionTitle = await resolveSessionTitle(
|
|
351
353
|
client,
|
|
@@ -383,6 +385,7 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
|
|
|
383
385
|
direction: "in"
|
|
384
386
|
});
|
|
385
387
|
reporter.success("Todo completion sent", `HTTP ${result.status}`);
|
|
388
|
+
conversation?.success(request.sessionID, "Todo completion sent");
|
|
386
389
|
}
|
|
387
390
|
|
|
388
391
|
// src/management.js
|
|
@@ -391,13 +394,17 @@ import {
|
|
|
391
394
|
mkdirSync,
|
|
392
395
|
readFileSync as readFileSync2,
|
|
393
396
|
renameSync,
|
|
394
|
-
statSync,
|
|
397
|
+
statSync as statSync2,
|
|
395
398
|
writeFileSync
|
|
396
399
|
} from "node:fs";
|
|
397
400
|
import { dirname, resolve } from "node:path";
|
|
398
401
|
|
|
402
|
+
// src/diagnostics.js
|
|
403
|
+
import { randomUUID } from "node:crypto";
|
|
404
|
+
import { statSync } from "node:fs";
|
|
405
|
+
|
|
399
406
|
// src/updates.js
|
|
400
|
-
var PLUGIN_VERSION = "0.2.
|
|
407
|
+
var PLUGIN_VERSION = "0.2.2";
|
|
401
408
|
var PACKAGE_NAME = "backeer-opencode";
|
|
402
409
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
403
410
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -517,11 +524,234 @@ function createUpdateNoticeHook(updateChecker) {
|
|
|
517
524
|
};
|
|
518
525
|
}
|
|
519
526
|
|
|
527
|
+
// src/diagnostics.js
|
|
528
|
+
var PASS = "\u2713";
|
|
529
|
+
var FAIL = "\xD7";
|
|
530
|
+
var INFO = "\u2022";
|
|
531
|
+
function tokenFileCheck(config) {
|
|
532
|
+
if (config.tokenSource === "environment") {
|
|
533
|
+
return { ok: true, text: "Token loaded from the environment; token file not required" };
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const stats = statSync(config.tokenFile);
|
|
537
|
+
const mode = stats.mode & 511;
|
|
538
|
+
if (!stats.isFile()) return { ok: false, text: "Token path is not a regular file" };
|
|
539
|
+
if ((mode & 63) !== 0) {
|
|
540
|
+
return {
|
|
541
|
+
ok: false,
|
|
542
|
+
text: `Token file mode ${mode.toString(8).padStart(3, "0")} is too permissive; expected 600`
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
ok: true,
|
|
547
|
+
text: `Token file is protected (mode ${mode.toString(8).padStart(3, "0")})`
|
|
548
|
+
};
|
|
549
|
+
} catch {
|
|
550
|
+
return { ok: false, text: "Token file is missing or unreadable" };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async function probeSource(config, fetchImplementation = globalThis.fetch) {
|
|
554
|
+
const controller = new AbortController();
|
|
555
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
556
|
+
try {
|
|
557
|
+
const response = await fetchImplementation(config.sourceHealthEndpoint, {
|
|
558
|
+
method: "GET",
|
|
559
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
560
|
+
signal: controller.signal
|
|
561
|
+
});
|
|
562
|
+
const responseBody = limit(await response.text(), 2e3);
|
|
563
|
+
if (!response.ok) {
|
|
564
|
+
throw new HttpError(
|
|
565
|
+
`Gateway source check failed with status ${response.status}`,
|
|
566
|
+
response.status,
|
|
567
|
+
responseBody
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
return JSON.parse(responseBody);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new Error("Gateway source check returned malformed JSON");
|
|
574
|
+
}
|
|
575
|
+
} finally {
|
|
576
|
+
clearTimeout(timeout);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function buildDoctorReport(config, { fetchImplementation = globalThis.fetch } = {}) {
|
|
580
|
+
const checks = [];
|
|
581
|
+
checks.push({ ok: true, text: `Plugin version ${PLUGIN_VERSION}` });
|
|
582
|
+
checks.push({
|
|
583
|
+
ok: config.origin.protocol === "https:",
|
|
584
|
+
text: `Gateway uses ${config.origin.protocol === "https:" ? "HTTPS" : "an insecure protocol"}: ${config.origin.origin}`
|
|
585
|
+
});
|
|
586
|
+
checks.push({
|
|
587
|
+
ok: Boolean(config.token),
|
|
588
|
+
text: config.token ? `Source token loaded from ${config.tokenSource}` : "Source token is missing"
|
|
589
|
+
});
|
|
590
|
+
checks.push(tokenFileCheck(config));
|
|
591
|
+
let source;
|
|
592
|
+
if (config.token) {
|
|
593
|
+
try {
|
|
594
|
+
const result = await probeSource(config, fetchImplementation);
|
|
595
|
+
source = result?.source;
|
|
596
|
+
checks.push({
|
|
597
|
+
ok: result?.ok === true && typeof source?.id === "string",
|
|
598
|
+
text: source?.id ? `Source authentication accepted: ${source.name || source.id} (${source.id})` : "Gateway did not return a source identity"
|
|
599
|
+
});
|
|
600
|
+
checks.push({
|
|
601
|
+
ok: result?.capabilities?.events === true && result?.capabilities?.commands === true,
|
|
602
|
+
text: result?.capabilities?.events === true && result?.capabilities?.commands === true ? "Event ingest and command stream are available" : "Event ingest or command stream is unavailable"
|
|
603
|
+
});
|
|
604
|
+
checks.push({
|
|
605
|
+
ok: result?.capabilities?.apns === true,
|
|
606
|
+
text: result?.capabilities?.apns === true ? "Gateway APNs delivery is enabled" : "Gateway APNs delivery is disabled"
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const detail = error instanceof HttpError && error.status === 401 ? "Source authentication rejected (HTTP 401)" : error?.name === "AbortError" ? "Gateway source check timed out" : `Gateway source check failed: ${limit(error?.message || error, 300)}`;
|
|
610
|
+
checks.push({ ok: false, text: detail });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const healthy = checks.every((check) => check.ok);
|
|
614
|
+
return [
|
|
615
|
+
"## Backeer doctor",
|
|
616
|
+
"",
|
|
617
|
+
...checks.map((check) => `${check.ok ? PASS : FAIL} ${check.text}`),
|
|
618
|
+
"",
|
|
619
|
+
healthy ? `${PASS} Result: healthy. Use \`/backeer test\` to verify phone delivery.` : `${FAIL} Result: issues found. Resolve failed checks and run \`/backeer doctor\` again.`,
|
|
620
|
+
"",
|
|
621
|
+
`${INFO} No event was created and the token value was not displayed.`
|
|
622
|
+
].join("\n");
|
|
623
|
+
}
|
|
624
|
+
async function publishTestNotification(config, reporter) {
|
|
625
|
+
if (!config.token) throw new Error("Backeer source token is missing");
|
|
626
|
+
const testID = randomUUID();
|
|
627
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
628
|
+
type: "application",
|
|
629
|
+
severity: "info",
|
|
630
|
+
title: "Backeer test notification",
|
|
631
|
+
body: "OpenCode integration is connected and push delivery is working.",
|
|
632
|
+
payload: [
|
|
633
|
+
{ key: "Plugin version", value: PLUGIN_VERSION },
|
|
634
|
+
{ key: "Test ID", value: testID }
|
|
635
|
+
],
|
|
636
|
+
externalId: `opencode:test:${testID}`,
|
|
637
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
638
|
+
notify: true,
|
|
639
|
+
direction: "in"
|
|
640
|
+
});
|
|
641
|
+
reporter.success("Test notification sent", `HTTP ${result.status}`);
|
|
642
|
+
return testID;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/session-messages.js
|
|
646
|
+
var FEEDBACK_SYMBOLS = {
|
|
647
|
+
info: "\u2022",
|
|
648
|
+
success: "\u2713",
|
|
649
|
+
applied: "\u2713",
|
|
650
|
+
warning: "!",
|
|
651
|
+
error: "\xD7"
|
|
652
|
+
};
|
|
653
|
+
async function sendSessionMessage(client, sessionID, text) {
|
|
654
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
655
|
+
throw new Error("OpenCode session messaging is unavailable");
|
|
656
|
+
}
|
|
657
|
+
await client.session.prompt({
|
|
658
|
+
path: { id: sessionID },
|
|
659
|
+
body: {
|
|
660
|
+
noReply: true,
|
|
661
|
+
parts: [{ type: "text", text, ignored: true }]
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
function formatBackeerFeedback(kind, message) {
|
|
666
|
+
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
|
|
667
|
+
return `> **Backeer** ${symbol} ${limit(message, 300)}`;
|
|
668
|
+
}
|
|
669
|
+
function createConversationFeedback({ client, reporter }) {
|
|
670
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
671
|
+
let disposed = false;
|
|
672
|
+
function sessionState(sessionID) {
|
|
673
|
+
let state = sessions.get(sessionID);
|
|
674
|
+
if (!state) {
|
|
675
|
+
state = { idle: false, flushing: false, queue: [], queued: /* @__PURE__ */ new Set() };
|
|
676
|
+
rememberBounded(sessions, sessionID, state);
|
|
677
|
+
}
|
|
678
|
+
return state;
|
|
679
|
+
}
|
|
680
|
+
async function flush(sessionID, state) {
|
|
681
|
+
if (disposed || state.flushing || !state.idle || state.queue.length === 0) return;
|
|
682
|
+
state.flushing = true;
|
|
683
|
+
try {
|
|
684
|
+
while (!disposed && state.idle && state.queue.length > 0) {
|
|
685
|
+
const item = state.queue[0];
|
|
686
|
+
try {
|
|
687
|
+
await sendSessionMessage(
|
|
688
|
+
client,
|
|
689
|
+
sessionID,
|
|
690
|
+
formatBackeerFeedback(item.kind, item.message)
|
|
691
|
+
);
|
|
692
|
+
state.queue.shift();
|
|
693
|
+
state.queued.delete(item.key);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
state.idle = false;
|
|
696
|
+
reporter.debug("Conversation feedback could not be displayed", error);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} finally {
|
|
700
|
+
state.flushing = false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function enqueue(kind, sessionID, message) {
|
|
704
|
+
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
705
|
+
const state = sessionState(sessionID);
|
|
706
|
+
const key = `${sessionID}:${kind}:${message}`;
|
|
707
|
+
if (state.queued.has(key)) return;
|
|
708
|
+
state.queue.push({ kind, message: limit(message, 300), key });
|
|
709
|
+
state.queued.add(key);
|
|
710
|
+
while (state.queue.length > 20) {
|
|
711
|
+
const removed = state.queue.shift();
|
|
712
|
+
state.queued.delete(removed.key);
|
|
713
|
+
}
|
|
714
|
+
if (state.idle) void flush(sessionID, state);
|
|
715
|
+
}
|
|
716
|
+
function setIdle(sessionID, idle) {
|
|
717
|
+
if (typeof sessionID !== "string" || !sessionID) return;
|
|
718
|
+
const state = sessionState(sessionID);
|
|
719
|
+
state.idle = idle;
|
|
720
|
+
if (idle) void flush(sessionID, state);
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
info: (sessionID, message) => enqueue("info", sessionID, message),
|
|
724
|
+
success: (sessionID, message) => enqueue("success", sessionID, message),
|
|
725
|
+
applied: (sessionID, message) => enqueue("applied", sessionID, message),
|
|
726
|
+
warning: (sessionID, message) => enqueue("warning", sessionID, message),
|
|
727
|
+
error: (sessionID, message) => enqueue("error", sessionID, message),
|
|
728
|
+
handle(event) {
|
|
729
|
+
const sessionID = event?.properties?.sessionID;
|
|
730
|
+
if (typeof sessionID !== "string") return;
|
|
731
|
+
if (event.type === "session.status") {
|
|
732
|
+
const status = event.properties?.status?.type;
|
|
733
|
+
if (status === "idle") setIdle(sessionID, true);
|
|
734
|
+
else if (status === "busy" || status === "retry") setIdle(sessionID, false);
|
|
735
|
+
} else if (event.type === "session.idle") {
|
|
736
|
+
setIdle(sessionID, true);
|
|
737
|
+
} else if (event.type === "permission.asked" || event.type === "question.asked" || event.type === "question.v2.asked" || event.type === "todo.updated") {
|
|
738
|
+
setIdle(sessionID, false);
|
|
739
|
+
} else if (event.type === "session.deleted") {
|
|
740
|
+
sessions.delete(sessionID);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
dispose() {
|
|
744
|
+
disposed = true;
|
|
745
|
+
sessions.clear();
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
520
750
|
// src/management.js
|
|
521
|
-
var COMMAND_DESCRIPTION = "
|
|
751
|
+
var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
|
|
522
752
|
function tokenFileState(path) {
|
|
523
753
|
try {
|
|
524
|
-
const stats =
|
|
754
|
+
const stats = statSync2(path);
|
|
525
755
|
const mode = (stats.mode & 511).toString(8).padStart(3, "0");
|
|
526
756
|
return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
|
|
527
757
|
} catch {
|
|
@@ -546,7 +776,7 @@ function writeTokenAtomically(path, token) {
|
|
|
546
776
|
function rotateTokenFromFile(config, sourcePathValue) {
|
|
547
777
|
if (!sourcePathValue.trim()) throw new Error("A token file path is required");
|
|
548
778
|
const sourcePath = resolve(cleanPathArgument(sourcePathValue));
|
|
549
|
-
const sourceStats =
|
|
779
|
+
const sourceStats = statSync2(sourcePath);
|
|
550
780
|
if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
|
|
551
781
|
if ((sourceStats.mode & 63) !== 0) {
|
|
552
782
|
throw new Error("The token source must not be readable by group or other users");
|
|
@@ -619,6 +849,8 @@ function helpMessage() {
|
|
|
619
849
|
"## Backeer commands",
|
|
620
850
|
"",
|
|
621
851
|
"- `/backeer` or `/backeer status` \u2014 show integration status",
|
|
852
|
+
"- `/backeer doctor` \u2014 diagnose config, credentials, gateway, and APNs without creating an event",
|
|
853
|
+
"- `/backeer test` \u2014 send a real test notification to the Backeer application",
|
|
622
854
|
"- `/backeer update-check` \u2014 check NPM for a newer version",
|
|
623
855
|
"- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
|
|
624
856
|
"- `/backeer token` \u2014 show secure token-change instructions",
|
|
@@ -641,18 +873,6 @@ function tokenInstructions() {
|
|
|
641
873
|
"`token-file` activates the new token immediately; no OpenCode restart is required."
|
|
642
874
|
].join("\n");
|
|
643
875
|
}
|
|
644
|
-
async function sendCommandMessage(client, sessionID, text) {
|
|
645
|
-
if (typeof client?.session?.prompt !== "function") {
|
|
646
|
-
throw new Error("OpenCode session messaging is unavailable");
|
|
647
|
-
}
|
|
648
|
-
await client.session.prompt({
|
|
649
|
-
path: { id: sessionID },
|
|
650
|
-
body: {
|
|
651
|
-
noReply: true,
|
|
652
|
-
parts: [{ type: "text", text, ignored: true }]
|
|
653
|
-
}
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
876
|
function registerBackeerCommand(opencodeConfig) {
|
|
657
877
|
opencodeConfig.command ??= {};
|
|
658
878
|
opencodeConfig.command.backeer = {
|
|
@@ -678,6 +898,17 @@ function createBackeerCommandHandler({
|
|
|
678
898
|
try {
|
|
679
899
|
if (!subcommand || subcommand === "status") {
|
|
680
900
|
message = await buildStatus(config, permissionController, updateChecker);
|
|
901
|
+
} else if (subcommand === "doctor") {
|
|
902
|
+
message = await buildDoctorReport(config);
|
|
903
|
+
} else if (subcommand === "test") {
|
|
904
|
+
const testID = await publishTestNotification(config, reporter);
|
|
905
|
+
message = [
|
|
906
|
+
"## Backeer test notification",
|
|
907
|
+
"",
|
|
908
|
+
"\u2713 The gateway accepted the test event.",
|
|
909
|
+
`- Test ID: ${testID}`,
|
|
910
|
+
"- Check the Backeer application for the notification."
|
|
911
|
+
].join("\n");
|
|
681
912
|
} else if (subcommand === "update-check") {
|
|
682
913
|
const update = await updateChecker.check({ force: true });
|
|
683
914
|
message = [
|
|
@@ -714,7 +945,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
714
945
|
message = helpMessage();
|
|
715
946
|
}
|
|
716
947
|
} catch (error) {
|
|
717
|
-
reporter.error("
|
|
948
|
+
reporter.error("Backeer command failed", error);
|
|
718
949
|
message = [
|
|
719
950
|
"## Backeer command failed",
|
|
720
951
|
"",
|
|
@@ -723,7 +954,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
723
954
|
"Use `/backeer token` for secure token-change instructions."
|
|
724
955
|
].join("\n");
|
|
725
956
|
}
|
|
726
|
-
await
|
|
957
|
+
await sendSessionMessage(client, input.sessionID, message);
|
|
727
958
|
};
|
|
728
959
|
}
|
|
729
960
|
|
|
@@ -764,7 +995,7 @@ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
|
|
|
764
995
|
return "";
|
|
765
996
|
}
|
|
766
997
|
}
|
|
767
|
-
async function publish(config, reporter, event, feedback) {
|
|
998
|
+
async function publish(config, reporter, conversation, sessionID, event, feedback) {
|
|
768
999
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
769
1000
|
type: "application",
|
|
770
1001
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -773,6 +1004,7 @@ async function publish(config, reporter, event, feedback) {
|
|
|
773
1004
|
...event
|
|
774
1005
|
});
|
|
775
1006
|
reporter.success(feedback, `HTTP ${result.status}`);
|
|
1007
|
+
conversation?.success(sessionID, feedback);
|
|
776
1008
|
}
|
|
777
1009
|
function secondsLabel(durationMs) {
|
|
778
1010
|
const seconds = Math.max(0, Math.round(durationMs / 1e3));
|
|
@@ -788,7 +1020,8 @@ async function publishSessionCompleted({
|
|
|
788
1020
|
directory,
|
|
789
1021
|
sessionID,
|
|
790
1022
|
messageID,
|
|
791
|
-
durationMs
|
|
1023
|
+
durationMs,
|
|
1024
|
+
conversation
|
|
792
1025
|
}) {
|
|
793
1026
|
const sessionTitle = await resolveSessionTitle2(
|
|
794
1027
|
client,
|
|
@@ -798,7 +1031,7 @@ async function publishSessionCompleted({
|
|
|
798
1031
|
);
|
|
799
1032
|
const duration = secondsLabel(durationMs);
|
|
800
1033
|
const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
|
|
801
|
-
await publish(config, reporter, {
|
|
1034
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
802
1035
|
severity: "info",
|
|
803
1036
|
title: "OpenCode task completed",
|
|
804
1037
|
body: `OpenCode finished${location} in ${duration}.`,
|
|
@@ -818,7 +1051,8 @@ async function publishSessionRetry({
|
|
|
818
1051
|
sessionID,
|
|
819
1052
|
correlation,
|
|
820
1053
|
attempt,
|
|
821
|
-
next
|
|
1054
|
+
next,
|
|
1055
|
+
conversation
|
|
822
1056
|
}) {
|
|
823
1057
|
const sessionTitle = await resolveSessionTitle2(
|
|
824
1058
|
client,
|
|
@@ -827,7 +1061,7 @@ async function publishSessionRetry({
|
|
|
827
1061
|
reporter
|
|
828
1062
|
);
|
|
829
1063
|
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
|
|
830
|
-
await publish(config, reporter, {
|
|
1064
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
831
1065
|
severity: "warning",
|
|
832
1066
|
title: "OpenCode is retrying",
|
|
833
1067
|
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
@@ -846,7 +1080,8 @@ async function publishSessionError({
|
|
|
846
1080
|
directory,
|
|
847
1081
|
sessionID,
|
|
848
1082
|
correlation,
|
|
849
|
-
error
|
|
1083
|
+
error,
|
|
1084
|
+
conversation
|
|
850
1085
|
}) {
|
|
851
1086
|
const sessionTitle = await resolveSessionTitle2(
|
|
852
1087
|
client,
|
|
@@ -857,7 +1092,7 @@ async function publishSessionError({
|
|
|
857
1092
|
const details = safeErrorDetails(error);
|
|
858
1093
|
const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
|
|
859
1094
|
const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
|
|
860
|
-
await publish(config, reporter, {
|
|
1095
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
861
1096
|
// Backeer/iOS currently support info, warning and critical severities.
|
|
862
1097
|
severity: "critical",
|
|
863
1098
|
title: "OpenCode task failed",
|
|
@@ -874,7 +1109,7 @@ async function publishSessionError({
|
|
|
874
1109
|
}
|
|
875
1110
|
async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
876
1111
|
const safeVersion = limit(version, 100);
|
|
877
|
-
await publish(config, reporter, {
|
|
1112
|
+
await publish(config, reporter, void 0, void 0, {
|
|
878
1113
|
severity: "info",
|
|
879
1114
|
title: "OpenCode update available",
|
|
880
1115
|
body: `OpenCode ${safeVersion} is available.`,
|
|
@@ -902,6 +1137,7 @@ function createLifecycleController({
|
|
|
902
1137
|
reporter,
|
|
903
1138
|
client,
|
|
904
1139
|
directory,
|
|
1140
|
+
conversation,
|
|
905
1141
|
now = () => Date.now(),
|
|
906
1142
|
publishers = {}
|
|
907
1143
|
}) {
|
|
@@ -927,8 +1163,11 @@ function createLifecycleController({
|
|
|
927
1163
|
}
|
|
928
1164
|
return state;
|
|
929
1165
|
}
|
|
930
|
-
function dispatch(label, operation) {
|
|
931
|
-
void Promise.resolve(operation).catch((error) =>
|
|
1166
|
+
function dispatch(label, operation, sessionID) {
|
|
1167
|
+
void Promise.resolve(operation).catch((error) => {
|
|
1168
|
+
reporter.error(label, error);
|
|
1169
|
+
conversation?.error(sessionID, label);
|
|
1170
|
+
});
|
|
932
1171
|
}
|
|
933
1172
|
function beginCycle(state) {
|
|
934
1173
|
if (state.busy) return;
|
|
@@ -956,8 +1195,10 @@ function createLifecycleController({
|
|
|
956
1195
|
directory,
|
|
957
1196
|
sessionID,
|
|
958
1197
|
messageID: limit(assistant.id, 200),
|
|
959
|
-
durationMs
|
|
960
|
-
|
|
1198
|
+
durationMs,
|
|
1199
|
+
conversation
|
|
1200
|
+
}),
|
|
1201
|
+
sessionID
|
|
961
1202
|
);
|
|
962
1203
|
}
|
|
963
1204
|
function handleMessage(event) {
|
|
@@ -1002,8 +1243,10 @@ function createLifecycleController({
|
|
|
1002
1243
|
sessionID,
|
|
1003
1244
|
correlation: limit(correlation, 200),
|
|
1004
1245
|
attempt: status.attempt,
|
|
1005
|
-
next: status.next
|
|
1006
|
-
|
|
1246
|
+
next: status.next,
|
|
1247
|
+
conversation
|
|
1248
|
+
}),
|
|
1249
|
+
sessionID
|
|
1007
1250
|
);
|
|
1008
1251
|
}
|
|
1009
1252
|
function handleError(event) {
|
|
@@ -1020,8 +1263,10 @@ function createLifecycleController({
|
|
|
1020
1263
|
directory,
|
|
1021
1264
|
sessionID,
|
|
1022
1265
|
correlation: errorCorrelation(event, state),
|
|
1023
|
-
error: event.properties?.error
|
|
1024
|
-
|
|
1266
|
+
error: event.properties?.error,
|
|
1267
|
+
conversation
|
|
1268
|
+
}),
|
|
1269
|
+
sessionID
|
|
1025
1270
|
);
|
|
1026
1271
|
}
|
|
1027
1272
|
function handleWaiting(event, waiting) {
|
|
@@ -1207,7 +1452,8 @@ function createPermissionController({
|
|
|
1207
1452
|
config,
|
|
1208
1453
|
reporter,
|
|
1209
1454
|
client,
|
|
1210
|
-
directory
|
|
1455
|
+
directory,
|
|
1456
|
+
conversation
|
|
1211
1457
|
}) {
|
|
1212
1458
|
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
1213
1459
|
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
@@ -1268,6 +1514,7 @@ function createPermissionController({
|
|
|
1268
1514
|
};
|
|
1269
1515
|
if (stale) removePendingByRequestID(pending.requestID);
|
|
1270
1516
|
reporter.error("Decision could not be applied", error);
|
|
1517
|
+
conversation?.error(pending.sessionID, "Decision could not be applied");
|
|
1271
1518
|
} finally {
|
|
1272
1519
|
processingCommands.delete(command.id);
|
|
1273
1520
|
}
|
|
@@ -1282,12 +1529,19 @@ function createPermissionController({
|
|
|
1282
1529
|
reporter.applied(
|
|
1283
1530
|
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1284
1531
|
);
|
|
1532
|
+
conversation?.applied(
|
|
1533
|
+
pending.sessionID,
|
|
1534
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1535
|
+
);
|
|
1285
1536
|
} else if (acknowledgement.status === "expired") {
|
|
1286
1537
|
reporter.warning("Decision expired before it could be applied");
|
|
1538
|
+
conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
|
|
1287
1539
|
} else if (acknowledgement.status === "stale") {
|
|
1288
1540
|
reporter.warning("Permission was already answered locally");
|
|
1541
|
+
conversation?.warning(pending.sessionID, "Permission was already answered locally");
|
|
1289
1542
|
} else if (acknowledgement.status === "rejected") {
|
|
1290
1543
|
reporter.warning("Decision was rejected by validation");
|
|
1544
|
+
conversation?.warning(pending.sessionID, "Decision was rejected by validation");
|
|
1291
1545
|
}
|
|
1292
1546
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
1293
1547
|
reporter.warning("Decision receipt could not be sent", error);
|
|
@@ -1350,7 +1604,8 @@ function createPermissionController({
|
|
|
1350
1604
|
|
|
1351
1605
|
// src/plugin.js
|
|
1352
1606
|
async function BackeerPlugin({ client, directory }, options = {}) {
|
|
1353
|
-
const bootstrapReporter =
|
|
1607
|
+
const bootstrapReporter = createOpenCodeReporter(
|
|
1608
|
+
client,
|
|
1354
1609
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL
|
|
1355
1610
|
);
|
|
1356
1611
|
let config;
|
|
@@ -1360,18 +1615,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1360
1615
|
bootstrapReporter.error("Plugin disabled \u2014 invalid configuration", error);
|
|
1361
1616
|
return {};
|
|
1362
1617
|
}
|
|
1363
|
-
const reporter =
|
|
1618
|
+
const reporter = createOpenCodeReporter(client, config.logLevel);
|
|
1619
|
+
const conversation = createConversationFeedback({ client, reporter });
|
|
1364
1620
|
const permissionController = createPermissionController({
|
|
1365
1621
|
config,
|
|
1366
1622
|
reporter,
|
|
1367
1623
|
client,
|
|
1368
|
-
directory
|
|
1624
|
+
directory,
|
|
1625
|
+
conversation
|
|
1369
1626
|
});
|
|
1370
1627
|
const lifecycleController = createLifecycleController({
|
|
1371
1628
|
config,
|
|
1372
1629
|
reporter,
|
|
1373
1630
|
client,
|
|
1374
|
-
directory
|
|
1631
|
+
directory,
|
|
1632
|
+
conversation
|
|
1375
1633
|
});
|
|
1376
1634
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
1377
1635
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
@@ -1402,8 +1660,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1402
1660
|
reporter,
|
|
1403
1661
|
event.properties,
|
|
1404
1662
|
directory,
|
|
1405
|
-
identity
|
|
1406
|
-
|
|
1663
|
+
identity,
|
|
1664
|
+
conversation
|
|
1665
|
+
).catch((error) => {
|
|
1666
|
+
reporter.error("Permission request could not be sent", error);
|
|
1667
|
+
conversation.error(identity.sessionID, "Permission request could not be sent");
|
|
1668
|
+
});
|
|
1407
1669
|
}
|
|
1408
1670
|
async function handleTodoUpdated(event) {
|
|
1409
1671
|
const { sessionID, todos } = event.properties;
|
|
@@ -1421,8 +1683,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1421
1683
|
event.properties,
|
|
1422
1684
|
event.id,
|
|
1423
1685
|
client,
|
|
1424
|
-
directory
|
|
1425
|
-
|
|
1686
|
+
directory,
|
|
1687
|
+
conversation
|
|
1688
|
+
).catch((error) => {
|
|
1689
|
+
reporter.error("Todo completion could not be sent", error);
|
|
1690
|
+
conversation.error(sessionID, "Todo completion could not be sent");
|
|
1691
|
+
});
|
|
1426
1692
|
}
|
|
1427
1693
|
async function handleQuestionAsked(event) {
|
|
1428
1694
|
const identity = questionIdentity(event.properties);
|
|
@@ -1438,8 +1704,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1438
1704
|
event.properties,
|
|
1439
1705
|
identity,
|
|
1440
1706
|
client,
|
|
1441
|
-
directory
|
|
1442
|
-
|
|
1707
|
+
directory,
|
|
1708
|
+
conversation
|
|
1709
|
+
).catch((error) => {
|
|
1710
|
+
reporter.error("Question could not be sent", error);
|
|
1711
|
+
conversation.error(identity.sessionID, "Question could not be sent");
|
|
1712
|
+
});
|
|
1443
1713
|
}
|
|
1444
1714
|
return {
|
|
1445
1715
|
config: async (opencodeConfig) => {
|
|
@@ -1449,6 +1719,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1449
1719
|
"chat.message": handleUpdateNotice,
|
|
1450
1720
|
event: async ({ event }) => {
|
|
1451
1721
|
if (!config.token) return;
|
|
1722
|
+
conversation.handle(event);
|
|
1452
1723
|
lifecycleController.handle(event);
|
|
1453
1724
|
if (config.permissions && event.type === "permission.asked") {
|
|
1454
1725
|
await handlePermissionAsked(event);
|
|
@@ -1473,6 +1744,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1473
1744
|
lifecycleController.dispose();
|
|
1474
1745
|
todoCompletionBySessionID.clear();
|
|
1475
1746
|
publishedQuestions.clear();
|
|
1747
|
+
conversation.dispose();
|
|
1476
1748
|
}
|
|
1477
1749
|
};
|
|
1478
1750
|
}
|