backeer-opencode 0.1.5 → 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 +9 -0
- package/README.md +45 -9
- package/dist/backeer-permission.js +388 -1
- 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 +5 -0
- package/src/plugin.js +12 -1
- package/src/updates.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.0 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Notify when an OpenCode assistant run completes successfully.
|
|
8
|
+
- Notify terminal session errors with redacted diagnostic summaries.
|
|
9
|
+
- Warn once when a session reaches its third retry attempt.
|
|
10
|
+
- Notify once per version when an OpenCode update is available.
|
|
11
|
+
- Add granular lifecycle configuration and lifecycle state to `/backeer status`.
|
|
12
|
+
- Use stable lifecycle event IDs and the completion sound for successful runs.
|
|
13
|
+
|
|
5
14
|
## 0.1.5 - 2026-09-01
|
|
6
15
|
|
|
7
16
|
- Check the NPM registry for a newer Backeer plugin version once per day.
|
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Backeer for OpenCode
|
|
2
2
|
|
|
3
3
|
`backeer-opencode` connects OpenCode to Backeer. It sends mobile notifications
|
|
4
|
-
for permission requests, questions,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
for permission requests, questions, completed todo lists, completed assistant
|
|
5
|
+
runs, session failures, prolonged retries, and OpenCode updates. While a
|
|
6
|
+
permission is pending, it also consumes an authenticated Backeer command stream
|
|
7
|
+
so an authorized user can answer **Allow once**, **Always allow**, or **Reject**
|
|
8
|
+
from the Backeer iOS application.
|
|
8
9
|
|
|
9
10
|
The plugin uses outbound HTTPS only. It does not start a server or require an
|
|
10
11
|
inbound firewall rule.
|
|
@@ -109,6 +110,13 @@ environment variables are supported:
|
|
|
109
110
|
| `BACKEER_NOTIFY_PERMISSIONS` | Set to `false` to disable permission notifications and remote replies |
|
|
110
111
|
| `BACKEER_NOTIFY_QUESTIONS` | Set to `false` to disable question notifications |
|
|
111
112
|
| `BACKEER_NOTIFY_TODOS` | Set to `false` to disable todo-completion notifications |
|
|
113
|
+
| `BACKEER_NOTIFY_LIFECYCLE` | Set to `false` to disable every lifecycle notification |
|
|
114
|
+
| `BACKEER_NOTIFY_SESSION_COMPLETED` | Set to `false` to disable successful-run notifications |
|
|
115
|
+
| `BACKEER_NOTIFY_SESSION_ERRORS` | Set to `false` to disable session-error notifications |
|
|
116
|
+
| `BACKEER_NOTIFY_SESSION_RETRIES` | Set to `false` to disable retry warnings |
|
|
117
|
+
| `BACKEER_NOTIFY_OPENCODE_UPDATES` | Set to `false` to disable OpenCode update notifications |
|
|
118
|
+
| `BACKEER_SESSION_COMPLETION_MIN_SECONDS` | Minimum run duration; defaults to `0` (every completion) |
|
|
119
|
+
| `BACKEER_RETRY_ATTEMPT_THRESHOLD` | First retry attempt that triggers a warning; defaults to `3` |
|
|
112
120
|
| `BACKEER_LOG_LEVEL` | `info` (default), `error`, `debug`, or `silent` |
|
|
113
121
|
|
|
114
122
|
For npm installations, non-secret options can also be supplied through the
|
|
@@ -122,7 +130,16 @@ OpenCode plugin tuple syntax:
|
|
|
122
130
|
{
|
|
123
131
|
"permissions": true,
|
|
124
132
|
"questions": true,
|
|
125
|
-
"todos": true
|
|
133
|
+
"todos": true,
|
|
134
|
+
"lifecycle": {
|
|
135
|
+
"enabled": true,
|
|
136
|
+
"completed": true,
|
|
137
|
+
"errors": true,
|
|
138
|
+
"retries": true,
|
|
139
|
+
"updates": true,
|
|
140
|
+
"completionMinSeconds": 0,
|
|
141
|
+
"retryAttemptThreshold": 3
|
|
142
|
+
}
|
|
126
143
|
}
|
|
127
144
|
]
|
|
128
145
|
]
|
|
@@ -130,7 +147,8 @@ OpenCode plugin tuple syntax:
|
|
|
130
147
|
```
|
|
131
148
|
|
|
132
149
|
Supported options are `origin`, `tokenFile`, `permissions`, `questions`,
|
|
133
|
-
`todos`, and `logLevel`.
|
|
150
|
+
`todos`, `lifecycle`, and `logLevel`. Use `"lifecycle": false` to disable the
|
|
151
|
+
entire lifecycle group. The token itself is deliberately not a plugin option.
|
|
134
152
|
|
|
135
153
|
## Console feedback
|
|
136
154
|
|
|
@@ -140,6 +158,9 @@ The plugin writes compact, single-line status messages instead of raw HTTP logs:
|
|
|
140
158
|
◆ Backeer Permission request sent
|
|
141
159
|
◆ Backeer Question sent
|
|
142
160
|
◆ Backeer Todo completion sent
|
|
161
|
+
◆ Backeer Task completion sent
|
|
162
|
+
◆ Backeer Retry warning sent
|
|
163
|
+
◆ Backeer Task failure sent
|
|
143
164
|
✓ Backeer Decision applied: Always allow
|
|
144
165
|
! Backeer Connection interrupted — retrying
|
|
145
166
|
```
|
|
@@ -163,9 +184,9 @@ The plugin registers a `/backeer` command inside OpenCode:
|
|
|
163
184
|
```
|
|
164
185
|
|
|
165
186
|
The status report shows the plugin version, gateway reachability, token source,
|
|
166
|
-
notification switches, command-stream state, pending
|
|
167
|
-
It never displays the token. Gateway reachability
|
|
168
|
-
credential.
|
|
187
|
+
notification switches and lifecycle thresholds, command-stream state, pending
|
|
188
|
+
permissions, and log level. It never displays the token. Gateway reachability
|
|
189
|
+
does not validate the source credential.
|
|
169
190
|
|
|
170
191
|
`/backeer token` displays safe rotation instructions. Never paste a raw token
|
|
171
192
|
into slash-command arguments because OpenCode may retain command text in session
|
|
@@ -198,6 +219,21 @@ The plugin publishes:
|
|
|
198
219
|
| `question.asked` | Notification-only question card |
|
|
199
220
|
| `question.v2.asked` | Notification-only question card, including plan-mode questions |
|
|
200
221
|
| `todo.updated` | One notification when a non-empty list transitions to fully completed |
|
|
222
|
+
| `message.updated` + `session.status`/`session.idle` | One notification for each successfully completed assistant run |
|
|
223
|
+
| `session.error` | Critical failure notification with a redacted error summary |
|
|
224
|
+
| `session.status` (`retry`) | One warning when the configured retry threshold is reached |
|
|
225
|
+
| `installation.update-available` | One notification per available OpenCode version |
|
|
226
|
+
|
|
227
|
+
Completion requires a completed, error-free assistant message in the current
|
|
228
|
+
busy cycle. An idle event caused by a pending permission or question is not
|
|
229
|
+
reported as successful. Repeated idle callbacks, retry callbacks, error events,
|
|
230
|
+
and update events use stable correlation IDs so they do not create duplicate
|
|
231
|
+
push notifications.
|
|
232
|
+
|
|
233
|
+
Lifecycle payloads intentionally omit command arguments, model output, file
|
|
234
|
+
paths and diffs, stack traces, HTTP response bodies, and response headers. Error
|
|
235
|
+
messages are length-limited and redact common credentials, URL queries, and
|
|
236
|
+
filesystem paths before leaving the OpenCode host.
|
|
201
237
|
|
|
202
238
|
Permission commands are delivered at least once. The plugin caches terminal
|
|
203
239
|
acknowledgements and resends the same acknowledgement on redelivery without
|
|
@@ -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
|
)
|
|
@@ -359,7 +397,7 @@ import {
|
|
|
359
397
|
import { dirname, resolve } from "node:path";
|
|
360
398
|
|
|
361
399
|
// src/updates.js
|
|
362
|
-
var PLUGIN_VERSION = "0.
|
|
400
|
+
var PLUGIN_VERSION = "0.2.0";
|
|
363
401
|
var PACKAGE_NAME = "backeer-opencode";
|
|
364
402
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
365
403
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -564,6 +602,11 @@ async function buildStatus(config, permissionController, updateChecker) {
|
|
|
564
602
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
565
603
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
566
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"}`,
|
|
567
610
|
`- Command stream: ${permissionStatus.stream}`,
|
|
568
611
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
569
612
|
`- Log level: ${config.logLevel}`,
|
|
@@ -684,6 +727,342 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
684
727
|
};
|
|
685
728
|
}
|
|
686
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
|
+
|
|
687
1066
|
// src/sse.js
|
|
688
1067
|
function parseSseFrame(frame) {
|
|
689
1068
|
const data = [];
|
|
@@ -988,6 +1367,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
988
1367
|
client,
|
|
989
1368
|
directory
|
|
990
1369
|
});
|
|
1370
|
+
const lifecycleController = createLifecycleController({
|
|
1371
|
+
config,
|
|
1372
|
+
reporter,
|
|
1373
|
+
client,
|
|
1374
|
+
directory
|
|
1375
|
+
});
|
|
991
1376
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
992
1377
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
993
1378
|
const updateChecker = createUpdateChecker();
|
|
@@ -1064,6 +1449,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1064
1449
|
"chat.message": handleUpdateNotice,
|
|
1065
1450
|
event: async ({ event }) => {
|
|
1066
1451
|
if (!config.token) return;
|
|
1452
|
+
lifecycleController.handle(event);
|
|
1067
1453
|
if (config.permissions && event.type === "permission.asked") {
|
|
1068
1454
|
await handlePermissionAsked(event);
|
|
1069
1455
|
return;
|
|
@@ -1084,6 +1470,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1084
1470
|
},
|
|
1085
1471
|
dispose: async () => {
|
|
1086
1472
|
await permissionController.dispose();
|
|
1473
|
+
lifecycleController.dispose();
|
|
1087
1474
|
todoCompletionBySessionID.clear();
|
|
1088
1475
|
publishedQuestions.clear();
|
|
1089
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": {
|
package/src/config.js
CHANGED
|
@@ -7,6 +7,50 @@ import { enabled } from "./utilities.js"
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
|
|
9
9
|
|
|
10
|
+
function integer(value, fallback, { minimum = 0, maximum = 86_400 } = {}) {
|
|
11
|
+
if (value === undefined || value === null || value === "") return fallback
|
|
12
|
+
const parsed = Number(value)
|
|
13
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
14
|
+
throw new Error(`Expected an integer between ${minimum} and ${maximum}`)
|
|
15
|
+
}
|
|
16
|
+
return parsed
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadLifecycleConfig(option) {
|
|
20
|
+
const values = option && typeof option === "object" ? option : {}
|
|
21
|
+
const masterValue = typeof option === "boolean"
|
|
22
|
+
? option
|
|
23
|
+
: values.enabled ?? process.env.BACKEER_NOTIFY_LIFECYCLE
|
|
24
|
+
const masterEnabled = enabled(masterValue)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
enabled: masterEnabled,
|
|
28
|
+
completed: masterEnabled && enabled(
|
|
29
|
+
values.completed ?? process.env.BACKEER_NOTIFY_SESSION_COMPLETED,
|
|
30
|
+
),
|
|
31
|
+
errors: masterEnabled && enabled(
|
|
32
|
+
values.errors ?? process.env.BACKEER_NOTIFY_SESSION_ERRORS,
|
|
33
|
+
),
|
|
34
|
+
retries: masterEnabled && enabled(
|
|
35
|
+
values.retries ?? process.env.BACKEER_NOTIFY_SESSION_RETRIES,
|
|
36
|
+
),
|
|
37
|
+
updates: masterEnabled && enabled(
|
|
38
|
+
values.updates ?? process.env.BACKEER_NOTIFY_OPENCODE_UPDATES,
|
|
39
|
+
),
|
|
40
|
+
completionMinSeconds: integer(
|
|
41
|
+
values.completionMinSeconds ??
|
|
42
|
+
process.env.BACKEER_SESSION_COMPLETION_MIN_SECONDS,
|
|
43
|
+
0,
|
|
44
|
+
),
|
|
45
|
+
retryAttemptThreshold: integer(
|
|
46
|
+
values.retryAttemptThreshold ??
|
|
47
|
+
process.env.BACKEER_RETRY_ATTEMPT_THRESHOLD,
|
|
48
|
+
3,
|
|
49
|
+
{ minimum: 1, maximum: 100 },
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
function defaultConfigDirectory() {
|
|
11
55
|
const base = process.env.XDG_CONFIG_HOME?.trim()
|
|
12
56
|
return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
|
|
@@ -52,6 +96,7 @@ export function loadConfig(options = {}) {
|
|
|
52
96
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
|
|
53
97
|
),
|
|
54
98
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
99
|
+
lifecycle: loadLifecycleConfig(options.lifecycle),
|
|
55
100
|
logLevel: normalizeLogLevel(
|
|
56
101
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
57
102
|
),
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
errorCorrelation,
|
|
3
|
+
publishOpenCodeUpdate,
|
|
4
|
+
publishSessionCompleted,
|
|
5
|
+
publishSessionError,
|
|
6
|
+
publishSessionRetry,
|
|
7
|
+
} from "./lifecycle-publishers.js"
|
|
8
|
+
import { limit, rememberBounded, stableHash } from "./utilities.js"
|
|
9
|
+
|
|
10
|
+
const QUESTION_EVENTS = new Set(["question.asked", "question.v2.asked"])
|
|
11
|
+
const QUESTION_REPLY_EVENTS = new Set([
|
|
12
|
+
"question.replied",
|
|
13
|
+
"question.v2.replied",
|
|
14
|
+
"question.answered",
|
|
15
|
+
"question.v2.answered",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export function createLifecycleController({
|
|
19
|
+
config,
|
|
20
|
+
reporter,
|
|
21
|
+
client,
|
|
22
|
+
directory,
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
publishers = {},
|
|
25
|
+
}) {
|
|
26
|
+
const sendCompleted = publishers.completed ?? publishSessionCompleted
|
|
27
|
+
const sendError = publishers.error ?? publishSessionError
|
|
28
|
+
const sendRetry = publishers.retry ?? publishSessionRetry
|
|
29
|
+
const sendUpdate = publishers.update ?? publishOpenCodeUpdate
|
|
30
|
+
const sessions = new Map()
|
|
31
|
+
const publishedUpdates = new Map()
|
|
32
|
+
|
|
33
|
+
function sessionState(sessionID) {
|
|
34
|
+
let state = sessions.get(sessionID)
|
|
35
|
+
if (!state) {
|
|
36
|
+
state = {
|
|
37
|
+
busy: false,
|
|
38
|
+
busySince: 0,
|
|
39
|
+
failed: false,
|
|
40
|
+
retryNotified: false,
|
|
41
|
+
waiting: false,
|
|
42
|
+
lastAssistant: undefined,
|
|
43
|
+
completedMessageID: "",
|
|
44
|
+
}
|
|
45
|
+
rememberBounded(sessions, sessionID, state)
|
|
46
|
+
}
|
|
47
|
+
return state
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dispatch(label, operation) {
|
|
51
|
+
void Promise.resolve(operation).catch((error) => reporter.error(label, error))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function beginCycle(state) {
|
|
55
|
+
if (state.busy) return
|
|
56
|
+
state.busy = true
|
|
57
|
+
state.busySince = now()
|
|
58
|
+
state.failed = false
|
|
59
|
+
state.retryNotified = false
|
|
60
|
+
state.waiting = false
|
|
61
|
+
state.lastAssistant = undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function finishCycle(sessionID, state) {
|
|
65
|
+
if (!state.busy) return
|
|
66
|
+
state.busy = false
|
|
67
|
+
const assistant = state.lastAssistant
|
|
68
|
+
const durationMs = Math.max(0, now() - state.busySince)
|
|
69
|
+
const qualifies = assistant?.id && assistant.completed && !assistant.error &&
|
|
70
|
+
!state.failed && !state.waiting &&
|
|
71
|
+
durationMs >= config.lifecycle.completionMinSeconds * 1000 &&
|
|
72
|
+
state.completedMessageID !== assistant.id
|
|
73
|
+
if (!qualifies || !config.lifecycle.completed) return
|
|
74
|
+
|
|
75
|
+
state.completedMessageID = assistant.id
|
|
76
|
+
dispatch(
|
|
77
|
+
"Task completion could not be sent",
|
|
78
|
+
sendCompleted({
|
|
79
|
+
config,
|
|
80
|
+
reporter,
|
|
81
|
+
client,
|
|
82
|
+
directory,
|
|
83
|
+
sessionID,
|
|
84
|
+
messageID: limit(assistant.id, 200),
|
|
85
|
+
durationMs,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleMessage(event) {
|
|
91
|
+
const info = event.properties?.info
|
|
92
|
+
if (info?.role !== "assistant" || typeof info.sessionID !== "string") return
|
|
93
|
+
const state = sessionState(info.sessionID)
|
|
94
|
+
state.lastAssistant = {
|
|
95
|
+
id: typeof info.id === "string" ? info.id : "",
|
|
96
|
+
completed: Number.isFinite(info.time?.completed),
|
|
97
|
+
error: Boolean(info.error),
|
|
98
|
+
}
|
|
99
|
+
if (info.error) state.failed = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleStatus(event) {
|
|
103
|
+
const sessionID = event.properties?.sessionID
|
|
104
|
+
const status = event.properties?.status
|
|
105
|
+
if (typeof sessionID !== "string" || typeof status?.type !== "string") return
|
|
106
|
+
const state = sessionState(sessionID)
|
|
107
|
+
|
|
108
|
+
if (status.type === "busy") {
|
|
109
|
+
beginCycle(state)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (status.type === "idle") {
|
|
113
|
+
finishCycle(sessionID, state)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (status.type !== "retry") return
|
|
117
|
+
|
|
118
|
+
beginCycle(state)
|
|
119
|
+
if (!config.lifecycle.retries || state.retryNotified) return
|
|
120
|
+
if (!Number.isInteger(status.attempt) ||
|
|
121
|
+
status.attempt < config.lifecycle.retryAttemptThreshold) return
|
|
122
|
+
|
|
123
|
+
state.retryNotified = true
|
|
124
|
+
const correlation = state.lastAssistant?.id || stableHash(
|
|
125
|
+
`${status.attempt}:${Number.isFinite(status.next) ? status.next : "unknown"}`,
|
|
126
|
+
)
|
|
127
|
+
dispatch(
|
|
128
|
+
"Retry warning could not be sent",
|
|
129
|
+
sendRetry({
|
|
130
|
+
config,
|
|
131
|
+
reporter,
|
|
132
|
+
client,
|
|
133
|
+
directory,
|
|
134
|
+
sessionID,
|
|
135
|
+
correlation: limit(correlation, 200),
|
|
136
|
+
attempt: status.attempt,
|
|
137
|
+
next: status.next,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleError(event) {
|
|
143
|
+
const sessionID = typeof event.properties?.sessionID === "string"
|
|
144
|
+
? event.properties.sessionID
|
|
145
|
+
: ""
|
|
146
|
+
const state = sessionID ? sessionState(sessionID) : undefined
|
|
147
|
+
if (state) state.failed = true
|
|
148
|
+
if (!config.lifecycle.errors) return
|
|
149
|
+
|
|
150
|
+
dispatch(
|
|
151
|
+
"Task failure could not be sent",
|
|
152
|
+
sendError({
|
|
153
|
+
config,
|
|
154
|
+
reporter,
|
|
155
|
+
client,
|
|
156
|
+
directory,
|
|
157
|
+
sessionID,
|
|
158
|
+
correlation: errorCorrelation(event, state),
|
|
159
|
+
error: event.properties?.error,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleWaiting(event, waiting) {
|
|
165
|
+
const sessionID = event.properties?.sessionID
|
|
166
|
+
if (typeof sessionID !== "string") return
|
|
167
|
+
sessionState(sessionID).waiting = waiting
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
handle(event) {
|
|
172
|
+
if (!config.lifecycle.enabled || !event?.type) return
|
|
173
|
+
|
|
174
|
+
if (event.type === "message.updated") {
|
|
175
|
+
handleMessage(event)
|
|
176
|
+
} else if (event.type === "session.status") {
|
|
177
|
+
handleStatus(event)
|
|
178
|
+
} else if (event.type === "session.idle") {
|
|
179
|
+
const sessionID = event.properties?.sessionID
|
|
180
|
+
if (typeof sessionID === "string") finishCycle(sessionID, sessionState(sessionID))
|
|
181
|
+
} else if (event.type === "session.error") {
|
|
182
|
+
handleError(event)
|
|
183
|
+
} else if (event.type === "permission.asked" || QUESTION_EVENTS.has(event.type)) {
|
|
184
|
+
handleWaiting(event, true)
|
|
185
|
+
} else if (event.type === "permission.replied" || QUESTION_REPLY_EVENTS.has(event.type)) {
|
|
186
|
+
handleWaiting(event, false)
|
|
187
|
+
} else if (event.type === "installation.update-available" &&
|
|
188
|
+
config.lifecycle.updates) {
|
|
189
|
+
const version = event.properties?.version
|
|
190
|
+
if (typeof version !== "string" || !version.trim()) return
|
|
191
|
+
if (publishedUpdates.has(version)) return
|
|
192
|
+
rememberBounded(publishedUpdates, version, true)
|
|
193
|
+
dispatch(
|
|
194
|
+
"OpenCode update could not be sent",
|
|
195
|
+
sendUpdate({ config, reporter, version: version.trim() }),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
dispose() {
|
|
201
|
+
sessions.clear()
|
|
202
|
+
publishedUpdates.clear()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { postJsonWithRetry } from "./http.js"
|
|
2
|
+
import { limit, stableHash } from "./utilities.js"
|
|
3
|
+
|
|
4
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
5
|
+
const SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi
|
|
6
|
+
const BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi
|
|
7
|
+
const URL_WITH_QUERY = /\bhttps?:\/\/[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
|
|
8
|
+
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g
|
|
9
|
+
const POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g
|
|
10
|
+
|
|
11
|
+
export function sanitizeErrorMessage(value) {
|
|
12
|
+
return limit(value, 2000)
|
|
13
|
+
.replace(CONTROL_CHARACTERS, " ")
|
|
14
|
+
.replace(BEARER_TOKEN, "Bearer [redacted]")
|
|
15
|
+
.replace(SECRET_ASSIGNMENT, "$1=[redacted]")
|
|
16
|
+
.replace(URL_WITH_QUERY, (value) => {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value)
|
|
19
|
+
return `${url.origin}${url.pathname}`
|
|
20
|
+
} catch {
|
|
21
|
+
return "[url redacted]"
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
.replace(WINDOWS_PATH, "[path redacted]")
|
|
25
|
+
.replace(POSIX_PATH, "$1[path redacted]")
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, 500)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function safeErrorDetails(error) {
|
|
32
|
+
const name = limit(error?.name || "OpenCodeError", 100)
|
|
33
|
+
const message = sanitizeErrorMessage(error?.data?.message ?? error?.message)
|
|
34
|
+
const statusCode = Number.isInteger(error?.data?.statusCode)
|
|
35
|
+
? String(error.data.statusCode)
|
|
36
|
+
: ""
|
|
37
|
+
return { name, message, statusCode }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
41
|
+
if (!sessionID || typeof client?.session?.get !== "function") return ""
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await client.session.get({
|
|
45
|
+
path: { id: sessionID },
|
|
46
|
+
query: { directory },
|
|
47
|
+
throwOnError: true,
|
|
48
|
+
})
|
|
49
|
+
return limit((result?.data ?? result)?.title)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
reporter.debug("Session title unavailable", error)
|
|
52
|
+
return ""
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function publish(config, reporter, event, feedback) {
|
|
57
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
58
|
+
type: "application",
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
notify: true,
|
|
61
|
+
direction: "in",
|
|
62
|
+
...event,
|
|
63
|
+
})
|
|
64
|
+
reporter.success(feedback, `HTTP ${result.status}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function secondsLabel(durationMs) {
|
|
68
|
+
const seconds = Math.max(0, Math.round(durationMs / 1000))
|
|
69
|
+
if (seconds < 60) return `${seconds}s`
|
|
70
|
+
const minutes = Math.floor(seconds / 60)
|
|
71
|
+
const remainder = seconds % 60
|
|
72
|
+
return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function publishSessionCompleted({
|
|
76
|
+
config,
|
|
77
|
+
reporter,
|
|
78
|
+
client,
|
|
79
|
+
directory,
|
|
80
|
+
sessionID,
|
|
81
|
+
messageID,
|
|
82
|
+
durationMs,
|
|
83
|
+
}) {
|
|
84
|
+
const sessionTitle = await resolveSessionTitle(
|
|
85
|
+
client,
|
|
86
|
+
directory,
|
|
87
|
+
sessionID,
|
|
88
|
+
reporter,
|
|
89
|
+
)
|
|
90
|
+
const duration = secondsLabel(durationMs)
|
|
91
|
+
const location = sessionTitle ? ` “${sessionTitle}”` : ""
|
|
92
|
+
await publish(config, reporter, {
|
|
93
|
+
severity: "info",
|
|
94
|
+
title: "OpenCode task completed",
|
|
95
|
+
body: `OpenCode finished${location} in ${duration}.`,
|
|
96
|
+
payload: [
|
|
97
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
98
|
+
{ key: "Outcome", value: "Completed" },
|
|
99
|
+
{ key: "Duration", value: duration },
|
|
100
|
+
].filter((item) => item.value),
|
|
101
|
+
externalId: `opencode:lifecycle:completed:${sessionID}:${messageID}`,
|
|
102
|
+
}, "Task completion sent")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function publishSessionRetry({
|
|
106
|
+
config,
|
|
107
|
+
reporter,
|
|
108
|
+
client,
|
|
109
|
+
directory,
|
|
110
|
+
sessionID,
|
|
111
|
+
correlation,
|
|
112
|
+
attempt,
|
|
113
|
+
next,
|
|
114
|
+
}) {
|
|
115
|
+
const sessionTitle = await resolveSessionTitle(
|
|
116
|
+
client,
|
|
117
|
+
directory,
|
|
118
|
+
sessionID,
|
|
119
|
+
reporter,
|
|
120
|
+
)
|
|
121
|
+
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : ""
|
|
122
|
+
await publish(config, reporter, {
|
|
123
|
+
severity: "warning",
|
|
124
|
+
title: "OpenCode is retrying",
|
|
125
|
+
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
126
|
+
payload: [
|
|
127
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
128
|
+
{ key: "Retry attempt", value: String(attempt) },
|
|
129
|
+
{ key: "Next retry", value: retryAt },
|
|
130
|
+
].filter((item) => item.value),
|
|
131
|
+
externalId: `opencode:lifecycle:retry:${sessionID}:${correlation}`,
|
|
132
|
+
}, "Retry warning sent")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function publishSessionError({
|
|
136
|
+
config,
|
|
137
|
+
reporter,
|
|
138
|
+
client,
|
|
139
|
+
directory,
|
|
140
|
+
sessionID,
|
|
141
|
+
correlation,
|
|
142
|
+
error,
|
|
143
|
+
}) {
|
|
144
|
+
const sessionTitle = await resolveSessionTitle(
|
|
145
|
+
client,
|
|
146
|
+
directory,
|
|
147
|
+
sessionID,
|
|
148
|
+
reporter,
|
|
149
|
+
)
|
|
150
|
+
const details = safeErrorDetails(error)
|
|
151
|
+
const location = sessionTitle ? ` in “${sessionTitle}”` : ""
|
|
152
|
+
const body = details.message
|
|
153
|
+
? `OpenCode failed${location}: ${details.message}`
|
|
154
|
+
: `OpenCode failed${location}.`
|
|
155
|
+
await publish(config, reporter, {
|
|
156
|
+
// Backeer/iOS currently support info, warning and critical severities.
|
|
157
|
+
severity: "critical",
|
|
158
|
+
title: "OpenCode task failed",
|
|
159
|
+
body: limit(body, 1000),
|
|
160
|
+
payload: [
|
|
161
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
162
|
+
{ key: "Outcome", value: "Failed" },
|
|
163
|
+
{ key: "Error type", value: details.name },
|
|
164
|
+
{ key: "HTTP status", value: details.statusCode },
|
|
165
|
+
{ key: "Error", value: details.message },
|
|
166
|
+
].filter((item) => item.value),
|
|
167
|
+
externalId: `opencode:lifecycle:error:${sessionID || "global"}:${correlation}`,
|
|
168
|
+
}, "Task failure sent")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
172
|
+
const safeVersion = limit(version, 100)
|
|
173
|
+
await publish(config, reporter, {
|
|
174
|
+
severity: "info",
|
|
175
|
+
title: "OpenCode update available",
|
|
176
|
+
body: `OpenCode ${safeVersion} is available.`,
|
|
177
|
+
payload: [{ key: "Available version", value: safeVersion }],
|
|
178
|
+
externalId: `opencode:lifecycle:update:${safeVersion}`,
|
|
179
|
+
}, "OpenCode update sent")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function errorCorrelation(event, sessionState) {
|
|
183
|
+
if (typeof event?.id === "string" && event.id) return limit(event.id, 200)
|
|
184
|
+
if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200)
|
|
185
|
+
const details = safeErrorDetails(event?.properties?.error)
|
|
186
|
+
return stableHash(`${details.name}:${details.statusCode}:${details.message}`)
|
|
187
|
+
}
|
package/src/management.js
CHANGED
|
@@ -111,6 +111,11 @@ async function buildStatus(config, permissionController, updateChecker) {
|
|
|
111
111
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
112
112
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
113
113
|
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
114
|
+
`- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
|
|
115
|
+
` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
|
|
116
|
+
` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
|
|
117
|
+
` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
|
|
118
|
+
` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
|
|
114
119
|
`- Command stream: ${permissionStatus.stream}`,
|
|
115
120
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
116
121
|
`- Log level: ${config.logLevel}`,
|
package/src/plugin.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createBackeerCommandHandler,
|
|
11
11
|
registerBackeerCommand,
|
|
12
12
|
} from "./management.js"
|
|
13
|
+
import { createLifecycleController } from "./lifecycle-controller.js"
|
|
13
14
|
import { createPermissionController } from "./permission-controller.js"
|
|
14
15
|
import { createConsoleReporter } from "./reporter.js"
|
|
15
16
|
import {
|
|
@@ -21,7 +22,8 @@ import { rememberBounded } from "./utilities.js"
|
|
|
21
22
|
/**
|
|
22
23
|
* OpenCode server plugin entrypoint.
|
|
23
24
|
*
|
|
24
|
-
* Supported options: origin, tokenFile, permissions, questions, todos,
|
|
25
|
+
* Supported options: origin, tokenFile, permissions, questions, todos,
|
|
26
|
+
* lifecycle, logLevel.
|
|
25
27
|
* The ingest token is intentionally accepted only through the environment or a
|
|
26
28
|
* protected file so it cannot accidentally be committed in opencode.json.
|
|
27
29
|
*/
|
|
@@ -45,6 +47,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
45
47
|
client,
|
|
46
48
|
directory,
|
|
47
49
|
})
|
|
50
|
+
const lifecycleController = createLifecycleController({
|
|
51
|
+
config,
|
|
52
|
+
reporter,
|
|
53
|
+
client,
|
|
54
|
+
directory,
|
|
55
|
+
})
|
|
48
56
|
const todoCompletionBySessionID = new Map()
|
|
49
57
|
const publishedQuestions = new Map()
|
|
50
58
|
const updateChecker = createUpdateChecker()
|
|
@@ -135,6 +143,8 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
135
143
|
event: async ({ event }) => {
|
|
136
144
|
if (!config.token) return
|
|
137
145
|
|
|
146
|
+
lifecycleController.handle(event)
|
|
147
|
+
|
|
138
148
|
if (config.permissions && event.type === "permission.asked") {
|
|
139
149
|
await handlePermissionAsked(event)
|
|
140
150
|
return
|
|
@@ -162,6 +172,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
162
172
|
|
|
163
173
|
dispose: async () => {
|
|
164
174
|
await permissionController.dispose()
|
|
175
|
+
lifecycleController.dispose()
|
|
165
176
|
todoCompletionBySessionID.clear()
|
|
166
177
|
publishedQuestions.clear()
|
|
167
178
|
},
|
package/src/updates.js
CHANGED