backeer-opencode 0.8.2 → 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 +19 -0
- package/dist/backeer-permission.js +227 -284
- package/docs/live-control.md +25 -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 +3 -4
- package/src/live-bridge-events.js +6 -9
- package/src/live-bridge.js +53 -76
- 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/permission-controller.js +4 -4
- package/src/plugin.js +3 -0
- 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
package/docs/live-control.md
CHANGED
|
@@ -38,12 +38,12 @@ Alternatively, `backeer-codex` 0.3.0 configures this file and bundles the local
|
|
|
38
38
|
adapter as a plugin MCP server. No new server is deployed. Profiles hold file
|
|
39
39
|
references, never credentials; keep the token in its existing protected file.
|
|
40
40
|
|
|
41
|
-
Managed tools
|
|
41
|
+
Managed tools include `connection_setup`, `diagnose_connection`, `list_connections`, `list_sessions`, `connect_session`,
|
|
42
42
|
`session_status`, `send_instruction`, `read_events`, `wait_for_events`, and
|
|
43
43
|
`disconnect_session`. Connect with an explicit connection name and existing
|
|
44
44
|
session ID. Store the returned `bindingId` only in that chat and pass it to every
|
|
45
45
|
subsequent bound tool. There is no global selected session. Bindings are immutable
|
|
46
|
-
and each owns an independent stream/cursor; even two chats observing the same
|
|
46
|
+
and each owns an independent session-filtered stream/cursor; even two chats observing the same
|
|
47
47
|
session do not consume one another's output. They are local capabilities, not a
|
|
48
48
|
multi-user authorization boundary.
|
|
49
49
|
|
|
@@ -55,14 +55,25 @@ Old single-connection clients remain compatible. No session creation, arbitrary
|
|
|
55
55
|
shell execution or permission bypass is added. Changed credentials require
|
|
56
56
|
disconnect/reconnect; no inline token or remote error body is exposed.
|
|
57
57
|
|
|
58
|
-
Missing profiles allow startup with an empty connection list.
|
|
59
|
-
can be saved, with at most 16 active streams/lookups in one manager. Disconnect
|
|
58
|
+
Missing profiles allow startup with an empty connection list. There is no imposed profile or active/pending binding quota. Disconnect
|
|
60
59
|
unused bindings; remote work continues. Bindings last until this MCP process
|
|
61
60
|
ends; reconnect to the same existing session after a restart. Missed history is
|
|
62
61
|
not fabricated. Existing gap acknowledgement, request-ID deduplication, heartbeat,
|
|
63
62
|
three-second transport reconnection and mobile disconnect/reconnect notices are
|
|
64
63
|
unchanged. The manager does not create or supervise the external SSH tunnel.
|
|
65
64
|
|
|
65
|
+
`connection_setup` is a read-only staged guide: choose a saved connection, or
|
|
66
|
+
discover a verified candidate to save, then choose a live main session. It never
|
|
67
|
+
selects a target automatically or stores a session ID. `diagnose_connection`
|
|
68
|
+
accepts exactly one connection name or binding ID and returns fixed safe messages
|
|
69
|
+
for a missing local listener, timeout, credential rejection, project mismatch or
|
|
70
|
+
unavailable session. Bound results include stream retry/timing/buffer state.
|
|
71
|
+
Temporary transport failures allow reads of already buffered output with
|
|
72
|
+
`verified: false`; the immutable binding and cursor survive. New instructions,
|
|
73
|
+
answers and gap acknowledgement still require exact target verification after
|
|
74
|
+
reconnection. Authentication, project and missing-session errors cannot use the
|
|
75
|
+
buffered-read fallback. No POST is automatically replayed.
|
|
76
|
+
|
|
66
77
|
Windows token files are checked with built-in PowerShell ACL inspection: owner
|
|
67
78
|
must be the current user, and allow entries may include only that user, SYSTEM
|
|
68
79
|
and Administrators. Broader grants fail closed; paths are never interpolated
|
|
@@ -115,24 +126,24 @@ This channel reports OpenCode sessions, assistant text, selected tool output and
|
|
|
115
126
|
| --- | --- |
|
|
116
127
|
| `session_status` | List observed project sessions or inspect an exact existing `sessionId`. |
|
|
117
128
|
| `read_events` | Read only unread buffered events, optionally after an explicit cursor. |
|
|
118
|
-
| `wait_for_events` | Wait on the persistent SSE connection
|
|
129
|
+
| `wait_for_events` | Wait on the persistent SSE connection until output or cancellation; optional waitMs chooses a deadline; no periodic status polling. |
|
|
119
130
|
| `send_instruction` | POST a prompt to an exact existing session using a caller-generated `requestId`. |
|
|
120
131
|
|
|
121
132
|
First list sessions and select the intended one. OpenCode V2's public plugin context does not expose a full session list: this version lists sessions observed through live events or a successful exact-ID lookup. To attach an existing idle V2 session that has not emitted an event since plugin startup, supply its known ID to `session_status`; no new session is created. Deleted or moved sessions are revalidated rather than trusted from cache.
|
|
122
133
|
|
|
123
134
|
Follow events by passing the returned cursor to `wait_for_events`; omit the cursor to consume this adapter process's unread events. Before the first instruction, the adapter captures a pre-command cursor so immediate output is not skipped. Output is untrusted data, not new instructions. Do not send a new task merely because an agent is quiet. An accepted instruction means delivery was accepted, **not** that the work succeeded. Confirm subsequent output and test results.
|
|
124
135
|
|
|
125
|
-
Status and pending-approval events arrive as they occur. V2 text is emitted when each text unit ends and tool output when each tool finishes, without waiting for the whole agent run. Raw reasoning and split token deltas are not exported. Long-running commands do not become a byte-for-byte streaming terminal.
|
|
136
|
+
Status and pending-approval events arrive as they occur. V2 text is emitted when each text unit ends and tool output when each tool finishes, without waiting for the whole agent run. Raw reasoning and split token deltas are not exported. Long-running commands do not become a byte-for-byte streaming terminal. The bridge does not truncate exported completed text/tool output; redaction remains best effort, not a guarantee against every secret format. Native OpenCode may still return partial output.
|
|
126
137
|
|
|
127
|
-
Use a unique request ID for a new instruction. If delivery is uncertain, retry the **same ID and exact prompt**. Changing the prompt with an existing ID is rejected. Deduplication lasts
|
|
138
|
+
Use a unique request ID for a new instruction. If delivery is uncertain, retry the **same ID and exact prompt**. Changing the prompt with an existing ID is rejected. Deduplication lasts for the bridge process lifetime; restarting the bridge clears that memory. No command-count quota or automatic age eviction is imposed. Instructions are not automatically retried. Busy sessions can reject an instruction; read status and wait instead of duplicating work.
|
|
128
139
|
|
|
129
|
-
|
|
140
|
+
Command signatures are hashed; full prompts are not retained in the deduplication history.
|
|
130
141
|
|
|
131
|
-
##
|
|
142
|
+
## Usage, reconnects and gaps
|
|
132
143
|
|
|
133
|
-
|
|
144
|
+
No fixed count, byte or age quotas are imposed on bridge/client history, profiles, candidates, chat bindings, SSE readers, instructions, question batches/options/custom answers or output. Default reads return available output without silently clipping it. Callers may optionally select `limit`, `maxChars` or `waitMs` for a particular call; these have no application-defined maximum. Waiting without `waitMs` continues until output or cancellation. Retained history consumes memory until disposal; physical resources, native OpenCode behavior and host/protocol constraints still apply.
|
|
134
145
|
|
|
135
|
-
There is **no maximum connection lifetime or agent-output idle timeout**. The bridge emits a tiny heartbeat every 15 seconds even when a task produces no output for hours. A 45-second watchdog measures missing **transport traffic**, not missing agent replies. If heartbeats disappear, the client discards the dead socket and reconnects.
|
|
146
|
+
There is **no maximum connection lifetime or agent-output idle timeout**. The bridge emits a tiny heartbeat every 15 seconds even when a task produces no output for hours. A 45-second watchdog measures missing **transport traffic**, not missing agent replies. If heartbeats disappear, the client discards the dead socket and reconnects. A caller-selected finite wait or host cancellation ends that tool call and leaves the underlying SSE connection running.
|
|
136
147
|
|
|
137
148
|
After each failed attempt or detected disconnect, the client retries every **three seconds**, without a retry-count limit or exponential backoff, until it connects or the MCP process is explicitly closed. The OpenCode V2 event subscription also retries after three seconds. Initial unavailability and authentication errors do not permanently stop reconnection; an invalid credential still cannot access anything and must be corrected through protected configuration. Individual HTTP/handshake timeouts are not task deadlines. Internet loss, host shutdown, application exit or a terminated tunnel can physically interrupt a connection; retrying SSE cannot recreate an SSH process, which is why the tunnel itself needs the supervisor described above.
|
|
138
149
|
|
|
@@ -154,7 +165,7 @@ Update the remote `backeer-opencode` bridge and local adapter (or `backeer-codex
|
|
|
154
165
|
|
|
155
166
|
`list_questions` takes the chat's `bindingId` (or explicit `sessionId` in legacy mode) and returns `questionId`, `revision`, and ordered questions with zero-based option indices, text, `multiple` and `custom` flags. `answer_question` takes the same target, exact `questionId`/`revision`, a unique `requestId`, and one answer per question: `{ "choices": [0] }` or `{ "choices": [], "text": "My own answer" }`. Combine choices only when `multiple` is true; text requires `custom` true. Empty choices with no text skip that question.
|
|
156
167
|
|
|
157
|
-
Reuse the exact requestId and payload after uncertain delivery. Changed or already answered questions are rejected; reread rather than switching targets. Idempotency is retained for
|
|
168
|
+
Reuse the exact requestId and payload after uncertain delivery. Changed or already answered questions are rejected; reread rather than switching targets. Idempotency is retained for the bridge process lifetime, not across remote restarts: after a bridge restart, inspect pending questions before deciding whether to resubmit. A successful native reply settles the ordinary question just as the terminal UI would. No permission decision, authentication form, arbitrary shell command or automatic approval is provided. Remote question text is untrusted task data, not authorization.
|
|
158
169
|
|
|
159
170
|
A local-only update is insufficient: load the updated remote bridge in the intended OpenCode process as well. HTTP 404/501 does not by itself prove that the bridge is old or that no questions are pending. The session-list response includes `bridgeVersion` and API capability flags; native shared-service availability is verified when reading questions. A compatible registered shared service can be reused despite an application patch-version difference, but its exact session and project/worktree ownership must still be verified. No second service is started and authentication is unchanged.
|
|
160
171
|
|
|
@@ -187,3 +198,5 @@ When the bridge is configured globally, set `liveBridge.projectDirectory` to the
|
|
|
187
198
|
OpenCode may remove `OPENCODE_PASSWORD` from runtime `process.env`. A Linux private server can reuse only its own original password from `/proc/self/environ`; it is neither logged nor copied to configuration. Native API PID, exact-session and project checks still apply. No other PID or port range is inspected.
|
|
188
199
|
|
|
189
200
|
If both a private stdio server and a shared server load that same project, `liveBridge.owner: "standalone"` restricts the listener to the private server. It detects the current process's existing `--stdio` flag rather than a stored PID or session ID. The shared server's other Backeer duties remain active. The default `owner: "server"` and optional `owner: "tui"` behavior remain unchanged.
|
|
201
|
+
|
|
202
|
+
Native session lookups share only the same in-flight exact-ID verification. Closing an HTTP reader removes its waiting subscription; it does not cancel another reader or restart the agent. Different session IDs remain independent and have no imposed concurrency quota.
|
package/package.json
CHANGED
package/src/delivery-tracker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { remember } from "./utilities.js"
|
|
2
2
|
|
|
3
3
|
export function createDeliveryTracker() {
|
|
4
4
|
const states = new Map()
|
|
@@ -6,13 +6,13 @@ export function createDeliveryTracker() {
|
|
|
6
6
|
return {
|
|
7
7
|
begin(key) {
|
|
8
8
|
if (states.has(key)) return false
|
|
9
|
-
|
|
9
|
+
remember(states, key, "publishing")
|
|
10
10
|
return true
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
succeed(key) {
|
|
14
14
|
if (states.get(key) === "publishing") {
|
|
15
|
-
|
|
15
|
+
remember(states, key, "published")
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
|
|
@@ -33,7 +33,7 @@ export function createCompletionTracker() {
|
|
|
33
33
|
let state = states.get(key)
|
|
34
34
|
if (!state) {
|
|
35
35
|
state = { complete: false, publishing: false, revision: 0 }
|
|
36
|
-
|
|
36
|
+
remember(states, key, state)
|
|
37
37
|
}
|
|
38
38
|
return state
|
|
39
39
|
}
|
package/src/diagnostics.js
CHANGED
|
@@ -3,7 +3,7 @@ import { statSync } from "node:fs"
|
|
|
3
3
|
|
|
4
4
|
import { HttpError, postJsonWithRetry } from "./http.js"
|
|
5
5
|
import { PLUGIN_VERSION } from "./updates.js"
|
|
6
|
-
import {
|
|
6
|
+
import { formatText } from "./utilities.js"
|
|
7
7
|
|
|
8
8
|
const PASS = "✓"
|
|
9
9
|
const FAIL = "×"
|
|
@@ -41,7 +41,7 @@ async function probeSource(config, fetchImplementation = globalThis.fetch) {
|
|
|
41
41
|
headers: { Authorization: `Bearer ${config.token}` },
|
|
42
42
|
signal: controller.signal,
|
|
43
43
|
})
|
|
44
|
-
const responseBody =
|
|
44
|
+
const responseBody = formatText(await response.text())
|
|
45
45
|
if (!response.ok) {
|
|
46
46
|
throw new HttpError(
|
|
47
47
|
`Gateway source check failed with status ${response.status}`,
|
|
@@ -107,7 +107,7 @@ export async function buildDoctorReport(
|
|
|
107
107
|
? "Source authentication rejected (HTTP 401)"
|
|
108
108
|
: error?.name === "AbortError"
|
|
109
109
|
? "Gateway source check timed out"
|
|
110
|
-
: `Gateway source check failed: ${
|
|
110
|
+
: `Gateway source check failed: ${formatText(error?.message || error)}`
|
|
111
111
|
checks.push({ ok: false, text: detail })
|
|
112
112
|
}
|
|
113
113
|
}
|
package/src/event-publishers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { postJsonWithRetry } from "./http.js"
|
|
2
|
-
import {
|
|
2
|
+
import { formatText, stableHash } from "./utilities.js"
|
|
3
3
|
|
|
4
4
|
const ACTION_PROTOCOL = "opencode-permission/v1"
|
|
5
5
|
const QUESTION_ACTION_PROTOCOL = "opencode-question/v1"
|
|
@@ -32,11 +32,11 @@ export async function publishPermission(
|
|
|
32
32
|
: ""
|
|
33
33
|
|
|
34
34
|
const payload = [
|
|
35
|
-
{ key: "Permission", value:
|
|
36
|
-
{ key: "Session", value:
|
|
37
|
-
{ key: "Request ID", value:
|
|
38
|
-
{ key: "Patterns", value:
|
|
39
|
-
{ key: "Working directory", value:
|
|
35
|
+
{ key: "Permission", value: formatText(permissionType) },
|
|
36
|
+
{ key: "Session", value: formatText(identity.sessionID) },
|
|
37
|
+
{ key: "Request ID", value: formatText(identity.requestID) },
|
|
38
|
+
{ key: "Patterns", value: formatText(resources) },
|
|
39
|
+
{ key: "Working directory", value: formatText(directory) },
|
|
40
40
|
{ key: "Action protocol", value: ACTION_PROTOCOL },
|
|
41
41
|
{ key: "Available decisions", value: "once, always, reject" },
|
|
42
42
|
].filter((item) => item.value.length > 0)
|
|
@@ -44,10 +44,10 @@ export async function publishPermission(
|
|
|
44
44
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
45
45
|
type: "application",
|
|
46
46
|
severity: "warning",
|
|
47
|
-
title: `OpenCode permission required: ${
|
|
47
|
+
title: `OpenCode permission required: ${formatText(permissionType)}`,
|
|
48
48
|
body: resources
|
|
49
|
-
? `OpenCode is waiting for approval to access: ${
|
|
50
|
-
: `OpenCode is waiting for approval for: ${
|
|
49
|
+
? `OpenCode is waiting for approval to access: ${formatText(resources)}`
|
|
50
|
+
: `OpenCode is waiting for approval for: ${formatText(permissionType)}`,
|
|
51
51
|
payload,
|
|
52
52
|
externalId: identity.externalId,
|
|
53
53
|
timestamp: new Date().toISOString(),
|
|
@@ -75,8 +75,8 @@ function formatQuestionOptions(options) {
|
|
|
75
75
|
|
|
76
76
|
return options
|
|
77
77
|
.map((option) => {
|
|
78
|
-
const label =
|
|
79
|
-
const description =
|
|
78
|
+
const label = formatText(option?.label)
|
|
79
|
+
const description = formatText(option?.description)
|
|
80
80
|
if (!label) return ""
|
|
81
81
|
return description ? `${label} — ${description}` : label
|
|
82
82
|
})
|
|
@@ -93,7 +93,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
93
93
|
query: { directory },
|
|
94
94
|
throwOnError: true,
|
|
95
95
|
})
|
|
96
|
-
return
|
|
96
|
+
return formatText((result?.data ?? result)?.title)
|
|
97
97
|
} catch (error) {
|
|
98
98
|
reporter.debug("Session title unavailable", error)
|
|
99
99
|
return ""
|
|
@@ -121,17 +121,17 @@ export async function publishQuestion(
|
|
|
121
121
|
)
|
|
122
122
|
const payload = [
|
|
123
123
|
{ key: "OpenCode session", value: sessionTitle },
|
|
124
|
-
{ key: "Session", value:
|
|
125
|
-
{ key: "Request ID", value:
|
|
124
|
+
{ key: "Session", value: formatText(identity.sessionID) },
|
|
125
|
+
{ key: "Request ID", value: formatText(identity.requestID) },
|
|
126
126
|
{ key: "Action protocol", value: QUESTION_ACTION_PROTOCOL },
|
|
127
127
|
...questions.flatMap((question, index) => {
|
|
128
128
|
const suffix = questions.length > 1 ? ` ${index + 1}` : ""
|
|
129
129
|
return [
|
|
130
|
-
{ key: `Question title${suffix}`, value:
|
|
131
|
-
{ key: `Question${suffix}`, value:
|
|
130
|
+
{ key: `Question title${suffix}`, value: formatText(question?.header) },
|
|
131
|
+
{ key: `Question${suffix}`, value: formatText(question?.question) },
|
|
132
132
|
{
|
|
133
133
|
key: `Options${suffix}`,
|
|
134
|
-
value:
|
|
134
|
+
value: formatText(formatQuestionOptions(question?.options)),
|
|
135
135
|
},
|
|
136
136
|
]
|
|
137
137
|
}),
|
|
@@ -139,12 +139,12 @@ export async function publishQuestion(
|
|
|
139
139
|
|
|
140
140
|
const firstQuestion = questions[0]
|
|
141
141
|
const actionQuestions = questions.map((question) => ({
|
|
142
|
-
header:
|
|
143
|
-
question:
|
|
142
|
+
header: formatText(question?.header),
|
|
143
|
+
question: formatText(question?.question),
|
|
144
144
|
options: Array.isArray(question?.options)
|
|
145
|
-
? question.options.
|
|
146
|
-
label:
|
|
147
|
-
description:
|
|
145
|
+
? question.options.map((option) => ({
|
|
146
|
+
label: formatText(option?.label),
|
|
147
|
+
description: formatText(option?.description),
|
|
148
148
|
})).filter((option) => option.label)
|
|
149
149
|
: [],
|
|
150
150
|
multiple: question?.multiple === true,
|
|
@@ -152,9 +152,9 @@ export async function publishQuestion(
|
|
|
152
152
|
}))
|
|
153
153
|
const body = questions
|
|
154
154
|
.map((question) => {
|
|
155
|
-
const header =
|
|
156
|
-
const text =
|
|
157
|
-
const options =
|
|
155
|
+
const header = formatText(question?.header)
|
|
156
|
+
const text = formatText(question?.question)
|
|
157
|
+
const options = formatText(formatQuestionOptions(question?.options))
|
|
158
158
|
return [header, text, options ? `Options: ${options}` : ""]
|
|
159
159
|
.filter(Boolean)
|
|
160
160
|
.join("\n")
|
|
@@ -164,8 +164,8 @@ export async function publishQuestion(
|
|
|
164
164
|
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
165
165
|
type: "application",
|
|
166
166
|
severity: "info",
|
|
167
|
-
title: `OpenCode question: ${
|
|
168
|
-
body:
|
|
167
|
+
title: `OpenCode question: ${formatText(firstQuestion?.header) || "Question"}`,
|
|
168
|
+
body: formatText(body),
|
|
169
169
|
payload,
|
|
170
170
|
externalId: identity.externalId,
|
|
171
171
|
timestamp: new Date().toISOString(),
|
|
@@ -205,16 +205,16 @@ export async function publishTodoCompletion(
|
|
|
205
205
|
)
|
|
206
206
|
const correlation =
|
|
207
207
|
typeof eventID === "string" && eventID.length > 0
|
|
208
|
-
?
|
|
208
|
+
? formatText(eventID)
|
|
209
209
|
: stableHash(JSON.stringify(todos))
|
|
210
210
|
const externalId = `opencode-todo:${request.sessionID}:${correlation}`
|
|
211
|
-
const visibleTodos = todos
|
|
211
|
+
const visibleTodos = todos
|
|
212
212
|
const payload = [
|
|
213
213
|
{ key: "OpenCode session", value: sessionTitle },
|
|
214
214
|
{ key: "Completed tasks", value: String(todos.length) },
|
|
215
215
|
...visibleTodos.flatMap((todo, index) => [
|
|
216
|
-
{ key: `Task ${index + 1}`, value:
|
|
217
|
-
{ key: `Priority ${index + 1}`, value:
|
|
216
|
+
{ key: `Task ${index + 1}`, value: formatText(todo?.content) },
|
|
217
|
+
{ key: `Priority ${index + 1}`, value: formatText(todo?.priority) },
|
|
218
218
|
]),
|
|
219
219
|
...(todos.length > visibleTodos.length
|
|
220
220
|
? [{ key: "Additional tasks", value: String(todos.length - visibleTodos.length) }]
|
|
@@ -222,8 +222,7 @@ export async function publishTodoCompletion(
|
|
|
222
222
|
].filter((item) => item.value.length > 0)
|
|
223
223
|
|
|
224
224
|
const summary = todos
|
|
225
|
-
.
|
|
226
|
-
.map((todo) => limit(todo?.content, 200))
|
|
225
|
+
.map((todo) => formatText(todo?.content))
|
|
227
226
|
.filter(Boolean)
|
|
228
227
|
.join(" • ")
|
|
229
228
|
const location = sessionTitle ? ` in ${sessionTitle}` : ""
|
|
@@ -236,7 +235,7 @@ export async function publishTodoCompletion(
|
|
|
236
235
|
type: "application",
|
|
237
236
|
severity: "info",
|
|
238
237
|
title: "OpenCode todo list completed",
|
|
239
|
-
body:
|
|
238
|
+
body: formatText(body),
|
|
240
239
|
payload,
|
|
241
240
|
externalId,
|
|
242
241
|
timestamp: new Date().toISOString(),
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const MAX_QUEUED_MESSAGES = 20
|
|
1
|
+
import { formatText, remember } from "./utilities.js"
|
|
4
2
|
|
|
5
3
|
export class FeedbackSessionStore {
|
|
6
4
|
constructor({ send, reporter, flushDelayMs = 150 }) {
|
|
@@ -20,10 +18,9 @@ export class FeedbackSessionStore {
|
|
|
20
18
|
blockers: new Set(),
|
|
21
19
|
queue: [],
|
|
22
20
|
queued: new Set(),
|
|
23
|
-
queueOverflowReported: false,
|
|
24
21
|
flushTimer: undefined,
|
|
25
22
|
}
|
|
26
|
-
|
|
23
|
+
remember(this.sessions, sessionID, state)
|
|
27
24
|
}
|
|
28
25
|
return state
|
|
29
26
|
}
|
|
@@ -64,16 +61,8 @@ export class FeedbackSessionStore {
|
|
|
64
61
|
const key = `${sessionID}:${kind}:${message}`
|
|
65
62
|
if (state.queued.has(key)) return
|
|
66
63
|
|
|
67
|
-
state.queue.push({ kind, message:
|
|
64
|
+
state.queue.push({ kind, message: formatText(message), key })
|
|
68
65
|
state.queued.add(key)
|
|
69
|
-
if (state.queue.length > MAX_QUEUED_MESSAGES) {
|
|
70
|
-
const removed = state.queue.shift()
|
|
71
|
-
state.queued.delete(removed.key)
|
|
72
|
-
if (!state.queueOverflowReported) {
|
|
73
|
-
state.queueOverflowReported = true
|
|
74
|
-
this.reporter.warning("Conversation feedback queue full — oldest message discarded")
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
66
|
this.scheduleFlush(sessionID, state)
|
|
78
67
|
}
|
|
79
68
|
|
package/src/http.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const RETRY_DELAYS_MS = [0, 500, 1500]
|
|
1
|
+
import { createBridgeSanitizer } from "./live-bridge-events.js"
|
|
4
2
|
|
|
5
3
|
export class HttpError extends Error {
|
|
6
4
|
constructor(message, status, responseBody = "") {
|
|
@@ -39,18 +37,18 @@ export function sleep(milliseconds, signal) {
|
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
export async function postJsonWithRetry(config, url, body, options = {}) {
|
|
42
|
-
const retryDelays = options.retryDelays
|
|
40
|
+
const retryDelays = options.retryDelays
|
|
41
|
+
const signal = options.signal ?? config.signal
|
|
43
42
|
|
|
44
|
-
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
43
|
+
for (let attempt = 0; retryDelays === undefined || attempt < retryDelays.length; attempt++) {
|
|
44
|
+
const retryDelay = retryDelays?.[attempt] ?? (attempt === 0 ? 0 : 3000)
|
|
45
|
+
if (retryDelay > 0) await (options.sleepImplementation ?? sleep)(retryDelay, signal)
|
|
46
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
49
47
|
|
|
50
48
|
const controller = new AbortController()
|
|
51
49
|
const abort = () => controller.abort()
|
|
52
50
|
const timeout = setTimeout(abort, options.timeoutMs ?? 5000)
|
|
53
|
-
|
|
51
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
54
52
|
|
|
55
53
|
try {
|
|
56
54
|
const response = await fetch(url, {
|
|
@@ -63,23 +61,23 @@ export async function postJsonWithRetry(config, url, body, options = {}) {
|
|
|
63
61
|
body: JSON.stringify(body),
|
|
64
62
|
signal: controller.signal,
|
|
65
63
|
})
|
|
66
|
-
const responseBody =
|
|
64
|
+
const responseBody = await response.text()
|
|
67
65
|
|
|
68
66
|
if (response.ok) return { status: response.status, responseBody }
|
|
69
|
-
if (!isRetryable(response.status) || attempt === retryDelays.length - 1) {
|
|
67
|
+
if (!isRetryable(response.status) || retryDelays !== undefined && attempt === retryDelays.length - 1) {
|
|
70
68
|
throw new HttpError(
|
|
71
69
|
`HTTP request failed with status ${response.status}`,
|
|
72
70
|
response.status,
|
|
73
|
-
responseBody,
|
|
71
|
+
createBridgeSanitizer([config.token]).text(responseBody),
|
|
74
72
|
)
|
|
75
73
|
}
|
|
76
74
|
} catch (error) {
|
|
77
|
-
if (
|
|
75
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
78
76
|
if (error instanceof HttpError) throw error
|
|
79
|
-
if (attempt === retryDelays.length - 1) throw error
|
|
77
|
+
if (retryDelays !== undefined && attempt === retryDelays.length - 1) throw error
|
|
80
78
|
} finally {
|
|
81
79
|
clearTimeout(timeout)
|
|
82
|
-
|
|
80
|
+
signal?.removeEventListener("abort", abort)
|
|
83
81
|
}
|
|
84
82
|
}
|
|
85
83
|
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
publishSessionError,
|
|
6
6
|
publishSessionRetry,
|
|
7
7
|
} from "./lifecycle-publishers.js"
|
|
8
|
-
import {
|
|
8
|
+
import { formatText, remember, stableHash } from "./utilities.js"
|
|
9
9
|
|
|
10
10
|
const QUESTION_EVENTS = new Set(["question.asked", "question.v2.asked"])
|
|
11
11
|
const QUESTION_REPLY_EVENTS = new Set([
|
|
@@ -43,7 +43,7 @@ export function createLifecycleController({
|
|
|
43
43
|
lastAssistant: undefined,
|
|
44
44
|
completedMessageID: "",
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
remember(sessions, sessionID, state)
|
|
47
47
|
}
|
|
48
48
|
return state
|
|
49
49
|
}
|
|
@@ -85,7 +85,7 @@ export function createLifecycleController({
|
|
|
85
85
|
client,
|
|
86
86
|
directory,
|
|
87
87
|
sessionID,
|
|
88
|
-
messageID:
|
|
88
|
+
messageID: formatText(assistant.id),
|
|
89
89
|
durationMs,
|
|
90
90
|
conversation,
|
|
91
91
|
}),
|
|
@@ -138,7 +138,7 @@ export function createLifecycleController({
|
|
|
138
138
|
client,
|
|
139
139
|
directory,
|
|
140
140
|
sessionID,
|
|
141
|
-
correlation:
|
|
141
|
+
correlation: formatText(correlation),
|
|
142
142
|
attempt: status.attempt,
|
|
143
143
|
next: status.next,
|
|
144
144
|
conversation,
|
|
@@ -199,7 +199,7 @@ export function createLifecycleController({
|
|
|
199
199
|
const version = event.properties?.version
|
|
200
200
|
if (typeof version !== "string" || !version.trim()) return
|
|
201
201
|
if (publishedUpdates.has(version)) return
|
|
202
|
-
|
|
202
|
+
remember(publishedUpdates, version, true)
|
|
203
203
|
dispatch(
|
|
204
204
|
"OpenCode update could not be sent",
|
|
205
205
|
sendUpdate({ config, reporter, version: version.trim() }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { postJsonWithRetry } from "./http.js"
|
|
2
|
-
import {
|
|
2
|
+
import { formatText, stableHash } from "./utilities.js"
|
|
3
3
|
|
|
4
4
|
// Intentional sanitization of non-printable control bytes before logging.
|
|
5
5
|
// eslint-disable-next-line no-control-regex
|
|
@@ -11,7 +11,7 @@ const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g
|
|
|
11
11
|
const POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g
|
|
12
12
|
|
|
13
13
|
export function sanitizeErrorMessage(value) {
|
|
14
|
-
return
|
|
14
|
+
return formatText(value)
|
|
15
15
|
.replace(CONTROL_CHARACTERS, " ")
|
|
16
16
|
.replace(BEARER_TOKEN, "Bearer [redacted]")
|
|
17
17
|
.replace(SECRET_ASSIGNMENT, "$1=[redacted]")
|
|
@@ -27,11 +27,10 @@ export function sanitizeErrorMessage(value) {
|
|
|
27
27
|
.replace(POSIX_PATH, "$1[path redacted]")
|
|
28
28
|
.replace(/\s+/g, " ")
|
|
29
29
|
.trim()
|
|
30
|
-
.slice(0, 500)
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
export function safeErrorDetails(error) {
|
|
34
|
-
const name =
|
|
33
|
+
const name = formatText(error?.name || "OpenCodeError")
|
|
35
34
|
const message = sanitizeErrorMessage(error?.data?.message ?? error?.message)
|
|
36
35
|
const statusCode = Number.isInteger(error?.data?.statusCode)
|
|
37
36
|
? String(error.data.statusCode)
|
|
@@ -48,7 +47,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
|
48
47
|
query: { directory },
|
|
49
48
|
throwOnError: true,
|
|
50
49
|
})
|
|
51
|
-
return
|
|
50
|
+
return formatText((result?.data ?? result)?.title)
|
|
52
51
|
} catch (error) {
|
|
53
52
|
reporter.debug("Session title unavailable", error)
|
|
54
53
|
return ""
|
|
@@ -162,7 +161,7 @@ export async function publishSessionError({
|
|
|
162
161
|
// Backeer/iOS currently support info, warning and critical severities.
|
|
163
162
|
severity: "critical",
|
|
164
163
|
title: "OpenCode task failed",
|
|
165
|
-
body:
|
|
164
|
+
body: formatText(body),
|
|
166
165
|
payload: [
|
|
167
166
|
{ key: "OpenCode session", value: sessionTitle },
|
|
168
167
|
{ key: "Outcome", value: "Failed" },
|
|
@@ -175,7 +174,7 @@ export async function publishSessionError({
|
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
export async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
178
|
-
const safeVersion =
|
|
177
|
+
const safeVersion = formatText(version)
|
|
179
178
|
await publish(config, reporter, undefined, undefined, {
|
|
180
179
|
severity: "info",
|
|
181
180
|
title: "OpenCode update available",
|
|
@@ -186,8 +185,8 @@ export async function publishOpenCodeUpdate({ config, reporter, version }) {
|
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
export function errorCorrelation(event, sessionState) {
|
|
189
|
-
if (typeof event?.id === "string" && event.id) return
|
|
190
|
-
if (sessionState?.lastAssistant?.id) return
|
|
188
|
+
if (typeof event?.id === "string" && event.id) return formatText(event.id)
|
|
189
|
+
if (sessionState?.lastAssistant?.id) return formatText(sessionState.lastAssistant.id)
|
|
191
190
|
const details = safeErrorDetails(event?.properties?.error)
|
|
192
191
|
return stableHash(`${details.name}:${details.statusCode}:${details.message}`)
|
|
193
192
|
}
|
|
@@ -15,24 +15,23 @@ export function createV2LiveClient(ctx) {
|
|
|
15
15
|
if (disposed) return info
|
|
16
16
|
await ownsSession(info)
|
|
17
17
|
known.set(id, info)
|
|
18
|
-
if (known.size > 100) known.delete(known.keys().next().value)
|
|
19
18
|
return info
|
|
20
19
|
}
|
|
21
20
|
return {
|
|
22
21
|
async list() {
|
|
23
22
|
const refreshed = []
|
|
24
23
|
if (typeof ctx.session.list === "function") {
|
|
25
|
-
const result = await ctx.session.list({ directory: ctx.location.directory,
|
|
24
|
+
const result = await ctx.session.list({ directory: ctx.location.directory, order: "desc" })
|
|
26
25
|
const values = Array.isArray(result) ? result : result?.data
|
|
27
26
|
if (!Array.isArray(values)) throw new Error("Invalid native session list")
|
|
28
|
-
for (const info of values
|
|
27
|
+
for (const info of values) {
|
|
29
28
|
if (await ownsSession(info)) {
|
|
30
29
|
refreshed.push(info)
|
|
31
30
|
known.set(info.id, info)
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
|
-
for (const id of [...known.keys()]
|
|
34
|
+
for (const id of [...known.keys()]) {
|
|
36
35
|
if (refreshed.some(info => info.id === id)) continue
|
|
37
36
|
try { refreshed.push(await get(id)) } catch { known.delete(id) }
|
|
38
37
|
}
|
|
@@ -10,16 +10,15 @@ function readCredential(file) {
|
|
|
10
10
|
descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) |
|
|
11
11
|
(constants.O_NONBLOCK ?? 0))
|
|
12
12
|
const stat = fstatSync(descriptor)
|
|
13
|
-
if (!stat.isFile() || stat.
|
|
14
|
-
(process.platform !== "win32" && ((stat.mode & 0o077) !== 0 ||
|
|
13
|
+
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 ||
|
|
15
14
|
stat.uid !== process.getuid()))) {
|
|
16
15
|
throw new Error("invalid_file")
|
|
17
16
|
}
|
|
18
17
|
const token = readFileSync(descriptor, "utf8").trim()
|
|
19
|
-
if (!/^[A-Za-z0-9_-]{32,
|
|
18
|
+
if (!/^[A-Za-z0-9_-]{32,}$/.test(token)) throw new Error("invalid_token")
|
|
20
19
|
return token
|
|
21
20
|
} catch {
|
|
22
|
-
throw new Error("Live control credential must be an owner-only regular file containing a 32
|
|
21
|
+
throw new Error("Live control credential must be an owner-only regular file containing a token of at least 32 characters")
|
|
23
22
|
} finally {
|
|
24
23
|
if (descriptor !== undefined) closeSync(descriptor)
|
|
25
24
|
}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
const ID = /^[A-Za-z0-9_-]
|
|
1
|
+
const ID = /^[A-Za-z0-9_-]+$/
|
|
2
2
|
export const validBridgeID = (value) => typeof value === "string" && ID.test(value)
|
|
3
3
|
|
|
4
4
|
export function createBridgeSanitizer(secrets = []) {
|
|
5
5
|
const known = secrets.filter((value) => typeof value === "string" && value.length > 0)
|
|
6
6
|
const assistants = new Map()
|
|
7
|
-
function
|
|
8
|
-
if (typeof value !== "string") return
|
|
9
|
-
let result = value
|
|
7
|
+
function text(value) {
|
|
8
|
+
if (typeof value !== "string") return ""
|
|
9
|
+
let result = value
|
|
10
10
|
for (const secret of known) result = result.split(secret).join("[REDACTED]")
|
|
11
11
|
result = result
|
|
12
12
|
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]")
|
|
13
13
|
.replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
|
|
14
14
|
.replace(/\b(?:sk|bgi)_[A-Za-z0-9_-]{12,}|\bsk-[A-Za-z0-9_-]{12,}/g, "[REDACTED]")
|
|
15
15
|
.replace(/((?:api[_-]?key|access[_-]?token|secret|password|authorization)\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]")
|
|
16
|
-
return
|
|
16
|
+
return result
|
|
17
17
|
}
|
|
18
|
-
function text(value, max) { return clipped(value, max).value }
|
|
19
18
|
function content(key, value) {
|
|
20
|
-
|
|
21
|
-
return { [key]: result.value, ...(result.truncated ? { truncated: true } : {}) }
|
|
19
|
+
return { [key]: text(value) }
|
|
22
20
|
}
|
|
23
21
|
function sanitize(event) {
|
|
24
22
|
const p = event?.properties ?? event?.data ?? {}
|
|
@@ -30,7 +28,6 @@ export function createBridgeSanitizer(secrets = []) {
|
|
|
30
28
|
if (!validBridgeID(info?.id)) return undefined
|
|
31
29
|
if (info.role === "assistant") {
|
|
32
30
|
assistants.set(info.id, sessionID)
|
|
33
|
-
if (assistants.size > 256) assistants.delete(assistants.keys().next().value)
|
|
34
31
|
}
|
|
35
32
|
return info.role === "assistant" ? { ...base, data: {
|
|
36
33
|
messageID: info.id, completed: !!info.time?.completed, failed: !!info.error,
|