backeer-opencode 0.2.0 → 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 +17 -0
- package/README.md +34 -18
- package/dist/backeer-permission.js +343 -71
- package/package.json +1 -1
- package/src/config.js +1 -0
- package/src/diagnostics.js +148 -0
- 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 +18 -16
- 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/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -85,6 +85,7 @@ export function loadConfig(options = {}) {
|
|
|
85
85
|
return {
|
|
86
86
|
origin,
|
|
87
87
|
eventsEndpoint: new URL("/v1/events", origin),
|
|
88
|
+
sourceHealthEndpoint: new URL("/v1/source/health", origin),
|
|
88
89
|
commandStreamEndpoint: new URL("/v1/commands/stream", origin),
|
|
89
90
|
token: loadedToken.token,
|
|
90
91
|
tokenSource: loadedToken.source,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { statSync } from "node:fs"
|
|
3
|
+
|
|
4
|
+
import { HttpError, postJsonWithRetry } from "./http.js"
|
|
5
|
+
import { PLUGIN_VERSION } from "./updates.js"
|
|
6
|
+
import { limit } from "./utilities.js"
|
|
7
|
+
|
|
8
|
+
const PASS = "✓"
|
|
9
|
+
const FAIL = "×"
|
|
10
|
+
const INFO = "•"
|
|
11
|
+
|
|
12
|
+
function tokenFileCheck(config) {
|
|
13
|
+
if (config.tokenSource === "environment") {
|
|
14
|
+
return { ok: true, text: "Token loaded from the environment; token file not required" }
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const stats = statSync(config.tokenFile)
|
|
18
|
+
const mode = stats.mode & 0o777
|
|
19
|
+
if (!stats.isFile()) return { ok: false, text: "Token path is not a regular file" }
|
|
20
|
+
if ((mode & 0o077) !== 0) {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
text: `Token file mode ${(mode).toString(8).padStart(3, "0")} is too permissive; expected 600`,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
text: `Token file is protected (mode ${mode.toString(8).padStart(3, "0")})`,
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return { ok: false, text: "Token file is missing or unreadable" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function probeSource(config, fetchImplementation = globalThis.fetch) {
|
|
36
|
+
const controller = new AbortController()
|
|
37
|
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetchImplementation(config.sourceHealthEndpoint, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
})
|
|
44
|
+
const responseBody = limit(await response.text(), 2000)
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new HttpError(
|
|
47
|
+
`Gateway source check failed with status ${response.status}`,
|
|
48
|
+
response.status,
|
|
49
|
+
responseBody,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(responseBody)
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error("Gateway source check returned malformed JSON")
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timeout)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function buildDoctorReport(
|
|
63
|
+
config,
|
|
64
|
+
{ fetchImplementation = globalThis.fetch } = {},
|
|
65
|
+
) {
|
|
66
|
+
const checks = []
|
|
67
|
+
checks.push({ ok: true, text: `Plugin version ${PLUGIN_VERSION}` })
|
|
68
|
+
checks.push({
|
|
69
|
+
ok: config.origin.protocol === "https:",
|
|
70
|
+
text: `Gateway uses ${config.origin.protocol === "https:" ? "HTTPS" : "an insecure protocol"}: ${config.origin.origin}`,
|
|
71
|
+
})
|
|
72
|
+
checks.push({
|
|
73
|
+
ok: Boolean(config.token),
|
|
74
|
+
text: config.token
|
|
75
|
+
? `Source token loaded from ${config.tokenSource}`
|
|
76
|
+
: "Source token is missing",
|
|
77
|
+
})
|
|
78
|
+
checks.push(tokenFileCheck(config))
|
|
79
|
+
|
|
80
|
+
let source
|
|
81
|
+
if (config.token) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await probeSource(config, fetchImplementation)
|
|
84
|
+
source = result?.source
|
|
85
|
+
checks.push({
|
|
86
|
+
ok: result?.ok === true && typeof source?.id === "string",
|
|
87
|
+
text: source?.id
|
|
88
|
+
? `Source authentication accepted: ${source.name || source.id} (${source.id})`
|
|
89
|
+
: "Gateway did not return a source identity",
|
|
90
|
+
})
|
|
91
|
+
checks.push({
|
|
92
|
+
ok: result?.capabilities?.events === true &&
|
|
93
|
+
result?.capabilities?.commands === true,
|
|
94
|
+
text: result?.capabilities?.events === true &&
|
|
95
|
+
result?.capabilities?.commands === true
|
|
96
|
+
? "Event ingest and command stream are available"
|
|
97
|
+
: "Event ingest or command stream is unavailable",
|
|
98
|
+
})
|
|
99
|
+
checks.push({
|
|
100
|
+
ok: result?.capabilities?.apns === true,
|
|
101
|
+
text: result?.capabilities?.apns === true
|
|
102
|
+
? "Gateway APNs delivery is enabled"
|
|
103
|
+
: "Gateway APNs delivery is disabled",
|
|
104
|
+
})
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const detail = error instanceof HttpError && error.status === 401
|
|
107
|
+
? "Source authentication rejected (HTTP 401)"
|
|
108
|
+
: error?.name === "AbortError"
|
|
109
|
+
? "Gateway source check timed out"
|
|
110
|
+
: `Gateway source check failed: ${limit(error?.message || error, 300)}`
|
|
111
|
+
checks.push({ ok: false, text: detail })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const healthy = checks.every((check) => check.ok)
|
|
116
|
+
return [
|
|
117
|
+
"## Backeer doctor",
|
|
118
|
+
"",
|
|
119
|
+
...checks.map((check) => `${check.ok ? PASS : FAIL} ${check.text}`),
|
|
120
|
+
"",
|
|
121
|
+
healthy
|
|
122
|
+
? `${PASS} Result: healthy. Use \`/backeer test\` to verify phone delivery.`
|
|
123
|
+
: `${FAIL} Result: issues found. Resolve failed checks and run \`/backeer doctor\` again.`,
|
|
124
|
+
"",
|
|
125
|
+
`${INFO} No event was created and the token value was not displayed.`,
|
|
126
|
+
].join("\n")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function publishTestNotification(config, reporter) {
|
|
130
|
+
if (!config.token) throw new Error("Backeer source token is missing")
|
|
131
|
+
const testID = randomUUID()
|
|
132
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
133
|
+
type: "application",
|
|
134
|
+
severity: "info",
|
|
135
|
+
title: "Backeer test notification",
|
|
136
|
+
body: "OpenCode integration is connected and push delivery is working.",
|
|
137
|
+
payload: [
|
|
138
|
+
{ key: "Plugin version", value: PLUGIN_VERSION },
|
|
139
|
+
{ key: "Test ID", value: testID },
|
|
140
|
+
],
|
|
141
|
+
externalId: `opencode:test:${testID}`,
|
|
142
|
+
timestamp: new Date().toISOString(),
|
|
143
|
+
notify: true,
|
|
144
|
+
direction: "in",
|
|
145
|
+
})
|
|
146
|
+
reporter.success("Test notification sent", `HTTP ${result.status}`)
|
|
147
|
+
return testID
|
|
148
|
+
}
|
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
|
@@ -8,13 +8,15 @@ import {
|
|
|
8
8
|
} from "node:fs"
|
|
9
9
|
import { dirname, resolve } from "node:path"
|
|
10
10
|
|
|
11
|
+
import { buildDoctorReport, publishTestNotification } from "./diagnostics.js"
|
|
12
|
+
import { sendSessionMessage } from "./session-messages.js"
|
|
11
13
|
import {
|
|
12
14
|
formatAgentUpdateInstruction,
|
|
13
15
|
PLUGIN_VERSION,
|
|
14
16
|
} from "./updates.js"
|
|
15
17
|
|
|
16
18
|
const COMMAND_DESCRIPTION =
|
|
17
|
-
"
|
|
19
|
+
"Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]"
|
|
18
20
|
|
|
19
21
|
function tokenFileState(path) {
|
|
20
22
|
try {
|
|
@@ -129,6 +131,8 @@ function helpMessage() {
|
|
|
129
131
|
"## Backeer commands",
|
|
130
132
|
"",
|
|
131
133
|
"- `/backeer` or `/backeer status` — show integration status",
|
|
134
|
+
"- `/backeer doctor` — diagnose config, credentials, gateway, and APNs without creating an event",
|
|
135
|
+
"- `/backeer test` — send a real test notification to the Backeer application",
|
|
132
136
|
"- `/backeer update-check` — check NPM for a newer version",
|
|
133
137
|
"- `/backeer update` — ask the OpenCode agent to install and verify it",
|
|
134
138
|
"- `/backeer token` — show secure token-change instructions",
|
|
@@ -153,19 +157,6 @@ function tokenInstructions() {
|
|
|
153
157
|
].join("\n")
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
async function sendCommandMessage(client, sessionID, text) {
|
|
157
|
-
if (typeof client?.session?.prompt !== "function") {
|
|
158
|
-
throw new Error("OpenCode session messaging is unavailable")
|
|
159
|
-
}
|
|
160
|
-
await client.session.prompt({
|
|
161
|
-
path: { id: sessionID },
|
|
162
|
-
body: {
|
|
163
|
-
noReply: true,
|
|
164
|
-
parts: [{ type: "text", text, ignored: true }],
|
|
165
|
-
},
|
|
166
|
-
})
|
|
167
|
-
}
|
|
168
|
-
|
|
169
160
|
export function registerBackeerCommand(opencodeConfig) {
|
|
170
161
|
opencodeConfig.command ??= {}
|
|
171
162
|
opencodeConfig.command.backeer = {
|
|
@@ -198,6 +189,17 @@ export function createBackeerCommandHandler({
|
|
|
198
189
|
try {
|
|
199
190
|
if (!subcommand || subcommand === "status") {
|
|
200
191
|
message = await buildStatus(config, permissionController, updateChecker)
|
|
192
|
+
} else if (subcommand === "doctor") {
|
|
193
|
+
message = await buildDoctorReport(config)
|
|
194
|
+
} else if (subcommand === "test") {
|
|
195
|
+
const testID = await publishTestNotification(config, reporter)
|
|
196
|
+
message = [
|
|
197
|
+
"## Backeer test notification",
|
|
198
|
+
"",
|
|
199
|
+
"✓ The gateway accepted the test event.",
|
|
200
|
+
`- Test ID: ${testID}`,
|
|
201
|
+
"- Check the Backeer application for the notification.",
|
|
202
|
+
].join("\n")
|
|
201
203
|
} else if (subcommand === "update-check") {
|
|
202
204
|
const update = await updateChecker.check({ force: true })
|
|
203
205
|
message = [
|
|
@@ -238,7 +240,7 @@ export function createBackeerCommandHandler({
|
|
|
238
240
|
message = helpMessage()
|
|
239
241
|
}
|
|
240
242
|
} catch (error) {
|
|
241
|
-
reporter.error("
|
|
243
|
+
reporter.error("Backeer command failed", error)
|
|
242
244
|
message = [
|
|
243
245
|
"## Backeer command failed",
|
|
244
246
|
"",
|
|
@@ -248,6 +250,6 @@ export function createBackeerCommandHandler({
|
|
|
248
250
|
].join("\n")
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
await
|
|
253
|
+
await sendSessionMessage(client, input.sessionID, message)
|
|
252
254
|
}
|
|
253
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
|
+
}
|