backeer-opencode 0.1.1 → 0.1.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.
@@ -0,0 +1,183 @@
1
+ import { HttpError, postJsonWithRetry } from "./http.js"
2
+ import { runCommandStream } from "./sse.js"
3
+ import { rememberBounded } from "./utilities.js"
4
+
5
+ const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
6
+ const DECISION_LABELS = {
7
+ once: "Allow once",
8
+ always: "Always allow",
9
+ reject: "Reject",
10
+ }
11
+
12
+ async function replyToOpenCode(client, pending, decision) {
13
+ const result = await client.postSessionIdPermissionsPermissionId({
14
+ path: { id: pending.sessionID, permissionID: pending.requestID },
15
+ query: { directory: pending.directory },
16
+ body: { response: decision },
17
+ throwOnError: true,
18
+ })
19
+
20
+ if (result?.data === false || result === false) {
21
+ throw new HttpError("OpenCode did not apply the permission reply", 409)
22
+ }
23
+ }
24
+
25
+ async function acknowledgeCommand(config, commandID, acknowledgement) {
26
+ const url = new URL(
27
+ `/v1/commands/${encodeURIComponent(commandID)}/ack`,
28
+ config.origin,
29
+ )
30
+ await postJsonWithRetry(config, url, acknowledgement)
31
+ }
32
+
33
+ export function createPermissionController({
34
+ config,
35
+ reporter,
36
+ client,
37
+ directory,
38
+ }) {
39
+ const pendingByExternalID = new Map()
40
+ const externalIDByRequestID = new Map()
41
+ const processingCommands = new Set()
42
+ const completedCommands = new Map()
43
+ const publishedPermissions = new Map()
44
+ let commandStream
45
+ let disposed = false
46
+
47
+ function removePendingByRequestID(requestID) {
48
+ const externalId = externalIDByRequestID.get(requestID)
49
+ if (!externalId) return
50
+ externalIDByRequestID.delete(requestID)
51
+ pendingByExternalID.delete(externalId)
52
+ }
53
+
54
+ function stopCommandStreamIfIdle() {
55
+ if (pendingByExternalID.size === 0) commandStream?.controller.abort()
56
+ }
57
+
58
+ async function handleCommand(command) {
59
+ if (!command || typeof command !== "object") return
60
+ if (command.type !== "opencode_permission_reply") return
61
+ if (typeof command.id !== "string" || command.id.length > 200) return
62
+
63
+ const previousAcknowledgement = completedCommands.get(command.id)
64
+ if (previousAcknowledgement) {
65
+ await acknowledgeCommand(config, command.id, previousAcknowledgement)
66
+ .catch((error) => reporter.warning("Decision receipt retry failed", error))
67
+ return
68
+ }
69
+ if (processingCommands.has(command.id)) return
70
+ if (typeof command.externalId !== "string") return
71
+
72
+ const pending = pendingByExternalID.get(command.externalId)
73
+ if (!pending) return
74
+
75
+ processingCommands.add(command.id)
76
+ let acknowledgement
77
+ try {
78
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
79
+ acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
80
+ } else {
81
+ const expiresAt = Date.parse(command.expiresAt)
82
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
83
+ acknowledgement = { status: "expired", decision: command.decision }
84
+ } else if (command.decision === "always" && command.confirmed !== true) {
85
+ acknowledgement = {
86
+ status: "rejected",
87
+ decision: command.decision,
88
+ errorCode: "confirmation_required",
89
+ }
90
+ } else {
91
+ await replyToOpenCode(client, pending, command.decision)
92
+ acknowledgement = { status: "applied", decision: command.decision }
93
+ removePendingByRequestID(pending.requestID)
94
+ }
95
+ }
96
+ } catch (error) {
97
+ const stale = error instanceof HttpError && error.status === 404
98
+ acknowledgement = {
99
+ status: stale ? "stale" : "failed",
100
+ decision: ALLOWED_DECISIONS.has(command.decision)
101
+ ? command.decision
102
+ : undefined,
103
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
104
+ }
105
+ if (stale) removePendingByRequestID(pending.requestID)
106
+ reporter.error("Decision could not be applied", error)
107
+ } finally {
108
+ processingCommands.delete(command.id)
109
+ }
110
+
111
+ acknowledgement = {
112
+ ...acknowledgement,
113
+ type: "opencode_permission_reply_ack",
114
+ externalId: command.externalId,
115
+ processedAt: new Date().toISOString(),
116
+ }
117
+ rememberBounded(completedCommands, command.id, acknowledgement)
118
+
119
+ if (acknowledgement.status === "applied") {
120
+ reporter.applied(
121
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
122
+ )
123
+ } else if (acknowledgement.status === "expired") {
124
+ reporter.warning("Decision expired before it could be applied")
125
+ } else if (acknowledgement.status === "stale") {
126
+ reporter.warning("Permission was already answered locally")
127
+ } else if (acknowledgement.status === "rejected") {
128
+ reporter.warning("Decision was rejected by validation")
129
+ }
130
+
131
+ await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
132
+ reporter.warning("Decision receipt could not be sent", error)
133
+ })
134
+ stopCommandStreamIfIdle()
135
+ }
136
+
137
+ function ensureCommandStream() {
138
+ if (disposed || commandStream || pendingByExternalID.size === 0) return
139
+
140
+ const controller = new AbortController()
141
+ const current = {
142
+ controller,
143
+ task: runCommandStream(config, reporter, {
144
+ signal: controller.signal,
145
+ hasPending: () => pendingByExternalID.size > 0,
146
+ onCommand: handleCommand,
147
+ }),
148
+ }
149
+ commandStream = current
150
+ void current.task.finally(() => {
151
+ if (commandStream === current) commandStream = undefined
152
+ if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
153
+ })
154
+ }
155
+
156
+ return {
157
+ register(identity) {
158
+ pendingByExternalID.set(identity.externalId, { ...identity, directory })
159
+ externalIDByRequestID.set(identity.requestID, identity.externalId)
160
+ ensureCommandStream()
161
+
162
+ if (publishedPermissions.has(identity.externalId)) return false
163
+ rememberBounded(publishedPermissions, identity.externalId, true)
164
+ return true
165
+ },
166
+
167
+ resolveLocally(requestID) {
168
+ removePendingByRequestID(requestID)
169
+ stopCommandStreamIfIdle()
170
+ },
171
+
172
+ async dispose() {
173
+ disposed = true
174
+ commandStream?.controller.abort()
175
+ await commandStream?.task.catch(() => {})
176
+ pendingByExternalID.clear()
177
+ externalIDByRequestID.clear()
178
+ processingCommands.clear()
179
+ completedCommands.clear()
180
+ publishedPermissions.clear()
181
+ },
182
+ }
183
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,143 @@
1
+ import { loadConfig } from "./config.js"
2
+ import {
3
+ permissionIdentity,
4
+ publishPermission,
5
+ publishQuestion,
6
+ publishTodoCompletion,
7
+ questionIdentity,
8
+ } from "./event-publishers.js"
9
+ import { createPermissionController } from "./permission-controller.js"
10
+ import { createConsoleReporter } from "./reporter.js"
11
+ import { rememberBounded } from "./utilities.js"
12
+
13
+ /**
14
+ * OpenCode server plugin entrypoint.
15
+ *
16
+ * Supported options: origin, tokenFile, permissions, questions, todos, logLevel.
17
+ * The ingest token is intentionally accepted only through the environment or a
18
+ * protected file so it cannot accidentally be committed in opencode.json.
19
+ */
20
+ export default async function BackeerPlugin({ client, directory }, options = {}) {
21
+ const bootstrapReporter = createConsoleReporter(
22
+ options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
23
+ )
24
+ let config
25
+ try {
26
+ config = loadConfig(options)
27
+ } catch (error) {
28
+ bootstrapReporter.error("Plugin disabled — invalid configuration", error)
29
+ return {}
30
+ }
31
+
32
+ const reporter = createConsoleReporter(config.logLevel)
33
+
34
+ if (!config.token) {
35
+ reporter.error(
36
+ "Plugin disabled — run: npx backeer-opencode@latest configure",
37
+ `Token not found at ${config.tokenFile}`,
38
+ )
39
+ return {}
40
+ }
41
+
42
+ const permissionController = createPermissionController({
43
+ config,
44
+ reporter,
45
+ client,
46
+ directory,
47
+ })
48
+ const todoCompletionBySessionID = new Map()
49
+ const publishedQuestions = new Map()
50
+
51
+ async function handlePermissionAsked(event) {
52
+ const identity = permissionIdentity(event.properties)
53
+ if (!identity) {
54
+ reporter.error("Permission event is missing correlation IDs")
55
+ return
56
+ }
57
+ if (!permissionController.register(identity)) return
58
+
59
+ void publishPermission(
60
+ config,
61
+ reporter,
62
+ event.properties,
63
+ directory,
64
+ identity,
65
+ ).catch((error) => reporter.error("Permission request could not be sent", error))
66
+ }
67
+
68
+ async function handleTodoUpdated(event) {
69
+ const { sessionID, todos } = event.properties
70
+ if (typeof sessionID !== "string" || !Array.isArray(todos)) {
71
+ reporter.error("Todo event is missing a valid session or list")
72
+ return
73
+ }
74
+
75
+ const isComplete = todos.length > 0 &&
76
+ todos.every((todo) => todo?.status === "completed")
77
+ const wasComplete = todoCompletionBySessionID.get(sessionID) === true
78
+ rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
79
+ if (!isComplete || wasComplete) return
80
+
81
+ void publishTodoCompletion(
82
+ config,
83
+ reporter,
84
+ event.properties,
85
+ event.id,
86
+ client,
87
+ directory,
88
+ ).catch((error) => reporter.error("Todo completion could not be sent", error))
89
+ }
90
+
91
+ async function handleQuestionAsked(event) {
92
+ const identity = questionIdentity(event.properties)
93
+ if (!identity) {
94
+ reporter.error("Question event is missing correlation IDs")
95
+ return
96
+ }
97
+ if (publishedQuestions.has(identity.externalId)) return
98
+ rememberBounded(publishedQuestions, identity.externalId, true)
99
+
100
+ void publishQuestion(
101
+ config,
102
+ reporter,
103
+ event.properties,
104
+ identity,
105
+ client,
106
+ directory,
107
+ ).catch((error) => reporter.error("Question could not be sent", error))
108
+ }
109
+
110
+ return {
111
+ event: async ({ event }) => {
112
+ if (config.permissions && event.type === "permission.asked") {
113
+ await handlePermissionAsked(event)
114
+ return
115
+ }
116
+
117
+ if (config.permissions && event.type === "permission.replied") {
118
+ permissionController.resolveLocally(
119
+ event.properties.requestID ?? event.properties.id,
120
+ )
121
+ return
122
+ }
123
+
124
+ if (config.todos && event.type === "todo.updated") {
125
+ await handleTodoUpdated(event)
126
+ return
127
+ }
128
+
129
+ if (
130
+ config.questions &&
131
+ (event.type === "question.asked" || event.type === "question.v2.asked")
132
+ ) {
133
+ await handleQuestionAsked(event)
134
+ }
135
+ },
136
+
137
+ dispose: async () => {
138
+ await permissionController.dispose()
139
+ todoCompletionBySessionID.clear()
140
+ publishedQuestions.clear()
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,60 @@
1
+ import { limit } from "./utilities.js"
2
+
3
+ const LOG_LEVELS = new Set(["silent", "error", "info", "debug"])
4
+ const LOG_RANK = { silent: 0, error: 1, info: 2, debug: 3 }
5
+ const LOG_DEDUPLICATION_MS = 30_000
6
+
7
+ export function normalizeLogLevel(value) {
8
+ const level = String(value ?? "info").trim().toLowerCase()
9
+ return LOG_LEVELS.has(level) ? level : "info"
10
+ }
11
+
12
+ export function createConsoleReporter(levelValue) {
13
+ const level = normalizeLogLevel(levelValue)
14
+ const useColor = Boolean(process.stdout?.isTTY) &&
15
+ !("NO_COLOR" in process.env) && process.env.TERM !== "dumb"
16
+ const recentlyEmitted = new Map()
17
+
18
+ const decorate = (code, text) => useColor ? `\u001b[${code}m${text}\u001b[0m` : text
19
+ const symbols = {
20
+ info: decorate("36", "◆"),
21
+ success: decorate("32", "◆"),
22
+ applied: decorate("32", "✓"),
23
+ warning: decorate("33", "!"),
24
+ error: decorate("31", "×"),
25
+ debug: decorate("2", "·"),
26
+ }
27
+ const brand = decorate("1", "Backeer")
28
+
29
+ function emit(requiredLevel, kind, message, detail, deduplicate = false) {
30
+ if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return
31
+
32
+ const key = `${kind}:${message}`
33
+ const now = Date.now()
34
+ if (deduplicate) {
35
+ const previous = recentlyEmitted.get(key)
36
+ if (previous && now - previous < LOG_DEDUPLICATION_MS) return
37
+ recentlyEmitted.delete(key)
38
+ recentlyEmitted.set(key, now)
39
+ while (recentlyEmitted.size > 100) {
40
+ recentlyEmitted.delete(recentlyEmitted.keys().next().value)
41
+ }
42
+ }
43
+
44
+ const technical = level === "debug" && detail
45
+ ? decorate("2", ` · ${limit(detail, 500)}`)
46
+ : ""
47
+ const line = `${symbols[kind]} ${brand} ${message}${technical}`
48
+ if (kind === "error" || kind === "warning") console.error(line)
49
+ else console.log(line)
50
+ }
51
+
52
+ return {
53
+ info: (message, detail) => emit("info", "info", message, detail),
54
+ success: (message, detail) => emit("info", "success", message, detail),
55
+ applied: (message, detail) => emit("info", "applied", message, detail),
56
+ warning: (message, detail) => emit("error", "warning", message, detail, true),
57
+ error: (message, detail) => emit("error", "error", message, detail, true),
58
+ debug: (message, detail) => emit("debug", "debug", message, detail),
59
+ }
60
+ }
package/src/sse.js ADDED
@@ -0,0 +1,139 @@
1
+ import { HttpError, isAbortError, sleep } from "./http.js"
2
+ import { limit } from "./utilities.js"
3
+
4
+ export function parseSseFrame(frame) {
5
+ const data = []
6
+ let event = "message"
7
+ let id
8
+
9
+ for (const line of frame.split(/\r?\n/)) {
10
+ if (!line || line.startsWith(":")) continue
11
+ const separator = line.indexOf(":")
12
+ const field = separator === -1 ? line : line.slice(0, separator)
13
+ let value = separator === -1 ? "" : line.slice(separator + 1)
14
+ if (value.startsWith(" ")) value = value.slice(1)
15
+
16
+ if (field === "event") event = value
17
+ if (field === "id") id = value
18
+ if (field === "data") data.push(value)
19
+ }
20
+
21
+ if (data.length === 0) return
22
+ return { event, id, data: data.join("\n") }
23
+ }
24
+
25
+ export function nextSseFrame(buffer) {
26
+ const lfIndex = buffer.indexOf("\n\n")
27
+ const crlfIndex = buffer.indexOf("\r\n\r\n")
28
+ if (lfIndex === -1 && crlfIndex === -1) return
29
+
30
+ if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
31
+ return {
32
+ frame: buffer.slice(0, crlfIndex),
33
+ rest: buffer.slice(crlfIndex + 4),
34
+ }
35
+ }
36
+ return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
37
+ }
38
+
39
+ export async function consumeSse(response, state, onCommand, signal, reporter) {
40
+ if (!response.body) throw new Error("Backeer command stream has no body")
41
+
42
+ const reader = response.body.getReader()
43
+ const decoder = new TextDecoder()
44
+ let buffer = ""
45
+
46
+ try {
47
+ while (!signal.aborted) {
48
+ const item = await reader.read()
49
+ if (item.done) break
50
+ buffer += decoder.decode(item.value, { stream: true })
51
+
52
+ for (;;) {
53
+ const next = nextSseFrame(buffer)
54
+ if (!next) break
55
+ buffer = next.rest
56
+ const message = parseSseFrame(next.frame)
57
+ if (!message) continue
58
+ if (message.id) state.lastEventID = message.id
59
+ if (message.event !== "message" && message.event !== "command") continue
60
+
61
+ try {
62
+ await onCommand(JSON.parse(message.data))
63
+ } catch (error) {
64
+ if (error instanceof SyntaxError) {
65
+ reporter.error("Invalid command received", "SSE data is not valid JSON")
66
+ continue
67
+ }
68
+ throw error
69
+ }
70
+ }
71
+ }
72
+ } finally {
73
+ await reader.cancel().catch(() => {})
74
+ }
75
+ }
76
+
77
+ export async function runCommandStream(
78
+ config,
79
+ reporter,
80
+ { signal, hasPending, onCommand },
81
+ ) {
82
+ const state = { lastEventID: "" }
83
+ let reconnectDelay = 1000
84
+ let lastLoggedError = ""
85
+
86
+ while (!signal.aborted && hasPending()) {
87
+ try {
88
+ const headers = {
89
+ Accept: "text/event-stream",
90
+ Authorization: `Bearer ${config.token}`,
91
+ }
92
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
93
+
94
+ const response = await fetch(config.commandStreamEndpoint, {
95
+ headers,
96
+ signal,
97
+ })
98
+ if (!response.ok) {
99
+ const responseBody = limit(await response.text(), 500)
100
+ throw new HttpError(
101
+ `Backeer command stream failed with status ${response.status}`,
102
+ response.status,
103
+ responseBody,
104
+ )
105
+ }
106
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
107
+ throw new Error("Backeer command stream returned an invalid content type")
108
+ }
109
+
110
+ reconnectDelay = 1000
111
+ lastLoggedError = ""
112
+ await consumeSse(response, state, onCommand, signal, reporter)
113
+ } catch (error) {
114
+ if (signal.aborted || isAbortError(error)) return
115
+ const message = error instanceof HttpError
116
+ ? `${error.message}: ${error.responseBody}`
117
+ : limit(error, 500)
118
+ if (message !== lastLoggedError) {
119
+ const authenticationFailure = error instanceof HttpError &&
120
+ [401, 403].includes(error.status)
121
+ reporter.error(
122
+ authenticationFailure
123
+ ? "Authentication failed — run: npx backeer-opencode@latest configure"
124
+ : "Connection interrupted — retrying",
125
+ message,
126
+ )
127
+ lastLoggedError = message
128
+ }
129
+ if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
130
+ reconnectDelay = 60_000
131
+ }
132
+ }
133
+
134
+ if (!signal.aborted && hasPending()) {
135
+ await sleep(reconnectDelay, signal)
136
+ reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,25 @@
1
+ const MAX_TRACKED_ITEMS = 500
2
+
3
+ export const limit = (value, length = 1000) =>
4
+ String(value ?? "").slice(0, length)
5
+
6
+ export function enabled(value, fallback = true) {
7
+ if (typeof value === "boolean") return value
8
+ if (typeof value !== "string") return fallback
9
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase())
10
+ }
11
+
12
+ export function stableHash(value) {
13
+ let hash = 2166136261
14
+ for (let index = 0; index < value.length; index++) {
15
+ hash ^= value.charCodeAt(index)
16
+ hash = Math.imul(hash, 16777619)
17
+ }
18
+ return (hash >>> 0).toString(16).padStart(8, "0")
19
+ }
20
+
21
+ export function rememberBounded(map, key, value) {
22
+ map.delete(key)
23
+ map.set(key, value)
24
+ while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
25
+ }