backeer-opencode 0.8.1 → 0.10.0
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 +25 -0
- package/dist/backeer-permission.js +263 -303
- package/docs/live-control.md +51 -12
- package/package.json +1 -1
- package/src/delivery-tracker.js +4 -4
- package/src/diagnostics.js +3 -3
- package/src/event-publishers.js +33 -34
- package/src/feedback-session-store.js +3 -14
- package/src/http.js +14 -16
- package/src/lifecycle-controller.js +5 -5
- package/src/lifecycle-publishers.js +8 -9
- package/src/live-bridge-client.js +3 -4
- package/src/live-bridge-config.js +10 -6
- package/src/live-bridge-events.js +6 -9
- package/src/live-bridge.js +58 -77
- package/src/live-control-client.js +84 -38
- package/src/live-control-mcp.js +10 -10
- package/src/live-diagnostics.js +42 -0
- package/src/live-discovery.js +11 -3
- package/src/live-errors.js +4 -0
- package/src/live-managed-mcp.js +16 -10
- package/src/live-profiles.js +6 -6
- package/src/live-question-tools.js +5 -5
- package/src/live-questions.js +4 -5
- package/src/live-session-manager.js +101 -15
- package/src/live-session-scope.js +3 -4
- package/src/opencode-v2-endpoint.js +8 -2
- package/src/permission-controller.js +4 -4
- package/src/plugin.js +5 -1
- package/src/reporter.js +4 -8
- package/src/session-messages.js +3 -3
- package/src/sse.js +7 -7
- package/src/updates.js +1 -1
- package/src/utilities.js +3 -6
|
@@ -2,11 +2,12 @@ import { randomUUID } from "node:crypto"
|
|
|
2
2
|
import { LiveControlClient, readLiveControlToken } from "./live-control-client.js"
|
|
3
3
|
import { ConnectionError, loadConnectionProfiles, saveConnectionProfiles, connectionProfile } from "./live-profiles.js"
|
|
4
4
|
import { discoverLiveConnections, discoveryDefaults } from "./live-discovery.js"
|
|
5
|
+
import { connectionDiagnostic } from "./live-diagnostics.js"
|
|
5
6
|
|
|
6
7
|
// Bindings are immutable capabilities, kept in the calling chat, never a global "current session".
|
|
7
8
|
// Each binding owns its SSE client/cursor, even when two chats target the same session.
|
|
8
9
|
export class LiveSessionManager {
|
|
9
|
-
constructor({ profilesFile, clientFactory = options => new LiveControlClient(options), maxBindings =
|
|
10
|
+
constructor({ profilesFile, clientFactory = options => new LiveControlClient(options), maxBindings = Infinity,
|
|
10
11
|
discovery = discoveryDefaults() }) {
|
|
11
12
|
this.profilesFile = profilesFile
|
|
12
13
|
this.clientFactory = clientFactory
|
|
@@ -18,6 +19,7 @@ export class LiveSessionManager {
|
|
|
18
19
|
this.candidates = new Map()
|
|
19
20
|
this.saving = Promise.resolve()
|
|
20
21
|
this.shutdown = new AbortController()
|
|
22
|
+
this.pendingBindings = 0
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
async profiles() {
|
|
@@ -31,11 +33,12 @@ export class LiveSessionManager {
|
|
|
31
33
|
return profile
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
async client(profile) {
|
|
35
|
-
|
|
36
|
+
async client(profile, sessionId) {
|
|
37
|
+
let token
|
|
38
|
+
try { token = await readLiveControlToken(profile.tokenFile) }
|
|
39
|
+
catch (error) { throw Object.assign(error, { connectionCode: connectionDiagnostic(error, { tokenFile: true }).code }) }
|
|
36
40
|
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
37
|
-
|
|
38
|
-
const client = this.clientFactory({ url: profile.url, token, expectedDirectory: profile.directory })
|
|
41
|
+
const client = this.clientFactory({ url: profile.url, token, expectedDirectory: profile.directory, sessionId })
|
|
39
42
|
this.clients.add(client)
|
|
40
43
|
return client
|
|
41
44
|
}
|
|
@@ -51,6 +54,7 @@ export class LiveSessionManager {
|
|
|
51
54
|
try {
|
|
52
55
|
const result = await client.listSessions()
|
|
53
56
|
this.checkProject(profile, result)
|
|
57
|
+
this.checkSessionList(result)
|
|
54
58
|
return { connection, ...result, sessions: result.sessions.filter(item => includeChildren || !item.parentID),
|
|
55
59
|
subagentCount: result.sessions.filter(item => item.parentID).length }
|
|
56
60
|
} finally { await this.release(client) }
|
|
@@ -58,26 +62,43 @@ export class LiveSessionManager {
|
|
|
58
62
|
|
|
59
63
|
checkProject(profile, result) {
|
|
60
64
|
if (result?.directory !== profile.directory) {
|
|
61
|
-
throw new ConnectionError("Project verification failed. Check the tunnel target and exact project directory; the remote bridge must support project verification (0.6.0 or newer).")
|
|
65
|
+
throw Object.assign(new ConnectionError("Project verification failed. Check the tunnel target and exact project directory; the remote bridge must support project verification (0.6.0 or newer)."), { connectionCode: "project_mismatch" })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
checkSessionList(result) {
|
|
70
|
+
if (!Array.isArray(result?.sessions) || result.sessions.some(item =>
|
|
71
|
+
typeof item?.id !== "string" || !/^[A-Za-z0-9_-]+$/.test(item.id) || typeof item.title !== "string")) {
|
|
72
|
+
throw Object.assign(new Error("Invalid bridge session list"), { connectionCode: "invalid_bridge_response" })
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
async connect(connection, sessionId, { sessionTitle } = {}) {
|
|
77
|
+
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
78
|
+
if (this.bindings.size + this.pendingBindings >= this.maxBindings) {
|
|
79
|
+
throw new ConnectionError("Connection limit reached. Disconnect an unused chat binding first.")
|
|
80
|
+
}
|
|
81
|
+
this.pendingBindings++
|
|
82
|
+
try { return await this.connectSelected(connection, sessionId, { sessionTitle }) }
|
|
83
|
+
finally { this.pendingBindings-- }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async connectSelected(connection, sessionId, { sessionTitle } = {}) {
|
|
66
87
|
if ((sessionId === undefined) === (sessionTitle === undefined)) {
|
|
67
88
|
throw new ConnectionError("Choose exactly one session ID or exact session title from the current list.")
|
|
68
89
|
}
|
|
69
90
|
if (sessionTitle !== undefined) {
|
|
70
|
-
if (typeof sessionTitle !== "string" || !sessionTitle.trim()
|
|
91
|
+
if (typeof sessionTitle !== "string" || !sessionTitle.trim()) throw new ConnectionError("Invalid session title.")
|
|
71
92
|
const result = await this.listSessions(connection)
|
|
72
93
|
const matches = result.sessions.filter(item => item.title === sessionTitle)
|
|
73
94
|
if (matches.length !== 1) throw new ConnectionError("Session title is missing or ambiguous. Refresh the session list and select an exact session ID.")
|
|
74
95
|
sessionId = matches[0].id
|
|
75
96
|
}
|
|
76
|
-
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]
|
|
97
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId === "global") {
|
|
77
98
|
throw new ConnectionError("Invalid OpenCode session ID.")
|
|
78
99
|
}
|
|
79
100
|
const profile = await this.profile(connection)
|
|
80
|
-
const client = await this.client(profile)
|
|
101
|
+
const client = await this.client(profile, sessionId)
|
|
81
102
|
try {
|
|
82
103
|
const session = await client.sessionStatus(sessionId)
|
|
83
104
|
this.checkProject(profile, session)
|
|
@@ -85,7 +106,7 @@ export class LiveSessionManager {
|
|
|
85
106
|
await client.start()
|
|
86
107
|
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
87
108
|
const bindingId = randomUUID()
|
|
88
|
-
this.bindings.set(bindingId, { profile, sessionId, client })
|
|
109
|
+
this.bindings.set(bindingId, { profile, sessionId, client, session })
|
|
89
110
|
return { bindingId, connection, session, cursor: client.cursor,
|
|
90
111
|
connectionStatus: client.status, scope: "Keep this bindingId only in this chat. Other chats connect separately." }
|
|
91
112
|
} catch (error) { await this.release(client); throw error }
|
|
@@ -113,7 +134,8 @@ export class LiveSessionManager {
|
|
|
113
134
|
|
|
114
135
|
async status(bindingId) {
|
|
115
136
|
const value = await this.verified(bindingId)
|
|
116
|
-
return { bindingId, connection: value.profile.name, session: value.session,
|
|
137
|
+
return { bindingId, connection: value.profile.name, session: value.session, verified: true,
|
|
138
|
+
connectionStatus: value.client.status, transport: value.client.connectionState() }
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
async send(bindingId, { requestId, prompt }, signal) {
|
|
@@ -132,8 +154,68 @@ export class LiveSessionManager {
|
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
async read(bindingId, options, waitMs = 0, signal) {
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
let value
|
|
158
|
+
try { value = await this.verified(bindingId) }
|
|
159
|
+
catch (error) {
|
|
160
|
+
// A temporary tunnel outage must not hide output already buffered for this
|
|
161
|
+
// immutable binding. Permission/project/session failures still fail closed.
|
|
162
|
+
if (!error.connectionCode || !connectionDiagnostic(error).retryable) throw error
|
|
163
|
+
value = this.binding(bindingId)
|
|
164
|
+
const current = await this.profile(value.profile.name)
|
|
165
|
+
if (JSON.stringify(current) !== JSON.stringify(value.profile)) throw new ConnectionError("Connection profile changed. Disconnect and reconnect this chat to verify the new target.")
|
|
166
|
+
const result = value.client.snapshot({ ...options, sessionId: value.sessionId })
|
|
167
|
+
return { ...result, verified: false, diagnostic: connectionDiagnostic(error),
|
|
168
|
+
nextStep: "Keep this binding and cursor. The stream retries every 3 seconds; verify the same session after reconnection before sending or acknowledging a gap." }
|
|
169
|
+
}
|
|
170
|
+
const result = await value.client.readEvents({ ...options, sessionId: value.sessionId }, { waitMs, signal })
|
|
171
|
+
return { ...result, verified: true }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async diagnose({ connection, bindingId } = {}) {
|
|
175
|
+
if ((connection === undefined) === (bindingId === undefined)) throw new ConnectionError("Choose one saved connection or one chat binding to diagnose.")
|
|
176
|
+
const bound = bindingId ? this.binding(bindingId) : undefined
|
|
177
|
+
const profile = bound?.profile ?? await this.profile(connection)
|
|
178
|
+
let client
|
|
179
|
+
try {
|
|
180
|
+
if (bound) {
|
|
181
|
+
const result = await this.status(bindingId)
|
|
182
|
+
return { ...result, healthy: result.connectionStatus === "connected" }
|
|
183
|
+
}
|
|
184
|
+
client = await this.client(profile)
|
|
185
|
+
const result = await client.listSessions()
|
|
186
|
+
this.checkProject(profile, result)
|
|
187
|
+
this.checkSessionList(result)
|
|
188
|
+
return { connection: profile.name, directory: profile.directory, healthy: true, verified: true,
|
|
189
|
+
nextStep: "Choose an existing session by title. No remote work was changed." }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Local profile errors should retain their own precise safe message.
|
|
192
|
+
if (error instanceof ConnectionError && !error.connectionCode) throw error
|
|
193
|
+
return { connection: profile.name, ...(bindingId ? { bindingId } : {}), healthy: false, verified: false,
|
|
194
|
+
diagnostic: connectionDiagnostic(error), ...(bound ? { transport: bound.client.connectionState() } : {}) }
|
|
195
|
+
} finally { if (client) await this.release(client) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async setup({ connection, url, tokenFile } = {}, signal) {
|
|
199
|
+
if (connection !== undefined) {
|
|
200
|
+
if (url !== undefined || tokenFile !== undefined) throw new ConnectionError("Choose a saved connection or discovery inputs, not both.")
|
|
201
|
+
try {
|
|
202
|
+
const result = await this.listSessions(connection)
|
|
203
|
+
return { stage: "choose_session", ...result, nextTool: "connect_session", requiresSelection: true,
|
|
204
|
+
nextStep: result.sessions.length ? "Ask the user to choose a session by title, then connect this chat. Never select the newest automatically."
|
|
205
|
+
: "No main sessions are available. Keep existing work intact; ask the user which session to use." }
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error instanceof ConnectionError && !error.connectionCode) throw error
|
|
208
|
+
return { stage: "fix_connection", connection, diagnostic: connectionDiagnostic(error), nextTool: "diagnose_connection" }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const profiles = await this.profiles()
|
|
212
|
+
if (profiles.length && url === undefined && tokenFile === undefined) {
|
|
213
|
+
return { stage: "choose_connection", connections: profiles.map(({ name, url, directory }) => ({ name, url, directory })),
|
|
214
|
+
nextTool: "connection_setup", requiresSelection: true, nextStep: "Ask the user to select the intended connection, then call connection_setup with its name." }
|
|
215
|
+
}
|
|
216
|
+
const result = await this.discover({ url, tokenFile }, signal)
|
|
217
|
+
return { stage: result.candidates.length ? "choose_candidate" : "fix_connection", ...result,
|
|
218
|
+
nextTool: result.candidates.length ? "save_connection" : "discover_connections", requiresSelection: true }
|
|
137
219
|
}
|
|
138
220
|
|
|
139
221
|
async disconnect(bindingId) {
|
|
@@ -160,7 +242,6 @@ export class LiveSessionManager {
|
|
|
160
242
|
for (const candidate of result.candidates) {
|
|
161
243
|
let id = [...this.candidates].find(([, item]) => JSON.stringify(item.profile) === JSON.stringify(candidate.profile))?.[0]
|
|
162
244
|
if (!id) {
|
|
163
|
-
if (this.candidates.size >= 32) throw new ConnectionError("Discovery candidate limit reached. Restart discovery in a new MCP process.")
|
|
164
245
|
id = randomUUID()
|
|
165
246
|
}
|
|
166
247
|
this.candidates.set(id, candidate)
|
|
@@ -168,6 +249,7 @@ export class LiveSessionManager {
|
|
|
168
249
|
url: candidate.profile.url, directory: candidate.profile.directory, sessions: candidate.sessions, subagentCount: candidate.subagentCount })
|
|
169
250
|
}
|
|
170
251
|
return { candidates, diagnostics: candidates.length ? [] : result.diagnostics,
|
|
252
|
+
diagnosticDetails: candidates.length ? [] : result.diagnosticDetails,
|
|
171
253
|
nextStep: candidates.length ? "Select the intended connection, save it with a short name, then choose a session by title."
|
|
172
254
|
: "Open the intended SSH tunnel and select an existing protected live-control token file. Do not paste credentials into chat." }
|
|
173
255
|
}
|
|
@@ -179,7 +261,11 @@ export class LiveSessionManager {
|
|
|
179
261
|
if (!candidate || this.closed) throw new ConnectionError("Discovery candidate not found. Discover connections again.")
|
|
180
262
|
const profile = connectionProfile({ ...candidate.profile, name })
|
|
181
263
|
const client = await this.client(profile)
|
|
182
|
-
try {
|
|
264
|
+
try {
|
|
265
|
+
const result = await client.listSessions()
|
|
266
|
+
this.checkProject(profile, result)
|
|
267
|
+
this.checkSessionList(result)
|
|
268
|
+
}
|
|
183
269
|
finally { await this.release(client) }
|
|
184
270
|
if (this.closed) throw new ConnectionError("Connection manager is closed.")
|
|
185
271
|
const profiles = await this.profiles()
|
|
@@ -14,9 +14,9 @@ async function commonDirectory(directory) {
|
|
|
14
14
|
// Follow only an already observed exact session into a real worktree of the
|
|
15
15
|
// same Git repository. A path prefix or a remote projectID alone is not proof.
|
|
16
16
|
export function approvedWorktreeSessions(value) {
|
|
17
|
-
if (!Array.isArray(value)
|
|
18
|
-
return value.filter(item => /^[A-Za-z0-9_-]
|
|
19
|
-
typeof item.directory === "string" && isAbsolute(item.directory)
|
|
17
|
+
if (!Array.isArray(value)) return []
|
|
18
|
+
return value.filter(item => /^[A-Za-z0-9_-]+$/.test(item?.sessionID) && item.sessionID !== "global" &&
|
|
19
|
+
typeof item.directory === "string" && isAbsolute(item.directory))
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function createSessionScope(directory, { resolveCommon = commonDirectory, approvedSessions = [] } = {}) {
|
|
@@ -28,7 +28,6 @@ export function createSessionScope(directory, { resolveCommon = commonDirectory,
|
|
|
28
28
|
if (location === directory) {
|
|
29
29
|
if (info.id) {
|
|
30
30
|
observed.set(info.id, info.projectID)
|
|
31
|
-
if (observed.size > 256) observed.delete(observed.keys().next().value)
|
|
32
31
|
}
|
|
33
32
|
return true
|
|
34
33
|
}
|
|
@@ -28,14 +28,20 @@ export async function ownLoopbackUrls({ platform = process.platform, fs = { read
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function privateEndpoint({ argv = process.argv, env = process.env, pid = process.pid,
|
|
31
|
+
platform = process.platform, initialEnvironment = () => readFile("/proc/self/environ", "utf8"),
|
|
31
32
|
urls = ownLoopbackUrls, request = fetch } = {}) {
|
|
32
33
|
if (!argv.includes("--stdio")) return undefined
|
|
33
34
|
const flag = argv.indexOf("--port")
|
|
34
35
|
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
|
|
35
36
|
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
|
|
36
|
-
const password = env.OPENCODE_PASSWORD
|
|
37
37
|
try {
|
|
38
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535
|
|
38
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error()
|
|
39
|
+
// OpenCode removes this variable at runtime. Linux retains this process's
|
|
40
|
+
// initial environment; read only our own existing password, never another PID.
|
|
41
|
+
const password = env.OPENCODE_PASSWORD || (platform === "linux"
|
|
42
|
+
? (await initialEnvironment()).split("\0").find(value => value.startsWith("OPENCODE_PASSWORD="))?.slice(18)
|
|
43
|
+
: undefined)
|
|
44
|
+
if (!password) throw new Error()
|
|
39
45
|
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
|
|
40
46
|
for (const url of candidates) {
|
|
41
47
|
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
validateQuestionCommand,
|
|
11
11
|
} from "./question-command.js"
|
|
12
12
|
import { runCommandStream } from "./sse.js"
|
|
13
|
-
import {
|
|
13
|
+
import { remember } from "./utilities.js"
|
|
14
14
|
|
|
15
15
|
async function replyToOpenCode(client, pending, decision) {
|
|
16
16
|
const result = await client.postSessionIdPermissionsPermissionId({
|
|
@@ -110,7 +110,7 @@ export async function replyToQuestion(client, pending, response) {
|
|
|
110
110
|
|
|
111
111
|
async function postQuestionState(config, eventID, path, body = {}) {
|
|
112
112
|
const url = new URL(`/v1/events/${encodeURIComponent(eventID)}/${path}`, config.origin)
|
|
113
|
-
await postJsonWithRetry(config, url, body
|
|
113
|
+
await postJsonWithRetry(config, url, body)
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function createPermissionController({
|
|
@@ -173,7 +173,7 @@ export function createPermissionController({
|
|
|
173
173
|
async function handleCommand(command) {
|
|
174
174
|
if (!command || typeof command !== "object") return
|
|
175
175
|
if (!["opencode_permission_reply", "opencode_question_reply"].includes(command.type)) return
|
|
176
|
-
if (typeof command.id !== "string"
|
|
176
|
+
if (typeof command.id !== "string") return
|
|
177
177
|
|
|
178
178
|
const previousAcknowledgement = completedCommands.get(command.id)
|
|
179
179
|
if (previousAcknowledgement) {
|
|
@@ -238,7 +238,7 @@ export function createPermissionController({
|
|
|
238
238
|
externalId: command.externalId,
|
|
239
239
|
processedAt: new Date().toISOString(),
|
|
240
240
|
}
|
|
241
|
-
|
|
241
|
+
remember(completedCommands, command.id, acknowledgement)
|
|
242
242
|
|
|
243
243
|
if (isQuestion) {
|
|
244
244
|
reportQuestionAcknowledgement(acknowledgement, pending, reporter, conversation)
|
package/src/plugin.js
CHANGED
|
@@ -45,10 +45,13 @@ export default async function BackeerPlugin({ client, directory, feedback, v2 =
|
|
|
45
45
|
return {}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const shutdown = new AbortController()
|
|
49
|
+
config.signal = shutdown.signal
|
|
48
50
|
const reporter = createOpenCodeReporter(client, config.logLevel)
|
|
49
51
|
const conversation = feedback ?? createConversationFeedback({ client, reporter })
|
|
50
52
|
if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error)
|
|
51
|
-
const bridgeConfig = config.liveBridge.owner === "tui"
|
|
53
|
+
const bridgeConfig = config.liveBridge.owner === "tui" ||
|
|
54
|
+
(config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")))
|
|
52
55
|
? { ...config, liveBridge: { enabled: false } } : config
|
|
53
56
|
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
54
57
|
reporter.error("Live control unavailable", error)
|
|
@@ -232,6 +235,7 @@ export default async function BackeerPlugin({ client, directory, feedback, v2 =
|
|
|
232
235
|
await Promise.all([handleNotificationEvent(event), liveBridge.handle(event)])
|
|
233
236
|
},
|
|
234
237
|
dispose: async () => {
|
|
238
|
+
shutdown.abort()
|
|
235
239
|
await liveBridge.dispose()
|
|
236
240
|
await permissionController.dispose()
|
|
237
241
|
lifecycleController.dispose()
|
package/src/reporter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatText } from "./utilities.js"
|
|
2
2
|
|
|
3
3
|
const LOG_LEVELS = new Set(["silent", "error", "info", "debug"])
|
|
4
4
|
const LOG_RANK = { silent: 0, error: 1, info: 2, debug: 3 }
|
|
@@ -36,13 +36,10 @@ export function createConsoleReporter(levelValue) {
|
|
|
36
36
|
if (previous && now - previous < LOG_DEDUPLICATION_MS) return
|
|
37
37
|
recentlyEmitted.delete(key)
|
|
38
38
|
recentlyEmitted.set(key, now)
|
|
39
|
-
while (recentlyEmitted.size > 100) {
|
|
40
|
-
recentlyEmitted.delete(recentlyEmitted.keys().next().value)
|
|
41
|
-
}
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
const technical = level === "debug" && detail
|
|
45
|
-
? decorate("2", ` · ${
|
|
42
|
+
? decorate("2", ` · ${formatText(detail)}`)
|
|
46
43
|
: ""
|
|
47
44
|
const line = `${symbols[kind]} ${brand} ${message}${technical}`
|
|
48
45
|
if (kind === "error" || kind === "warning") console.error(line)
|
|
@@ -80,10 +77,10 @@ export function createOpenCodeReporter(client, levelValue) {
|
|
|
80
77
|
level: kind === "warning" ? "warn" : kind === "applied" || kind === "success"
|
|
81
78
|
? "info"
|
|
82
79
|
: kind,
|
|
83
|
-
message:
|
|
80
|
+
message: formatText(message),
|
|
84
81
|
}
|
|
85
82
|
if (level === "debug" && detail) {
|
|
86
|
-
body.extra = { detail:
|
|
83
|
+
body.extra = { detail: formatText(detail?.message ?? detail) }
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
// Logging during plugin initialization can deadlock older OpenCode builds.
|
|
@@ -106,5 +103,4 @@ export function createOpenCodeReporter(client, levelValue) {
|
|
|
106
103
|
function rememberRecent(map, key, value) {
|
|
107
104
|
map.delete(key)
|
|
108
105
|
map.set(key, value)
|
|
109
|
-
while (map.size > 100) map.delete(map.keys().next().value)
|
|
110
106
|
}
|
package/src/session-messages.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FeedbackSessionStore } from "./feedback-session-store.js"
|
|
2
|
-
import {
|
|
2
|
+
import { formatText } from "./utilities.js"
|
|
3
3
|
|
|
4
4
|
const FEEDBACK_SYMBOLS = {
|
|
5
5
|
info: "•",
|
|
@@ -82,7 +82,7 @@ export async function sendSessionMessage(client, sessionID, text) {
|
|
|
82
82
|
|
|
83
83
|
export function formatBackeerFeedback(kind, message) {
|
|
84
84
|
const symbol = FEEDBACK_SYMBOLS[kind] ?? FEEDBACK_SYMBOLS.info
|
|
85
|
-
return `▣ Backeer | ${symbol} ${
|
|
85
|
+
return `▣ Backeer | ${symbol} ${formatText(message)}`
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function formatBackeerFeedbackBatch(items) {
|
|
@@ -93,7 +93,7 @@ export function formatBackeerFeedbackBatch(items) {
|
|
|
93
93
|
"▣ Backeer",
|
|
94
94
|
...items.map((item) => {
|
|
95
95
|
const symbol = FEEDBACK_SYMBOLS[item.kind] ?? FEEDBACK_SYMBOLS.info
|
|
96
|
-
return `${symbol} ${
|
|
96
|
+
return `${symbol} ${formatText(item.message)}`
|
|
97
97
|
}),
|
|
98
98
|
].join("\n")
|
|
99
99
|
}
|
package/src/sse.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpError, isAbortError, sleep } from "./http.js"
|
|
2
|
-
import {
|
|
2
|
+
import { formatText } from "./utilities.js"
|
|
3
3
|
|
|
4
4
|
export function parseSseFrame(frame) {
|
|
5
5
|
const data = []
|
|
@@ -87,7 +87,7 @@ async function openCommandStream(config, state, signal, fetchImplementation) {
|
|
|
87
87
|
signal,
|
|
88
88
|
})
|
|
89
89
|
if (!response.ok) {
|
|
90
|
-
const responseBody =
|
|
90
|
+
const responseBody = formatText(await response.text())
|
|
91
91
|
throw new HttpError(
|
|
92
92
|
`Backeer command stream failed with status ${response.status}`,
|
|
93
93
|
response.status,
|
|
@@ -123,7 +123,7 @@ async function consumeConnection({
|
|
|
123
123
|
function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
|
|
124
124
|
const message = error instanceof HttpError
|
|
125
125
|
? `${error.message}: ${error.responseBody}`
|
|
126
|
-
:
|
|
126
|
+
: formatText(error)
|
|
127
127
|
if (message !== lastLoggedError) {
|
|
128
128
|
const authenticationFailure = error instanceof HttpError &&
|
|
129
129
|
[401, 403].includes(error.status)
|
|
@@ -137,7 +137,7 @@ function streamErrorState(error, lastLoggedError, reconnectDelay, reporter) {
|
|
|
137
137
|
return {
|
|
138
138
|
lastLoggedError: message,
|
|
139
139
|
reconnectDelay: error instanceof HttpError && [401, 403, 404].includes(error.status)
|
|
140
|
-
?
|
|
140
|
+
? 3000
|
|
141
141
|
: reconnectDelay,
|
|
142
142
|
}
|
|
143
143
|
}
|
|
@@ -156,7 +156,7 @@ export async function runCommandStream(
|
|
|
156
156
|
},
|
|
157
157
|
) {
|
|
158
158
|
const state = { lastEventID: "", framesReceived: 0 }
|
|
159
|
-
let reconnectDelay =
|
|
159
|
+
let reconnectDelay = 3000
|
|
160
160
|
let lastLoggedError = ""
|
|
161
161
|
|
|
162
162
|
while (!signal.aborted && hasPending()) {
|
|
@@ -176,7 +176,7 @@ export async function runCommandStream(
|
|
|
176
176
|
now,
|
|
177
177
|
stableConnectionMs,
|
|
178
178
|
onStable: () => {
|
|
179
|
-
reconnectDelay =
|
|
179
|
+
reconnectDelay = 3000
|
|
180
180
|
lastLoggedError = ""
|
|
181
181
|
},
|
|
182
182
|
})
|
|
@@ -189,7 +189,7 @@ export async function runCommandStream(
|
|
|
189
189
|
|
|
190
190
|
if (!signal.aborted && hasPending()) {
|
|
191
191
|
await sleepImplementation(reconnectDelay, signal)
|
|
192
|
-
reconnectDelay =
|
|
192
|
+
reconnectDelay = 3000
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
}
|
package/src/updates.js
CHANGED
package/src/utilities.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export const limit = (value, length = 1000) =>
|
|
4
|
-
String(value ?? "").slice(0, length)
|
|
1
|
+
// Legacy formatting API: normalize text without truncating it.
|
|
2
|
+
export const formatText = value => String(value ?? "")
|
|
5
3
|
|
|
6
4
|
export function enabled(value, fallback = true) {
|
|
7
5
|
if (typeof value === "boolean") return value
|
|
@@ -18,8 +16,7 @@ export function stableHash(value) {
|
|
|
18
16
|
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
export function
|
|
19
|
+
export function remember(map, key, value) {
|
|
22
20
|
map.delete(key)
|
|
23
21
|
map.set(key, value)
|
|
24
|
-
while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
|
|
25
22
|
}
|