backeer-opencode 0.2.3 → 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 +14 -0
- package/dist/backeer-permission.js +376 -201
- 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 +67 -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")
|
|
@@ -35,139 +86,28 @@ export function formatBackeerFeedback(kind, message) {
|
|
|
35
86
|
}
|
|
36
87
|
|
|
37
88
|
export function createConversationFeedback({ client, reporter }) {
|
|
38
|
-
const sessions = new
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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))
|
|
117
|
-
}
|
|
118
|
-
|
|
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
|
-
}
|
|
89
|
+
const sessions = new FeedbackSessionStore({
|
|
90
|
+
reporter,
|
|
91
|
+
send: (sessionID, kind, message) => sendSessionMessage(
|
|
92
|
+
client,
|
|
93
|
+
sessionID,
|
|
94
|
+
formatBackeerFeedback(kind, message),
|
|
95
|
+
),
|
|
96
|
+
})
|
|
131
97
|
|
|
132
98
|
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),
|
|
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),
|
|
138
104
|
|
|
139
105
|
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
|
-
}
|
|
106
|
+
handleFeedbackEvent(sessions, event)
|
|
166
107
|
},
|
|
167
108
|
|
|
168
109
|
dispose() {
|
|
169
|
-
|
|
170
|
-
sessions.clear()
|
|
110
|
+
sessions.dispose()
|
|
171
111
|
},
|
|
172
112
|
}
|
|
173
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