backeer-opencode 0.2.2 → 0.2.4

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: "•",
@@ -8,6 +9,64 @@ const FEEDBACK_SYMBOLS = {
8
9
  error: "×",
9
10
  }
10
11
 
12
+ const QUESTION_ASKED_EVENTS = new Set(["question.asked", "question.v2.asked"])
13
+ const QUESTION_REPLIED_EVENTS = new Set([
14
+ "question.replied",
15
+ "question.v2.replied",
16
+ "question.answered",
17
+ "question.v2.answered",
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
+
11
70
  export async function sendSessionMessage(client, sessionID, text) {
12
71
  if (typeof client?.session?.prompt !== "function") {
13
72
  throw new Error("OpenCode session messaging is unavailable")
@@ -27,96 +86,28 @@ export function formatBackeerFeedback(kind, message) {
27
86
  }
28
87
 
29
88
  export function createConversationFeedback({ client, reporter }) {
30
- const sessions = new Map()
31
- let disposed = false
32
-
33
- function sessionState(sessionID) {
34
- let state = sessions.get(sessionID)
35
- if (!state) {
36
- state = { idle: false, flushing: false, queue: [], queued: new Set() }
37
- rememberBounded(sessions, sessionID, state)
38
- }
39
- return state
40
- }
41
-
42
- async function flush(sessionID, state) {
43
- if (disposed || state.flushing || !state.idle || state.queue.length === 0) return
44
- state.flushing = true
45
- try {
46
- while (!disposed && state.idle && state.queue.length > 0) {
47
- const item = state.queue[0]
48
- try {
49
- await sendSessionMessage(
50
- client,
51
- sessionID,
52
- formatBackeerFeedback(item.kind, item.message),
53
- )
54
- state.queue.shift()
55
- state.queued.delete(item.key)
56
- } catch (error) {
57
- state.idle = false
58
- reporter.debug("Conversation feedback could not be displayed", error)
59
- }
60
- }
61
- } finally {
62
- state.flushing = false
63
- }
64
- }
65
-
66
- function enqueue(kind, sessionID, message) {
67
- if (disposed || typeof sessionID !== "string" || !sessionID || !message) return
68
- const state = sessionState(sessionID)
69
- const key = `${sessionID}:${kind}:${message}`
70
- if (state.queued.has(key)) return
71
-
72
- state.queue.push({ kind, message: limit(message, 300), key })
73
- state.queued.add(key)
74
- while (state.queue.length > 20) {
75
- const removed = state.queue.shift()
76
- state.queued.delete(removed.key)
77
- }
78
- if (state.idle) void flush(sessionID, state)
79
- }
80
-
81
- function setIdle(sessionID, idle) {
82
- if (typeof sessionID !== "string" || !sessionID) return
83
- const state = sessionState(sessionID)
84
- state.idle = idle
85
- if (idle) void flush(sessionID, state)
86
- }
89
+ const sessions = new FeedbackSessionStore({
90
+ reporter,
91
+ send: (sessionID, kind, message) => sendSessionMessage(
92
+ client,
93
+ sessionID,
94
+ formatBackeerFeedback(kind, message),
95
+ ),
96
+ })
87
97
 
88
98
  return {
89
- info: (sessionID, message) => enqueue("info", sessionID, message),
90
- success: (sessionID, message) => enqueue("success", sessionID, message),
91
- applied: (sessionID, message) => enqueue("applied", sessionID, message),
92
- warning: (sessionID, message) => enqueue("warning", sessionID, message),
93
- error: (sessionID, message) => enqueue("error", sessionID, message),
99
+ info: (sessionID, message) => sessions.enqueue("info", sessionID, message),
100
+ success: (sessionID, message) => sessions.enqueue("success", sessionID, message),
101
+ applied: (sessionID, message) => sessions.enqueue("applied", sessionID, message),
102
+ warning: (sessionID, message) => sessions.enqueue("warning", sessionID, message),
103
+ error: (sessionID, message) => sessions.enqueue("error", sessionID, message),
94
104
 
95
105
  handle(event) {
96
- const sessionID = event?.properties?.sessionID
97
- if (typeof sessionID !== "string") return
98
-
99
- if (event.type === "session.status") {
100
- const status = event.properties?.status?.type
101
- if (status === "idle") setIdle(sessionID, true)
102
- else if (status === "busy" || status === "retry") setIdle(sessionID, false)
103
- } else if (event.type === "session.idle") {
104
- setIdle(sessionID, true)
105
- } else if (
106
- event.type === "permission.asked" ||
107
- event.type === "question.asked" ||
108
- event.type === "question.v2.asked" ||
109
- event.type === "todo.updated"
110
- ) {
111
- setIdle(sessionID, false)
112
- } else if (event.type === "session.deleted") {
113
- sessions.delete(sessionID)
114
- }
106
+ handleFeedbackEvent(sessions, event)
115
107
  },
116
108
 
117
109
  dispose() {
118
- disposed = true
119
- sessions.clear()
110
+ sessions.dispose()
120
111
  },
121
112
  }
122
113
  }
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.2"
1
+ export const PLUGIN_VERSION = "0.2.4"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"