backeer-opencode 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +57 -9
- package/dist/backeer-permission.js +529 -6
- package/package.json +2 -2
- package/src/config.js +46 -0
- package/src/diagnostics.js +148 -0
- package/src/lifecycle-controller.js +205 -0
- package/src/lifecycle-publishers.js +187 -0
- package/src/management.js +21 -2
- package/src/plugin.js +12 -1
- package/src/updates.js +1 -1
|
@@ -77,6 +77,43 @@ function createConsoleReporter(levelValue) {
|
|
|
77
77
|
|
|
78
78
|
// src/config.js
|
|
79
79
|
var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
|
|
80
|
+
function integer(value, fallback, { minimum = 0, maximum = 86400 } = {}) {
|
|
81
|
+
if (value === void 0 || value === null || value === "") return fallback;
|
|
82
|
+
const parsed = Number(value);
|
|
83
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
84
|
+
throw new Error(`Expected an integer between ${minimum} and ${maximum}`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
function loadLifecycleConfig(option) {
|
|
89
|
+
const values = option && typeof option === "object" ? option : {};
|
|
90
|
+
const masterValue = typeof option === "boolean" ? option : values.enabled ?? process.env.BACKEER_NOTIFY_LIFECYCLE;
|
|
91
|
+
const masterEnabled = enabled(masterValue);
|
|
92
|
+
return {
|
|
93
|
+
enabled: masterEnabled,
|
|
94
|
+
completed: masterEnabled && enabled(
|
|
95
|
+
values.completed ?? process.env.BACKEER_NOTIFY_SESSION_COMPLETED
|
|
96
|
+
),
|
|
97
|
+
errors: masterEnabled && enabled(
|
|
98
|
+
values.errors ?? process.env.BACKEER_NOTIFY_SESSION_ERRORS
|
|
99
|
+
),
|
|
100
|
+
retries: masterEnabled && enabled(
|
|
101
|
+
values.retries ?? process.env.BACKEER_NOTIFY_SESSION_RETRIES
|
|
102
|
+
),
|
|
103
|
+
updates: masterEnabled && enabled(
|
|
104
|
+
values.updates ?? process.env.BACKEER_NOTIFY_OPENCODE_UPDATES
|
|
105
|
+
),
|
|
106
|
+
completionMinSeconds: integer(
|
|
107
|
+
values.completionMinSeconds ?? process.env.BACKEER_SESSION_COMPLETION_MIN_SECONDS,
|
|
108
|
+
0
|
|
109
|
+
),
|
|
110
|
+
retryAttemptThreshold: integer(
|
|
111
|
+
values.retryAttemptThreshold ?? process.env.BACKEER_RETRY_ATTEMPT_THRESHOLD,
|
|
112
|
+
3,
|
|
113
|
+
{ minimum: 1, maximum: 100 }
|
|
114
|
+
)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
80
117
|
function defaultConfigDirectory() {
|
|
81
118
|
const base = process.env.XDG_CONFIG_HOME?.trim();
|
|
82
119
|
return base ? join(base, "opencode") : join(homedir(), ".config", "opencode");
|
|
@@ -103,6 +140,7 @@ function loadConfig(options = {}) {
|
|
|
103
140
|
return {
|
|
104
141
|
origin,
|
|
105
142
|
eventsEndpoint: new URL("/v1/events", origin),
|
|
143
|
+
sourceHealthEndpoint: new URL("/v1/source/health", origin),
|
|
106
144
|
commandStreamEndpoint: new URL("/v1/commands/stream", origin),
|
|
107
145
|
token: loadedToken.token,
|
|
108
146
|
tokenSource: loadedToken.source,
|
|
@@ -114,6 +152,7 @@ function loadConfig(options = {}) {
|
|
|
114
152
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS
|
|
115
153
|
),
|
|
116
154
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
155
|
+
lifecycle: loadLifecycleConfig(options.lifecycle),
|
|
117
156
|
logLevel: normalizeLogLevel(
|
|
118
157
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL
|
|
119
158
|
)
|
|
@@ -353,13 +392,17 @@ import {
|
|
|
353
392
|
mkdirSync,
|
|
354
393
|
readFileSync as readFileSync2,
|
|
355
394
|
renameSync,
|
|
356
|
-
statSync,
|
|
395
|
+
statSync as statSync2,
|
|
357
396
|
writeFileSync
|
|
358
397
|
} from "node:fs";
|
|
359
398
|
import { dirname, resolve } from "node:path";
|
|
360
399
|
|
|
400
|
+
// src/diagnostics.js
|
|
401
|
+
import { randomUUID } from "node:crypto";
|
|
402
|
+
import { statSync } from "node:fs";
|
|
403
|
+
|
|
361
404
|
// src/updates.js
|
|
362
|
-
var PLUGIN_VERSION = "0.1
|
|
405
|
+
var PLUGIN_VERSION = "0.2.1";
|
|
363
406
|
var PACKAGE_NAME = "backeer-opencode";
|
|
364
407
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
365
408
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -479,11 +522,129 @@ function createUpdateNoticeHook(updateChecker) {
|
|
|
479
522
|
};
|
|
480
523
|
}
|
|
481
524
|
|
|
525
|
+
// src/diagnostics.js
|
|
526
|
+
var PASS = "\u2713";
|
|
527
|
+
var FAIL = "\xD7";
|
|
528
|
+
var INFO = "\u2022";
|
|
529
|
+
function tokenFileCheck(config) {
|
|
530
|
+
if (config.tokenSource === "environment") {
|
|
531
|
+
return { ok: true, text: "Token loaded from the environment; token file not required" };
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const stats = statSync(config.tokenFile);
|
|
535
|
+
const mode = stats.mode & 511;
|
|
536
|
+
if (!stats.isFile()) return { ok: false, text: "Token path is not a regular file" };
|
|
537
|
+
if ((mode & 63) !== 0) {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
text: `Token file mode ${mode.toString(8).padStart(3, "0")} is too permissive; expected 600`
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
text: `Token file is protected (mode ${mode.toString(8).padStart(3, "0")})`
|
|
546
|
+
};
|
|
547
|
+
} catch {
|
|
548
|
+
return { ok: false, text: "Token file is missing or unreadable" };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function probeSource(config, fetchImplementation = globalThis.fetch) {
|
|
552
|
+
const controller = new AbortController();
|
|
553
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
554
|
+
try {
|
|
555
|
+
const response = await fetchImplementation(config.sourceHealthEndpoint, {
|
|
556
|
+
method: "GET",
|
|
557
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
558
|
+
signal: controller.signal
|
|
559
|
+
});
|
|
560
|
+
const responseBody = limit(await response.text(), 2e3);
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
throw new HttpError(
|
|
563
|
+
`Gateway source check failed with status ${response.status}`,
|
|
564
|
+
response.status,
|
|
565
|
+
responseBody
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
return JSON.parse(responseBody);
|
|
570
|
+
} catch {
|
|
571
|
+
throw new Error("Gateway source check returned malformed JSON");
|
|
572
|
+
}
|
|
573
|
+
} finally {
|
|
574
|
+
clearTimeout(timeout);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function buildDoctorReport(config, { fetchImplementation = globalThis.fetch } = {}) {
|
|
578
|
+
const checks = [];
|
|
579
|
+
checks.push({ ok: true, text: `Plugin version ${PLUGIN_VERSION}` });
|
|
580
|
+
checks.push({
|
|
581
|
+
ok: config.origin.protocol === "https:",
|
|
582
|
+
text: `Gateway uses ${config.origin.protocol === "https:" ? "HTTPS" : "an insecure protocol"}: ${config.origin.origin}`
|
|
583
|
+
});
|
|
584
|
+
checks.push({
|
|
585
|
+
ok: Boolean(config.token),
|
|
586
|
+
text: config.token ? `Source token loaded from ${config.tokenSource}` : "Source token is missing"
|
|
587
|
+
});
|
|
588
|
+
checks.push(tokenFileCheck(config));
|
|
589
|
+
let source;
|
|
590
|
+
if (config.token) {
|
|
591
|
+
try {
|
|
592
|
+
const result = await probeSource(config, fetchImplementation);
|
|
593
|
+
source = result?.source;
|
|
594
|
+
checks.push({
|
|
595
|
+
ok: result?.ok === true && typeof source?.id === "string",
|
|
596
|
+
text: source?.id ? `Source authentication accepted: ${source.name || source.id} (${source.id})` : "Gateway did not return a source identity"
|
|
597
|
+
});
|
|
598
|
+
checks.push({
|
|
599
|
+
ok: result?.capabilities?.events === true && result?.capabilities?.commands === true,
|
|
600
|
+
text: result?.capabilities?.events === true && result?.capabilities?.commands === true ? "Event ingest and command stream are available" : "Event ingest or command stream is unavailable"
|
|
601
|
+
});
|
|
602
|
+
checks.push({
|
|
603
|
+
ok: result?.capabilities?.apns === true,
|
|
604
|
+
text: result?.capabilities?.apns === true ? "Gateway APNs delivery is enabled" : "Gateway APNs delivery is disabled"
|
|
605
|
+
});
|
|
606
|
+
} catch (error) {
|
|
607
|
+
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)}`;
|
|
608
|
+
checks.push({ ok: false, text: detail });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const healthy = checks.every((check) => check.ok);
|
|
612
|
+
return [
|
|
613
|
+
"## Backeer doctor",
|
|
614
|
+
"",
|
|
615
|
+
...checks.map((check) => `${check.ok ? PASS : FAIL} ${check.text}`),
|
|
616
|
+
"",
|
|
617
|
+
healthy ? `${PASS} Result: healthy. Use \`/backeer test\` to verify phone delivery.` : `${FAIL} Result: issues found. Resolve failed checks and run \`/backeer doctor\` again.`,
|
|
618
|
+
"",
|
|
619
|
+
`${INFO} No event was created and the token value was not displayed.`
|
|
620
|
+
].join("\n");
|
|
621
|
+
}
|
|
622
|
+
async function publishTestNotification(config, reporter) {
|
|
623
|
+
if (!config.token) throw new Error("Backeer source token is missing");
|
|
624
|
+
const testID = randomUUID();
|
|
625
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
626
|
+
type: "application",
|
|
627
|
+
severity: "info",
|
|
628
|
+
title: "Backeer test notification",
|
|
629
|
+
body: "OpenCode integration is connected and push delivery is working.",
|
|
630
|
+
payload: [
|
|
631
|
+
{ key: "Plugin version", value: PLUGIN_VERSION },
|
|
632
|
+
{ key: "Test ID", value: testID }
|
|
633
|
+
],
|
|
634
|
+
externalId: `opencode:test:${testID}`,
|
|
635
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
636
|
+
notify: true,
|
|
637
|
+
direction: "in"
|
|
638
|
+
});
|
|
639
|
+
reporter.success("Test notification sent", `HTTP ${result.status}`);
|
|
640
|
+
return testID;
|
|
641
|
+
}
|
|
642
|
+
|
|
482
643
|
// src/management.js
|
|
483
|
-
var COMMAND_DESCRIPTION = "
|
|
644
|
+
var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
|
|
484
645
|
function tokenFileState(path) {
|
|
485
646
|
try {
|
|
486
|
-
const stats =
|
|
647
|
+
const stats = statSync2(path);
|
|
487
648
|
const mode = (stats.mode & 511).toString(8).padStart(3, "0");
|
|
488
649
|
return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
|
|
489
650
|
} catch {
|
|
@@ -508,7 +669,7 @@ function writeTokenAtomically(path, token) {
|
|
|
508
669
|
function rotateTokenFromFile(config, sourcePathValue) {
|
|
509
670
|
if (!sourcePathValue.trim()) throw new Error("A token file path is required");
|
|
510
671
|
const sourcePath = resolve(cleanPathArgument(sourcePathValue));
|
|
511
|
-
const sourceStats =
|
|
672
|
+
const sourceStats = statSync2(sourcePath);
|
|
512
673
|
if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
|
|
513
674
|
if ((sourceStats.mode & 63) !== 0) {
|
|
514
675
|
throw new Error("The token source must not be readable by group or other users");
|
|
@@ -564,6 +725,11 @@ async function buildStatus(config, permissionController, updateChecker) {
|
|
|
564
725
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
565
726
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
566
727
|
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
728
|
+
`- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
|
|
729
|
+
` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
|
|
730
|
+
` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
|
|
731
|
+
` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
|
|
732
|
+
` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
|
|
567
733
|
`- Command stream: ${permissionStatus.stream}`,
|
|
568
734
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
569
735
|
`- Log level: ${config.logLevel}`,
|
|
@@ -576,6 +742,8 @@ function helpMessage() {
|
|
|
576
742
|
"## Backeer commands",
|
|
577
743
|
"",
|
|
578
744
|
"- `/backeer` or `/backeer status` \u2014 show integration status",
|
|
745
|
+
"- `/backeer doctor` \u2014 diagnose config, credentials, gateway, and APNs without creating an event",
|
|
746
|
+
"- `/backeer test` \u2014 send a real test notification to the Backeer application",
|
|
579
747
|
"- `/backeer update-check` \u2014 check NPM for a newer version",
|
|
580
748
|
"- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
|
|
581
749
|
"- `/backeer token` \u2014 show secure token-change instructions",
|
|
@@ -635,6 +803,17 @@ function createBackeerCommandHandler({
|
|
|
635
803
|
try {
|
|
636
804
|
if (!subcommand || subcommand === "status") {
|
|
637
805
|
message = await buildStatus(config, permissionController, updateChecker);
|
|
806
|
+
} else if (subcommand === "doctor") {
|
|
807
|
+
message = await buildDoctorReport(config);
|
|
808
|
+
} else if (subcommand === "test") {
|
|
809
|
+
const testID = await publishTestNotification(config, reporter);
|
|
810
|
+
message = [
|
|
811
|
+
"## Backeer test notification",
|
|
812
|
+
"",
|
|
813
|
+
"\u2713 The gateway accepted the test event.",
|
|
814
|
+
`- Test ID: ${testID}`,
|
|
815
|
+
"- Check the Backeer application for the notification."
|
|
816
|
+
].join("\n");
|
|
638
817
|
} else if (subcommand === "update-check") {
|
|
639
818
|
const update = await updateChecker.check({ force: true });
|
|
640
819
|
message = [
|
|
@@ -671,7 +850,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
671
850
|
message = helpMessage();
|
|
672
851
|
}
|
|
673
852
|
} catch (error) {
|
|
674
|
-
reporter.error("
|
|
853
|
+
reporter.error("Backeer command failed", error);
|
|
675
854
|
message = [
|
|
676
855
|
"## Backeer command failed",
|
|
677
856
|
"",
|
|
@@ -684,6 +863,342 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
684
863
|
};
|
|
685
864
|
}
|
|
686
865
|
|
|
866
|
+
// src/lifecycle-publishers.js
|
|
867
|
+
var CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
868
|
+
var SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi;
|
|
869
|
+
var BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi;
|
|
870
|
+
var URL_WITH_QUERY = /\bhttps?:\/\/[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi;
|
|
871
|
+
var WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g;
|
|
872
|
+
var POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g;
|
|
873
|
+
function sanitizeErrorMessage(value) {
|
|
874
|
+
return limit(value, 2e3).replace(CONTROL_CHARACTERS, " ").replace(BEARER_TOKEN, "Bearer [redacted]").replace(SECRET_ASSIGNMENT, "$1=[redacted]").replace(URL_WITH_QUERY, (value2) => {
|
|
875
|
+
try {
|
|
876
|
+
const url = new URL(value2);
|
|
877
|
+
return `${url.origin}${url.pathname}`;
|
|
878
|
+
} catch {
|
|
879
|
+
return "[url redacted]";
|
|
880
|
+
}
|
|
881
|
+
}).replace(WINDOWS_PATH, "[path redacted]").replace(POSIX_PATH, "$1[path redacted]").replace(/\s+/g, " ").trim().slice(0, 500);
|
|
882
|
+
}
|
|
883
|
+
function safeErrorDetails(error) {
|
|
884
|
+
const name = limit(error?.name || "OpenCodeError", 100);
|
|
885
|
+
const message = sanitizeErrorMessage(error?.data?.message ?? error?.message);
|
|
886
|
+
const statusCode = Number.isInteger(error?.data?.statusCode) ? String(error.data.statusCode) : "";
|
|
887
|
+
return { name, message, statusCode };
|
|
888
|
+
}
|
|
889
|
+
async function resolveSessionTitle2(client, directory, sessionID, reporter) {
|
|
890
|
+
if (!sessionID || typeof client?.session?.get !== "function") return "";
|
|
891
|
+
try {
|
|
892
|
+
const result = await client.session.get({
|
|
893
|
+
path: { id: sessionID },
|
|
894
|
+
query: { directory },
|
|
895
|
+
throwOnError: true
|
|
896
|
+
});
|
|
897
|
+
return limit((result?.data ?? result)?.title);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
reporter.debug("Session title unavailable", error);
|
|
900
|
+
return "";
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async function publish(config, reporter, event, feedback) {
|
|
904
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
905
|
+
type: "application",
|
|
906
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
907
|
+
notify: true,
|
|
908
|
+
direction: "in",
|
|
909
|
+
...event
|
|
910
|
+
});
|
|
911
|
+
reporter.success(feedback, `HTTP ${result.status}`);
|
|
912
|
+
}
|
|
913
|
+
function secondsLabel(durationMs) {
|
|
914
|
+
const seconds = Math.max(0, Math.round(durationMs / 1e3));
|
|
915
|
+
if (seconds < 60) return `${seconds}s`;
|
|
916
|
+
const minutes = Math.floor(seconds / 60);
|
|
917
|
+
const remainder = seconds % 60;
|
|
918
|
+
return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
|
919
|
+
}
|
|
920
|
+
async function publishSessionCompleted({
|
|
921
|
+
config,
|
|
922
|
+
reporter,
|
|
923
|
+
client,
|
|
924
|
+
directory,
|
|
925
|
+
sessionID,
|
|
926
|
+
messageID,
|
|
927
|
+
durationMs
|
|
928
|
+
}) {
|
|
929
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
930
|
+
client,
|
|
931
|
+
directory,
|
|
932
|
+
sessionID,
|
|
933
|
+
reporter
|
|
934
|
+
);
|
|
935
|
+
const duration = secondsLabel(durationMs);
|
|
936
|
+
const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
|
|
937
|
+
await publish(config, reporter, {
|
|
938
|
+
severity: "info",
|
|
939
|
+
title: "OpenCode task completed",
|
|
940
|
+
body: `OpenCode finished${location} in ${duration}.`,
|
|
941
|
+
payload: [
|
|
942
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
943
|
+
{ key: "Outcome", value: "Completed" },
|
|
944
|
+
{ key: "Duration", value: duration }
|
|
945
|
+
].filter((item) => item.value),
|
|
946
|
+
externalId: `opencode:lifecycle:completed:${sessionID}:${messageID}`
|
|
947
|
+
}, "Task completion sent");
|
|
948
|
+
}
|
|
949
|
+
async function publishSessionRetry({
|
|
950
|
+
config,
|
|
951
|
+
reporter,
|
|
952
|
+
client,
|
|
953
|
+
directory,
|
|
954
|
+
sessionID,
|
|
955
|
+
correlation,
|
|
956
|
+
attempt,
|
|
957
|
+
next
|
|
958
|
+
}) {
|
|
959
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
960
|
+
client,
|
|
961
|
+
directory,
|
|
962
|
+
sessionID,
|
|
963
|
+
reporter
|
|
964
|
+
);
|
|
965
|
+
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
|
|
966
|
+
await publish(config, reporter, {
|
|
967
|
+
severity: "warning",
|
|
968
|
+
title: "OpenCode is retrying",
|
|
969
|
+
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
970
|
+
payload: [
|
|
971
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
972
|
+
{ key: "Retry attempt", value: String(attempt) },
|
|
973
|
+
{ key: "Next retry", value: retryAt }
|
|
974
|
+
].filter((item) => item.value),
|
|
975
|
+
externalId: `opencode:lifecycle:retry:${sessionID}:${correlation}`
|
|
976
|
+
}, "Retry warning sent");
|
|
977
|
+
}
|
|
978
|
+
async function publishSessionError({
|
|
979
|
+
config,
|
|
980
|
+
reporter,
|
|
981
|
+
client,
|
|
982
|
+
directory,
|
|
983
|
+
sessionID,
|
|
984
|
+
correlation,
|
|
985
|
+
error
|
|
986
|
+
}) {
|
|
987
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
988
|
+
client,
|
|
989
|
+
directory,
|
|
990
|
+
sessionID,
|
|
991
|
+
reporter
|
|
992
|
+
);
|
|
993
|
+
const details = safeErrorDetails(error);
|
|
994
|
+
const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
|
|
995
|
+
const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
|
|
996
|
+
await publish(config, reporter, {
|
|
997
|
+
// Backeer/iOS currently support info, warning and critical severities.
|
|
998
|
+
severity: "critical",
|
|
999
|
+
title: "OpenCode task failed",
|
|
1000
|
+
body: limit(body, 1e3),
|
|
1001
|
+
payload: [
|
|
1002
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
1003
|
+
{ key: "Outcome", value: "Failed" },
|
|
1004
|
+
{ key: "Error type", value: details.name },
|
|
1005
|
+
{ key: "HTTP status", value: details.statusCode },
|
|
1006
|
+
{ key: "Error", value: details.message }
|
|
1007
|
+
].filter((item) => item.value),
|
|
1008
|
+
externalId: `opencode:lifecycle:error:${sessionID || "global"}:${correlation}`
|
|
1009
|
+
}, "Task failure sent");
|
|
1010
|
+
}
|
|
1011
|
+
async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
1012
|
+
const safeVersion = limit(version, 100);
|
|
1013
|
+
await publish(config, reporter, {
|
|
1014
|
+
severity: "info",
|
|
1015
|
+
title: "OpenCode update available",
|
|
1016
|
+
body: `OpenCode ${safeVersion} is available.`,
|
|
1017
|
+
payload: [{ key: "Available version", value: safeVersion }],
|
|
1018
|
+
externalId: `opencode:lifecycle:update:${safeVersion}`
|
|
1019
|
+
}, "OpenCode update sent");
|
|
1020
|
+
}
|
|
1021
|
+
function errorCorrelation(event, sessionState) {
|
|
1022
|
+
if (typeof event?.id === "string" && event.id) return limit(event.id, 200);
|
|
1023
|
+
if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200);
|
|
1024
|
+
const details = safeErrorDetails(event?.properties?.error);
|
|
1025
|
+
return stableHash(`${details.name}:${details.statusCode}:${details.message}`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/lifecycle-controller.js
|
|
1029
|
+
var QUESTION_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
|
|
1030
|
+
var QUESTION_REPLY_EVENTS = /* @__PURE__ */ new Set([
|
|
1031
|
+
"question.replied",
|
|
1032
|
+
"question.v2.replied",
|
|
1033
|
+
"question.answered",
|
|
1034
|
+
"question.v2.answered"
|
|
1035
|
+
]);
|
|
1036
|
+
function createLifecycleController({
|
|
1037
|
+
config,
|
|
1038
|
+
reporter,
|
|
1039
|
+
client,
|
|
1040
|
+
directory,
|
|
1041
|
+
now = () => Date.now(),
|
|
1042
|
+
publishers = {}
|
|
1043
|
+
}) {
|
|
1044
|
+
const sendCompleted = publishers.completed ?? publishSessionCompleted;
|
|
1045
|
+
const sendError = publishers.error ?? publishSessionError;
|
|
1046
|
+
const sendRetry = publishers.retry ?? publishSessionRetry;
|
|
1047
|
+
const sendUpdate = publishers.update ?? publishOpenCodeUpdate;
|
|
1048
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1049
|
+
const publishedUpdates = /* @__PURE__ */ new Map();
|
|
1050
|
+
function sessionState(sessionID) {
|
|
1051
|
+
let state = sessions.get(sessionID);
|
|
1052
|
+
if (!state) {
|
|
1053
|
+
state = {
|
|
1054
|
+
busy: false,
|
|
1055
|
+
busySince: 0,
|
|
1056
|
+
failed: false,
|
|
1057
|
+
retryNotified: false,
|
|
1058
|
+
waiting: false,
|
|
1059
|
+
lastAssistant: void 0,
|
|
1060
|
+
completedMessageID: ""
|
|
1061
|
+
};
|
|
1062
|
+
rememberBounded(sessions, sessionID, state);
|
|
1063
|
+
}
|
|
1064
|
+
return state;
|
|
1065
|
+
}
|
|
1066
|
+
function dispatch(label, operation) {
|
|
1067
|
+
void Promise.resolve(operation).catch((error) => reporter.error(label, error));
|
|
1068
|
+
}
|
|
1069
|
+
function beginCycle(state) {
|
|
1070
|
+
if (state.busy) return;
|
|
1071
|
+
state.busy = true;
|
|
1072
|
+
state.busySince = now();
|
|
1073
|
+
state.failed = false;
|
|
1074
|
+
state.retryNotified = false;
|
|
1075
|
+
state.waiting = false;
|
|
1076
|
+
state.lastAssistant = void 0;
|
|
1077
|
+
}
|
|
1078
|
+
function finishCycle(sessionID, state) {
|
|
1079
|
+
if (!state.busy) return;
|
|
1080
|
+
state.busy = false;
|
|
1081
|
+
const assistant = state.lastAssistant;
|
|
1082
|
+
const durationMs = Math.max(0, now() - state.busySince);
|
|
1083
|
+
const qualifies = assistant?.id && assistant.completed && !assistant.error && !state.failed && !state.waiting && durationMs >= config.lifecycle.completionMinSeconds * 1e3 && state.completedMessageID !== assistant.id;
|
|
1084
|
+
if (!qualifies || !config.lifecycle.completed) return;
|
|
1085
|
+
state.completedMessageID = assistant.id;
|
|
1086
|
+
dispatch(
|
|
1087
|
+
"Task completion could not be sent",
|
|
1088
|
+
sendCompleted({
|
|
1089
|
+
config,
|
|
1090
|
+
reporter,
|
|
1091
|
+
client,
|
|
1092
|
+
directory,
|
|
1093
|
+
sessionID,
|
|
1094
|
+
messageID: limit(assistant.id, 200),
|
|
1095
|
+
durationMs
|
|
1096
|
+
})
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
function handleMessage(event) {
|
|
1100
|
+
const info = event.properties?.info;
|
|
1101
|
+
if (info?.role !== "assistant" || typeof info.sessionID !== "string") return;
|
|
1102
|
+
const state = sessionState(info.sessionID);
|
|
1103
|
+
state.lastAssistant = {
|
|
1104
|
+
id: typeof info.id === "string" ? info.id : "",
|
|
1105
|
+
completed: Number.isFinite(info.time?.completed),
|
|
1106
|
+
error: Boolean(info.error)
|
|
1107
|
+
};
|
|
1108
|
+
if (info.error) state.failed = true;
|
|
1109
|
+
}
|
|
1110
|
+
function handleStatus(event) {
|
|
1111
|
+
const sessionID = event.properties?.sessionID;
|
|
1112
|
+
const status = event.properties?.status;
|
|
1113
|
+
if (typeof sessionID !== "string" || typeof status?.type !== "string") return;
|
|
1114
|
+
const state = sessionState(sessionID);
|
|
1115
|
+
if (status.type === "busy") {
|
|
1116
|
+
beginCycle(state);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (status.type === "idle") {
|
|
1120
|
+
finishCycle(sessionID, state);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (status.type !== "retry") return;
|
|
1124
|
+
beginCycle(state);
|
|
1125
|
+
if (!config.lifecycle.retries || state.retryNotified) return;
|
|
1126
|
+
if (!Number.isInteger(status.attempt) || status.attempt < config.lifecycle.retryAttemptThreshold) return;
|
|
1127
|
+
state.retryNotified = true;
|
|
1128
|
+
const correlation = state.lastAssistant?.id || stableHash(
|
|
1129
|
+
`${status.attempt}:${Number.isFinite(status.next) ? status.next : "unknown"}`
|
|
1130
|
+
);
|
|
1131
|
+
dispatch(
|
|
1132
|
+
"Retry warning could not be sent",
|
|
1133
|
+
sendRetry({
|
|
1134
|
+
config,
|
|
1135
|
+
reporter,
|
|
1136
|
+
client,
|
|
1137
|
+
directory,
|
|
1138
|
+
sessionID,
|
|
1139
|
+
correlation: limit(correlation, 200),
|
|
1140
|
+
attempt: status.attempt,
|
|
1141
|
+
next: status.next
|
|
1142
|
+
})
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
function handleError(event) {
|
|
1146
|
+
const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : "";
|
|
1147
|
+
const state = sessionID ? sessionState(sessionID) : void 0;
|
|
1148
|
+
if (state) state.failed = true;
|
|
1149
|
+
if (!config.lifecycle.errors) return;
|
|
1150
|
+
dispatch(
|
|
1151
|
+
"Task failure could not be sent",
|
|
1152
|
+
sendError({
|
|
1153
|
+
config,
|
|
1154
|
+
reporter,
|
|
1155
|
+
client,
|
|
1156
|
+
directory,
|
|
1157
|
+
sessionID,
|
|
1158
|
+
correlation: errorCorrelation(event, state),
|
|
1159
|
+
error: event.properties?.error
|
|
1160
|
+
})
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
function handleWaiting(event, waiting) {
|
|
1164
|
+
const sessionID = event.properties?.sessionID;
|
|
1165
|
+
if (typeof sessionID !== "string") return;
|
|
1166
|
+
sessionState(sessionID).waiting = waiting;
|
|
1167
|
+
}
|
|
1168
|
+
return {
|
|
1169
|
+
handle(event) {
|
|
1170
|
+
if (!config.lifecycle.enabled || !event?.type) return;
|
|
1171
|
+
if (event.type === "message.updated") {
|
|
1172
|
+
handleMessage(event);
|
|
1173
|
+
} else if (event.type === "session.status") {
|
|
1174
|
+
handleStatus(event);
|
|
1175
|
+
} else if (event.type === "session.idle") {
|
|
1176
|
+
const sessionID = event.properties?.sessionID;
|
|
1177
|
+
if (typeof sessionID === "string") finishCycle(sessionID, sessionState(sessionID));
|
|
1178
|
+
} else if (event.type === "session.error") {
|
|
1179
|
+
handleError(event);
|
|
1180
|
+
} else if (event.type === "permission.asked" || QUESTION_EVENTS.has(event.type)) {
|
|
1181
|
+
handleWaiting(event, true);
|
|
1182
|
+
} else if (event.type === "permission.replied" || QUESTION_REPLY_EVENTS.has(event.type)) {
|
|
1183
|
+
handleWaiting(event, false);
|
|
1184
|
+
} else if (event.type === "installation.update-available" && config.lifecycle.updates) {
|
|
1185
|
+
const version = event.properties?.version;
|
|
1186
|
+
if (typeof version !== "string" || !version.trim()) return;
|
|
1187
|
+
if (publishedUpdates.has(version)) return;
|
|
1188
|
+
rememberBounded(publishedUpdates, version, true);
|
|
1189
|
+
dispatch(
|
|
1190
|
+
"OpenCode update could not be sent",
|
|
1191
|
+
sendUpdate({ config, reporter, version: version.trim() })
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
dispose() {
|
|
1196
|
+
sessions.clear();
|
|
1197
|
+
publishedUpdates.clear();
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
687
1202
|
// src/sse.js
|
|
688
1203
|
function parseSseFrame(frame) {
|
|
689
1204
|
const data = [];
|
|
@@ -988,6 +1503,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
988
1503
|
client,
|
|
989
1504
|
directory
|
|
990
1505
|
});
|
|
1506
|
+
const lifecycleController = createLifecycleController({
|
|
1507
|
+
config,
|
|
1508
|
+
reporter,
|
|
1509
|
+
client,
|
|
1510
|
+
directory
|
|
1511
|
+
});
|
|
991
1512
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
992
1513
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
993
1514
|
const updateChecker = createUpdateChecker();
|
|
@@ -1064,6 +1585,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1064
1585
|
"chat.message": handleUpdateNotice,
|
|
1065
1586
|
event: async ({ event }) => {
|
|
1066
1587
|
if (!config.token) return;
|
|
1588
|
+
lifecycleController.handle(event);
|
|
1067
1589
|
if (config.permissions && event.type === "permission.asked") {
|
|
1068
1590
|
await handlePermissionAsked(event);
|
|
1069
1591
|
return;
|
|
@@ -1084,6 +1606,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1084
1606
|
},
|
|
1085
1607
|
dispose: async () => {
|
|
1086
1608
|
await permissionController.dispose();
|
|
1609
|
+
lifecycleController.dispose();
|
|
1087
1610
|
todoCompletionBySessionID.clear();
|
|
1088
1611
|
publishedQuestions.clear();
|
|
1089
1612
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backeer-opencode",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Backeer notifications and remote permission decisions for OpenCode",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|