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.
@@ -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: `list_connections`, `list_sessions`, `connect_session`,
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. Up to 32 profiles
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 for up to 25 seconds after setup; no periodic status polling. |
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. Truncated text/tool output carries `data.truncated: true`; redaction is best effort, not a guarantee against every secret format.
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 24 hours in the same bridge process; restarting the bridge clears that memory. A full deduplication cache rejects new requests rather than evicting still-protected IDs. Instructions are not automatically retried. Busy sessions can reject an instruction; read status and wait instead of duplicating work.
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
- The bridge retains at most 1,024 instruction identities for that 24-hour window.
140
+ Command signatures are hashed; full prompts are not retained in the deduplication history.
130
141
 
131
- ## Bounds, reconnects and gaps
142
+ ## Usage, reconnects and gaps
132
143
 
133
- The adapter retains at most 500 events / 1 MiB in memory. A normal read returns at most 20 events / 32,000 characters; explicit bounds allow at most 100 events / 64,000 characters. Large individual events request a larger bounded read rather than silently skipping them. Bridge events may themselves be truncated and carry truncation metadata; a tool cannot reconstruct omitted text.
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. The 25-second MCP wait limit only ends that tool call; it leaves the underlying SSE connection running.
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 24 hours within the bridge process, 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.8.2",
3
+ "version": "0.10.0",
4
4
  "description": "Backeer mobile approvals and authenticated live OpenCode control over SSE and MCP",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -1,4 +1,4 @@
1
- import { rememberBounded } from "./utilities.js"
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
- rememberBounded(states, key, "publishing")
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
- rememberBounded(states, key, "published")
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
- rememberBounded(states, key, state)
36
+ remember(states, key, state)
37
37
  }
38
38
  return state
39
39
  }
@@ -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 { limit } from "./utilities.js"
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 = limit(await response.text(), 2000)
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: ${limit(error?.message || error, 300)}`
110
+ : `Gateway source check failed: ${formatText(error?.message || error)}`
111
111
  checks.push({ ok: false, text: detail })
112
112
  }
113
113
  }
@@ -1,5 +1,5 @@
1
1
  import { postJsonWithRetry } from "./http.js"
2
- import { limit, stableHash } from "./utilities.js"
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: limit(permissionType) },
36
- { key: "Session", value: limit(identity.sessionID) },
37
- { key: "Request ID", value: limit(identity.requestID) },
38
- { key: "Patterns", value: limit(resources) },
39
- { key: "Working directory", value: limit(directory) },
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: ${limit(permissionType, 200)}`,
47
+ title: `OpenCode permission required: ${formatText(permissionType)}`,
48
48
  body: resources
49
- ? `OpenCode is waiting for approval to access: ${limit(resources, 500)}`
50
- : `OpenCode is waiting for approval for: ${limit(permissionType, 500)}`,
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 = limit(option?.label, 200)
79
- const description = limit(option?.description, 500)
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 limit((result?.data ?? result)?.title)
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: limit(identity.sessionID) },
125
- { key: "Request ID", value: limit(identity.requestID) },
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: limit(question?.header) },
131
- { key: `Question${suffix}`, value: limit(question?.question) },
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: limit(formatQuestionOptions(question?.options)),
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: limit(question?.header, 200),
143
- question: limit(question?.question, 2000),
142
+ header: formatText(question?.header),
143
+ question: formatText(question?.question),
144
144
  options: Array.isArray(question?.options)
145
- ? question.options.slice(0, 20).map((option) => ({
146
- label: limit(option?.label, 200),
147
- description: limit(option?.description, 500),
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 = limit(question?.header, 200)
156
- const text = limit(question?.question, 500)
157
- const options = limit(formatQuestionOptions(question?.options), 500)
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: ${limit(firstQuestion?.header, 200) || "Question"}`,
168
- body: limit(body, 2000),
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
- ? limit(eventID, 200)
208
+ ? formatText(eventID)
209
209
  : stableHash(JSON.stringify(todos))
210
210
  const externalId = `opencode-todo:${request.sessionID}:${correlation}`
211
- const visibleTodos = todos.slice(0, 20)
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: limit(todo?.content) },
217
- { key: `Priority ${index + 1}`, value: limit(todo?.priority, 100) },
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
- .slice(0, 5)
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: limit(body, 2000),
238
+ body: formatText(body),
240
239
  payload,
241
240
  externalId,
242
241
  timestamp: new Date().toISOString(),
@@ -1,6 +1,4 @@
1
- import { limit, rememberBounded } from "./utilities.js"
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
- rememberBounded(this.sessions, sessionID, state)
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: limit(message, 300), key })
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 { limit } from "./utilities.js"
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 ?? RETRY_DELAYS_MS
40
+ const retryDelays = options.retryDelays
41
+ const signal = options.signal ?? config.signal
43
42
 
44
- for (let attempt = 0; attempt < retryDelays.length; attempt++) {
45
- if (retryDelays[attempt] > 0) {
46
- await sleep(retryDelays[attempt], options.signal)
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
- options.signal?.addEventListener("abort", abort, { once: true })
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 = limit(await response.text(), 1000)
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 (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
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
- options.signal?.removeEventListener("abort", abort)
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 { limit, rememberBounded, stableHash } from "./utilities.js"
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
- rememberBounded(sessions, sessionID, state)
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: limit(assistant.id, 200),
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: limit(correlation, 200),
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
- rememberBounded(publishedUpdates, version, true)
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 { limit, stableHash } from "./utilities.js"
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 limit(value, 2000)
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 = limit(error?.name || "OpenCodeError", 100)
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 limit((result?.data ?? result)?.title)
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: limit(body, 1000),
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 = limit(version, 100)
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 limit(event.id, 200)
190
- if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200)
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, limit: 100, order: "desc" })
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.slice(0, 100)) {
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()].slice(0, 100)) {
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.size > 4096 ||
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,256}$/.test(token)) throw new Error("invalid_token")
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–256 character token")
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,160}$/
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 clipped(value, max = 12_000) {
8
- if (typeof value !== "string") return { value: "", truncated: false }
9
- let result = value.slice(0, 64_000)
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 { value: result.slice(0, max), truncated: value.length > 64_000 || result.length > max }
16
+ return result
17
17
  }
18
- function text(value, max) { return clipped(value, max).value }
19
18
  function content(key, value) {
20
- const result = clipped(value)
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,