backeer-opencode 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +22 -18
- package/dist/backeer-permission.js +202 -66
- package/package.json +1 -1
- package/src/event-publishers.js +6 -0
- package/src/lifecycle-controller.js +12 -2
- package/src/lifecycle-publishers.js +9 -5
- package/src/management.js +2 -14
- package/src/permission-controller.js +9 -0
- package/src/plugin.js +25 -6
- package/src/reporter.js +50 -0
- package/src/session-messages.js +122 -0
- package/src/updates.js +1 -1
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.2 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Show user-facing Backeer delivery and decision results in the OpenCode
|
|
8
|
+
conversation instead of writing them into the TUI input area.
|
|
9
|
+
- Queue conversation feedback until the session is idle and mark it as ignored
|
|
10
|
+
so it cannot trigger another agent response or enter model context.
|
|
11
|
+
- Send technical diagnostics through OpenCode's structured application log API
|
|
12
|
+
instead of stdout.
|
|
13
|
+
|
|
5
14
|
## 0.2.1 - 2026-09-01
|
|
6
15
|
|
|
7
16
|
- Add `/backeer doctor` for read-only local configuration, protected token,
|
package/README.md
CHANGED
|
@@ -150,25 +150,28 @@ Supported options are `origin`, `tokenFile`, `permissions`, `questions`,
|
|
|
150
150
|
`todos`, `lifecycle`, and `logLevel`. Use `"lifecycle": false` to disable the
|
|
151
151
|
entire lifecycle group. The token itself is deliberately not a plugin option.
|
|
152
152
|
|
|
153
|
-
##
|
|
153
|
+
## Conversation feedback
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
User-facing status messages appear as compact Backeer entries in the OpenCode
|
|
156
|
+
conversation:
|
|
156
157
|
|
|
157
158
|
```text
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
◆ Backeer Task failure sent
|
|
164
|
-
✓ Backeer Decision applied: Always allow
|
|
165
|
-
! Backeer Connection interrupted — retrying
|
|
159
|
+
Backeer ✓ Permission request sent
|
|
160
|
+
Backeer ✓ Question sent
|
|
161
|
+
Backeer ✓ Todo completion sent
|
|
162
|
+
Backeer ✓ Task completion sent
|
|
163
|
+
Backeer ✓ Decision applied: Always allow
|
|
166
164
|
```
|
|
167
165
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
These entries are marked `noReply` and `ignored`, so they are visible without
|
|
167
|
+
starting another agent turn or entering model context. Feedback generated while
|
|
168
|
+
an agent is active is queued until the session becomes idle; it never writes to
|
|
169
|
+
the user's input field.
|
|
170
|
+
|
|
171
|
+
Technical diagnostics use OpenCode's structured application log API instead of
|
|
172
|
+
stdout. Normal `info` logging omits HTTP details; set
|
|
173
|
+
`BACKEER_LOG_LEVEL=debug` when technical details are needed. Set it to `error`
|
|
174
|
+
for failures only or `silent` to disable structured logs.
|
|
172
175
|
|
|
173
176
|
## OpenCode command
|
|
174
177
|
|
|
@@ -263,10 +266,11 @@ npm run publish:check
|
|
|
263
266
|
|
|
264
267
|
## Development architecture
|
|
265
268
|
|
|
266
|
-
The npm plugin runs from the modular source in `src`. Configuration,
|
|
267
|
-
|
|
268
|
-
handling, and OpenCode event routing are kept in
|
|
269
|
-
root continues to expose only the default OpenCode
|
|
269
|
+
The npm plugin runs from the modular source in `src`. Configuration, structured
|
|
270
|
+
logging, conversation feedback, HTTP transport, SSE consumption, event
|
|
271
|
+
publishing, permission command handling, and OpenCode event routing are kept in
|
|
272
|
+
separate modules. The package root continues to expose only the default OpenCode
|
|
273
|
+
plugin entrypoint.
|
|
270
274
|
|
|
271
275
|
The legacy side-loaded installer still deploys one dependency-free file. Build
|
|
272
276
|
that generated artifact before running the installer directly from a source
|
|
@@ -33,37 +33,31 @@ function normalizeLogLevel(value) {
|
|
|
33
33
|
const level = String(value ?? "info").trim().toLowerCase();
|
|
34
34
|
return LOG_LEVELS.has(level) ? level : "info";
|
|
35
35
|
}
|
|
36
|
-
function
|
|
36
|
+
function createOpenCodeReporter(client, levelValue) {
|
|
37
37
|
const level = normalizeLogLevel(levelValue);
|
|
38
|
-
const useColor = Boolean(process.stdout?.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
|
|
39
38
|
const recentlyEmitted = /* @__PURE__ */ new Map();
|
|
40
|
-
const
|
|
41
|
-
const symbols = {
|
|
42
|
-
info: decorate("36", "\u25C6"),
|
|
43
|
-
success: decorate("32", "\u25C6"),
|
|
44
|
-
applied: decorate("32", "\u2713"),
|
|
45
|
-
warning: decorate("33", "!"),
|
|
46
|
-
error: decorate("31", "\xD7"),
|
|
47
|
-
debug: decorate("2", "\xB7")
|
|
48
|
-
};
|
|
49
|
-
const brand = decorate("1", "Backeer");
|
|
39
|
+
const method = client?.app?.log;
|
|
50
40
|
function emit(requiredLevel, kind, message, detail, deduplicate = false) {
|
|
51
|
-
if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return;
|
|
41
|
+
if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return;
|
|
52
42
|
const key = `${kind}:${message}`;
|
|
53
43
|
const now = Date.now();
|
|
54
44
|
if (deduplicate) {
|
|
55
45
|
const previous = recentlyEmitted.get(key);
|
|
56
46
|
if (previous && now - previous < LOG_DEDUPLICATION_MS) return;
|
|
57
|
-
recentlyEmitted
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
47
|
+
rememberRecent(recentlyEmitted, key, now);
|
|
48
|
+
}
|
|
49
|
+
const body = {
|
|
50
|
+
service: "backeer-opencode",
|
|
51
|
+
level: kind === "warning" ? "warn" : kind === "applied" || kind === "success" ? "info" : kind,
|
|
52
|
+
message: limit(message, 500)
|
|
53
|
+
};
|
|
54
|
+
if (level === "debug" && detail) {
|
|
55
|
+
body.extra = { detail: limit(detail?.message ?? detail, 500) };
|
|
62
56
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
void Promise.resolve(method.call(client.app, { body })).catch(() => {
|
|
59
|
+
});
|
|
60
|
+
}, 0);
|
|
67
61
|
}
|
|
68
62
|
return {
|
|
69
63
|
info: (message, detail) => emit("info", "info", message, detail),
|
|
@@ -74,6 +68,11 @@ function createConsoleReporter(levelValue) {
|
|
|
74
68
|
debug: (message, detail) => emit("debug", "debug", message, detail)
|
|
75
69
|
};
|
|
76
70
|
}
|
|
71
|
+
function rememberRecent(map, key, value) {
|
|
72
|
+
map.delete(key);
|
|
73
|
+
map.set(key, value);
|
|
74
|
+
while (map.size > 100) map.delete(map.keys().next().value);
|
|
75
|
+
}
|
|
77
76
|
|
|
78
77
|
// src/config.js
|
|
79
78
|
var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
|
|
@@ -245,7 +244,7 @@ function permissionIdentity(request) {
|
|
|
245
244
|
externalId: `opencode:${sessionID}:${requestID}`
|
|
246
245
|
};
|
|
247
246
|
}
|
|
248
|
-
async function publishPermission(config, reporter, request, directory, identity) {
|
|
247
|
+
async function publishPermission(config, reporter, request, directory, identity, conversation) {
|
|
249
248
|
const permissionType = request.permission ?? request.action ?? "unknown";
|
|
250
249
|
const resources = Array.isArray(request.patterns) ? request.patterns.join(", ") : Array.isArray(request.resources) ? request.resources.join(", ") : "";
|
|
251
250
|
const payload = [
|
|
@@ -269,6 +268,7 @@ async function publishPermission(config, reporter, request, directory, identity)
|
|
|
269
268
|
direction: "in"
|
|
270
269
|
});
|
|
271
270
|
reporter.success("Permission request sent", `HTTP ${result.status}`);
|
|
271
|
+
conversation?.success(identity.sessionID, "Permission request sent");
|
|
272
272
|
}
|
|
273
273
|
function questionIdentity(request) {
|
|
274
274
|
const requestID = request.id ?? request.requestID;
|
|
@@ -303,7 +303,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
303
303
|
return "";
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
-
async function publishQuestion(config, reporter, request, identity, client, directory) {
|
|
306
|
+
async function publishQuestion(config, reporter, request, identity, client, directory, conversation) {
|
|
307
307
|
const questions = Array.isArray(request.questions) ? request.questions : [];
|
|
308
308
|
if (questions.length === 0) return;
|
|
309
309
|
const sessionTitle = await resolveSessionTitle(
|
|
@@ -345,8 +345,9 @@ async function publishQuestion(config, reporter, request, identity, client, dire
|
|
|
345
345
|
direction: "in"
|
|
346
346
|
});
|
|
347
347
|
reporter.success("Question sent", `HTTP ${result.status}`);
|
|
348
|
+
conversation?.success(identity.sessionID, "Question sent");
|
|
348
349
|
}
|
|
349
|
-
async function publishTodoCompletion(config, reporter, request, eventID, client, directory) {
|
|
350
|
+
async function publishTodoCompletion(config, reporter, request, eventID, client, directory, conversation) {
|
|
350
351
|
const todos = request.todos;
|
|
351
352
|
const sessionTitle = await resolveSessionTitle(
|
|
352
353
|
client,
|
|
@@ -384,6 +385,7 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
|
|
|
384
385
|
direction: "in"
|
|
385
386
|
});
|
|
386
387
|
reporter.success("Todo completion sent", `HTTP ${result.status}`);
|
|
388
|
+
conversation?.success(request.sessionID, "Todo completion sent");
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
// src/management.js
|
|
@@ -402,7 +404,7 @@ import { randomUUID } from "node:crypto";
|
|
|
402
404
|
import { statSync } from "node:fs";
|
|
403
405
|
|
|
404
406
|
// src/updates.js
|
|
405
|
-
var PLUGIN_VERSION = "0.2.
|
|
407
|
+
var PLUGIN_VERSION = "0.2.2";
|
|
406
408
|
var PACKAGE_NAME = "backeer-opencode";
|
|
407
409
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
408
410
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -640,6 +642,111 @@ async function publishTestNotification(config, reporter) {
|
|
|
640
642
|
return testID;
|
|
641
643
|
}
|
|
642
644
|
|
|
645
|
+
// src/session-messages.js
|
|
646
|
+
var FEEDBACK_SYMBOLS = {
|
|
647
|
+
info: "\u2022",
|
|
648
|
+
success: "\u2713",
|
|
649
|
+
applied: "\u2713",
|
|
650
|
+
warning: "!",
|
|
651
|
+
error: "\xD7"
|
|
652
|
+
};
|
|
653
|
+
async function sendSessionMessage(client, sessionID, text) {
|
|
654
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
655
|
+
throw new Error("OpenCode session messaging is unavailable");
|
|
656
|
+
}
|
|
657
|
+
await client.session.prompt({
|
|
658
|
+
path: { id: sessionID },
|
|
659
|
+
body: {
|
|
660
|
+
noReply: true,
|
|
661
|
+
parts: [{ type: "text", text, ignored: true }]
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
function formatBackeerFeedback(kind, message) {
|
|
666
|
+
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
|
|
667
|
+
return `> **Backeer** ${symbol} ${limit(message, 300)}`;
|
|
668
|
+
}
|
|
669
|
+
function createConversationFeedback({ client, reporter }) {
|
|
670
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
671
|
+
let disposed = false;
|
|
672
|
+
function sessionState(sessionID) {
|
|
673
|
+
let state = sessions.get(sessionID);
|
|
674
|
+
if (!state) {
|
|
675
|
+
state = { idle: false, flushing: false, queue: [], queued: /* @__PURE__ */ new Set() };
|
|
676
|
+
rememberBounded(sessions, sessionID, state);
|
|
677
|
+
}
|
|
678
|
+
return state;
|
|
679
|
+
}
|
|
680
|
+
async function flush(sessionID, state) {
|
|
681
|
+
if (disposed || state.flushing || !state.idle || state.queue.length === 0) return;
|
|
682
|
+
state.flushing = true;
|
|
683
|
+
try {
|
|
684
|
+
while (!disposed && state.idle && state.queue.length > 0) {
|
|
685
|
+
const item = state.queue[0];
|
|
686
|
+
try {
|
|
687
|
+
await sendSessionMessage(
|
|
688
|
+
client,
|
|
689
|
+
sessionID,
|
|
690
|
+
formatBackeerFeedback(item.kind, item.message)
|
|
691
|
+
);
|
|
692
|
+
state.queue.shift();
|
|
693
|
+
state.queued.delete(item.key);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
state.idle = false;
|
|
696
|
+
reporter.debug("Conversation feedback could not be displayed", error);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} finally {
|
|
700
|
+
state.flushing = false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function enqueue(kind, sessionID, message) {
|
|
704
|
+
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
705
|
+
const state = sessionState(sessionID);
|
|
706
|
+
const key = `${sessionID}:${kind}:${message}`;
|
|
707
|
+
if (state.queued.has(key)) return;
|
|
708
|
+
state.queue.push({ kind, message: limit(message, 300), key });
|
|
709
|
+
state.queued.add(key);
|
|
710
|
+
while (state.queue.length > 20) {
|
|
711
|
+
const removed = state.queue.shift();
|
|
712
|
+
state.queued.delete(removed.key);
|
|
713
|
+
}
|
|
714
|
+
if (state.idle) void flush(sessionID, state);
|
|
715
|
+
}
|
|
716
|
+
function setIdle(sessionID, idle) {
|
|
717
|
+
if (typeof sessionID !== "string" || !sessionID) return;
|
|
718
|
+
const state = sessionState(sessionID);
|
|
719
|
+
state.idle = idle;
|
|
720
|
+
if (idle) void flush(sessionID, state);
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
info: (sessionID, message) => enqueue("info", sessionID, message),
|
|
724
|
+
success: (sessionID, message) => enqueue("success", sessionID, message),
|
|
725
|
+
applied: (sessionID, message) => enqueue("applied", sessionID, message),
|
|
726
|
+
warning: (sessionID, message) => enqueue("warning", sessionID, message),
|
|
727
|
+
error: (sessionID, message) => enqueue("error", sessionID, message),
|
|
728
|
+
handle(event) {
|
|
729
|
+
const sessionID = event?.properties?.sessionID;
|
|
730
|
+
if (typeof sessionID !== "string") return;
|
|
731
|
+
if (event.type === "session.status") {
|
|
732
|
+
const status = event.properties?.status?.type;
|
|
733
|
+
if (status === "idle") setIdle(sessionID, true);
|
|
734
|
+
else if (status === "busy" || status === "retry") setIdle(sessionID, false);
|
|
735
|
+
} else if (event.type === "session.idle") {
|
|
736
|
+
setIdle(sessionID, true);
|
|
737
|
+
} else if (event.type === "permission.asked" || event.type === "question.asked" || event.type === "question.v2.asked" || event.type === "todo.updated") {
|
|
738
|
+
setIdle(sessionID, false);
|
|
739
|
+
} else if (event.type === "session.deleted") {
|
|
740
|
+
sessions.delete(sessionID);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
dispose() {
|
|
744
|
+
disposed = true;
|
|
745
|
+
sessions.clear();
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
643
750
|
// src/management.js
|
|
644
751
|
var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
|
|
645
752
|
function tokenFileState(path) {
|
|
@@ -766,18 +873,6 @@ function tokenInstructions() {
|
|
|
766
873
|
"`token-file` activates the new token immediately; no OpenCode restart is required."
|
|
767
874
|
].join("\n");
|
|
768
875
|
}
|
|
769
|
-
async function sendCommandMessage(client, sessionID, text) {
|
|
770
|
-
if (typeof client?.session?.prompt !== "function") {
|
|
771
|
-
throw new Error("OpenCode session messaging is unavailable");
|
|
772
|
-
}
|
|
773
|
-
await client.session.prompt({
|
|
774
|
-
path: { id: sessionID },
|
|
775
|
-
body: {
|
|
776
|
-
noReply: true,
|
|
777
|
-
parts: [{ type: "text", text, ignored: true }]
|
|
778
|
-
}
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
876
|
function registerBackeerCommand(opencodeConfig) {
|
|
782
877
|
opencodeConfig.command ??= {};
|
|
783
878
|
opencodeConfig.command.backeer = {
|
|
@@ -859,7 +954,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
859
954
|
"Use `/backeer token` for secure token-change instructions."
|
|
860
955
|
].join("\n");
|
|
861
956
|
}
|
|
862
|
-
await
|
|
957
|
+
await sendSessionMessage(client, input.sessionID, message);
|
|
863
958
|
};
|
|
864
959
|
}
|
|
865
960
|
|
|
@@ -900,7 +995,7 @@ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
|
|
|
900
995
|
return "";
|
|
901
996
|
}
|
|
902
997
|
}
|
|
903
|
-
async function publish(config, reporter, event, feedback) {
|
|
998
|
+
async function publish(config, reporter, conversation, sessionID, event, feedback) {
|
|
904
999
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
905
1000
|
type: "application",
|
|
906
1001
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -909,6 +1004,7 @@ async function publish(config, reporter, event, feedback) {
|
|
|
909
1004
|
...event
|
|
910
1005
|
});
|
|
911
1006
|
reporter.success(feedback, `HTTP ${result.status}`);
|
|
1007
|
+
conversation?.success(sessionID, feedback);
|
|
912
1008
|
}
|
|
913
1009
|
function secondsLabel(durationMs) {
|
|
914
1010
|
const seconds = Math.max(0, Math.round(durationMs / 1e3));
|
|
@@ -924,7 +1020,8 @@ async function publishSessionCompleted({
|
|
|
924
1020
|
directory,
|
|
925
1021
|
sessionID,
|
|
926
1022
|
messageID,
|
|
927
|
-
durationMs
|
|
1023
|
+
durationMs,
|
|
1024
|
+
conversation
|
|
928
1025
|
}) {
|
|
929
1026
|
const sessionTitle = await resolveSessionTitle2(
|
|
930
1027
|
client,
|
|
@@ -934,7 +1031,7 @@ async function publishSessionCompleted({
|
|
|
934
1031
|
);
|
|
935
1032
|
const duration = secondsLabel(durationMs);
|
|
936
1033
|
const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
|
|
937
|
-
await publish(config, reporter, {
|
|
1034
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
938
1035
|
severity: "info",
|
|
939
1036
|
title: "OpenCode task completed",
|
|
940
1037
|
body: `OpenCode finished${location} in ${duration}.`,
|
|
@@ -954,7 +1051,8 @@ async function publishSessionRetry({
|
|
|
954
1051
|
sessionID,
|
|
955
1052
|
correlation,
|
|
956
1053
|
attempt,
|
|
957
|
-
next
|
|
1054
|
+
next,
|
|
1055
|
+
conversation
|
|
958
1056
|
}) {
|
|
959
1057
|
const sessionTitle = await resolveSessionTitle2(
|
|
960
1058
|
client,
|
|
@@ -963,7 +1061,7 @@ async function publishSessionRetry({
|
|
|
963
1061
|
reporter
|
|
964
1062
|
);
|
|
965
1063
|
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
|
|
966
|
-
await publish(config, reporter, {
|
|
1064
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
967
1065
|
severity: "warning",
|
|
968
1066
|
title: "OpenCode is retrying",
|
|
969
1067
|
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
@@ -982,7 +1080,8 @@ async function publishSessionError({
|
|
|
982
1080
|
directory,
|
|
983
1081
|
sessionID,
|
|
984
1082
|
correlation,
|
|
985
|
-
error
|
|
1083
|
+
error,
|
|
1084
|
+
conversation
|
|
986
1085
|
}) {
|
|
987
1086
|
const sessionTitle = await resolveSessionTitle2(
|
|
988
1087
|
client,
|
|
@@ -993,7 +1092,7 @@ async function publishSessionError({
|
|
|
993
1092
|
const details = safeErrorDetails(error);
|
|
994
1093
|
const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
|
|
995
1094
|
const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
|
|
996
|
-
await publish(config, reporter, {
|
|
1095
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
997
1096
|
// Backeer/iOS currently support info, warning and critical severities.
|
|
998
1097
|
severity: "critical",
|
|
999
1098
|
title: "OpenCode task failed",
|
|
@@ -1010,7 +1109,7 @@ async function publishSessionError({
|
|
|
1010
1109
|
}
|
|
1011
1110
|
async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
1012
1111
|
const safeVersion = limit(version, 100);
|
|
1013
|
-
await publish(config, reporter, {
|
|
1112
|
+
await publish(config, reporter, void 0, void 0, {
|
|
1014
1113
|
severity: "info",
|
|
1015
1114
|
title: "OpenCode update available",
|
|
1016
1115
|
body: `OpenCode ${safeVersion} is available.`,
|
|
@@ -1038,6 +1137,7 @@ function createLifecycleController({
|
|
|
1038
1137
|
reporter,
|
|
1039
1138
|
client,
|
|
1040
1139
|
directory,
|
|
1140
|
+
conversation,
|
|
1041
1141
|
now = () => Date.now(),
|
|
1042
1142
|
publishers = {}
|
|
1043
1143
|
}) {
|
|
@@ -1063,8 +1163,11 @@ function createLifecycleController({
|
|
|
1063
1163
|
}
|
|
1064
1164
|
return state;
|
|
1065
1165
|
}
|
|
1066
|
-
function dispatch(label, operation) {
|
|
1067
|
-
void Promise.resolve(operation).catch((error) =>
|
|
1166
|
+
function dispatch(label, operation, sessionID) {
|
|
1167
|
+
void Promise.resolve(operation).catch((error) => {
|
|
1168
|
+
reporter.error(label, error);
|
|
1169
|
+
conversation?.error(sessionID, label);
|
|
1170
|
+
});
|
|
1068
1171
|
}
|
|
1069
1172
|
function beginCycle(state) {
|
|
1070
1173
|
if (state.busy) return;
|
|
@@ -1092,8 +1195,10 @@ function createLifecycleController({
|
|
|
1092
1195
|
directory,
|
|
1093
1196
|
sessionID,
|
|
1094
1197
|
messageID: limit(assistant.id, 200),
|
|
1095
|
-
durationMs
|
|
1096
|
-
|
|
1198
|
+
durationMs,
|
|
1199
|
+
conversation
|
|
1200
|
+
}),
|
|
1201
|
+
sessionID
|
|
1097
1202
|
);
|
|
1098
1203
|
}
|
|
1099
1204
|
function handleMessage(event) {
|
|
@@ -1138,8 +1243,10 @@ function createLifecycleController({
|
|
|
1138
1243
|
sessionID,
|
|
1139
1244
|
correlation: limit(correlation, 200),
|
|
1140
1245
|
attempt: status.attempt,
|
|
1141
|
-
next: status.next
|
|
1142
|
-
|
|
1246
|
+
next: status.next,
|
|
1247
|
+
conversation
|
|
1248
|
+
}),
|
|
1249
|
+
sessionID
|
|
1143
1250
|
);
|
|
1144
1251
|
}
|
|
1145
1252
|
function handleError(event) {
|
|
@@ -1156,8 +1263,10 @@ function createLifecycleController({
|
|
|
1156
1263
|
directory,
|
|
1157
1264
|
sessionID,
|
|
1158
1265
|
correlation: errorCorrelation(event, state),
|
|
1159
|
-
error: event.properties?.error
|
|
1160
|
-
|
|
1266
|
+
error: event.properties?.error,
|
|
1267
|
+
conversation
|
|
1268
|
+
}),
|
|
1269
|
+
sessionID
|
|
1161
1270
|
);
|
|
1162
1271
|
}
|
|
1163
1272
|
function handleWaiting(event, waiting) {
|
|
@@ -1343,7 +1452,8 @@ function createPermissionController({
|
|
|
1343
1452
|
config,
|
|
1344
1453
|
reporter,
|
|
1345
1454
|
client,
|
|
1346
|
-
directory
|
|
1455
|
+
directory,
|
|
1456
|
+
conversation
|
|
1347
1457
|
}) {
|
|
1348
1458
|
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
1349
1459
|
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
@@ -1404,6 +1514,7 @@ function createPermissionController({
|
|
|
1404
1514
|
};
|
|
1405
1515
|
if (stale) removePendingByRequestID(pending.requestID);
|
|
1406
1516
|
reporter.error("Decision could not be applied", error);
|
|
1517
|
+
conversation?.error(pending.sessionID, "Decision could not be applied");
|
|
1407
1518
|
} finally {
|
|
1408
1519
|
processingCommands.delete(command.id);
|
|
1409
1520
|
}
|
|
@@ -1418,12 +1529,19 @@ function createPermissionController({
|
|
|
1418
1529
|
reporter.applied(
|
|
1419
1530
|
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1420
1531
|
);
|
|
1532
|
+
conversation?.applied(
|
|
1533
|
+
pending.sessionID,
|
|
1534
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1535
|
+
);
|
|
1421
1536
|
} else if (acknowledgement.status === "expired") {
|
|
1422
1537
|
reporter.warning("Decision expired before it could be applied");
|
|
1538
|
+
conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
|
|
1423
1539
|
} else if (acknowledgement.status === "stale") {
|
|
1424
1540
|
reporter.warning("Permission was already answered locally");
|
|
1541
|
+
conversation?.warning(pending.sessionID, "Permission was already answered locally");
|
|
1425
1542
|
} else if (acknowledgement.status === "rejected") {
|
|
1426
1543
|
reporter.warning("Decision was rejected by validation");
|
|
1544
|
+
conversation?.warning(pending.sessionID, "Decision was rejected by validation");
|
|
1427
1545
|
}
|
|
1428
1546
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
1429
1547
|
reporter.warning("Decision receipt could not be sent", error);
|
|
@@ -1486,7 +1604,8 @@ function createPermissionController({
|
|
|
1486
1604
|
|
|
1487
1605
|
// src/plugin.js
|
|
1488
1606
|
async function BackeerPlugin({ client, directory }, options = {}) {
|
|
1489
|
-
const bootstrapReporter =
|
|
1607
|
+
const bootstrapReporter = createOpenCodeReporter(
|
|
1608
|
+
client,
|
|
1490
1609
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL
|
|
1491
1610
|
);
|
|
1492
1611
|
let config;
|
|
@@ -1496,18 +1615,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1496
1615
|
bootstrapReporter.error("Plugin disabled \u2014 invalid configuration", error);
|
|
1497
1616
|
return {};
|
|
1498
1617
|
}
|
|
1499
|
-
const reporter =
|
|
1618
|
+
const reporter = createOpenCodeReporter(client, config.logLevel);
|
|
1619
|
+
const conversation = createConversationFeedback({ client, reporter });
|
|
1500
1620
|
const permissionController = createPermissionController({
|
|
1501
1621
|
config,
|
|
1502
1622
|
reporter,
|
|
1503
1623
|
client,
|
|
1504
|
-
directory
|
|
1624
|
+
directory,
|
|
1625
|
+
conversation
|
|
1505
1626
|
});
|
|
1506
1627
|
const lifecycleController = createLifecycleController({
|
|
1507
1628
|
config,
|
|
1508
1629
|
reporter,
|
|
1509
1630
|
client,
|
|
1510
|
-
directory
|
|
1631
|
+
directory,
|
|
1632
|
+
conversation
|
|
1511
1633
|
});
|
|
1512
1634
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
1513
1635
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
@@ -1538,8 +1660,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1538
1660
|
reporter,
|
|
1539
1661
|
event.properties,
|
|
1540
1662
|
directory,
|
|
1541
|
-
identity
|
|
1542
|
-
|
|
1663
|
+
identity,
|
|
1664
|
+
conversation
|
|
1665
|
+
).catch((error) => {
|
|
1666
|
+
reporter.error("Permission request could not be sent", error);
|
|
1667
|
+
conversation.error(identity.sessionID, "Permission request could not be sent");
|
|
1668
|
+
});
|
|
1543
1669
|
}
|
|
1544
1670
|
async function handleTodoUpdated(event) {
|
|
1545
1671
|
const { sessionID, todos } = event.properties;
|
|
@@ -1557,8 +1683,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1557
1683
|
event.properties,
|
|
1558
1684
|
event.id,
|
|
1559
1685
|
client,
|
|
1560
|
-
directory
|
|
1561
|
-
|
|
1686
|
+
directory,
|
|
1687
|
+
conversation
|
|
1688
|
+
).catch((error) => {
|
|
1689
|
+
reporter.error("Todo completion could not be sent", error);
|
|
1690
|
+
conversation.error(sessionID, "Todo completion could not be sent");
|
|
1691
|
+
});
|
|
1562
1692
|
}
|
|
1563
1693
|
async function handleQuestionAsked(event) {
|
|
1564
1694
|
const identity = questionIdentity(event.properties);
|
|
@@ -1574,8 +1704,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1574
1704
|
event.properties,
|
|
1575
1705
|
identity,
|
|
1576
1706
|
client,
|
|
1577
|
-
directory
|
|
1578
|
-
|
|
1707
|
+
directory,
|
|
1708
|
+
conversation
|
|
1709
|
+
).catch((error) => {
|
|
1710
|
+
reporter.error("Question could not be sent", error);
|
|
1711
|
+
conversation.error(identity.sessionID, "Question could not be sent");
|
|
1712
|
+
});
|
|
1579
1713
|
}
|
|
1580
1714
|
return {
|
|
1581
1715
|
config: async (opencodeConfig) => {
|
|
@@ -1585,6 +1719,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1585
1719
|
"chat.message": handleUpdateNotice,
|
|
1586
1720
|
event: async ({ event }) => {
|
|
1587
1721
|
if (!config.token) return;
|
|
1722
|
+
conversation.handle(event);
|
|
1588
1723
|
lifecycleController.handle(event);
|
|
1589
1724
|
if (config.permissions && event.type === "permission.asked") {
|
|
1590
1725
|
await handlePermissionAsked(event);
|
|
@@ -1609,6 +1744,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1609
1744
|
lifecycleController.dispose();
|
|
1610
1745
|
todoCompletionBySessionID.clear();
|
|
1611
1746
|
publishedQuestions.clear();
|
|
1747
|
+
conversation.dispose();
|
|
1612
1748
|
}
|
|
1613
1749
|
};
|
|
1614
1750
|
}
|
package/package.json
CHANGED
package/src/event-publishers.js
CHANGED
|
@@ -21,6 +21,7 @@ export async function publishPermission(
|
|
|
21
21
|
request,
|
|
22
22
|
directory,
|
|
23
23
|
identity,
|
|
24
|
+
conversation,
|
|
24
25
|
) {
|
|
25
26
|
const permissionType = request.permission ?? request.action ?? "unknown"
|
|
26
27
|
const resources = Array.isArray(request.patterns)
|
|
@@ -53,6 +54,7 @@ export async function publishPermission(
|
|
|
53
54
|
direction: "in",
|
|
54
55
|
})
|
|
55
56
|
reporter.success("Permission request sent", `HTTP ${result.status}`)
|
|
57
|
+
conversation?.success(identity.sessionID, "Permission request sent")
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function questionIdentity(request) {
|
|
@@ -104,6 +106,7 @@ export async function publishQuestion(
|
|
|
104
106
|
identity,
|
|
105
107
|
client,
|
|
106
108
|
directory,
|
|
109
|
+
conversation,
|
|
107
110
|
) {
|
|
108
111
|
const questions = Array.isArray(request.questions) ? request.questions : []
|
|
109
112
|
if (questions.length === 0) return
|
|
@@ -153,6 +156,7 @@ export async function publishQuestion(
|
|
|
153
156
|
direction: "in",
|
|
154
157
|
})
|
|
155
158
|
reporter.success("Question sent", `HTTP ${result.status}`)
|
|
159
|
+
conversation?.success(identity.sessionID, "Question sent")
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
export async function publishTodoCompletion(
|
|
@@ -162,6 +166,7 @@ export async function publishTodoCompletion(
|
|
|
162
166
|
eventID,
|
|
163
167
|
client,
|
|
164
168
|
directory,
|
|
169
|
+
conversation,
|
|
165
170
|
) {
|
|
166
171
|
const todos = request.todos
|
|
167
172
|
const sessionTitle = await resolveSessionTitle(
|
|
@@ -211,4 +216,5 @@ export async function publishTodoCompletion(
|
|
|
211
216
|
direction: "in",
|
|
212
217
|
})
|
|
213
218
|
reporter.success("Todo completion sent", `HTTP ${result.status}`)
|
|
219
|
+
conversation?.success(request.sessionID, "Todo completion sent")
|
|
214
220
|
}
|
|
@@ -20,6 +20,7 @@ export function createLifecycleController({
|
|
|
20
20
|
reporter,
|
|
21
21
|
client,
|
|
22
22
|
directory,
|
|
23
|
+
conversation,
|
|
23
24
|
now = () => Date.now(),
|
|
24
25
|
publishers = {},
|
|
25
26
|
}) {
|
|
@@ -47,8 +48,11 @@ export function createLifecycleController({
|
|
|
47
48
|
return state
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function dispatch(label, operation) {
|
|
51
|
-
void Promise.resolve(operation).catch((error) =>
|
|
51
|
+
function dispatch(label, operation, sessionID) {
|
|
52
|
+
void Promise.resolve(operation).catch((error) => {
|
|
53
|
+
reporter.error(label, error)
|
|
54
|
+
conversation?.error(sessionID, label)
|
|
55
|
+
})
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
function beginCycle(state) {
|
|
@@ -83,7 +87,9 @@ export function createLifecycleController({
|
|
|
83
87
|
sessionID,
|
|
84
88
|
messageID: limit(assistant.id, 200),
|
|
85
89
|
durationMs,
|
|
90
|
+
conversation,
|
|
86
91
|
}),
|
|
92
|
+
sessionID,
|
|
87
93
|
)
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -135,7 +141,9 @@ export function createLifecycleController({
|
|
|
135
141
|
correlation: limit(correlation, 200),
|
|
136
142
|
attempt: status.attempt,
|
|
137
143
|
next: status.next,
|
|
144
|
+
conversation,
|
|
138
145
|
}),
|
|
146
|
+
sessionID,
|
|
139
147
|
)
|
|
140
148
|
}
|
|
141
149
|
|
|
@@ -157,7 +165,9 @@ export function createLifecycleController({
|
|
|
157
165
|
sessionID,
|
|
158
166
|
correlation: errorCorrelation(event, state),
|
|
159
167
|
error: event.properties?.error,
|
|
168
|
+
conversation,
|
|
160
169
|
}),
|
|
170
|
+
sessionID,
|
|
161
171
|
)
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -53,7 +53,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function publish(config, reporter, event, feedback) {
|
|
56
|
+
async function publish(config, reporter, conversation, sessionID, event, feedback) {
|
|
57
57
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
58
58
|
type: "application",
|
|
59
59
|
timestamp: new Date().toISOString(),
|
|
@@ -62,6 +62,7 @@ async function publish(config, reporter, event, feedback) {
|
|
|
62
62
|
...event,
|
|
63
63
|
})
|
|
64
64
|
reporter.success(feedback, `HTTP ${result.status}`)
|
|
65
|
+
conversation?.success(sessionID, feedback)
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
function secondsLabel(durationMs) {
|
|
@@ -80,6 +81,7 @@ export async function publishSessionCompleted({
|
|
|
80
81
|
sessionID,
|
|
81
82
|
messageID,
|
|
82
83
|
durationMs,
|
|
84
|
+
conversation,
|
|
83
85
|
}) {
|
|
84
86
|
const sessionTitle = await resolveSessionTitle(
|
|
85
87
|
client,
|
|
@@ -89,7 +91,7 @@ export async function publishSessionCompleted({
|
|
|
89
91
|
)
|
|
90
92
|
const duration = secondsLabel(durationMs)
|
|
91
93
|
const location = sessionTitle ? ` “${sessionTitle}”` : ""
|
|
92
|
-
await publish(config, reporter, {
|
|
94
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
93
95
|
severity: "info",
|
|
94
96
|
title: "OpenCode task completed",
|
|
95
97
|
body: `OpenCode finished${location} in ${duration}.`,
|
|
@@ -111,6 +113,7 @@ export async function publishSessionRetry({
|
|
|
111
113
|
correlation,
|
|
112
114
|
attempt,
|
|
113
115
|
next,
|
|
116
|
+
conversation,
|
|
114
117
|
}) {
|
|
115
118
|
const sessionTitle = await resolveSessionTitle(
|
|
116
119
|
client,
|
|
@@ -119,7 +122,7 @@ export async function publishSessionRetry({
|
|
|
119
122
|
reporter,
|
|
120
123
|
)
|
|
121
124
|
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : ""
|
|
122
|
-
await publish(config, reporter, {
|
|
125
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
123
126
|
severity: "warning",
|
|
124
127
|
title: "OpenCode is retrying",
|
|
125
128
|
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
@@ -140,6 +143,7 @@ export async function publishSessionError({
|
|
|
140
143
|
sessionID,
|
|
141
144
|
correlation,
|
|
142
145
|
error,
|
|
146
|
+
conversation,
|
|
143
147
|
}) {
|
|
144
148
|
const sessionTitle = await resolveSessionTitle(
|
|
145
149
|
client,
|
|
@@ -152,7 +156,7 @@ export async function publishSessionError({
|
|
|
152
156
|
const body = details.message
|
|
153
157
|
? `OpenCode failed${location}: ${details.message}`
|
|
154
158
|
: `OpenCode failed${location}.`
|
|
155
|
-
await publish(config, reporter, {
|
|
159
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
156
160
|
// Backeer/iOS currently support info, warning and critical severities.
|
|
157
161
|
severity: "critical",
|
|
158
162
|
title: "OpenCode task failed",
|
|
@@ -170,7 +174,7 @@ export async function publishSessionError({
|
|
|
170
174
|
|
|
171
175
|
export async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
172
176
|
const safeVersion = limit(version, 100)
|
|
173
|
-
await publish(config, reporter, {
|
|
177
|
+
await publish(config, reporter, undefined, undefined, {
|
|
174
178
|
severity: "info",
|
|
175
179
|
title: "OpenCode update available",
|
|
176
180
|
body: `OpenCode ${safeVersion} is available.`,
|
package/src/management.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { dirname, resolve } from "node:path"
|
|
10
10
|
|
|
11
11
|
import { buildDoctorReport, publishTestNotification } from "./diagnostics.js"
|
|
12
|
+
import { sendSessionMessage } from "./session-messages.js"
|
|
12
13
|
import {
|
|
13
14
|
formatAgentUpdateInstruction,
|
|
14
15
|
PLUGIN_VERSION,
|
|
@@ -156,19 +157,6 @@ function tokenInstructions() {
|
|
|
156
157
|
].join("\n")
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
async function sendCommandMessage(client, sessionID, text) {
|
|
160
|
-
if (typeof client?.session?.prompt !== "function") {
|
|
161
|
-
throw new Error("OpenCode session messaging is unavailable")
|
|
162
|
-
}
|
|
163
|
-
await client.session.prompt({
|
|
164
|
-
path: { id: sessionID },
|
|
165
|
-
body: {
|
|
166
|
-
noReply: true,
|
|
167
|
-
parts: [{ type: "text", text, ignored: true }],
|
|
168
|
-
},
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
|
|
172
160
|
export function registerBackeerCommand(opencodeConfig) {
|
|
173
161
|
opencodeConfig.command ??= {}
|
|
174
162
|
opencodeConfig.command.backeer = {
|
|
@@ -262,6 +250,6 @@ export function createBackeerCommandHandler({
|
|
|
262
250
|
].join("\n")
|
|
263
251
|
}
|
|
264
252
|
|
|
265
|
-
await
|
|
253
|
+
await sendSessionMessage(client, input.sessionID, message)
|
|
266
254
|
}
|
|
267
255
|
}
|
|
@@ -35,6 +35,7 @@ export function createPermissionController({
|
|
|
35
35
|
reporter,
|
|
36
36
|
client,
|
|
37
37
|
directory,
|
|
38
|
+
conversation,
|
|
38
39
|
}) {
|
|
39
40
|
const pendingByExternalID = new Map()
|
|
40
41
|
const externalIDByRequestID = new Map()
|
|
@@ -104,6 +105,7 @@ export function createPermissionController({
|
|
|
104
105
|
}
|
|
105
106
|
if (stale) removePendingByRequestID(pending.requestID)
|
|
106
107
|
reporter.error("Decision could not be applied", error)
|
|
108
|
+
conversation?.error(pending.sessionID, "Decision could not be applied")
|
|
107
109
|
} finally {
|
|
108
110
|
processingCommands.delete(command.id)
|
|
109
111
|
}
|
|
@@ -120,12 +122,19 @@ export function createPermissionController({
|
|
|
120
122
|
reporter.applied(
|
|
121
123
|
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
|
|
122
124
|
)
|
|
125
|
+
conversation?.applied(
|
|
126
|
+
pending.sessionID,
|
|
127
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
|
|
128
|
+
)
|
|
123
129
|
} else if (acknowledgement.status === "expired") {
|
|
124
130
|
reporter.warning("Decision expired before it could be applied")
|
|
131
|
+
conversation?.warning(pending.sessionID, "Decision expired before it could be applied")
|
|
125
132
|
} else if (acknowledgement.status === "stale") {
|
|
126
133
|
reporter.warning("Permission was already answered locally")
|
|
134
|
+
conversation?.warning(pending.sessionID, "Permission was already answered locally")
|
|
127
135
|
} else if (acknowledgement.status === "rejected") {
|
|
128
136
|
reporter.warning("Decision was rejected by validation")
|
|
137
|
+
conversation?.warning(pending.sessionID, "Decision was rejected by validation")
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
package/src/plugin.js
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
} from "./management.js"
|
|
13
13
|
import { createLifecycleController } from "./lifecycle-controller.js"
|
|
14
14
|
import { createPermissionController } from "./permission-controller.js"
|
|
15
|
-
import {
|
|
15
|
+
import { createOpenCodeReporter } from "./reporter.js"
|
|
16
|
+
import { createConversationFeedback } from "./session-messages.js"
|
|
16
17
|
import {
|
|
17
18
|
createUpdateChecker,
|
|
18
19
|
createUpdateNoticeHook,
|
|
@@ -28,7 +29,8 @@ import { rememberBounded } from "./utilities.js"
|
|
|
28
29
|
* protected file so it cannot accidentally be committed in opencode.json.
|
|
29
30
|
*/
|
|
30
31
|
export default async function BackeerPlugin({ client, directory }, options = {}) {
|
|
31
|
-
const bootstrapReporter =
|
|
32
|
+
const bootstrapReporter = createOpenCodeReporter(
|
|
33
|
+
client,
|
|
32
34
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
33
35
|
)
|
|
34
36
|
let config
|
|
@@ -39,19 +41,22 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
39
41
|
return {}
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const reporter =
|
|
44
|
+
const reporter = createOpenCodeReporter(client, config.logLevel)
|
|
45
|
+
const conversation = createConversationFeedback({ client, reporter })
|
|
43
46
|
|
|
44
47
|
const permissionController = createPermissionController({
|
|
45
48
|
config,
|
|
46
49
|
reporter,
|
|
47
50
|
client,
|
|
48
51
|
directory,
|
|
52
|
+
conversation,
|
|
49
53
|
})
|
|
50
54
|
const lifecycleController = createLifecycleController({
|
|
51
55
|
config,
|
|
52
56
|
reporter,
|
|
53
57
|
client,
|
|
54
58
|
directory,
|
|
59
|
+
conversation,
|
|
55
60
|
})
|
|
56
61
|
const todoCompletionBySessionID = new Map()
|
|
57
62
|
const publishedQuestions = new Map()
|
|
@@ -86,7 +91,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
86
91
|
event.properties,
|
|
87
92
|
directory,
|
|
88
93
|
identity,
|
|
89
|
-
|
|
94
|
+
conversation,
|
|
95
|
+
).catch((error) => {
|
|
96
|
+
reporter.error("Permission request could not be sent", error)
|
|
97
|
+
conversation.error(identity.sessionID, "Permission request could not be sent")
|
|
98
|
+
})
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
async function handleTodoUpdated(event) {
|
|
@@ -109,7 +118,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
109
118
|
event.id,
|
|
110
119
|
client,
|
|
111
120
|
directory,
|
|
112
|
-
|
|
121
|
+
conversation,
|
|
122
|
+
).catch((error) => {
|
|
123
|
+
reporter.error("Todo completion could not be sent", error)
|
|
124
|
+
conversation.error(sessionID, "Todo completion could not be sent")
|
|
125
|
+
})
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
async function handleQuestionAsked(event) {
|
|
@@ -128,7 +141,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
128
141
|
identity,
|
|
129
142
|
client,
|
|
130
143
|
directory,
|
|
131
|
-
|
|
144
|
+
conversation,
|
|
145
|
+
).catch((error) => {
|
|
146
|
+
reporter.error("Question could not be sent", error)
|
|
147
|
+
conversation.error(identity.sessionID, "Question could not be sent")
|
|
148
|
+
})
|
|
132
149
|
}
|
|
133
150
|
|
|
134
151
|
return {
|
|
@@ -143,6 +160,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
143
160
|
event: async ({ event }) => {
|
|
144
161
|
if (!config.token) return
|
|
145
162
|
|
|
163
|
+
conversation.handle(event)
|
|
146
164
|
lifecycleController.handle(event)
|
|
147
165
|
|
|
148
166
|
if (config.permissions && event.type === "permission.asked") {
|
|
@@ -175,6 +193,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
175
193
|
lifecycleController.dispose()
|
|
176
194
|
todoCompletionBySessionID.clear()
|
|
177
195
|
publishedQuestions.clear()
|
|
196
|
+
conversation.dispose()
|
|
178
197
|
},
|
|
179
198
|
}
|
|
180
199
|
}
|
package/src/reporter.js
CHANGED
|
@@ -58,3 +58,53 @@ export function createConsoleReporter(levelValue) {
|
|
|
58
58
|
debug: (message, detail) => emit("debug", "debug", message, detail),
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
export function createOpenCodeReporter(client, levelValue) {
|
|
63
|
+
const level = normalizeLogLevel(levelValue)
|
|
64
|
+
const recentlyEmitted = new Map()
|
|
65
|
+
const method = client?.app?.log
|
|
66
|
+
|
|
67
|
+
function emit(requiredLevel, kind, message, detail, deduplicate = false) {
|
|
68
|
+
if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return
|
|
69
|
+
|
|
70
|
+
const key = `${kind}:${message}`
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
if (deduplicate) {
|
|
73
|
+
const previous = recentlyEmitted.get(key)
|
|
74
|
+
if (previous && now - previous < LOG_DEDUPLICATION_MS) return
|
|
75
|
+
rememberRecent(recentlyEmitted, key, now)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body = {
|
|
79
|
+
service: "backeer-opencode",
|
|
80
|
+
level: kind === "warning" ? "warn" : kind === "applied" || kind === "success"
|
|
81
|
+
? "info"
|
|
82
|
+
: kind,
|
|
83
|
+
message: limit(message, 500),
|
|
84
|
+
}
|
|
85
|
+
if (level === "debug" && detail) {
|
|
86
|
+
body.extra = { detail: limit(detail?.message ?? detail, 500) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Logging during plugin initialization can deadlock older OpenCode builds.
|
|
90
|
+
// Defer it and never fall back to stdout, because stdout corrupts the TUI prompt.
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
void Promise.resolve(method.call(client.app, { body })).catch(() => {})
|
|
93
|
+
}, 0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
info: (message, detail) => emit("info", "info", message, detail),
|
|
98
|
+
success: (message, detail) => emit("info", "success", message, detail),
|
|
99
|
+
applied: (message, detail) => emit("info", "applied", message, detail),
|
|
100
|
+
warning: (message, detail) => emit("error", "warning", message, detail, true),
|
|
101
|
+
error: (message, detail) => emit("error", "error", message, detail, true),
|
|
102
|
+
debug: (message, detail) => emit("debug", "debug", message, detail),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rememberRecent(map, key, value) {
|
|
107
|
+
map.delete(key)
|
|
108
|
+
map.set(key, value)
|
|
109
|
+
while (map.size > 100) map.delete(map.keys().next().value)
|
|
110
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { limit, rememberBounded } from "./utilities.js"
|
|
2
|
+
|
|
3
|
+
const FEEDBACK_SYMBOLS = {
|
|
4
|
+
info: "•",
|
|
5
|
+
success: "✓",
|
|
6
|
+
applied: "✓",
|
|
7
|
+
warning: "!",
|
|
8
|
+
error: "×",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sendSessionMessage(client, sessionID, text) {
|
|
12
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
13
|
+
throw new Error("OpenCode session messaging is unavailable")
|
|
14
|
+
}
|
|
15
|
+
await client.session.prompt({
|
|
16
|
+
path: { id: sessionID },
|
|
17
|
+
body: {
|
|
18
|
+
noReply: true,
|
|
19
|
+
parts: [{ type: "text", text, ignored: true }],
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatBackeerFeedback(kind, message) {
|
|
25
|
+
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
|
|
26
|
+
return `> **Backeer** ${symbol} ${limit(message, 300)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createConversationFeedback({ client, reporter }) {
|
|
30
|
+
const sessions = new Map()
|
|
31
|
+
let disposed = false
|
|
32
|
+
|
|
33
|
+
function sessionState(sessionID) {
|
|
34
|
+
let state = sessions.get(sessionID)
|
|
35
|
+
if (!state) {
|
|
36
|
+
state = { idle: false, flushing: false, queue: [], queued: new Set() }
|
|
37
|
+
rememberBounded(sessions, sessionID, state)
|
|
38
|
+
}
|
|
39
|
+
return state
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function flush(sessionID, state) {
|
|
43
|
+
if (disposed || state.flushing || !state.idle || state.queue.length === 0) return
|
|
44
|
+
state.flushing = true
|
|
45
|
+
try {
|
|
46
|
+
while (!disposed && state.idle && state.queue.length > 0) {
|
|
47
|
+
const item = state.queue[0]
|
|
48
|
+
try {
|
|
49
|
+
await sendSessionMessage(
|
|
50
|
+
client,
|
|
51
|
+
sessionID,
|
|
52
|
+
formatBackeerFeedback(item.kind, item.message),
|
|
53
|
+
)
|
|
54
|
+
state.queue.shift()
|
|
55
|
+
state.queued.delete(item.key)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
state.idle = false
|
|
58
|
+
reporter.debug("Conversation feedback could not be displayed", error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
state.flushing = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function enqueue(kind, sessionID, message) {
|
|
67
|
+
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return
|
|
68
|
+
const state = sessionState(sessionID)
|
|
69
|
+
const key = `${sessionID}:${kind}:${message}`
|
|
70
|
+
if (state.queued.has(key)) return
|
|
71
|
+
|
|
72
|
+
state.queue.push({ kind, message: limit(message, 300), key })
|
|
73
|
+
state.queued.add(key)
|
|
74
|
+
while (state.queue.length > 20) {
|
|
75
|
+
const removed = state.queue.shift()
|
|
76
|
+
state.queued.delete(removed.key)
|
|
77
|
+
}
|
|
78
|
+
if (state.idle) void flush(sessionID, state)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setIdle(sessionID, idle) {
|
|
82
|
+
if (typeof sessionID !== "string" || !sessionID) return
|
|
83
|
+
const state = sessionState(sessionID)
|
|
84
|
+
state.idle = idle
|
|
85
|
+
if (idle) void flush(sessionID, state)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
info: (sessionID, message) => enqueue("info", sessionID, message),
|
|
90
|
+
success: (sessionID, message) => enqueue("success", sessionID, message),
|
|
91
|
+
applied: (sessionID, message) => enqueue("applied", sessionID, message),
|
|
92
|
+
warning: (sessionID, message) => enqueue("warning", sessionID, message),
|
|
93
|
+
error: (sessionID, message) => enqueue("error", sessionID, message),
|
|
94
|
+
|
|
95
|
+
handle(event) {
|
|
96
|
+
const sessionID = event?.properties?.sessionID
|
|
97
|
+
if (typeof sessionID !== "string") return
|
|
98
|
+
|
|
99
|
+
if (event.type === "session.status") {
|
|
100
|
+
const status = event.properties?.status?.type
|
|
101
|
+
if (status === "idle") setIdle(sessionID, true)
|
|
102
|
+
else if (status === "busy" || status === "retry") setIdle(sessionID, false)
|
|
103
|
+
} else if (event.type === "session.idle") {
|
|
104
|
+
setIdle(sessionID, true)
|
|
105
|
+
} else if (
|
|
106
|
+
event.type === "permission.asked" ||
|
|
107
|
+
event.type === "question.asked" ||
|
|
108
|
+
event.type === "question.v2.asked" ||
|
|
109
|
+
event.type === "todo.updated"
|
|
110
|
+
) {
|
|
111
|
+
setIdle(sessionID, false)
|
|
112
|
+
} else if (event.type === "session.deleted") {
|
|
113
|
+
sessions.delete(sessionID)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
dispose() {
|
|
118
|
+
disposed = true
|
|
119
|
+
sessions.clear()
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/updates.js
CHANGED