backeer-opencode 0.1.4 → 0.2.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 +19 -0
- package/README.md +60 -9
- package/dist/backeer-permission.js +553 -7
- package/package.json +2 -2
- package/src/config.js +45 -0
- package/src/lifecycle-controller.js +205 -0
- package/src/lifecycle-publishers.js +187 -0
- package/src/management.js +51 -5
- package/src/plugin.js +21 -1
- package/src/updates.js +136 -0
|
@@ -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");
|
|
@@ -114,6 +151,7 @@ function loadConfig(options = {}) {
|
|
|
114
151
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS
|
|
115
152
|
),
|
|
116
153
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
154
|
+
lifecycle: loadLifecycleConfig(options.lifecycle),
|
|
117
155
|
logLevel: normalizeLogLevel(
|
|
118
156
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL
|
|
119
157
|
)
|
|
@@ -357,8 +395,130 @@ import {
|
|
|
357
395
|
writeFileSync
|
|
358
396
|
} from "node:fs";
|
|
359
397
|
import { dirname, resolve } from "node:path";
|
|
360
|
-
|
|
361
|
-
|
|
398
|
+
|
|
399
|
+
// src/updates.js
|
|
400
|
+
var PLUGIN_VERSION = "0.2.0";
|
|
401
|
+
var PACKAGE_NAME = "backeer-opencode";
|
|
402
|
+
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
403
|
+
var CHECK_TIMEOUT_MS = 3e3;
|
|
404
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
405
|
+
function parseVersion(value) {
|
|
406
|
+
const match = String(value).trim().match(
|
|
407
|
+
/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
408
|
+
);
|
|
409
|
+
if (!match) return;
|
|
410
|
+
return {
|
|
411
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
412
|
+
prerelease: match[4]
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function isVersionNewer(latestValue, currentValue) {
|
|
416
|
+
const latest = parseVersion(latestValue);
|
|
417
|
+
const current = parseVersion(currentValue);
|
|
418
|
+
if (!latest || !current) return false;
|
|
419
|
+
for (let index = 0; index < 3; index++) {
|
|
420
|
+
if (latest.parts[index] > current.parts[index]) return true;
|
|
421
|
+
if (latest.parts[index] < current.parts[index]) return false;
|
|
422
|
+
}
|
|
423
|
+
if (!latest.prerelease && current.prerelease) return true;
|
|
424
|
+
if (latest.prerelease && !current.prerelease) return false;
|
|
425
|
+
return Boolean(
|
|
426
|
+
latest.prerelease && current.prerelease && latest.prerelease !== current.prerelease
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
function createUpdateChecker({
|
|
430
|
+
fetchImplementation = globalThis.fetch,
|
|
431
|
+
now = () => Date.now()
|
|
432
|
+
} = {}) {
|
|
433
|
+
let checkedAt = 0;
|
|
434
|
+
let cachedResult;
|
|
435
|
+
let pendingCheck;
|
|
436
|
+
async function fetchLatest() {
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
|
439
|
+
try {
|
|
440
|
+
const response = await fetchImplementation(
|
|
441
|
+
`${NPM_REGISTRY_URL}/${PACKAGE_NAME}/latest`,
|
|
442
|
+
{ signal: controller.signal }
|
|
443
|
+
);
|
|
444
|
+
if (!response.ok) return { state: "unavailable", currentVersion: PLUGIN_VERSION };
|
|
445
|
+
const data = await response.json();
|
|
446
|
+
const latestVersion = typeof data?.version === "string" ? data.version.trim() : "";
|
|
447
|
+
if (!parseVersion(latestVersion)) {
|
|
448
|
+
return { state: "unavailable", currentVersion: PLUGIN_VERSION };
|
|
449
|
+
}
|
|
450
|
+
if (!isVersionNewer(latestVersion, PLUGIN_VERSION)) {
|
|
451
|
+
return {
|
|
452
|
+
state: "current",
|
|
453
|
+
currentVersion: PLUGIN_VERSION,
|
|
454
|
+
latestVersion
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
state: "available",
|
|
459
|
+
currentVersion: PLUGIN_VERSION,
|
|
460
|
+
latestVersion,
|
|
461
|
+
updateCommand: `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`
|
|
462
|
+
};
|
|
463
|
+
} catch {
|
|
464
|
+
return { state: "unavailable", currentVersion: PLUGIN_VERSION };
|
|
465
|
+
} finally {
|
|
466
|
+
clearTimeout(timeout);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
async check({ force = false } = {}) {
|
|
471
|
+
if (!force && checkedAt && now() - checkedAt < CHECK_INTERVAL_MS) {
|
|
472
|
+
return cachedResult;
|
|
473
|
+
}
|
|
474
|
+
if (pendingCheck) return pendingCheck;
|
|
475
|
+
pendingCheck = fetchLatest().then((result) => {
|
|
476
|
+
cachedResult = result;
|
|
477
|
+
checkedAt = now();
|
|
478
|
+
return result;
|
|
479
|
+
}).finally(() => {
|
|
480
|
+
pendingCheck = void 0;
|
|
481
|
+
});
|
|
482
|
+
return pendingCheck;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function formatAgentUpdateInstruction(info) {
|
|
487
|
+
return [
|
|
488
|
+
"[BACKEER UPDATE REQUESTED]",
|
|
489
|
+
`The user explicitly requested the Backeer OpenCode plugin update from v${info.currentVersion} to v${info.latestVersion}.`,
|
|
490
|
+
`Run exactly: ${info.updateCommand}`,
|
|
491
|
+
"Then verify that the global OpenCode config is pinned to the exact new version and that the matching package exists in the OpenCode cache.",
|
|
492
|
+
"Do not read, print, replace, or otherwise expose the Backeer token.",
|
|
493
|
+
"Do not use @latest and do not claim success until both installation and verification pass.",
|
|
494
|
+
"Do not terminate the active OpenCode process; tell the user to restart OpenCode after success."
|
|
495
|
+
].join("\n");
|
|
496
|
+
}
|
|
497
|
+
function createUpdateNoticeHook(updateChecker) {
|
|
498
|
+
const notifiedSessions = /* @__PURE__ */ new Set();
|
|
499
|
+
return async (input, output) => {
|
|
500
|
+
if (notifiedSessions.has(input.sessionID)) return;
|
|
501
|
+
notifiedSessions.add(input.sessionID);
|
|
502
|
+
const info = await updateChecker.check();
|
|
503
|
+
if (info.state !== "available") return;
|
|
504
|
+
output.parts.push({
|
|
505
|
+
id: `prt_backeer-update-${Date.now()}`,
|
|
506
|
+
sessionID: input.sessionID,
|
|
507
|
+
messageID: output.message.id,
|
|
508
|
+
type: "text",
|
|
509
|
+
synthetic: true,
|
|
510
|
+
text: [
|
|
511
|
+
"[BACKEER UPDATE AVAILABLE]",
|
|
512
|
+
`Backeer OpenCode plugin v${info.latestVersion} is available; current version is v${info.currentVersion}.`,
|
|
513
|
+
"Inform the user that an update is available and suggest `/backeer update`.",
|
|
514
|
+
"Do not install it solely because of this notice."
|
|
515
|
+
].join("\n")
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/management.js
|
|
521
|
+
var COMMAND_DESCRIPTION = "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]";
|
|
362
522
|
function tokenFileState(path) {
|
|
363
523
|
try {
|
|
364
524
|
const stats = statSync(path);
|
|
@@ -418,19 +578,35 @@ async function probeGateway(config) {
|
|
|
418
578
|
clearTimeout(timeout);
|
|
419
579
|
}
|
|
420
580
|
}
|
|
421
|
-
|
|
422
|
-
|
|
581
|
+
function updateStatus(info) {
|
|
582
|
+
if (info.state === "available") {
|
|
583
|
+
return `v${info.latestVersion} available \u2014 use /backeer update`;
|
|
584
|
+
}
|
|
585
|
+
if (info.state === "current") return `current (v${info.currentVersion})`;
|
|
586
|
+
return "check unavailable";
|
|
587
|
+
}
|
|
588
|
+
async function buildStatus(config, permissionController, updateChecker) {
|
|
589
|
+
const [gateway, update] = await Promise.all([
|
|
590
|
+
probeGateway(config),
|
|
591
|
+
updateChecker.check()
|
|
592
|
+
]);
|
|
423
593
|
const permissionStatus = permissionController.status();
|
|
424
594
|
return [
|
|
425
595
|
"## Backeer status",
|
|
426
596
|
"",
|
|
427
597
|
`- Plugin version: ${PLUGIN_VERSION}`,
|
|
598
|
+
`- Update: ${updateStatus(update)}`,
|
|
428
599
|
`- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
|
|
429
600
|
`- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
|
|
430
601
|
`- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
|
|
431
602
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
432
603
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
433
604
|
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
605
|
+
`- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
|
|
606
|
+
` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
|
|
607
|
+
` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
|
|
608
|
+
` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
|
|
609
|
+
` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
|
|
434
610
|
`- Command stream: ${permissionStatus.stream}`,
|
|
435
611
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
436
612
|
`- Log level: ${config.logLevel}`,
|
|
@@ -443,6 +619,8 @@ function helpMessage() {
|
|
|
443
619
|
"## Backeer commands",
|
|
444
620
|
"",
|
|
445
621
|
"- `/backeer` or `/backeer status` \u2014 show integration status",
|
|
622
|
+
"- `/backeer update-check` \u2014 check NPM for a newer version",
|
|
623
|
+
"- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
|
|
446
624
|
"- `/backeer token` \u2014 show secure token-change instructions",
|
|
447
625
|
"- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
|
|
448
626
|
"",
|
|
@@ -486,7 +664,8 @@ function createBackeerCommandHandler({
|
|
|
486
664
|
client,
|
|
487
665
|
config,
|
|
488
666
|
permissionController,
|
|
489
|
-
reporter
|
|
667
|
+
reporter,
|
|
668
|
+
updateChecker
|
|
490
669
|
}) {
|
|
491
670
|
return async (input, output) => {
|
|
492
671
|
if (input.command !== "backeer") return;
|
|
@@ -498,7 +677,26 @@ function createBackeerCommandHandler({
|
|
|
498
677
|
let message;
|
|
499
678
|
try {
|
|
500
679
|
if (!subcommand || subcommand === "status") {
|
|
501
|
-
message = await buildStatus(config, permissionController);
|
|
680
|
+
message = await buildStatus(config, permissionController, updateChecker);
|
|
681
|
+
} else if (subcommand === "update-check") {
|
|
682
|
+
const update = await updateChecker.check({ force: true });
|
|
683
|
+
message = [
|
|
684
|
+
"## Backeer update check",
|
|
685
|
+
"",
|
|
686
|
+
update.state === "available" ? `Version ${update.latestVersion} is available; current version is ${update.currentVersion}. Use \`/backeer update\` to let the OpenCode agent install and verify it.` : update.state === "current" ? `Backeer is current at version ${update.currentVersion}.` : "The NPM registry could not be reached. No update was attempted."
|
|
687
|
+
].join("\n");
|
|
688
|
+
} else if (subcommand === "update") {
|
|
689
|
+
const update = await updateChecker.check({ force: true });
|
|
690
|
+
if (update.state === "available") {
|
|
691
|
+
output.parts.push({
|
|
692
|
+
type: "text",
|
|
693
|
+
text: formatAgentUpdateInstruction(update)
|
|
694
|
+
});
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
message = update.state === "current" ? `## Backeer update
|
|
698
|
+
|
|
699
|
+
Backeer is already current at version ${update.currentVersion}.` : "## Backeer update\n\nThe NPM registry could not be reached. No update was attempted.";
|
|
502
700
|
} else if (subcommand === "token") {
|
|
503
701
|
message = tokenInstructions();
|
|
504
702
|
} else if (subcommand === "token-file") {
|
|
@@ -529,6 +727,342 @@ function createBackeerCommandHandler({
|
|
|
529
727
|
};
|
|
530
728
|
}
|
|
531
729
|
|
|
730
|
+
// src/lifecycle-publishers.js
|
|
731
|
+
var CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
732
|
+
var SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi;
|
|
733
|
+
var BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi;
|
|
734
|
+
var URL_WITH_QUERY = /\bhttps?:\/\/[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi;
|
|
735
|
+
var WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g;
|
|
736
|
+
var POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g;
|
|
737
|
+
function sanitizeErrorMessage(value) {
|
|
738
|
+
return limit(value, 2e3).replace(CONTROL_CHARACTERS, " ").replace(BEARER_TOKEN, "Bearer [redacted]").replace(SECRET_ASSIGNMENT, "$1=[redacted]").replace(URL_WITH_QUERY, (value2) => {
|
|
739
|
+
try {
|
|
740
|
+
const url = new URL(value2);
|
|
741
|
+
return `${url.origin}${url.pathname}`;
|
|
742
|
+
} catch {
|
|
743
|
+
return "[url redacted]";
|
|
744
|
+
}
|
|
745
|
+
}).replace(WINDOWS_PATH, "[path redacted]").replace(POSIX_PATH, "$1[path redacted]").replace(/\s+/g, " ").trim().slice(0, 500);
|
|
746
|
+
}
|
|
747
|
+
function safeErrorDetails(error) {
|
|
748
|
+
const name = limit(error?.name || "OpenCodeError", 100);
|
|
749
|
+
const message = sanitizeErrorMessage(error?.data?.message ?? error?.message);
|
|
750
|
+
const statusCode = Number.isInteger(error?.data?.statusCode) ? String(error.data.statusCode) : "";
|
|
751
|
+
return { name, message, statusCode };
|
|
752
|
+
}
|
|
753
|
+
async function resolveSessionTitle2(client, directory, sessionID, reporter) {
|
|
754
|
+
if (!sessionID || typeof client?.session?.get !== "function") return "";
|
|
755
|
+
try {
|
|
756
|
+
const result = await client.session.get({
|
|
757
|
+
path: { id: sessionID },
|
|
758
|
+
query: { directory },
|
|
759
|
+
throwOnError: true
|
|
760
|
+
});
|
|
761
|
+
return limit((result?.data ?? result)?.title);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
reporter.debug("Session title unavailable", error);
|
|
764
|
+
return "";
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async function publish(config, reporter, event, feedback) {
|
|
768
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
769
|
+
type: "application",
|
|
770
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
771
|
+
notify: true,
|
|
772
|
+
direction: "in",
|
|
773
|
+
...event
|
|
774
|
+
});
|
|
775
|
+
reporter.success(feedback, `HTTP ${result.status}`);
|
|
776
|
+
}
|
|
777
|
+
function secondsLabel(durationMs) {
|
|
778
|
+
const seconds = Math.max(0, Math.round(durationMs / 1e3));
|
|
779
|
+
if (seconds < 60) return `${seconds}s`;
|
|
780
|
+
const minutes = Math.floor(seconds / 60);
|
|
781
|
+
const remainder = seconds % 60;
|
|
782
|
+
return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
|
783
|
+
}
|
|
784
|
+
async function publishSessionCompleted({
|
|
785
|
+
config,
|
|
786
|
+
reporter,
|
|
787
|
+
client,
|
|
788
|
+
directory,
|
|
789
|
+
sessionID,
|
|
790
|
+
messageID,
|
|
791
|
+
durationMs
|
|
792
|
+
}) {
|
|
793
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
794
|
+
client,
|
|
795
|
+
directory,
|
|
796
|
+
sessionID,
|
|
797
|
+
reporter
|
|
798
|
+
);
|
|
799
|
+
const duration = secondsLabel(durationMs);
|
|
800
|
+
const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
|
|
801
|
+
await publish(config, reporter, {
|
|
802
|
+
severity: "info",
|
|
803
|
+
title: "OpenCode task completed",
|
|
804
|
+
body: `OpenCode finished${location} in ${duration}.`,
|
|
805
|
+
payload: [
|
|
806
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
807
|
+
{ key: "Outcome", value: "Completed" },
|
|
808
|
+
{ key: "Duration", value: duration }
|
|
809
|
+
].filter((item) => item.value),
|
|
810
|
+
externalId: `opencode:lifecycle:completed:${sessionID}:${messageID}`
|
|
811
|
+
}, "Task completion sent");
|
|
812
|
+
}
|
|
813
|
+
async function publishSessionRetry({
|
|
814
|
+
config,
|
|
815
|
+
reporter,
|
|
816
|
+
client,
|
|
817
|
+
directory,
|
|
818
|
+
sessionID,
|
|
819
|
+
correlation,
|
|
820
|
+
attempt,
|
|
821
|
+
next
|
|
822
|
+
}) {
|
|
823
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
824
|
+
client,
|
|
825
|
+
directory,
|
|
826
|
+
sessionID,
|
|
827
|
+
reporter
|
|
828
|
+
);
|
|
829
|
+
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
|
|
830
|
+
await publish(config, reporter, {
|
|
831
|
+
severity: "warning",
|
|
832
|
+
title: "OpenCode is retrying",
|
|
833
|
+
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
834
|
+
payload: [
|
|
835
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
836
|
+
{ key: "Retry attempt", value: String(attempt) },
|
|
837
|
+
{ key: "Next retry", value: retryAt }
|
|
838
|
+
].filter((item) => item.value),
|
|
839
|
+
externalId: `opencode:lifecycle:retry:${sessionID}:${correlation}`
|
|
840
|
+
}, "Retry warning sent");
|
|
841
|
+
}
|
|
842
|
+
async function publishSessionError({
|
|
843
|
+
config,
|
|
844
|
+
reporter,
|
|
845
|
+
client,
|
|
846
|
+
directory,
|
|
847
|
+
sessionID,
|
|
848
|
+
correlation,
|
|
849
|
+
error
|
|
850
|
+
}) {
|
|
851
|
+
const sessionTitle = await resolveSessionTitle2(
|
|
852
|
+
client,
|
|
853
|
+
directory,
|
|
854
|
+
sessionID,
|
|
855
|
+
reporter
|
|
856
|
+
);
|
|
857
|
+
const details = safeErrorDetails(error);
|
|
858
|
+
const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
|
|
859
|
+
const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
|
|
860
|
+
await publish(config, reporter, {
|
|
861
|
+
// Backeer/iOS currently support info, warning and critical severities.
|
|
862
|
+
severity: "critical",
|
|
863
|
+
title: "OpenCode task failed",
|
|
864
|
+
body: limit(body, 1e3),
|
|
865
|
+
payload: [
|
|
866
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
867
|
+
{ key: "Outcome", value: "Failed" },
|
|
868
|
+
{ key: "Error type", value: details.name },
|
|
869
|
+
{ key: "HTTP status", value: details.statusCode },
|
|
870
|
+
{ key: "Error", value: details.message }
|
|
871
|
+
].filter((item) => item.value),
|
|
872
|
+
externalId: `opencode:lifecycle:error:${sessionID || "global"}:${correlation}`
|
|
873
|
+
}, "Task failure sent");
|
|
874
|
+
}
|
|
875
|
+
async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
876
|
+
const safeVersion = limit(version, 100);
|
|
877
|
+
await publish(config, reporter, {
|
|
878
|
+
severity: "info",
|
|
879
|
+
title: "OpenCode update available",
|
|
880
|
+
body: `OpenCode ${safeVersion} is available.`,
|
|
881
|
+
payload: [{ key: "Available version", value: safeVersion }],
|
|
882
|
+
externalId: `opencode:lifecycle:update:${safeVersion}`
|
|
883
|
+
}, "OpenCode update sent");
|
|
884
|
+
}
|
|
885
|
+
function errorCorrelation(event, sessionState) {
|
|
886
|
+
if (typeof event?.id === "string" && event.id) return limit(event.id, 200);
|
|
887
|
+
if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200);
|
|
888
|
+
const details = safeErrorDetails(event?.properties?.error);
|
|
889
|
+
return stableHash(`${details.name}:${details.statusCode}:${details.message}`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/lifecycle-controller.js
|
|
893
|
+
var QUESTION_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
|
|
894
|
+
var QUESTION_REPLY_EVENTS = /* @__PURE__ */ new Set([
|
|
895
|
+
"question.replied",
|
|
896
|
+
"question.v2.replied",
|
|
897
|
+
"question.answered",
|
|
898
|
+
"question.v2.answered"
|
|
899
|
+
]);
|
|
900
|
+
function createLifecycleController({
|
|
901
|
+
config,
|
|
902
|
+
reporter,
|
|
903
|
+
client,
|
|
904
|
+
directory,
|
|
905
|
+
now = () => Date.now(),
|
|
906
|
+
publishers = {}
|
|
907
|
+
}) {
|
|
908
|
+
const sendCompleted = publishers.completed ?? publishSessionCompleted;
|
|
909
|
+
const sendError = publishers.error ?? publishSessionError;
|
|
910
|
+
const sendRetry = publishers.retry ?? publishSessionRetry;
|
|
911
|
+
const sendUpdate = publishers.update ?? publishOpenCodeUpdate;
|
|
912
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
913
|
+
const publishedUpdates = /* @__PURE__ */ new Map();
|
|
914
|
+
function sessionState(sessionID) {
|
|
915
|
+
let state = sessions.get(sessionID);
|
|
916
|
+
if (!state) {
|
|
917
|
+
state = {
|
|
918
|
+
busy: false,
|
|
919
|
+
busySince: 0,
|
|
920
|
+
failed: false,
|
|
921
|
+
retryNotified: false,
|
|
922
|
+
waiting: false,
|
|
923
|
+
lastAssistant: void 0,
|
|
924
|
+
completedMessageID: ""
|
|
925
|
+
};
|
|
926
|
+
rememberBounded(sessions, sessionID, state);
|
|
927
|
+
}
|
|
928
|
+
return state;
|
|
929
|
+
}
|
|
930
|
+
function dispatch(label, operation) {
|
|
931
|
+
void Promise.resolve(operation).catch((error) => reporter.error(label, error));
|
|
932
|
+
}
|
|
933
|
+
function beginCycle(state) {
|
|
934
|
+
if (state.busy) return;
|
|
935
|
+
state.busy = true;
|
|
936
|
+
state.busySince = now();
|
|
937
|
+
state.failed = false;
|
|
938
|
+
state.retryNotified = false;
|
|
939
|
+
state.waiting = false;
|
|
940
|
+
state.lastAssistant = void 0;
|
|
941
|
+
}
|
|
942
|
+
function finishCycle(sessionID, state) {
|
|
943
|
+
if (!state.busy) return;
|
|
944
|
+
state.busy = false;
|
|
945
|
+
const assistant = state.lastAssistant;
|
|
946
|
+
const durationMs = Math.max(0, now() - state.busySince);
|
|
947
|
+
const qualifies = assistant?.id && assistant.completed && !assistant.error && !state.failed && !state.waiting && durationMs >= config.lifecycle.completionMinSeconds * 1e3 && state.completedMessageID !== assistant.id;
|
|
948
|
+
if (!qualifies || !config.lifecycle.completed) return;
|
|
949
|
+
state.completedMessageID = assistant.id;
|
|
950
|
+
dispatch(
|
|
951
|
+
"Task completion could not be sent",
|
|
952
|
+
sendCompleted({
|
|
953
|
+
config,
|
|
954
|
+
reporter,
|
|
955
|
+
client,
|
|
956
|
+
directory,
|
|
957
|
+
sessionID,
|
|
958
|
+
messageID: limit(assistant.id, 200),
|
|
959
|
+
durationMs
|
|
960
|
+
})
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
function handleMessage(event) {
|
|
964
|
+
const info = event.properties?.info;
|
|
965
|
+
if (info?.role !== "assistant" || typeof info.sessionID !== "string") return;
|
|
966
|
+
const state = sessionState(info.sessionID);
|
|
967
|
+
state.lastAssistant = {
|
|
968
|
+
id: typeof info.id === "string" ? info.id : "",
|
|
969
|
+
completed: Number.isFinite(info.time?.completed),
|
|
970
|
+
error: Boolean(info.error)
|
|
971
|
+
};
|
|
972
|
+
if (info.error) state.failed = true;
|
|
973
|
+
}
|
|
974
|
+
function handleStatus(event) {
|
|
975
|
+
const sessionID = event.properties?.sessionID;
|
|
976
|
+
const status = event.properties?.status;
|
|
977
|
+
if (typeof sessionID !== "string" || typeof status?.type !== "string") return;
|
|
978
|
+
const state = sessionState(sessionID);
|
|
979
|
+
if (status.type === "busy") {
|
|
980
|
+
beginCycle(state);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (status.type === "idle") {
|
|
984
|
+
finishCycle(sessionID, state);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (status.type !== "retry") return;
|
|
988
|
+
beginCycle(state);
|
|
989
|
+
if (!config.lifecycle.retries || state.retryNotified) return;
|
|
990
|
+
if (!Number.isInteger(status.attempt) || status.attempt < config.lifecycle.retryAttemptThreshold) return;
|
|
991
|
+
state.retryNotified = true;
|
|
992
|
+
const correlation = state.lastAssistant?.id || stableHash(
|
|
993
|
+
`${status.attempt}:${Number.isFinite(status.next) ? status.next : "unknown"}`
|
|
994
|
+
);
|
|
995
|
+
dispatch(
|
|
996
|
+
"Retry warning could not be sent",
|
|
997
|
+
sendRetry({
|
|
998
|
+
config,
|
|
999
|
+
reporter,
|
|
1000
|
+
client,
|
|
1001
|
+
directory,
|
|
1002
|
+
sessionID,
|
|
1003
|
+
correlation: limit(correlation, 200),
|
|
1004
|
+
attempt: status.attempt,
|
|
1005
|
+
next: status.next
|
|
1006
|
+
})
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
function handleError(event) {
|
|
1010
|
+
const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : "";
|
|
1011
|
+
const state = sessionID ? sessionState(sessionID) : void 0;
|
|
1012
|
+
if (state) state.failed = true;
|
|
1013
|
+
if (!config.lifecycle.errors) return;
|
|
1014
|
+
dispatch(
|
|
1015
|
+
"Task failure could not be sent",
|
|
1016
|
+
sendError({
|
|
1017
|
+
config,
|
|
1018
|
+
reporter,
|
|
1019
|
+
client,
|
|
1020
|
+
directory,
|
|
1021
|
+
sessionID,
|
|
1022
|
+
correlation: errorCorrelation(event, state),
|
|
1023
|
+
error: event.properties?.error
|
|
1024
|
+
})
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
function handleWaiting(event, waiting) {
|
|
1028
|
+
const sessionID = event.properties?.sessionID;
|
|
1029
|
+
if (typeof sessionID !== "string") return;
|
|
1030
|
+
sessionState(sessionID).waiting = waiting;
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
handle(event) {
|
|
1034
|
+
if (!config.lifecycle.enabled || !event?.type) return;
|
|
1035
|
+
if (event.type === "message.updated") {
|
|
1036
|
+
handleMessage(event);
|
|
1037
|
+
} else if (event.type === "session.status") {
|
|
1038
|
+
handleStatus(event);
|
|
1039
|
+
} else if (event.type === "session.idle") {
|
|
1040
|
+
const sessionID = event.properties?.sessionID;
|
|
1041
|
+
if (typeof sessionID === "string") finishCycle(sessionID, sessionState(sessionID));
|
|
1042
|
+
} else if (event.type === "session.error") {
|
|
1043
|
+
handleError(event);
|
|
1044
|
+
} else if (event.type === "permission.asked" || QUESTION_EVENTS.has(event.type)) {
|
|
1045
|
+
handleWaiting(event, true);
|
|
1046
|
+
} else if (event.type === "permission.replied" || QUESTION_REPLY_EVENTS.has(event.type)) {
|
|
1047
|
+
handleWaiting(event, false);
|
|
1048
|
+
} else if (event.type === "installation.update-available" && config.lifecycle.updates) {
|
|
1049
|
+
const version = event.properties?.version;
|
|
1050
|
+
if (typeof version !== "string" || !version.trim()) return;
|
|
1051
|
+
if (publishedUpdates.has(version)) return;
|
|
1052
|
+
rememberBounded(publishedUpdates, version, true);
|
|
1053
|
+
dispatch(
|
|
1054
|
+
"OpenCode update could not be sent",
|
|
1055
|
+
sendUpdate({ config, reporter, version: version.trim() })
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
dispose() {
|
|
1060
|
+
sessions.clear();
|
|
1061
|
+
publishedUpdates.clear();
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
532
1066
|
// src/sse.js
|
|
533
1067
|
function parseSseFrame(frame) {
|
|
534
1068
|
const data = [];
|
|
@@ -833,14 +1367,23 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
833
1367
|
client,
|
|
834
1368
|
directory
|
|
835
1369
|
});
|
|
1370
|
+
const lifecycleController = createLifecycleController({
|
|
1371
|
+
config,
|
|
1372
|
+
reporter,
|
|
1373
|
+
client,
|
|
1374
|
+
directory
|
|
1375
|
+
});
|
|
836
1376
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
837
1377
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
1378
|
+
const updateChecker = createUpdateChecker();
|
|
838
1379
|
const handleBackeerCommand = createBackeerCommandHandler({
|
|
839
1380
|
client,
|
|
840
1381
|
config,
|
|
841
1382
|
permissionController,
|
|
842
|
-
reporter
|
|
1383
|
+
reporter,
|
|
1384
|
+
updateChecker
|
|
843
1385
|
});
|
|
1386
|
+
const handleUpdateNotice = createUpdateNoticeHook(updateChecker);
|
|
844
1387
|
if (!config.token) {
|
|
845
1388
|
reporter.error(
|
|
846
1389
|
"Notifications disabled \u2014 use /backeer token",
|
|
@@ -903,8 +1446,10 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
903
1446
|
registerBackeerCommand(opencodeConfig);
|
|
904
1447
|
},
|
|
905
1448
|
"command.execute.before": handleBackeerCommand,
|
|
1449
|
+
"chat.message": handleUpdateNotice,
|
|
906
1450
|
event: async ({ event }) => {
|
|
907
1451
|
if (!config.token) return;
|
|
1452
|
+
lifecycleController.handle(event);
|
|
908
1453
|
if (config.permissions && event.type === "permission.asked") {
|
|
909
1454
|
await handlePermissionAsked(event);
|
|
910
1455
|
return;
|
|
@@ -925,6 +1470,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
925
1470
|
},
|
|
926
1471
|
dispose: async () => {
|
|
927
1472
|
await permissionController.dispose();
|
|
1473
|
+
lifecycleController.dispose();
|
|
928
1474
|
todoCompletionBySessionID.clear();
|
|
929
1475
|
publishedQuestions.clear();
|
|
930
1476
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backeer-opencode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Backeer notifications and remote permission decisions for OpenCode",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|