backeer-opencode 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +60 -9
- package/dist/backeer-permission.js +553 -7
- package/package.json +2 -2
- package/src/config.js +45 -0
- package/src/lifecycle-controller.js +205 -0
- package/src/lifecycle-publishers.js +187 -0
- package/src/management.js +51 -5
- package/src/plugin.js +21 -1
- package/src/updates.js +136 -0
package/src/config.js
CHANGED
|
@@ -7,6 +7,50 @@ import { enabled } from "./utilities.js"
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
|
|
9
9
|
|
|
10
|
+
function integer(value, fallback, { minimum = 0, maximum = 86_400 } = {}) {
|
|
11
|
+
if (value === undefined || value === null || value === "") return fallback
|
|
12
|
+
const parsed = Number(value)
|
|
13
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
14
|
+
throw new Error(`Expected an integer between ${minimum} and ${maximum}`)
|
|
15
|
+
}
|
|
16
|
+
return parsed
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadLifecycleConfig(option) {
|
|
20
|
+
const values = option && typeof option === "object" ? option : {}
|
|
21
|
+
const masterValue = typeof option === "boolean"
|
|
22
|
+
? option
|
|
23
|
+
: values.enabled ?? process.env.BACKEER_NOTIFY_LIFECYCLE
|
|
24
|
+
const masterEnabled = enabled(masterValue)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
enabled: masterEnabled,
|
|
28
|
+
completed: masterEnabled && enabled(
|
|
29
|
+
values.completed ?? process.env.BACKEER_NOTIFY_SESSION_COMPLETED,
|
|
30
|
+
),
|
|
31
|
+
errors: masterEnabled && enabled(
|
|
32
|
+
values.errors ?? process.env.BACKEER_NOTIFY_SESSION_ERRORS,
|
|
33
|
+
),
|
|
34
|
+
retries: masterEnabled && enabled(
|
|
35
|
+
values.retries ?? process.env.BACKEER_NOTIFY_SESSION_RETRIES,
|
|
36
|
+
),
|
|
37
|
+
updates: masterEnabled && enabled(
|
|
38
|
+
values.updates ?? process.env.BACKEER_NOTIFY_OPENCODE_UPDATES,
|
|
39
|
+
),
|
|
40
|
+
completionMinSeconds: integer(
|
|
41
|
+
values.completionMinSeconds ??
|
|
42
|
+
process.env.BACKEER_SESSION_COMPLETION_MIN_SECONDS,
|
|
43
|
+
0,
|
|
44
|
+
),
|
|
45
|
+
retryAttemptThreshold: integer(
|
|
46
|
+
values.retryAttemptThreshold ??
|
|
47
|
+
process.env.BACKEER_RETRY_ATTEMPT_THRESHOLD,
|
|
48
|
+
3,
|
|
49
|
+
{ minimum: 1, maximum: 100 },
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
function defaultConfigDirectory() {
|
|
11
55
|
const base = process.env.XDG_CONFIG_HOME?.trim()
|
|
12
56
|
return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
|
|
@@ -52,6 +96,7 @@ export function loadConfig(options = {}) {
|
|
|
52
96
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
|
|
53
97
|
),
|
|
54
98
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
99
|
+
lifecycle: loadLifecycleConfig(options.lifecycle),
|
|
55
100
|
logLevel: normalizeLogLevel(
|
|
56
101
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
57
102
|
),
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
errorCorrelation,
|
|
3
|
+
publishOpenCodeUpdate,
|
|
4
|
+
publishSessionCompleted,
|
|
5
|
+
publishSessionError,
|
|
6
|
+
publishSessionRetry,
|
|
7
|
+
} from "./lifecycle-publishers.js"
|
|
8
|
+
import { limit, rememberBounded, stableHash } from "./utilities.js"
|
|
9
|
+
|
|
10
|
+
const QUESTION_EVENTS = new Set(["question.asked", "question.v2.asked"])
|
|
11
|
+
const QUESTION_REPLY_EVENTS = new Set([
|
|
12
|
+
"question.replied",
|
|
13
|
+
"question.v2.replied",
|
|
14
|
+
"question.answered",
|
|
15
|
+
"question.v2.answered",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export function createLifecycleController({
|
|
19
|
+
config,
|
|
20
|
+
reporter,
|
|
21
|
+
client,
|
|
22
|
+
directory,
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
publishers = {},
|
|
25
|
+
}) {
|
|
26
|
+
const sendCompleted = publishers.completed ?? publishSessionCompleted
|
|
27
|
+
const sendError = publishers.error ?? publishSessionError
|
|
28
|
+
const sendRetry = publishers.retry ?? publishSessionRetry
|
|
29
|
+
const sendUpdate = publishers.update ?? publishOpenCodeUpdate
|
|
30
|
+
const sessions = new Map()
|
|
31
|
+
const publishedUpdates = new Map()
|
|
32
|
+
|
|
33
|
+
function sessionState(sessionID) {
|
|
34
|
+
let state = sessions.get(sessionID)
|
|
35
|
+
if (!state) {
|
|
36
|
+
state = {
|
|
37
|
+
busy: false,
|
|
38
|
+
busySince: 0,
|
|
39
|
+
failed: false,
|
|
40
|
+
retryNotified: false,
|
|
41
|
+
waiting: false,
|
|
42
|
+
lastAssistant: undefined,
|
|
43
|
+
completedMessageID: "",
|
|
44
|
+
}
|
|
45
|
+
rememberBounded(sessions, sessionID, state)
|
|
46
|
+
}
|
|
47
|
+
return state
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dispatch(label, operation) {
|
|
51
|
+
void Promise.resolve(operation).catch((error) => reporter.error(label, error))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function beginCycle(state) {
|
|
55
|
+
if (state.busy) return
|
|
56
|
+
state.busy = true
|
|
57
|
+
state.busySince = now()
|
|
58
|
+
state.failed = false
|
|
59
|
+
state.retryNotified = false
|
|
60
|
+
state.waiting = false
|
|
61
|
+
state.lastAssistant = undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function finishCycle(sessionID, state) {
|
|
65
|
+
if (!state.busy) return
|
|
66
|
+
state.busy = false
|
|
67
|
+
const assistant = state.lastAssistant
|
|
68
|
+
const durationMs = Math.max(0, now() - state.busySince)
|
|
69
|
+
const qualifies = assistant?.id && assistant.completed && !assistant.error &&
|
|
70
|
+
!state.failed && !state.waiting &&
|
|
71
|
+
durationMs >= config.lifecycle.completionMinSeconds * 1000 &&
|
|
72
|
+
state.completedMessageID !== assistant.id
|
|
73
|
+
if (!qualifies || !config.lifecycle.completed) return
|
|
74
|
+
|
|
75
|
+
state.completedMessageID = assistant.id
|
|
76
|
+
dispatch(
|
|
77
|
+
"Task completion could not be sent",
|
|
78
|
+
sendCompleted({
|
|
79
|
+
config,
|
|
80
|
+
reporter,
|
|
81
|
+
client,
|
|
82
|
+
directory,
|
|
83
|
+
sessionID,
|
|
84
|
+
messageID: limit(assistant.id, 200),
|
|
85
|
+
durationMs,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleMessage(event) {
|
|
91
|
+
const info = event.properties?.info
|
|
92
|
+
if (info?.role !== "assistant" || typeof info.sessionID !== "string") return
|
|
93
|
+
const state = sessionState(info.sessionID)
|
|
94
|
+
state.lastAssistant = {
|
|
95
|
+
id: typeof info.id === "string" ? info.id : "",
|
|
96
|
+
completed: Number.isFinite(info.time?.completed),
|
|
97
|
+
error: Boolean(info.error),
|
|
98
|
+
}
|
|
99
|
+
if (info.error) state.failed = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleStatus(event) {
|
|
103
|
+
const sessionID = event.properties?.sessionID
|
|
104
|
+
const status = event.properties?.status
|
|
105
|
+
if (typeof sessionID !== "string" || typeof status?.type !== "string") return
|
|
106
|
+
const state = sessionState(sessionID)
|
|
107
|
+
|
|
108
|
+
if (status.type === "busy") {
|
|
109
|
+
beginCycle(state)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (status.type === "idle") {
|
|
113
|
+
finishCycle(sessionID, state)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (status.type !== "retry") return
|
|
117
|
+
|
|
118
|
+
beginCycle(state)
|
|
119
|
+
if (!config.lifecycle.retries || state.retryNotified) return
|
|
120
|
+
if (!Number.isInteger(status.attempt) ||
|
|
121
|
+
status.attempt < config.lifecycle.retryAttemptThreshold) return
|
|
122
|
+
|
|
123
|
+
state.retryNotified = true
|
|
124
|
+
const correlation = state.lastAssistant?.id || stableHash(
|
|
125
|
+
`${status.attempt}:${Number.isFinite(status.next) ? status.next : "unknown"}`,
|
|
126
|
+
)
|
|
127
|
+
dispatch(
|
|
128
|
+
"Retry warning could not be sent",
|
|
129
|
+
sendRetry({
|
|
130
|
+
config,
|
|
131
|
+
reporter,
|
|
132
|
+
client,
|
|
133
|
+
directory,
|
|
134
|
+
sessionID,
|
|
135
|
+
correlation: limit(correlation, 200),
|
|
136
|
+
attempt: status.attempt,
|
|
137
|
+
next: status.next,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleError(event) {
|
|
143
|
+
const sessionID = typeof event.properties?.sessionID === "string"
|
|
144
|
+
? event.properties.sessionID
|
|
145
|
+
: ""
|
|
146
|
+
const state = sessionID ? sessionState(sessionID) : undefined
|
|
147
|
+
if (state) state.failed = true
|
|
148
|
+
if (!config.lifecycle.errors) return
|
|
149
|
+
|
|
150
|
+
dispatch(
|
|
151
|
+
"Task failure could not be sent",
|
|
152
|
+
sendError({
|
|
153
|
+
config,
|
|
154
|
+
reporter,
|
|
155
|
+
client,
|
|
156
|
+
directory,
|
|
157
|
+
sessionID,
|
|
158
|
+
correlation: errorCorrelation(event, state),
|
|
159
|
+
error: event.properties?.error,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleWaiting(event, waiting) {
|
|
165
|
+
const sessionID = event.properties?.sessionID
|
|
166
|
+
if (typeof sessionID !== "string") return
|
|
167
|
+
sessionState(sessionID).waiting = waiting
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
handle(event) {
|
|
172
|
+
if (!config.lifecycle.enabled || !event?.type) return
|
|
173
|
+
|
|
174
|
+
if (event.type === "message.updated") {
|
|
175
|
+
handleMessage(event)
|
|
176
|
+
} else if (event.type === "session.status") {
|
|
177
|
+
handleStatus(event)
|
|
178
|
+
} else if (event.type === "session.idle") {
|
|
179
|
+
const sessionID = event.properties?.sessionID
|
|
180
|
+
if (typeof sessionID === "string") finishCycle(sessionID, sessionState(sessionID))
|
|
181
|
+
} else if (event.type === "session.error") {
|
|
182
|
+
handleError(event)
|
|
183
|
+
} else if (event.type === "permission.asked" || QUESTION_EVENTS.has(event.type)) {
|
|
184
|
+
handleWaiting(event, true)
|
|
185
|
+
} else if (event.type === "permission.replied" || QUESTION_REPLY_EVENTS.has(event.type)) {
|
|
186
|
+
handleWaiting(event, false)
|
|
187
|
+
} else if (event.type === "installation.update-available" &&
|
|
188
|
+
config.lifecycle.updates) {
|
|
189
|
+
const version = event.properties?.version
|
|
190
|
+
if (typeof version !== "string" || !version.trim()) return
|
|
191
|
+
if (publishedUpdates.has(version)) return
|
|
192
|
+
rememberBounded(publishedUpdates, version, true)
|
|
193
|
+
dispatch(
|
|
194
|
+
"OpenCode update could not be sent",
|
|
195
|
+
sendUpdate({ config, reporter, version: version.trim() }),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
dispose() {
|
|
201
|
+
sessions.clear()
|
|
202
|
+
publishedUpdates.clear()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { postJsonWithRetry } from "./http.js"
|
|
2
|
+
import { limit, stableHash } from "./utilities.js"
|
|
3
|
+
|
|
4
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
5
|
+
const SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi
|
|
6
|
+
const BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi
|
|
7
|
+
const URL_WITH_QUERY = /\bhttps?:\/\/[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
|
|
8
|
+
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g
|
|
9
|
+
const POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g
|
|
10
|
+
|
|
11
|
+
export function sanitizeErrorMessage(value) {
|
|
12
|
+
return limit(value, 2000)
|
|
13
|
+
.replace(CONTROL_CHARACTERS, " ")
|
|
14
|
+
.replace(BEARER_TOKEN, "Bearer [redacted]")
|
|
15
|
+
.replace(SECRET_ASSIGNMENT, "$1=[redacted]")
|
|
16
|
+
.replace(URL_WITH_QUERY, (value) => {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value)
|
|
19
|
+
return `${url.origin}${url.pathname}`
|
|
20
|
+
} catch {
|
|
21
|
+
return "[url redacted]"
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
.replace(WINDOWS_PATH, "[path redacted]")
|
|
25
|
+
.replace(POSIX_PATH, "$1[path redacted]")
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, 500)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function safeErrorDetails(error) {
|
|
32
|
+
const name = limit(error?.name || "OpenCodeError", 100)
|
|
33
|
+
const message = sanitizeErrorMessage(error?.data?.message ?? error?.message)
|
|
34
|
+
const statusCode = Number.isInteger(error?.data?.statusCode)
|
|
35
|
+
? String(error.data.statusCode)
|
|
36
|
+
: ""
|
|
37
|
+
return { name, message, statusCode }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
41
|
+
if (!sessionID || typeof client?.session?.get !== "function") return ""
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await client.session.get({
|
|
45
|
+
path: { id: sessionID },
|
|
46
|
+
query: { directory },
|
|
47
|
+
throwOnError: true,
|
|
48
|
+
})
|
|
49
|
+
return limit((result?.data ?? result)?.title)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
reporter.debug("Session title unavailable", error)
|
|
52
|
+
return ""
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function publish(config, reporter, event, feedback) {
|
|
57
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
58
|
+
type: "application",
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
notify: true,
|
|
61
|
+
direction: "in",
|
|
62
|
+
...event,
|
|
63
|
+
})
|
|
64
|
+
reporter.success(feedback, `HTTP ${result.status}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function secondsLabel(durationMs) {
|
|
68
|
+
const seconds = Math.max(0, Math.round(durationMs / 1000))
|
|
69
|
+
if (seconds < 60) return `${seconds}s`
|
|
70
|
+
const minutes = Math.floor(seconds / 60)
|
|
71
|
+
const remainder = seconds % 60
|
|
72
|
+
return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function publishSessionCompleted({
|
|
76
|
+
config,
|
|
77
|
+
reporter,
|
|
78
|
+
client,
|
|
79
|
+
directory,
|
|
80
|
+
sessionID,
|
|
81
|
+
messageID,
|
|
82
|
+
durationMs,
|
|
83
|
+
}) {
|
|
84
|
+
const sessionTitle = await resolveSessionTitle(
|
|
85
|
+
client,
|
|
86
|
+
directory,
|
|
87
|
+
sessionID,
|
|
88
|
+
reporter,
|
|
89
|
+
)
|
|
90
|
+
const duration = secondsLabel(durationMs)
|
|
91
|
+
const location = sessionTitle ? ` “${sessionTitle}”` : ""
|
|
92
|
+
await publish(config, reporter, {
|
|
93
|
+
severity: "info",
|
|
94
|
+
title: "OpenCode task completed",
|
|
95
|
+
body: `OpenCode finished${location} in ${duration}.`,
|
|
96
|
+
payload: [
|
|
97
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
98
|
+
{ key: "Outcome", value: "Completed" },
|
|
99
|
+
{ key: "Duration", value: duration },
|
|
100
|
+
].filter((item) => item.value),
|
|
101
|
+
externalId: `opencode:lifecycle:completed:${sessionID}:${messageID}`,
|
|
102
|
+
}, "Task completion sent")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function publishSessionRetry({
|
|
106
|
+
config,
|
|
107
|
+
reporter,
|
|
108
|
+
client,
|
|
109
|
+
directory,
|
|
110
|
+
sessionID,
|
|
111
|
+
correlation,
|
|
112
|
+
attempt,
|
|
113
|
+
next,
|
|
114
|
+
}) {
|
|
115
|
+
const sessionTitle = await resolveSessionTitle(
|
|
116
|
+
client,
|
|
117
|
+
directory,
|
|
118
|
+
sessionID,
|
|
119
|
+
reporter,
|
|
120
|
+
)
|
|
121
|
+
const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : ""
|
|
122
|
+
await publish(config, reporter, {
|
|
123
|
+
severity: "warning",
|
|
124
|
+
title: "OpenCode is retrying",
|
|
125
|
+
body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
|
|
126
|
+
payload: [
|
|
127
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
128
|
+
{ key: "Retry attempt", value: String(attempt) },
|
|
129
|
+
{ key: "Next retry", value: retryAt },
|
|
130
|
+
].filter((item) => item.value),
|
|
131
|
+
externalId: `opencode:lifecycle:retry:${sessionID}:${correlation}`,
|
|
132
|
+
}, "Retry warning sent")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function publishSessionError({
|
|
136
|
+
config,
|
|
137
|
+
reporter,
|
|
138
|
+
client,
|
|
139
|
+
directory,
|
|
140
|
+
sessionID,
|
|
141
|
+
correlation,
|
|
142
|
+
error,
|
|
143
|
+
}) {
|
|
144
|
+
const sessionTitle = await resolveSessionTitle(
|
|
145
|
+
client,
|
|
146
|
+
directory,
|
|
147
|
+
sessionID,
|
|
148
|
+
reporter,
|
|
149
|
+
)
|
|
150
|
+
const details = safeErrorDetails(error)
|
|
151
|
+
const location = sessionTitle ? ` in “${sessionTitle}”` : ""
|
|
152
|
+
const body = details.message
|
|
153
|
+
? `OpenCode failed${location}: ${details.message}`
|
|
154
|
+
: `OpenCode failed${location}.`
|
|
155
|
+
await publish(config, reporter, {
|
|
156
|
+
// Backeer/iOS currently support info, warning and critical severities.
|
|
157
|
+
severity: "critical",
|
|
158
|
+
title: "OpenCode task failed",
|
|
159
|
+
body: limit(body, 1000),
|
|
160
|
+
payload: [
|
|
161
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
162
|
+
{ key: "Outcome", value: "Failed" },
|
|
163
|
+
{ key: "Error type", value: details.name },
|
|
164
|
+
{ key: "HTTP status", value: details.statusCode },
|
|
165
|
+
{ key: "Error", value: details.message },
|
|
166
|
+
].filter((item) => item.value),
|
|
167
|
+
externalId: `opencode:lifecycle:error:${sessionID || "global"}:${correlation}`,
|
|
168
|
+
}, "Task failure sent")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
172
|
+
const safeVersion = limit(version, 100)
|
|
173
|
+
await publish(config, reporter, {
|
|
174
|
+
severity: "info",
|
|
175
|
+
title: "OpenCode update available",
|
|
176
|
+
body: `OpenCode ${safeVersion} is available.`,
|
|
177
|
+
payload: [{ key: "Available version", value: safeVersion }],
|
|
178
|
+
externalId: `opencode:lifecycle:update:${safeVersion}`,
|
|
179
|
+
}, "OpenCode update sent")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function errorCorrelation(event, sessionState) {
|
|
183
|
+
if (typeof event?.id === "string" && event.id) return limit(event.id, 200)
|
|
184
|
+
if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200)
|
|
185
|
+
const details = safeErrorDetails(event?.properties?.error)
|
|
186
|
+
return stableHash(`${details.name}:${details.statusCode}:${details.message}`)
|
|
187
|
+
}
|
package/src/management.js
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
} from "node:fs"
|
|
9
9
|
import { dirname, resolve } from "node:path"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import {
|
|
12
|
+
formatAgentUpdateInstruction,
|
|
13
|
+
PLUGIN_VERSION,
|
|
14
|
+
} from "./updates.js"
|
|
12
15
|
|
|
13
16
|
const COMMAND_DESCRIPTION =
|
|
14
|
-
"Show Backeer status or manage its token: /backeer [status|token|token-file PATH]"
|
|
17
|
+
"Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]"
|
|
15
18
|
|
|
16
19
|
function tokenFileState(path) {
|
|
17
20
|
try {
|
|
@@ -83,19 +86,36 @@ async function probeGateway(config) {
|
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
function updateStatus(info) {
|
|
90
|
+
if (info.state === "available") {
|
|
91
|
+
return `v${info.latestVersion} available — use /backeer update`
|
|
92
|
+
}
|
|
93
|
+
if (info.state === "current") return `current (v${info.currentVersion})`
|
|
94
|
+
return "check unavailable"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function buildStatus(config, permissionController, updateChecker) {
|
|
98
|
+
const [gateway, update] = await Promise.all([
|
|
99
|
+
probeGateway(config),
|
|
100
|
+
updateChecker.check(),
|
|
101
|
+
])
|
|
88
102
|
const permissionStatus = permissionController.status()
|
|
89
103
|
return [
|
|
90
104
|
"## Backeer status",
|
|
91
105
|
"",
|
|
92
106
|
`- Plugin version: ${PLUGIN_VERSION}`,
|
|
107
|
+
`- Update: ${updateStatus(update)}`,
|
|
93
108
|
`- Gateway: ${config.origin.origin} — ${gateway}`,
|
|
94
109
|
`- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
|
|
95
110
|
`- Token file: ${config.tokenFile} — ${tokenFileState(config.tokenFile)}`,
|
|
96
111
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
97
112
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
98
113
|
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
114
|
+
`- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
|
|
115
|
+
` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
|
|
116
|
+
` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
|
|
117
|
+
` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
|
|
118
|
+
` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
|
|
99
119
|
`- Command stream: ${permissionStatus.stream}`,
|
|
100
120
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
101
121
|
`- Log level: ${config.logLevel}`,
|
|
@@ -109,6 +129,8 @@ function helpMessage() {
|
|
|
109
129
|
"## Backeer commands",
|
|
110
130
|
"",
|
|
111
131
|
"- `/backeer` or `/backeer status` — show integration status",
|
|
132
|
+
"- `/backeer update-check` — check NPM for a newer version",
|
|
133
|
+
"- `/backeer update` — ask the OpenCode agent to install and verify it",
|
|
112
134
|
"- `/backeer token` — show secure token-change instructions",
|
|
113
135
|
"- `/backeer token-file /absolute/protected/path` — import and activate a token",
|
|
114
136
|
"",
|
|
@@ -157,6 +179,7 @@ export function createBackeerCommandHandler({
|
|
|
157
179
|
config,
|
|
158
180
|
permissionController,
|
|
159
181
|
reporter,
|
|
182
|
+
updateChecker,
|
|
160
183
|
}) {
|
|
161
184
|
return async (input, output) => {
|
|
162
185
|
if (input.command !== "backeer") return
|
|
@@ -174,7 +197,30 @@ export function createBackeerCommandHandler({
|
|
|
174
197
|
let message
|
|
175
198
|
try {
|
|
176
199
|
if (!subcommand || subcommand === "status") {
|
|
177
|
-
message = await buildStatus(config, permissionController)
|
|
200
|
+
message = await buildStatus(config, permissionController, updateChecker)
|
|
201
|
+
} else if (subcommand === "update-check") {
|
|
202
|
+
const update = await updateChecker.check({ force: true })
|
|
203
|
+
message = [
|
|
204
|
+
"## Backeer update check",
|
|
205
|
+
"",
|
|
206
|
+
update.state === "available"
|
|
207
|
+
? `Version ${update.latestVersion} is available; current version is ${update.currentVersion}. Use \`/backeer update\` to let the OpenCode agent install and verify it.`
|
|
208
|
+
: update.state === "current"
|
|
209
|
+
? `Backeer is current at version ${update.currentVersion}.`
|
|
210
|
+
: "The NPM registry could not be reached. No update was attempted.",
|
|
211
|
+
].join("\n")
|
|
212
|
+
} else if (subcommand === "update") {
|
|
213
|
+
const update = await updateChecker.check({ force: true })
|
|
214
|
+
if (update.state === "available") {
|
|
215
|
+
output.parts.push({
|
|
216
|
+
type: "text",
|
|
217
|
+
text: formatAgentUpdateInstruction(update),
|
|
218
|
+
})
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
message = update.state === "current"
|
|
222
|
+
? `## Backeer update\n\nBackeer is already current at version ${update.currentVersion}.`
|
|
223
|
+
: "## Backeer update\n\nThe NPM registry could not be reached. No update was attempted."
|
|
178
224
|
} else if (subcommand === "token") {
|
|
179
225
|
message = tokenInstructions()
|
|
180
226
|
} else if (subcommand === "token-file") {
|
package/src/plugin.js
CHANGED
|
@@ -10,14 +10,20 @@ import {
|
|
|
10
10
|
createBackeerCommandHandler,
|
|
11
11
|
registerBackeerCommand,
|
|
12
12
|
} from "./management.js"
|
|
13
|
+
import { createLifecycleController } from "./lifecycle-controller.js"
|
|
13
14
|
import { createPermissionController } from "./permission-controller.js"
|
|
14
15
|
import { createConsoleReporter } from "./reporter.js"
|
|
16
|
+
import {
|
|
17
|
+
createUpdateChecker,
|
|
18
|
+
createUpdateNoticeHook,
|
|
19
|
+
} from "./updates.js"
|
|
15
20
|
import { rememberBounded } from "./utilities.js"
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* OpenCode server plugin entrypoint.
|
|
19
24
|
*
|
|
20
|
-
* Supported options: origin, tokenFile, permissions, questions, todos,
|
|
25
|
+
* Supported options: origin, tokenFile, permissions, questions, todos,
|
|
26
|
+
* lifecycle, logLevel.
|
|
21
27
|
* The ingest token is intentionally accepted only through the environment or a
|
|
22
28
|
* protected file so it cannot accidentally be committed in opencode.json.
|
|
23
29
|
*/
|
|
@@ -41,14 +47,23 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
41
47
|
client,
|
|
42
48
|
directory,
|
|
43
49
|
})
|
|
50
|
+
const lifecycleController = createLifecycleController({
|
|
51
|
+
config,
|
|
52
|
+
reporter,
|
|
53
|
+
client,
|
|
54
|
+
directory,
|
|
55
|
+
})
|
|
44
56
|
const todoCompletionBySessionID = new Map()
|
|
45
57
|
const publishedQuestions = new Map()
|
|
58
|
+
const updateChecker = createUpdateChecker()
|
|
46
59
|
const handleBackeerCommand = createBackeerCommandHandler({
|
|
47
60
|
client,
|
|
48
61
|
config,
|
|
49
62
|
permissionController,
|
|
50
63
|
reporter,
|
|
64
|
+
updateChecker,
|
|
51
65
|
})
|
|
66
|
+
const handleUpdateNotice = createUpdateNoticeHook(updateChecker)
|
|
52
67
|
|
|
53
68
|
if (!config.token) {
|
|
54
69
|
reporter.error(
|
|
@@ -123,9 +138,13 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
123
138
|
|
|
124
139
|
"command.execute.before": handleBackeerCommand,
|
|
125
140
|
|
|
141
|
+
"chat.message": handleUpdateNotice,
|
|
142
|
+
|
|
126
143
|
event: async ({ event }) => {
|
|
127
144
|
if (!config.token) return
|
|
128
145
|
|
|
146
|
+
lifecycleController.handle(event)
|
|
147
|
+
|
|
129
148
|
if (config.permissions && event.type === "permission.asked") {
|
|
130
149
|
await handlePermissionAsked(event)
|
|
131
150
|
return
|
|
@@ -153,6 +172,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
153
172
|
|
|
154
173
|
dispose: async () => {
|
|
155
174
|
await permissionController.dispose()
|
|
175
|
+
lifecycleController.dispose()
|
|
156
176
|
todoCompletionBySessionID.clear()
|
|
157
177
|
publishedQuestions.clear()
|
|
158
178
|
},
|