backeer-opencode 0.1.5 → 0.2.1
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 +57 -9
- package/dist/backeer-permission.js +529 -6
- package/package.json +2 -2
- package/src/config.js +46 -0
- package/src/diagnostics.js +148 -0
- package/src/lifecycle-controller.js +205 -0
- package/src/lifecycle-publishers.js +187 -0
- package/src/management.js +21 -2
- package/src/plugin.js +12 -1
- package/src/updates.js +1 -1
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")
|
|
@@ -41,6 +85,7 @@ export function loadConfig(options = {}) {
|
|
|
41
85
|
return {
|
|
42
86
|
origin,
|
|
43
87
|
eventsEndpoint: new URL("/v1/events", origin),
|
|
88
|
+
sourceHealthEndpoint: new URL("/v1/source/health", origin),
|
|
44
89
|
commandStreamEndpoint: new URL("/v1/commands/stream", origin),
|
|
45
90
|
token: loadedToken.token,
|
|
46
91
|
tokenSource: loadedToken.source,
|
|
@@ -52,6 +97,7 @@ export function loadConfig(options = {}) {
|
|
|
52
97
|
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
|
|
53
98
|
),
|
|
54
99
|
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
100
|
+
lifecycle: loadLifecycleConfig(options.lifecycle),
|
|
55
101
|
logLevel: normalizeLogLevel(
|
|
56
102
|
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
57
103
|
),
|
|
@@ -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
|
+
}
|
|
@@ -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,13 +8,14 @@ import {
|
|
|
8
8
|
} from "node:fs"
|
|
9
9
|
import { dirname, resolve } from "node:path"
|
|
10
10
|
|
|
11
|
+
import { buildDoctorReport, publishTestNotification } from "./diagnostics.js"
|
|
11
12
|
import {
|
|
12
13
|
formatAgentUpdateInstruction,
|
|
13
14
|
PLUGIN_VERSION,
|
|
14
15
|
} from "./updates.js"
|
|
15
16
|
|
|
16
17
|
const COMMAND_DESCRIPTION =
|
|
17
|
-
"
|
|
18
|
+
"Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]"
|
|
18
19
|
|
|
19
20
|
function tokenFileState(path) {
|
|
20
21
|
try {
|
|
@@ -111,6 +112,11 @@ async function buildStatus(config, permissionController, updateChecker) {
|
|
|
111
112
|
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
112
113
|
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
113
114
|
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
115
|
+
`- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
|
|
116
|
+
` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
|
|
117
|
+
` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
|
|
118
|
+
` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
|
|
119
|
+
` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
|
|
114
120
|
`- Command stream: ${permissionStatus.stream}`,
|
|
115
121
|
`- Pending permissions: ${permissionStatus.pending}`,
|
|
116
122
|
`- Log level: ${config.logLevel}`,
|
|
@@ -124,6 +130,8 @@ function helpMessage() {
|
|
|
124
130
|
"## Backeer commands",
|
|
125
131
|
"",
|
|
126
132
|
"- `/backeer` or `/backeer status` — show integration status",
|
|
133
|
+
"- `/backeer doctor` — diagnose config, credentials, gateway, and APNs without creating an event",
|
|
134
|
+
"- `/backeer test` — send a real test notification to the Backeer application",
|
|
127
135
|
"- `/backeer update-check` — check NPM for a newer version",
|
|
128
136
|
"- `/backeer update` — ask the OpenCode agent to install and verify it",
|
|
129
137
|
"- `/backeer token` — show secure token-change instructions",
|
|
@@ -193,6 +201,17 @@ export function createBackeerCommandHandler({
|
|
|
193
201
|
try {
|
|
194
202
|
if (!subcommand || subcommand === "status") {
|
|
195
203
|
message = await buildStatus(config, permissionController, updateChecker)
|
|
204
|
+
} else if (subcommand === "doctor") {
|
|
205
|
+
message = await buildDoctorReport(config)
|
|
206
|
+
} else if (subcommand === "test") {
|
|
207
|
+
const testID = await publishTestNotification(config, reporter)
|
|
208
|
+
message = [
|
|
209
|
+
"## Backeer test notification",
|
|
210
|
+
"",
|
|
211
|
+
"✓ The gateway accepted the test event.",
|
|
212
|
+
`- Test ID: ${testID}`,
|
|
213
|
+
"- Check the Backeer application for the notification.",
|
|
214
|
+
].join("\n")
|
|
196
215
|
} else if (subcommand === "update-check") {
|
|
197
216
|
const update = await updateChecker.check({ force: true })
|
|
198
217
|
message = [
|
|
@@ -233,7 +252,7 @@ export function createBackeerCommandHandler({
|
|
|
233
252
|
message = helpMessage()
|
|
234
253
|
}
|
|
235
254
|
} catch (error) {
|
|
236
|
-
reporter.error("
|
|
255
|
+
reporter.error("Backeer command failed", error)
|
|
237
256
|
message = [
|
|
238
257
|
"## Backeer command failed",
|
|
239
258
|
"",
|