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.
- package/CHANGELOG.md +21 -0
- package/dist/backeer-permission.js +408 -209
- package/package.json +7 -3
- package/src/delivery-tracker.js +75 -0
- package/src/feedback-session-store.js +122 -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 +81 -127
- 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: "•",
|
|
@@ -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
|
|
85
|
+
return `▣ Backeer | ${symbol} ${limit(message, 300)}`
|
|
35
86
|
}
|
|
36
87
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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