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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/backeer-permission.js +390 -172
- package/package.json +7 -3
- package/src/delivery-tracker.js +75 -0
- package/src/feedback-session-store.js +110 -0
- package/src/lifecycle-publishers.js +2 -0
- package/src/permission-command.js +60 -0
- package/src/permission-controller.js +14 -57
- package/src/plugin.js +33 -23
- package/src/session-messages.js +75 -84
- package/src/sse.js +99 -43
- package/src/updates.js +1 -1
package/src/session-messages.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
191
|
+
await sleepImplementation(reconnectDelay, signal)
|
|
136
192
|
reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
|
|
137
193
|
}
|
|
138
194
|
}
|
package/src/updates.js
CHANGED