backeer-opencode 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +23 -18
- package/dist/backeer-permission.js +245 -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 +173 -0
- package/src/updates.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.3 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Keep conversation feedback queued while OpenCode permission or question UI is
|
|
8
|
+
active, even if OpenCode emits an intermediate `session.idle` event.
|
|
9
|
+
- Wait for the interaction reply and the following real idle transition before
|
|
10
|
+
injecting an ignored Backeer status message into the conversation.
|
|
11
|
+
- Add regression coverage for permission and question UI lifecycles.
|
|
12
|
+
|
|
13
|
+
## 0.2.2 - 2026-09-01
|
|
14
|
+
|
|
15
|
+
- Show user-facing Backeer delivery and decision results in the OpenCode
|
|
16
|
+
conversation instead of writing them into the TUI input area.
|
|
17
|
+
- Queue conversation feedback until the session is idle and mark it as ignored
|
|
18
|
+
so it cannot trigger another agent response or enter model context.
|
|
19
|
+
- Send technical diagnostics through OpenCode's structured application log API
|
|
20
|
+
instead of stdout.
|
|
21
|
+
|
|
5
22
|
## 0.2.1 - 2026-09-01
|
|
6
23
|
|
|
7
24
|
- Add `/backeer doctor` for read-only local configuration, protected token,
|
package/README.md
CHANGED
|
@@ -150,25 +150,29 @@ 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, a permission prompt is open, or a question is awaiting an
|
|
169
|
+
answer remains queued until the interaction closes and the session becomes idle.
|
|
170
|
+
It never writes to the user's input field or replaces interactive OpenCode UI.
|
|
171
|
+
|
|
172
|
+
Technical diagnostics use OpenCode's structured application log API instead of
|
|
173
|
+
stdout. Normal `info` logging omits HTTP details; set
|
|
174
|
+
`BACKEER_LOG_LEVEL=debug` when technical details are needed. Set it to `error`
|
|
175
|
+
for failures only or `silent` to disable structured logs.
|
|
172
176
|
|
|
173
177
|
## OpenCode command
|
|
174
178
|
|
|
@@ -263,10 +267,11 @@ npm run publish:check
|
|
|
263
267
|
|
|
264
268
|
## Development architecture
|
|
265
269
|
|
|
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
|
|
270
|
+
The npm plugin runs from the modular source in `src`. Configuration, structured
|
|
271
|
+
logging, conversation feedback, HTTP transport, SSE consumption, event
|
|
272
|
+
publishing, permission command handling, and OpenCode event routing are kept in
|
|
273
|
+
separate modules. The package root continues to expose only the default OpenCode
|
|
274
|
+
plugin entrypoint.
|
|
270
275
|
|
|
271
276
|
The legacy side-loaded installer still deploys one dependency-free file. Build
|
|
272
277
|
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.3";
|
|
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,154 @@ 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
|
+
var QUESTION_ASKED_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
|
|
654
|
+
var QUESTION_REPLIED_EVENTS = /* @__PURE__ */ new Set([
|
|
655
|
+
"question.replied",
|
|
656
|
+
"question.v2.replied",
|
|
657
|
+
"question.answered",
|
|
658
|
+
"question.v2.answered"
|
|
659
|
+
]);
|
|
660
|
+
async function sendSessionMessage(client, sessionID, text) {
|
|
661
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
662
|
+
throw new Error("OpenCode session messaging is unavailable");
|
|
663
|
+
}
|
|
664
|
+
await client.session.prompt({
|
|
665
|
+
path: { id: sessionID },
|
|
666
|
+
body: {
|
|
667
|
+
noReply: true,
|
|
668
|
+
parts: [{ type: "text", text, ignored: true }]
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
function formatBackeerFeedback(kind, message) {
|
|
673
|
+
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info;
|
|
674
|
+
return `> **Backeer** ${symbol} ${limit(message, 300)}`;
|
|
675
|
+
}
|
|
676
|
+
function createConversationFeedback({ client, reporter }) {
|
|
677
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
678
|
+
let disposed = false;
|
|
679
|
+
function sessionState(sessionID) {
|
|
680
|
+
let state = sessions.get(sessionID);
|
|
681
|
+
if (!state) {
|
|
682
|
+
state = {
|
|
683
|
+
idle: false,
|
|
684
|
+
flushing: false,
|
|
685
|
+
blockers: /* @__PURE__ */ new Set(),
|
|
686
|
+
queue: [],
|
|
687
|
+
queued: /* @__PURE__ */ new Set()
|
|
688
|
+
};
|
|
689
|
+
rememberBounded(sessions, sessionID, state);
|
|
690
|
+
}
|
|
691
|
+
return state;
|
|
692
|
+
}
|
|
693
|
+
async function flush(sessionID, state) {
|
|
694
|
+
if (disposed || state.flushing || !state.idle || state.blockers.size > 0 || state.queue.length === 0) return;
|
|
695
|
+
state.flushing = true;
|
|
696
|
+
try {
|
|
697
|
+
while (!disposed && state.idle && state.blockers.size === 0 && state.queue.length > 0) {
|
|
698
|
+
const item = state.queue[0];
|
|
699
|
+
try {
|
|
700
|
+
await sendSessionMessage(
|
|
701
|
+
client,
|
|
702
|
+
sessionID,
|
|
703
|
+
formatBackeerFeedback(item.kind, item.message)
|
|
704
|
+
);
|
|
705
|
+
state.queue.shift();
|
|
706
|
+
state.queued.delete(item.key);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
state.idle = false;
|
|
709
|
+
reporter.debug("Conversation feedback could not be displayed", error);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
state.flushing = false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function enqueue(kind, sessionID, message) {
|
|
717
|
+
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return;
|
|
718
|
+
const state = sessionState(sessionID);
|
|
719
|
+
const key = `${sessionID}:${kind}:${message}`;
|
|
720
|
+
if (state.queued.has(key)) return;
|
|
721
|
+
state.queue.push({ kind, message: limit(message, 300), key });
|
|
722
|
+
state.queued.add(key);
|
|
723
|
+
while (state.queue.length > 20) {
|
|
724
|
+
const removed = state.queue.shift();
|
|
725
|
+
state.queued.delete(removed.key);
|
|
726
|
+
}
|
|
727
|
+
if (state.idle && state.blockers.size === 0) void flush(sessionID, state);
|
|
728
|
+
}
|
|
729
|
+
function setIdle(sessionID, idle) {
|
|
730
|
+
if (typeof sessionID !== "string" || !sessionID) return;
|
|
731
|
+
const state = sessionState(sessionID);
|
|
732
|
+
state.idle = idle;
|
|
733
|
+
if (idle) void flush(sessionID, state);
|
|
734
|
+
}
|
|
735
|
+
function interactionKey(kind, event) {
|
|
736
|
+
const value = event.properties?.requestID ?? event.properties?.id;
|
|
737
|
+
return `${kind}:${typeof value === "string" && value ? value : "pending"}`;
|
|
738
|
+
}
|
|
739
|
+
function beginInteraction(sessionID, kind, event) {
|
|
740
|
+
const state = sessionState(sessionID);
|
|
741
|
+
state.idle = false;
|
|
742
|
+
state.blockers.add(interactionKey(kind, event));
|
|
743
|
+
}
|
|
744
|
+
function endInteraction(sessionID, kind, event) {
|
|
745
|
+
const state = sessionState(sessionID);
|
|
746
|
+
const key = interactionKey(kind, event);
|
|
747
|
+
if (!state.blockers.delete(key)) {
|
|
748
|
+
const fallback = [...state.blockers].find((blocker) => blocker.startsWith(`${kind}:`));
|
|
749
|
+
if (fallback) state.blockers.delete(fallback);
|
|
750
|
+
}
|
|
751
|
+
state.idle = false;
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
info: (sessionID, message) => enqueue("info", sessionID, message),
|
|
755
|
+
success: (sessionID, message) => enqueue("success", sessionID, message),
|
|
756
|
+
applied: (sessionID, message) => enqueue("applied", sessionID, message),
|
|
757
|
+
warning: (sessionID, message) => enqueue("warning", sessionID, message),
|
|
758
|
+
error: (sessionID, message) => enqueue("error", sessionID, message),
|
|
759
|
+
handle(event) {
|
|
760
|
+
const sessionID = event?.properties?.sessionID;
|
|
761
|
+
if (typeof sessionID !== "string") return;
|
|
762
|
+
if (event.type === "session.status") {
|
|
763
|
+
const status = event.properties?.status?.type;
|
|
764
|
+
if (status === "idle") setIdle(sessionID, true);
|
|
765
|
+
else if (status === "busy" || status === "retry") setIdle(sessionID, false);
|
|
766
|
+
} else if (event.type === "session.idle") {
|
|
767
|
+
setIdle(sessionID, true);
|
|
768
|
+
} else if (event.type === "permission.asked") {
|
|
769
|
+
beginInteraction(sessionID, "permission", event);
|
|
770
|
+
} else if (QUESTION_ASKED_EVENTS.has(event.type)) {
|
|
771
|
+
beginInteraction(sessionID, "question", event);
|
|
772
|
+
} else if (event.type === "permission.replied") {
|
|
773
|
+
endInteraction(sessionID, "permission", event);
|
|
774
|
+
} else if (QUESTION_REPLIED_EVENTS.has(event.type)) {
|
|
775
|
+
endInteraction(sessionID, "question", event);
|
|
776
|
+
} else if (event.type === "todo.updated") {
|
|
777
|
+
setIdle(sessionID, false);
|
|
778
|
+
} else if (event.type === "session.error") {
|
|
779
|
+
const state = sessionState(sessionID);
|
|
780
|
+
state.blockers.clear();
|
|
781
|
+
state.idle = false;
|
|
782
|
+
} else if (event.type === "session.deleted") {
|
|
783
|
+
sessions.delete(sessionID);
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
dispose() {
|
|
787
|
+
disposed = true;
|
|
788
|
+
sessions.clear();
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
643
793
|
// src/management.js
|
|
644
794
|
var COMMAND_DESCRIPTION = "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]";
|
|
645
795
|
function tokenFileState(path) {
|
|
@@ -766,18 +916,6 @@ function tokenInstructions() {
|
|
|
766
916
|
"`token-file` activates the new token immediately; no OpenCode restart is required."
|
|
767
917
|
].join("\n");
|
|
768
918
|
}
|
|
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
919
|
function registerBackeerCommand(opencodeConfig) {
|
|
782
920
|
opencodeConfig.command ??= {};
|
|
783
921
|
opencodeConfig.command.backeer = {
|
|
@@ -859,7 +997,7 @@ Backeer is already current at version ${update.currentVersion}.` : "## Backeer u
|
|
|
859
997
|
"Use `/backeer token` for secure token-change instructions."
|
|
860
998
|
].join("\n");
|
|
861
999
|
}
|
|
862
|
-
await
|
|
1000
|
+
await sendSessionMessage(client, input.sessionID, message);
|
|
863
1001
|
};
|
|
864
1002
|
}
|
|
865
1003
|
|
|
@@ -900,7 +1038,7 @@ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
|
|
|
900
1038
|
return "";
|
|
901
1039
|
}
|
|
902
1040
|
}
|
|
903
|
-
async function publish(config, reporter, event, feedback) {
|
|
1041
|
+
async function publish(config, reporter, conversation, sessionID, event, feedback) {
|
|
904
1042
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
905
1043
|
type: "application",
|
|
906
1044
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -909,6 +1047,7 @@ async function publish(config, reporter, event, feedback) {
|
|
|
909
1047
|
...event
|
|
910
1048
|
});
|
|
911
1049
|
reporter.success(feedback, `HTTP ${result.status}`);
|
|
1050
|
+
conversation?.success(sessionID, feedback);
|
|
912
1051
|
}
|
|
913
1052
|
function secondsLabel(durationMs) {
|
|
914
1053
|
const seconds = Math.max(0, Math.round(durationMs / 1e3));
|
|
@@ -924,7 +1063,8 @@ async function publishSessionCompleted({
|
|
|
924
1063
|
directory,
|
|
925
1064
|
sessionID,
|
|
926
1065
|
messageID,
|
|
927
|
-
durationMs
|
|
1066
|
+
durationMs,
|
|
1067
|
+
conversation
|
|
928
1068
|
}) {
|
|
929
1069
|
const sessionTitle = await resolveSessionTitle2(
|
|
930
1070
|
client,
|
|
@@ -934,7 +1074,7 @@ async function publishSessionCompleted({
|
|
|
934
1074
|
);
|
|
935
1075
|
const duration = secondsLabel(durationMs);
|
|
936
1076
|
const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
|
|
937
|
-
await publish(config, reporter, {
|
|
1077
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
938
1078
|
severity: "info",
|
|
939
1079
|
title: "OpenCode task completed",
|
|
940
1080
|
body: `OpenCode finished${location} in ${duration}.`,
|
|
@@ -954,7 +1094,8 @@ async function publishSessionRetry({
|
|
|
954
1094
|
sessionID,
|
|
955
1095
|
correlation,
|
|
956
1096
|
attempt,
|
|
957
|
-
next
|
|
1097
|
+
next,
|
|
1098
|
+
conversation
|
|
958
1099
|
}) {
|
|
959
1100
|
const sessionTitle = await resolveSessionTitle2(
|
|
960
1101
|
client,
|
|
@@ -963,7 +1104,7 @@ async function publishSessionRetry({
|
|
|
963
1104
|
reporter
|
|
964
1105
|
);
|
|
965
1106
|
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
|
|
966
|
-
await publish(config, reporter, {
|
|
1107
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
967
1108
|
severity: "warning",
|
|
968
1109
|
title: "OpenCode is retrying",
|
|
969
1110
|
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
@@ -982,7 +1123,8 @@ async function publishSessionError({
|
|
|
982
1123
|
directory,
|
|
983
1124
|
sessionID,
|
|
984
1125
|
correlation,
|
|
985
|
-
error
|
|
1126
|
+
error,
|
|
1127
|
+
conversation
|
|
986
1128
|
}) {
|
|
987
1129
|
const sessionTitle = await resolveSessionTitle2(
|
|
988
1130
|
client,
|
|
@@ -993,7 +1135,7 @@ async function publishSessionError({
|
|
|
993
1135
|
const details = safeErrorDetails(error);
|
|
994
1136
|
const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
|
|
995
1137
|
const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
|
|
996
|
-
await publish(config, reporter, {
|
|
1138
|
+
await publish(config, reporter, conversation, sessionID, {
|
|
997
1139
|
// Backeer/iOS currently support info, warning and critical severities.
|
|
998
1140
|
severity: "critical",
|
|
999
1141
|
title: "OpenCode task failed",
|
|
@@ -1010,7 +1152,7 @@ async function publishSessionError({
|
|
|
1010
1152
|
}
|
|
1011
1153
|
async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
1012
1154
|
const safeVersion = limit(version, 100);
|
|
1013
|
-
await publish(config, reporter, {
|
|
1155
|
+
await publish(config, reporter, void 0, void 0, {
|
|
1014
1156
|
severity: "info",
|
|
1015
1157
|
title: "OpenCode update available",
|
|
1016
1158
|
body: `OpenCode ${safeVersion} is available.`,
|
|
@@ -1038,6 +1180,7 @@ function createLifecycleController({
|
|
|
1038
1180
|
reporter,
|
|
1039
1181
|
client,
|
|
1040
1182
|
directory,
|
|
1183
|
+
conversation,
|
|
1041
1184
|
now = () => Date.now(),
|
|
1042
1185
|
publishers = {}
|
|
1043
1186
|
}) {
|
|
@@ -1063,8 +1206,11 @@ function createLifecycleController({
|
|
|
1063
1206
|
}
|
|
1064
1207
|
return state;
|
|
1065
1208
|
}
|
|
1066
|
-
function dispatch(label, operation) {
|
|
1067
|
-
void Promise.resolve(operation).catch((error) =>
|
|
1209
|
+
function dispatch(label, operation, sessionID) {
|
|
1210
|
+
void Promise.resolve(operation).catch((error) => {
|
|
1211
|
+
reporter.error(label, error);
|
|
1212
|
+
conversation?.error(sessionID, label);
|
|
1213
|
+
});
|
|
1068
1214
|
}
|
|
1069
1215
|
function beginCycle(state) {
|
|
1070
1216
|
if (state.busy) return;
|
|
@@ -1092,8 +1238,10 @@ function createLifecycleController({
|
|
|
1092
1238
|
directory,
|
|
1093
1239
|
sessionID,
|
|
1094
1240
|
messageID: limit(assistant.id, 200),
|
|
1095
|
-
durationMs
|
|
1096
|
-
|
|
1241
|
+
durationMs,
|
|
1242
|
+
conversation
|
|
1243
|
+
}),
|
|
1244
|
+
sessionID
|
|
1097
1245
|
);
|
|
1098
1246
|
}
|
|
1099
1247
|
function handleMessage(event) {
|
|
@@ -1138,8 +1286,10 @@ function createLifecycleController({
|
|
|
1138
1286
|
sessionID,
|
|
1139
1287
|
correlation: limit(correlation, 200),
|
|
1140
1288
|
attempt: status.attempt,
|
|
1141
|
-
next: status.next
|
|
1142
|
-
|
|
1289
|
+
next: status.next,
|
|
1290
|
+
conversation
|
|
1291
|
+
}),
|
|
1292
|
+
sessionID
|
|
1143
1293
|
);
|
|
1144
1294
|
}
|
|
1145
1295
|
function handleError(event) {
|
|
@@ -1156,8 +1306,10 @@ function createLifecycleController({
|
|
|
1156
1306
|
directory,
|
|
1157
1307
|
sessionID,
|
|
1158
1308
|
correlation: errorCorrelation(event, state),
|
|
1159
|
-
error: event.properties?.error
|
|
1160
|
-
|
|
1309
|
+
error: event.properties?.error,
|
|
1310
|
+
conversation
|
|
1311
|
+
}),
|
|
1312
|
+
sessionID
|
|
1161
1313
|
);
|
|
1162
1314
|
}
|
|
1163
1315
|
function handleWaiting(event, waiting) {
|
|
@@ -1343,7 +1495,8 @@ function createPermissionController({
|
|
|
1343
1495
|
config,
|
|
1344
1496
|
reporter,
|
|
1345
1497
|
client,
|
|
1346
|
-
directory
|
|
1498
|
+
directory,
|
|
1499
|
+
conversation
|
|
1347
1500
|
}) {
|
|
1348
1501
|
const pendingByExternalID = /* @__PURE__ */ new Map();
|
|
1349
1502
|
const externalIDByRequestID = /* @__PURE__ */ new Map();
|
|
@@ -1404,6 +1557,7 @@ function createPermissionController({
|
|
|
1404
1557
|
};
|
|
1405
1558
|
if (stale) removePendingByRequestID(pending.requestID);
|
|
1406
1559
|
reporter.error("Decision could not be applied", error);
|
|
1560
|
+
conversation?.error(pending.sessionID, "Decision could not be applied");
|
|
1407
1561
|
} finally {
|
|
1408
1562
|
processingCommands.delete(command.id);
|
|
1409
1563
|
}
|
|
@@ -1418,12 +1572,19 @@ function createPermissionController({
|
|
|
1418
1572
|
reporter.applied(
|
|
1419
1573
|
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1420
1574
|
);
|
|
1575
|
+
conversation?.applied(
|
|
1576
|
+
pending.sessionID,
|
|
1577
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`
|
|
1578
|
+
);
|
|
1421
1579
|
} else if (acknowledgement.status === "expired") {
|
|
1422
1580
|
reporter.warning("Decision expired before it could be applied");
|
|
1581
|
+
conversation?.warning(pending.sessionID, "Decision expired before it could be applied");
|
|
1423
1582
|
} else if (acknowledgement.status === "stale") {
|
|
1424
1583
|
reporter.warning("Permission was already answered locally");
|
|
1584
|
+
conversation?.warning(pending.sessionID, "Permission was already answered locally");
|
|
1425
1585
|
} else if (acknowledgement.status === "rejected") {
|
|
1426
1586
|
reporter.warning("Decision was rejected by validation");
|
|
1587
|
+
conversation?.warning(pending.sessionID, "Decision was rejected by validation");
|
|
1427
1588
|
}
|
|
1428
1589
|
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
1429
1590
|
reporter.warning("Decision receipt could not be sent", error);
|
|
@@ -1486,7 +1647,8 @@ function createPermissionController({
|
|
|
1486
1647
|
|
|
1487
1648
|
// src/plugin.js
|
|
1488
1649
|
async function BackeerPlugin({ client, directory }, options = {}) {
|
|
1489
|
-
const bootstrapReporter =
|
|
1650
|
+
const bootstrapReporter = createOpenCodeReporter(
|
|
1651
|
+
client,
|
|
1490
1652
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL
|
|
1491
1653
|
);
|
|
1492
1654
|
let config;
|
|
@@ -1496,18 +1658,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1496
1658
|
bootstrapReporter.error("Plugin disabled \u2014 invalid configuration", error);
|
|
1497
1659
|
return {};
|
|
1498
1660
|
}
|
|
1499
|
-
const reporter =
|
|
1661
|
+
const reporter = createOpenCodeReporter(client, config.logLevel);
|
|
1662
|
+
const conversation = createConversationFeedback({ client, reporter });
|
|
1500
1663
|
const permissionController = createPermissionController({
|
|
1501
1664
|
config,
|
|
1502
1665
|
reporter,
|
|
1503
1666
|
client,
|
|
1504
|
-
directory
|
|
1667
|
+
directory,
|
|
1668
|
+
conversation
|
|
1505
1669
|
});
|
|
1506
1670
|
const lifecycleController = createLifecycleController({
|
|
1507
1671
|
config,
|
|
1508
1672
|
reporter,
|
|
1509
1673
|
client,
|
|
1510
|
-
directory
|
|
1674
|
+
directory,
|
|
1675
|
+
conversation
|
|
1511
1676
|
});
|
|
1512
1677
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
1513
1678
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
@@ -1538,8 +1703,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1538
1703
|
reporter,
|
|
1539
1704
|
event.properties,
|
|
1540
1705
|
directory,
|
|
1541
|
-
identity
|
|
1542
|
-
|
|
1706
|
+
identity,
|
|
1707
|
+
conversation
|
|
1708
|
+
).catch((error) => {
|
|
1709
|
+
reporter.error("Permission request could not be sent", error);
|
|
1710
|
+
conversation.error(identity.sessionID, "Permission request could not be sent");
|
|
1711
|
+
});
|
|
1543
1712
|
}
|
|
1544
1713
|
async function handleTodoUpdated(event) {
|
|
1545
1714
|
const { sessionID, todos } = event.properties;
|
|
@@ -1557,8 +1726,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1557
1726
|
event.properties,
|
|
1558
1727
|
event.id,
|
|
1559
1728
|
client,
|
|
1560
|
-
directory
|
|
1561
|
-
|
|
1729
|
+
directory,
|
|
1730
|
+
conversation
|
|
1731
|
+
).catch((error) => {
|
|
1732
|
+
reporter.error("Todo completion could not be sent", error);
|
|
1733
|
+
conversation.error(sessionID, "Todo completion could not be sent");
|
|
1734
|
+
});
|
|
1562
1735
|
}
|
|
1563
1736
|
async function handleQuestionAsked(event) {
|
|
1564
1737
|
const identity = questionIdentity(event.properties);
|
|
@@ -1574,8 +1747,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1574
1747
|
event.properties,
|
|
1575
1748
|
identity,
|
|
1576
1749
|
client,
|
|
1577
|
-
directory
|
|
1578
|
-
|
|
1750
|
+
directory,
|
|
1751
|
+
conversation
|
|
1752
|
+
).catch((error) => {
|
|
1753
|
+
reporter.error("Question could not be sent", error);
|
|
1754
|
+
conversation.error(identity.sessionID, "Question could not be sent");
|
|
1755
|
+
});
|
|
1579
1756
|
}
|
|
1580
1757
|
return {
|
|
1581
1758
|
config: async (opencodeConfig) => {
|
|
@@ -1585,6 +1762,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1585
1762
|
"chat.message": handleUpdateNotice,
|
|
1586
1763
|
event: async ({ event }) => {
|
|
1587
1764
|
if (!config.token) return;
|
|
1765
|
+
conversation.handle(event);
|
|
1588
1766
|
lifecycleController.handle(event);
|
|
1589
1767
|
if (config.permissions && event.type === "permission.asked") {
|
|
1590
1768
|
await handlePermissionAsked(event);
|
|
@@ -1609,6 +1787,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
1609
1787
|
lifecycleController.dispose();
|
|
1610
1788
|
todoCompletionBySessionID.clear();
|
|
1611
1789
|
publishedQuestions.clear();
|
|
1790
|
+
conversation.dispose();
|
|
1612
1791
|
}
|
|
1613
1792
|
};
|
|
1614
1793
|
}
|
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,173 @@
|
|
|
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
|
+
const QUESTION_ASKED_EVENTS = new Set(["question.asked", "question.v2.asked"])
|
|
12
|
+
const QUESTION_REPLIED_EVENTS = new Set([
|
|
13
|
+
"question.replied",
|
|
14
|
+
"question.v2.replied",
|
|
15
|
+
"question.answered",
|
|
16
|
+
"question.v2.answered",
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export async function sendSessionMessage(client, sessionID, text) {
|
|
20
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
21
|
+
throw new Error("OpenCode session messaging is unavailable")
|
|
22
|
+
}
|
|
23
|
+
await client.session.prompt({
|
|
24
|
+
path: { id: sessionID },
|
|
25
|
+
body: {
|
|
26
|
+
noReply: true,
|
|
27
|
+
parts: [{ type: "text", text, ignored: true }],
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatBackeerFeedback(kind, message) {
|
|
33
|
+
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
|
|
34
|
+
return `> **Backeer** ${symbol} ${limit(message, 300)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createConversationFeedback({ client, reporter }) {
|
|
38
|
+
const sessions = new Map()
|
|
39
|
+
let disposed = false
|
|
40
|
+
|
|
41
|
+
function sessionState(sessionID) {
|
|
42
|
+
let state = sessions.get(sessionID)
|
|
43
|
+
if (!state) {
|
|
44
|
+
state = {
|
|
45
|
+
idle: false,
|
|
46
|
+
flushing: false,
|
|
47
|
+
blockers: new Set(),
|
|
48
|
+
queue: [],
|
|
49
|
+
queued: new Set(),
|
|
50
|
+
}
|
|
51
|
+
rememberBounded(sessions, sessionID, state)
|
|
52
|
+
}
|
|
53
|
+
return state
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function flush(sessionID, state) {
|
|
57
|
+
if (
|
|
58
|
+
disposed || state.flushing || !state.idle ||
|
|
59
|
+
state.blockers.size > 0 || state.queue.length === 0
|
|
60
|
+
) return
|
|
61
|
+
state.flushing = true
|
|
62
|
+
try {
|
|
63
|
+
while (
|
|
64
|
+
!disposed && state.idle && state.blockers.size === 0 &&
|
|
65
|
+
state.queue.length > 0
|
|
66
|
+
) {
|
|
67
|
+
const item = state.queue[0]
|
|
68
|
+
try {
|
|
69
|
+
await sendSessionMessage(
|
|
70
|
+
client,
|
|
71
|
+
sessionID,
|
|
72
|
+
formatBackeerFeedback(item.kind, item.message),
|
|
73
|
+
)
|
|
74
|
+
state.queue.shift()
|
|
75
|
+
state.queued.delete(item.key)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
state.idle = false
|
|
78
|
+
reporter.debug("Conversation feedback could not be displayed", error)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
state.flushing = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function enqueue(kind, sessionID, message) {
|
|
87
|
+
if (disposed || typeof sessionID !== "string" || !sessionID || !message) return
|
|
88
|
+
const state = sessionState(sessionID)
|
|
89
|
+
const key = `${sessionID}:${kind}:${message}`
|
|
90
|
+
if (state.queued.has(key)) return
|
|
91
|
+
|
|
92
|
+
state.queue.push({ kind, message: limit(message, 300), key })
|
|
93
|
+
state.queued.add(key)
|
|
94
|
+
while (state.queue.length > 20) {
|
|
95
|
+
const removed = state.queue.shift()
|
|
96
|
+
state.queued.delete(removed.key)
|
|
97
|
+
}
|
|
98
|
+
if (state.idle && state.blockers.size === 0) void flush(sessionID, state)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setIdle(sessionID, idle) {
|
|
102
|
+
if (typeof sessionID !== "string" || !sessionID) return
|
|
103
|
+
const state = sessionState(sessionID)
|
|
104
|
+
state.idle = idle
|
|
105
|
+
if (idle) void flush(sessionID, state)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function interactionKey(kind, event) {
|
|
109
|
+
const value = event.properties?.requestID ?? event.properties?.id
|
|
110
|
+
return `${kind}:${typeof value === "string" && value ? value : "pending"}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function beginInteraction(sessionID, kind, event) {
|
|
114
|
+
const state = sessionState(sessionID)
|
|
115
|
+
state.idle = false
|
|
116
|
+
state.blockers.add(interactionKey(kind, event))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function endInteraction(sessionID, kind, event) {
|
|
120
|
+
const state = sessionState(sessionID)
|
|
121
|
+
const key = interactionKey(kind, event)
|
|
122
|
+
if (!state.blockers.delete(key)) {
|
|
123
|
+
const fallback = [...state.blockers]
|
|
124
|
+
.find((blocker) => blocker.startsWith(`${kind}:`))
|
|
125
|
+
if (fallback) state.blockers.delete(fallback)
|
|
126
|
+
}
|
|
127
|
+
// The reply event can arrive before OpenCode has removed its permission UI
|
|
128
|
+
// or resumed the agent. Wait for the next idle event before injecting text.
|
|
129
|
+
state.idle = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
info: (sessionID, message) => enqueue("info", sessionID, message),
|
|
134
|
+
success: (sessionID, message) => enqueue("success", sessionID, message),
|
|
135
|
+
applied: (sessionID, message) => enqueue("applied", sessionID, message),
|
|
136
|
+
warning: (sessionID, message) => enqueue("warning", sessionID, message),
|
|
137
|
+
error: (sessionID, message) => enqueue("error", sessionID, message),
|
|
138
|
+
|
|
139
|
+
handle(event) {
|
|
140
|
+
const sessionID = event?.properties?.sessionID
|
|
141
|
+
if (typeof sessionID !== "string") return
|
|
142
|
+
|
|
143
|
+
if (event.type === "session.status") {
|
|
144
|
+
const status = event.properties?.status?.type
|
|
145
|
+
if (status === "idle") setIdle(sessionID, true)
|
|
146
|
+
else if (status === "busy" || status === "retry") setIdle(sessionID, false)
|
|
147
|
+
} else if (event.type === "session.idle") {
|
|
148
|
+
setIdle(sessionID, true)
|
|
149
|
+
} else if (event.type === "permission.asked") {
|
|
150
|
+
beginInteraction(sessionID, "permission", event)
|
|
151
|
+
} else if (QUESTION_ASKED_EVENTS.has(event.type)) {
|
|
152
|
+
beginInteraction(sessionID, "question", event)
|
|
153
|
+
} else if (event.type === "permission.replied") {
|
|
154
|
+
endInteraction(sessionID, "permission", event)
|
|
155
|
+
} else if (QUESTION_REPLIED_EVENTS.has(event.type)) {
|
|
156
|
+
endInteraction(sessionID, "question", event)
|
|
157
|
+
} else if (event.type === "todo.updated") {
|
|
158
|
+
setIdle(sessionID, false)
|
|
159
|
+
} else if (event.type === "session.error") {
|
|
160
|
+
const state = sessionState(sessionID)
|
|
161
|
+
state.blockers.clear()
|
|
162
|
+
state.idle = false
|
|
163
|
+
} else if (event.type === "session.deleted") {
|
|
164
|
+
sessions.delete(sessionID)
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
dispose() {
|
|
169
|
+
disposed = true
|
|
170
|
+
sessions.clear()
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/updates.js
CHANGED