backeer-opencode 0.2.3 → 0.2.5

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.
@@ -1,4 +1,5 @@
1
- import { limit, rememberBounded } from "./utilities.js"
1
+ import { FeedbackSessionStore } from "./feedback-session-store.js"
2
+ import { limit } from "./utilities.js"
2
3
 
3
4
  const FEEDBACK_SYMBOLS = {
4
5
  info: "•",
@@ -16,6 +17,56 @@ const QUESTION_REPLIED_EVENTS = new Set([
16
17
  "question.v2.answered",
17
18
  ])
18
19
 
20
+ function interactionKey(kind, event) {
21
+ const value = event.properties?.requestID ?? event.properties?.id
22
+ return `${kind}:${typeof value === "string" && value ? value : "pending"}`
23
+ }
24
+
25
+ const INTERACTION_EVENTS = new Map([
26
+ ["permission.asked", { kind: "permission", phase: "begin" }],
27
+ ["permission.replied", { kind: "permission", phase: "end" }],
28
+ ...[...QUESTION_ASKED_EVENTS]
29
+ .map((type) => [type, { kind: "question", phase: "begin" }]),
30
+ ...[...QUESTION_REPLIED_EVENTS]
31
+ .map((type) => [type, { kind: "question", phase: "end" }]),
32
+ ])
33
+
34
+ const SESSION_EVENT_HANDLERS = {
35
+ "session.status": (sessions, event, sessionID) => {
36
+ const status = event.properties?.status?.type
37
+ if (status === "idle") sessions.setIdle(sessionID, true)
38
+ else if (status === "busy" || status === "retry") {
39
+ sessions.setIdle(sessionID, false)
40
+ }
41
+ },
42
+ "session.idle": (sessions, _event, sessionID) => {
43
+ sessions.setIdle(sessionID, true)
44
+ },
45
+ "todo.updated": (sessions, _event, sessionID) => {
46
+ sessions.setIdle(sessionID, false)
47
+ },
48
+ "session.error": (sessions, _event, sessionID) => {
49
+ sessions.failSession(sessionID)
50
+ },
51
+ "session.deleted": (sessions, _event, sessionID) => {
52
+ sessions.delete(sessionID)
53
+ },
54
+ }
55
+
56
+ function handleFeedbackEvent(sessions, event) {
57
+ const sessionID = event?.properties?.sessionID
58
+ if (typeof sessionID !== "string") return
59
+
60
+ const interaction = INTERACTION_EVENTS.get(event.type)
61
+ if (interaction) {
62
+ const key = interactionKey(interaction.kind, event)
63
+ if (interaction.phase === "begin") sessions.beginInteraction(sessionID, key)
64
+ else sessions.endInteraction(sessionID, key, interaction.kind)
65
+ return
66
+ }
67
+ SESSION_EVENT_HANDLERS[event.type]?.(sessions, event, sessionID)
68
+ }
69
+
19
70
  export async function sendSessionMessage(client, sessionID, text) {
20
71
  if (typeof client?.session?.prompt !== "function") {
21
72
  throw new Error("OpenCode session messaging is unavailable")
@@ -31,143 +82,46 @@ export async function sendSessionMessage(client, sessionID, text) {
31
82
 
32
83
  export function formatBackeerFeedback(kind, message) {
33
84
  const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
34
- return `> **Backeer** ${symbol} ${limit(message, 300)}`
85
+ return `▣ Backeer | ${symbol} ${limit(message, 300)}`
35
86
  }
36
87
 
37
- export function createConversationFeedback({ client, reporter }) {
38
- const sessions = new Map()
39
- let disposed = false
40
-
41
- function sessionState(sessionID) {
42
- let state = sessions.get(sessionID)
43
- if (!state) {
44
- state = {
45
- idle: false,
46
- flushing: false,
47
- blockers: new Set(),
48
- queue: [],
49
- queued: new Set(),
50
- }
51
- rememberBounded(sessions, sessionID, state)
52
- }
53
- return state
54
- }
55
-
56
- async function flush(sessionID, state) {
57
- if (
58
- disposed || state.flushing || !state.idle ||
59
- state.blockers.size > 0 || state.queue.length === 0
60
- ) return
61
- state.flushing = true
62
- try {
63
- while (
64
- !disposed && state.idle && state.blockers.size === 0 &&
65
- state.queue.length > 0
66
- ) {
67
- const item = state.queue[0]
68
- try {
69
- await sendSessionMessage(
70
- client,
71
- sessionID,
72
- formatBackeerFeedback(item.kind, item.message),
73
- )
74
- state.queue.shift()
75
- state.queued.delete(item.key)
76
- } catch (error) {
77
- state.idle = false
78
- reporter.debug("Conversation feedback could not be displayed", error)
79
- }
80
- }
81
- } finally {
82
- state.flushing = false
83
- }
84
- }
85
-
86
- function enqueue(kind, sessionID, message) {
87
- if (disposed || typeof sessionID !== "string" || !sessionID || !message) return
88
- const state = sessionState(sessionID)
89
- const key = `${sessionID}:${kind}:${message}`
90
- if (state.queued.has(key)) return
91
-
92
- state.queue.push({ kind, message: limit(message, 300), key })
93
- state.queued.add(key)
94
- while (state.queue.length > 20) {
95
- const removed = state.queue.shift()
96
- state.queued.delete(removed.key)
97
- }
98
- if (state.idle && state.blockers.size === 0) void flush(sessionID, state)
99
- }
100
-
101
- function setIdle(sessionID, idle) {
102
- if (typeof sessionID !== "string" || !sessionID) return
103
- const state = sessionState(sessionID)
104
- state.idle = idle
105
- if (idle) void flush(sessionID, state)
106
- }
107
-
108
- function interactionKey(kind, event) {
109
- const value = event.properties?.requestID ?? event.properties?.id
110
- return `${kind}:${typeof value === "string" && value ? value : "pending"}`
111
- }
112
-
113
- function beginInteraction(sessionID, kind, event) {
114
- const state = sessionState(sessionID)
115
- state.idle = false
116
- state.blockers.add(interactionKey(kind, event))
88
+ export function formatBackeerFeedbackBatch(items) {
89
+ if (items.length === 1) {
90
+ return formatBackeerFeedback(items[0].kind, items[0].message)
117
91
  }
92
+ return [
93
+ "▣ Backeer",
94
+ ...items.map((item) => {
95
+ const symbol = FEEDBACK_SYMBOLS[item.kind] ?? FEEDBACK_SYMBOLS.info
96
+ return `${symbol} ${limit(item.message, 300)}`
97
+ }),
98
+ ].join("\n")
99
+ }
118
100
 
119
- function endInteraction(sessionID, kind, event) {
120
- const state = sessionState(sessionID)
121
- const key = interactionKey(kind, event)
122
- if (!state.blockers.delete(key)) {
123
- const fallback = [...state.blockers]
124
- .find((blocker) => blocker.startsWith(`${kind}:`))
125
- if (fallback) state.blockers.delete(fallback)
126
- }
127
- // The reply event can arrive before OpenCode has removed its permission UI
128
- // or resumed the agent. Wait for the next idle event before injecting text.
129
- state.idle = false
130
- }
101
+ export function createConversationFeedback({ client, reporter, flushDelayMs = 150 }) {
102
+ const sessions = new FeedbackSessionStore({
103
+ reporter,
104
+ flushDelayMs,
105
+ send: (sessionID, items) => sendSessionMessage(
106
+ client,
107
+ sessionID,
108
+ formatBackeerFeedbackBatch(items),
109
+ ),
110
+ })
131
111
 
132
112
  return {
133
- info: (sessionID, message) => enqueue("info", sessionID, message),
134
- success: (sessionID, message) => enqueue("success", sessionID, message),
135
- applied: (sessionID, message) => enqueue("applied", sessionID, message),
136
- warning: (sessionID, message) => enqueue("warning", sessionID, message),
137
- error: (sessionID, message) => enqueue("error", sessionID, message),
113
+ info: (sessionID, message) => sessions.enqueue("info", sessionID, message),
114
+ success: (sessionID, message) => sessions.enqueue("success", sessionID, message),
115
+ applied: (sessionID, message) => sessions.enqueue("applied", sessionID, message),
116
+ warning: (sessionID, message) => sessions.enqueue("warning", sessionID, message),
117
+ error: (sessionID, message) => sessions.enqueue("error", sessionID, message),
138
118
 
139
119
  handle(event) {
140
- const sessionID = event?.properties?.sessionID
141
- if (typeof sessionID !== "string") return
142
-
143
- if (event.type === "session.status") {
144
- const status = event.properties?.status?.type
145
- if (status === "idle") setIdle(sessionID, true)
146
- else if (status === "busy" || status === "retry") setIdle(sessionID, false)
147
- } else if (event.type === "session.idle") {
148
- setIdle(sessionID, true)
149
- } else if (event.type === "permission.asked") {
150
- beginInteraction(sessionID, "permission", event)
151
- } else if (QUESTION_ASKED_EVENTS.has(event.type)) {
152
- beginInteraction(sessionID, "question", event)
153
- } else if (event.type === "permission.replied") {
154
- endInteraction(sessionID, "permission", event)
155
- } else if (QUESTION_REPLIED_EVENTS.has(event.type)) {
156
- endInteraction(sessionID, "question", event)
157
- } else if (event.type === "todo.updated") {
158
- setIdle(sessionID, false)
159
- } else if (event.type === "session.error") {
160
- const state = sessionState(sessionID)
161
- state.blockers.clear()
162
- state.idle = false
163
- } else if (event.type === "session.deleted") {
164
- sessions.delete(sessionID)
165
- }
120
+ handleFeedbackEvent(sessions, event)
166
121
  },
167
122
 
168
123
  dispose() {
169
- disposed = true
170
- sessions.clear()
124
+ sessions.dispose()
171
125
  },
172
126
  }
173
127
  }
package/src/sse.js CHANGED
@@ -53,6 +53,7 @@ export async function consumeSse(response, state, onCommand, signal, reporter) {
53
53
  const next = nextSseFrame(buffer)
54
54
  if (!next) break
55
55
  buffer = next.rest
56
+ state.framesReceived = (state.framesReceived ?? 0) + 1
56
57
  const message = parseSseFrame(next.frame)
57
58
  if (!message) continue
58
59
  if (message.id) state.lastEventID = message.id
@@ -74,65 +75,120 @@ export async function consumeSse(response, state, onCommand, signal, reporter) {
74
75
  }
75
76
  }
76
77
 
78
+ async function openCommandStream(config, state, signal, fetchImplementation) {
79
+ const headers = {
80
+ Accept: "text/event-stream",
81
+ Authorization: `Bearer ${config.token}`,
82
+ }
83
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
84
+
85
+ const response = await fetchImplementation(config.commandStreamEndpoint, {
86
+ headers,
87
+ signal,
88
+ })
89
+ if (!response.ok) {
90
+ const responseBody = limit(await response.text(), 500)
91
+ throw new HttpError(
92
+ `Backeer command stream failed with status ${response.status}`,
93
+ response.status,
94
+ responseBody,
95
+ )
96
+ }
97
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
98
+ throw new Error("Backeer command stream returned an invalid content type")
99
+ }
100
+ return response
101
+ }
102
+
103
+ async function consumeConnection({
104
+ response,
105
+ state,
106
+ onCommand,
107
+ signal,
108
+ reporter,
109
+ now,
110
+ stableConnectionMs,
111
+ onStable,
112
+ }) {
113
+ const connectedAt = now()
114
+ const framesBeforeConnection = state.framesReceived
115
+ try {
116
+ await consumeSse(response, state, onCommand, signal, reporter)
117
+ } finally {
118
+ const receivedFrame = state.framesReceived > framesBeforeConnection
119
+ if (receivedFrame || now() - connectedAt >= stableConnectionMs) onStable()
120
+ }
121
+ }
122
+
123
+ function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
124
+ const message = error instanceof HttpError
125
+ ? `${error.message}: ${error.responseBody}`
126
+ : limit(error, 500)
127
+ if (message !== lastLoggedError) {
128
+ const authenticationFailure = error instanceof HttpError &&
129
+ [401, 403].includes(error.status)
130
+ reporter.error(
131
+ authenticationFailure
132
+ ? "Authentication failed — run: npx backeer-opencode@latest configure"
133
+ : "Connection interrupted — retrying",
134
+ message,
135
+ )
136
+ }
137
+ return {
138
+ lastLoggedError: message,
139
+ reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status)
140
+ ? 60_000
141
+ : reconnectDelay,
142
+ }
143
+ }
144
+
77
145
  export async function runCommandStream(
78
146
  config,
79
147
  reporter,
80
- { signal, hasPending, onCommand },
148
+ {
149
+ signal,
150
+ hasPending,
151
+ onCommand,
152
+ fetchImplementation = fetch,
153
+ sleepImplementation = sleep,
154
+ now = Date.now,
155
+ stableConnectionMs = 5_000,
156
+ },
81
157
  ) {
82
- const state = { lastEventID: "" }
158
+ const state = { lastEventID: "", framesReceived: 0 }
83
159
  let reconnectDelay = 1000
84
160
  let lastLoggedError = ""
85
161
 
86
162
  while (!signal.aborted && hasPending()) {
87
163
  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,
164
+ const response = await openCommandStream(
165
+ config,
166
+ state,
167
+ signal,
168
+ fetchImplementation,
169
+ )
170
+ await consumeConnection({
171
+ response,
172
+ state,
173
+ onCommand,
96
174
  signal,
175
+ reporter,
176
+ now,
177
+ stableConnectionMs,
178
+ onStable: () => {
179
+ reconnectDelay = 1000
180
+ lastLoggedError = ""
181
+ },
97
182
  })
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
183
  } catch (error) {
114
184
  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
- }
185
+ const next = streamErrorState(error, lastLoggedError, reconnectDelay, reporter)
186
+ reconnectDelay = next.reconnectDelay
187
+ lastLoggedError = next.lastLoggedError
132
188
  }
133
189
 
134
190
  if (!signal.aborted && hasPending()) {
135
- await sleep(reconnectDelay, signal)
191
+ await sleepImplementation(reconnectDelay, signal)
136
192
  reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
137
193
  }
138
194
  }
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.2.3"
1
+ export const PLUGIN_VERSION = "0.2.5"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"