backeer-opencode 0.3.2 → 0.5.1

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.
@@ -0,0 +1,80 @@
1
+ # OpenCode live control
2
+
3
+ The plugin exposes a small, opt-in HTTP bridge **inside the existing OpenCode process**. Output travels over persistent SSE; explicit instructions use authenticated POST requests. The local MCP adapter turns these into tools that Codex can call. No hosted relay, new cloud server, public port, WebSocket service, or terminal OCR is required.
4
+
5
+ ```text
6
+ Codex — MCP stdio — local adapter — SSH tunnel — OpenCode plugin
7
+ ← SSE events
8
+ → POST instruction
9
+ ```
10
+
11
+ This channel reports OpenCode sessions, assistant text, selected tool output and status. It is not an arbitrary SSH shell or a byte-for-byte terminal recorder. Existing mobile permissions and questions stay on the existing Backeer path. The adapter cannot approve them.
12
+
13
+ ## Setup
14
+
15
+ 1. Install this version of `backeer-opencode` on the OpenCode host and on the computer running Codex. Enable the bridge using the configuration documented in the package README. It binds only to `127.0.0.1`; the default port is `43871`.
16
+ 2. Use a **separate random live-control token**, not the mobile ingest token. Keep matching token files on both computers. Files must be regular, owned by the current account and inaccessible to other users (`chmod 600`). Do not put token values in prompts, URLs, shell arguments or MCP configuration. The local adapter reads only the protected file. Token rotation requires restarting the configured plugin and adapter.
17
+ 3. Forward the port using an existing authorized SSH host configuration:
18
+
19
+ ```sh
20
+ while true; do
21
+ ssh -N -T -o BatchMode=yes -o ExitOnForwardFailure=yes \
22
+ -o ConnectTimeout=3 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
23
+ -L 127.0.0.1:43871:127.0.0.1:43871 existing-ssh-host
24
+ sleep 3
25
+ done
26
+ ```
27
+
28
+ This uses your existing SSH authentication and host verification. It does not create credentials or change server firewall rules. `BatchMode` requires an already usable noninteractive SSH identity. The loop restarts a failed tunnel after three seconds; SSH keepalives detect a silent broken transport. Keep this tunnel supervisor running while tracking work. For an existing externally managed tunnel, configure equivalent reconnect behavior instead of starting a duplicate. For different concurrent OpenCode processes, give each bridge/tunnel a distinct port.
29
+ 4. Register the `backeer-opencode-mcp` executable as a **local stdio MCP server** in the desired Codex client. Example configuration (replace absolute paths):
30
+
31
+ ```toml
32
+ [mcp_servers.backeer_opencode_live]
33
+ command = "/absolute/path/to/node"
34
+ args = ["/absolute/path/to/opencode-plugin/bin/backeer-opencode-mcp.js"]
35
+
36
+ [mcp_servers.backeer_opencode_live.env]
37
+ BACKEER_LIVE_URL = "http://127.0.0.1:43871"
38
+ BACKEER_LIVE_TOKEN_FILE = "/absolute/private/path/live-control.token"
39
+ ```
40
+
41
+ These are **adapter** settings. No configuration is written automatically. The MCP process uses stdout exclusively for protocol messages; startup errors go to stderr. Node.js 18 or later is supported through the official MCP SDK v1 line. See the [official stdio/server documentation](https://ts.sdk.modelcontextprotocol.io/server).
42
+
43
+ ## Tools and normal workflow
44
+
45
+ | Tool | Behavior |
46
+ | --- | --- |
47
+ | `session_status` | List observed project sessions or inspect an exact existing `sessionId`. |
48
+ | `read_events` | Read only unread buffered events, optionally after an explicit cursor. |
49
+ | `wait_for_events` | Wait on the persistent SSE connection for up to 25 seconds after setup; no periodic status polling. |
50
+ | `send_instruction` | POST a prompt to an exact existing session using a caller-generated `requestId`. |
51
+
52
+ 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.
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ 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.
59
+
60
+ The bridge retains at most 1,024 instruction identities for that 24-hour window.
61
+
62
+ ## Bounds, reconnects and gaps
63
+
64
+ 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.
65
+
66
+ 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.
67
+
68
+ 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.
69
+
70
+ If the bridge restarted or retained history expired, the result has `gap` instead of claiming complete output. The transport reconnects automatically from the new bridge cursor and buffers new events, but the history gap remains visible until acknowledged. Inspect `session_status` and explicitly call `read_events` or `wait_for_events` with `afterCursor = gap.cursor` to acknowledge resynchronization. Old events are not recovered by that action. Do not automatically resend the last instruction after a gap.
71
+
72
+ ## Mobile connection notifications
73
+
74
+ With the existing Backeer ingest credential configured on the OpenCode host, the bridge sends a push-enabled application event when its **last live SSE reader disconnects**, and another when a reader returns. The first normal connection, individual retry attempts and deliberate plugin shutdown do not produce alerts. Multiple simultaneous readers count as one aggregate connection; one remaining reader keeps it connected. Notification delivery uses the existing gateway, outside the event-processing path, and never includes prompts, documents or credentials. Slow delivery coalesces pending states to the latest state rather than creating an unbounded queue.
75
+
76
+ This detects closure as observed on the host; a half-open network failure may take keepalive detection time. Notifications are best effort with the gateway's existing HTTP retries. A stopped host cannot send a push, and delivery cannot be guaranteed while its gateway/internet path is unavailable. Live phone delivery requires a working Backeer source and mobile push setup. This package does not enable or deploy them automatically.
77
+
78
+ Closing the MCP connection aborts its HTTP/SSE requests, removes wait listeners and clears the in-memory event buffer. It does **not** stop the remote OpenCode task or the user-managed SSH tunnel.
79
+
80
+ The Codex configuration example follows the [official MCP configuration reference](https://developers.openai.com/codex/mcp). No Codex application settings, SSH credentials, public network listeners, or remote installations are changed automatically by this package.
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.3.2",
4
- "description": "Backeer lifecycle notifications and remote permission and question replies for OpenCode",
3
+ "version": "0.5.1",
4
+ "description": "Backeer mobile approvals and authenticated live OpenCode control over SSE and MCP",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js"
9
9
  },
10
10
  "bin": {
11
- "backeer-opencode": "bin/backeer-opencode.js"
11
+ "backeer-opencode": "bin/backeer-opencode.js",
12
+ "backeer-opencode-mcp": "bin/backeer-opencode-mcp.js"
12
13
  },
13
14
  "files": [
14
15
  "src",
@@ -16,11 +17,12 @@
16
17
  "bin",
17
18
  "README.md",
18
19
  "CHANGELOG.md",
19
- "LICENSE"
20
+ "LICENSE",
21
+ "docs"
20
22
  ],
21
23
  "scripts": {
22
24
  "build": "esbuild src/index.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/backeer-permission.js --legal-comments=none",
23
- "check": "npm run lint && node --check bin/backeer-opencode.js && npm test",
25
+ "check": "npm run lint && node --check bin/backeer-opencode.js && node --check bin/backeer-opencode-mcp.js && npm test",
24
26
  "lint": "eslint src bin test eslint.config.js --max-warnings=0",
25
27
  "pretest": "npm run build",
26
28
  "test": "node --test",
@@ -50,6 +52,11 @@
50
52
  "publishConfig": {
51
53
  "access": "public"
52
54
  },
55
+ "dependencies": {
56
+ "@modelcontextprotocol/sdk": "1.32.2",
57
+ "@opencode/client": "2.0.26",
58
+ "zod": "4.6.7"
59
+ },
53
60
  "devDependencies": {
54
61
  "@eslint/js": "^9.39.5",
55
62
  "esbuild": "^0.28.2",
package/src/config.js CHANGED
@@ -4,6 +4,7 @@ import { join } from "node:path"
4
4
 
5
5
  import { normalizeLogLevel } from "./reporter.js"
6
6
  import { enabled } from "./utilities.js"
7
+ import { loadLiveBridgeConfig } from "./live-bridge-config.js"
7
8
 
8
9
  const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
9
10
 
@@ -90,6 +91,9 @@ export function loadConfig(options = {}) {
90
91
  token: loadedToken.token,
91
92
  tokenSource: loadedToken.source,
92
93
  tokenFile,
94
+ liveBridge: loadLiveBridgeConfig(options.liveBridge, {
95
+ configDirectory: defaultConfigDirectory(), ingestToken: loadedToken.token,
96
+ }),
93
97
  permissions: enabled(
94
98
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
95
99
  ),
package/src/index.js CHANGED
@@ -1 +1,6 @@
1
- export { default } from "./plugin.js"
1
+ import server from "./plugin.js"
2
+ import { setup } from "./opencode-v2.js"
3
+
4
+ // A plain definition avoids an authoring-SDK dependency in the installer bundle.
5
+ // OpenCode >=1.18.29 calls server(); OpenCode V2 calls setup().
6
+ export default { id: "backeer-opencode", setup, server }
@@ -0,0 +1,52 @@
1
+ // The bridge submits normal user prompts. It never invokes shell, synthetic
2
+ // feedback, permission replies, or form replies on the caller's behalf.
3
+ export function createV2LiveClient(ctx) {
4
+ // 2.0.26's plugin SessionDomain intentionally omits list(). Track sessions
5
+ // observed through get/events instead of inventing that context method or
6
+ // starting a second OpenCode service just for discovery.
7
+ const known = new Map()
8
+ let disposed = false
9
+ async function get(id) {
10
+ const info = await ctx.session.get({ sessionID: id })
11
+ if (disposed) return info
12
+ known.set(id, info)
13
+ if (known.size > 100) known.delete(known.keys().next().value)
14
+ return info
15
+ }
16
+ return {
17
+ async list() {
18
+ if (typeof ctx.session.list === "function") {
19
+ return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" })
20
+ }
21
+ const refreshed = []
22
+ for (const id of known.keys()) {
23
+ try { refreshed.push(await get(id)) } catch { known.delete(id) }
24
+ }
25
+ return refreshed
26
+ },
27
+ get,
28
+ prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
29
+ dispose: () => { disposed = true; known.clear() },
30
+ }
31
+ }
32
+
33
+ function unwrap(value) {
34
+ if (value?.error) throw new Error("OpenCode request failed")
35
+ return value?.data ?? value
36
+ }
37
+
38
+ export function createV1LiveClient(client, directory) {
39
+ return {
40
+ list: async () => unwrap(await client.session.list({ query: { directory }, throwOnError: true })),
41
+ get: async (id) => unwrap(await client.session.get({ path: { id }, query: { directory }, throwOnError: true })),
42
+ async prompt(id, text) {
43
+ if (typeof client.session.promptAsync !== "function") {
44
+ throw Object.assign(new Error("OpenCode asynchronous prompts are unavailable"), { status: 501 })
45
+ }
46
+ return unwrap(await client.session.promptAsync({
47
+ path: { id }, query: { directory },
48
+ body: { parts: [{ type: "text", text }] }, throwOnError: true,
49
+ }))
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs"
2
+ import { isAbsolute, join } from "node:path"
3
+
4
+ function readCredential(file) {
5
+ if (typeof file !== "string" || !isAbsolute(file)) {
6
+ throw new Error("Live control requires an absolute credential-file path")
7
+ }
8
+ let descriptor
9
+ try {
10
+ descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) |
11
+ (constants.O_NONBLOCK ?? 0))
12
+ const stat = fstatSync(descriptor)
13
+ if (!stat.isFile() || stat.size > 4096 ||
14
+ (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 ||
15
+ stat.uid !== process.getuid()))) {
16
+ throw new Error("invalid_file")
17
+ }
18
+ const token = readFileSync(descriptor, "utf8").trim()
19
+ if (!/^[A-Za-z0-9_-]{32,256}$/.test(token)) throw new Error("invalid_token")
20
+ return token
21
+ } catch {
22
+ throw new Error("Live control credential must be an owner-only regular file containing a 32–256 character token")
23
+ } finally {
24
+ if (descriptor !== undefined) closeSync(descriptor)
25
+ }
26
+ }
27
+
28
+ export function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
29
+ const values = option && typeof option === "object" ? option : {}
30
+ const enabled = (typeof option === "boolean" ? option : values.enabled) ??
31
+ process.env.BACKEER_LIVE_ENABLED
32
+ if (enabled !== true && enabled !== "true" && enabled !== "1") return { enabled: false }
33
+ try {
34
+ const rawPort = values.port ?? process.env.BACKEER_LIVE_PORT ?? 43871
35
+ const port = Number(rawPort)
36
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
37
+ throw new Error("Live control port must be an integer between 1 and 65535")
38
+ }
39
+ const tokenFile = values.tokenFile ?? process.env.BACKEER_LIVE_TOKEN_FILE ??
40
+ join(configDirectory, "secrets", "backeer-live-token")
41
+ const token = readCredential(tokenFile)
42
+ if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token")
43
+ return { enabled: true, token, tokenFile, port }
44
+ } catch (error) {
45
+ // A bad opt-in must not disable the existing mobile approval integration.
46
+ return { enabled: false, error: error.message }
47
+ }
48
+ }
@@ -0,0 +1,80 @@
1
+ const ID = /^[A-Za-z0-9_-]{1,160}$/
2
+ export const validBridgeID = (value) => typeof value === "string" && ID.test(value)
3
+
4
+ export function createBridgeSanitizer(secrets = []) {
5
+ const known = secrets.filter((value) => typeof value === "string" && value.length > 0)
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)
10
+ for (const secret of known) result = result.split(secret).join("[REDACTED]")
11
+ result = result
12
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]")
13
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
14
+ .replace(/\b(?:sk|bgi)_[A-Za-z0-9_-]{12,}|\bsk-[A-Za-z0-9_-]{12,}/g, "[REDACTED]")
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 }
17
+ }
18
+ function text(value, max) { return clipped(value, max).value }
19
+ function content(key, value) {
20
+ const result = clipped(value)
21
+ return { [key]: result.value, ...(result.truncated ? { truncated: true } : {}) }
22
+ }
23
+ function sanitize(event) {
24
+ const p = event?.properties ?? event?.data ?? {}
25
+ const sessionID = p.sessionID ?? p.info?.sessionID ?? p.part?.sessionID
26
+ if (!validBridgeID(sessionID) || sessionID === "global") return undefined
27
+ const base = { type: event.type, sessionID, data: {} }
28
+ if (event.type === "message.updated") {
29
+ const info = p.info
30
+ if (!validBridgeID(info?.id)) return undefined
31
+ if (info.role === "assistant") {
32
+ assistants.set(info.id, sessionID)
33
+ if (assistants.size > 256) assistants.delete(assistants.keys().next().value)
34
+ }
35
+ return info.role === "assistant" ? { ...base, data: {
36
+ messageID: info.id, completed: !!info.time?.completed, failed: !!info.error,
37
+ } } : undefined
38
+ }
39
+ if (event.type === "message.part.updated") {
40
+ const part = p.part
41
+ if (assistants.get(part?.messageID) !== sessionID) return undefined
42
+ if (part.type === "text") return { ...base, type: "assistant.text", data: {
43
+ messageID: part.messageID, ...content("text", part.text), replace: true,
44
+ } }
45
+ if (part.type !== "tool") return undefined
46
+ const state = part.state ?? {}
47
+ return { ...base, type: "tool.status", data: {
48
+ tool: text(part.tool, 80), status: text(state.status, 40),
49
+ ...(state.status === "completed" ? content("output", state.output) : {}),
50
+ ...(state.status === "error" ? { failed: true } : {}),
51
+ } }
52
+ }
53
+ if (event.type === "session.text.ended") return { ...base, type: "assistant.text", data: {
54
+ messageID: validBridgeID(p.assistantMessageID) ? p.assistantMessageID : undefined,
55
+ ...content("text", p.text), replace: true,
56
+ } }
57
+ // Raw deltas can split secrets across frames. Publish completed text units
58
+ // instead; execution/tool transitions still arrive immediately.
59
+ if (["session.tool.called", "session.tool.success", "session.tool.failed"].includes(event.type)) {
60
+ return { ...base, type: "tool.status", data: {
61
+ status: event.type.split(".").at(-1),
62
+ ...(event.type === "session.tool.success" ? content("output",
63
+ (p.content ?? []).filter((item) => item?.type === "text").map((item) => item.text).join("\n"),
64
+ ) : {}),
65
+ } }
66
+ }
67
+ if (["permission.asked", "question.asked", "question.v2.asked"].includes(event.type)) {
68
+ return { ...base, type: "approval.waiting", data: { kind: event.type.split(".")[0] } }
69
+ }
70
+ if (["permission.replied", "question.replied", "question.v2.replied", "question.rejected", "question.v2.rejected"].includes(event.type)) {
71
+ return { ...base, type: "approval.settled", data: { kind: event.type.split(".")[0] } }
72
+ }
73
+ if (event.type === "session.status") return { ...base, data: { status:
74
+ ["idle", "busy", "retry"].includes(p.status?.type) ? p.status.type : "unknown",
75
+ } }
76
+ if (["session.idle", "session.error", "session.deleted"].includes(event.type)) return base
77
+ return undefined
78
+ }
79
+ return { sanitize, text, clear: () => assistants.clear() }
80
+ }
@@ -0,0 +1,273 @@
1
+ import { createServer } from "node:http"
2
+ import { randomUUID, timingSafeEqual } from "node:crypto"
3
+ import { createV1LiveClient } from "./live-bridge-client.js"
4
+ import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
5
+ import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
6
+
7
+ const LIMITS = { events: 256, bytes: 2_000_000, clients: 8, commands: 1024, commandTTL: 86_400_000, body: 32_768, requests: 16 }
8
+ function failure(status, code) { return Object.assign(new Error(code), { status, code }) }
9
+ function json(response, status, value) {
10
+ if (!response.destroyed && !response.headersSent) {
11
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" })
12
+ response.end(JSON.stringify(value))
13
+ }
14
+ }
15
+ async function readBody(request) {
16
+ const chunks = []
17
+ let bytes = 0
18
+ for await (const chunk of request) {
19
+ bytes += chunk.length
20
+ if (bytes > LIMITS.body) throw failure(413, "body_too_large")
21
+ chunks.push(chunk)
22
+ }
23
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")) } catch { throw failure(400, "invalid_json") }
24
+ }
25
+
26
+ export async function createLiveBridge({ config, client, directory, reporter }) {
27
+ const options = config.liveBridge
28
+ if (!options?.enabled) return { handle() {}, async dispose() {}, address: undefined }
29
+ const token = options.token
30
+ if (typeof token !== "string" || token.length < 32 || token === config.token) {
31
+ throw new Error("Live bridge requires a separate credential of at least 32 characters")
32
+ }
33
+ const api = client.liveBridge ?? createV1LiveClient(client, directory)
34
+ const connectionNotifications = createLiveConnectionNotifier(config, reporter)
35
+ const sanitizer = createBridgeSanitizer([token, config.token])
36
+ const epoch = randomUUID()
37
+ const events = [], streams = new Set(), commands = new Map(), states = new Map()
38
+ let sequence = 0, bytes = 0, active = 0, disposed = false, disposeTask
39
+ let lookups = 0
40
+ const lookupWaiters = new Set()
41
+ const cursor = () => `${epoch}:${sequence}`
42
+
43
+ function lookup(id) {
44
+ if (disposed) return Promise.reject(failure(503, "bridge_closed"))
45
+ if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"))
46
+ lookups++
47
+ return new Promise((resolve, reject) => {
48
+ let finished = false
49
+ const finish = (complete, value) => {
50
+ if (finished) return
51
+ finished = true
52
+ clearTimeout(timer)
53
+ lookupWaiters.delete(cancel)
54
+ complete(value)
55
+ }
56
+ const cancel = () => finish(reject, failure(503, "bridge_closed"))
57
+ const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1000)
58
+ lookupWaiters.add(cancel)
59
+ // The caller's deadline does not release this slot: unsupported SDK
60
+ // cancellation must not let timed-out, still-running calls accumulate.
61
+ Promise.resolve().then(() => api.get(id)).then(
62
+ (value) => { lookups--; finish(resolve, value) },
63
+ (error) => { lookups--; finish(reject, error) },
64
+ )
65
+ })
66
+ }
67
+
68
+ async function session(id) {
69
+ if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session")
70
+ let info
71
+ try { info = await lookup(id) } catch (error) {
72
+ if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error
73
+ throw failure(404, "session_not_found")
74
+ }
75
+ if (info?.id !== id || (info.location?.directory ?? info.directory) !== directory) {
76
+ throw failure(404, "session_not_found")
77
+ }
78
+ return {
79
+ id, title: sanitizer.text(info.title, 240),
80
+ ...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
81
+ status: states.get(id) ?? "unknown",
82
+ }
83
+ }
84
+ function removeStream(stream) {
85
+ const removed = streams.delete(stream)
86
+ clearInterval(stream.heartbeat)
87
+ stream.queue.length = 0
88
+ stream.queuedBytes = 0
89
+ if (removed && !streams.size && !disposed) connectionNotifications.update(false)
90
+ }
91
+ function write(stream, value) {
92
+ if (!streams.has(stream)) return
93
+ if (stream.blocked) {
94
+ stream.queue.push(value)
95
+ stream.queuedBytes += Buffer.byteLength(value)
96
+ if (stream.queuedBytes > LIMITS.bytes) {
97
+ removeStream(stream)
98
+ stream.response.destroy()
99
+ }
100
+ return
101
+ }
102
+ if (!stream.response.write(value)) {
103
+ stream.blocked = true
104
+ }
105
+ }
106
+ async function handle(event) {
107
+ if (disposed) return
108
+ const safe = sanitizer.sanitize(event)
109
+ if (!safe) return
110
+ // V1 events may be global. Do not publish another project's session just
111
+ // because the event happened to reach this plugin instance.
112
+ try { await session(safe.sessionID) } catch { return }
113
+ if (disposed) return
114
+ if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status)
115
+ if (safe.type === "session.idle") states.set(safe.sessionID, "idle")
116
+ if (safe.type === "session.deleted") states.delete(safe.sessionID)
117
+ if (states.size > 256) states.delete(states.keys().next().value)
118
+ const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() }
119
+ let frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
120
+ if (Buffer.byteLength(frame) > 32_768) {
121
+ record.data = { truncated: true }
122
+ frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
123
+ }
124
+ const size = Buffer.byteLength(frame)
125
+ events.push({ sequence, sessionID: record.sessionID, frame, size })
126
+ bytes += size
127
+ while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size
128
+ for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame)
129
+ }
130
+ function authorized(request) {
131
+ if (request.headers.origin || request.headers["sec-fetch-site"]) return false
132
+ const expected = Buffer.from(`Bearer ${token}`)
133
+ const actual = Buffer.from(request.headers.authorization ?? "")
134
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
135
+ }
136
+ function parseCursor(value) {
137
+ if (!value) return { after: 0 }
138
+ const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value)
139
+ if (!match) throw failure(400, "invalid_cursor")
140
+ if (match[1] !== epoch) return { gap: "stream_restarted" }
141
+ const after = Number(match[2])
142
+ if (!Number.isSafeInteger(after) || after > sequence) throw failure(400, "invalid_cursor")
143
+ if (events.length && after < events[0].sequence - 1) return { gap: "cursor_expired" }
144
+ return { after }
145
+ }
146
+ async function route(request, response) {
147
+ if (!authorized(request)) return json(response, 401, { error: "unauthorized" })
148
+ if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" })
149
+ active++
150
+ const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15_000)
151
+ timeout.unref()
152
+ try {
153
+ const url = new URL(request.url, "http://127.0.0.1")
154
+ if (url.username || url.password) throw failure(400, "invalid_url")
155
+ if (request.method === "GET" && url.pathname === "/v1/sessions") {
156
+ const result = await api.list()
157
+ const values = Array.isArray(result) ? result : result?.data
158
+ if (!Array.isArray(values)) throw failure(502, "invalid_session_list")
159
+ const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) &&
160
+ (info.location?.directory ?? info.directory) === directory)
161
+ return json(response, 200, { sessions: own.map((info) => ({
162
+ id: info.id, title: sanitizer.text(info.title, 240), status: states.get(info.id) ?? "unknown",
163
+ ...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
164
+ })), cursor: cursor() })
165
+ }
166
+ if (request.method === "GET" && url.pathname === "/v1/events") {
167
+ const selected = url.searchParams.get("sessionID") || undefined
168
+ if (selected) await session(selected)
169
+ const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"])
170
+ if (response.headersSent) return
171
+ if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams")
172
+ clearTimeout(timeout)
173
+ response.writeHead(200, {
174
+ "content-type": "text/event-stream", "cache-control": "no-cache, no-transform",
175
+ "x-accel-buffering": "no", connection: "keep-alive",
176
+ })
177
+ if (parsed.gap) {
178
+ response.end(`event: backeer.gap\ndata: ${JSON.stringify({ reason: parsed.gap, cursor: cursor() })}\n\n`)
179
+ return
180
+ }
181
+ const stream = { response, sessionID: selected, heartbeat: undefined,
182
+ blocked: false, queue: [], queuedBytes: 0 }
183
+ streams.add(stream)
184
+ connectionNotifications.update(true)
185
+ request.socket.setTimeout(0)
186
+ request.socket.setKeepAlive(true, LIVE_HEARTBEAT_MS)
187
+ response.on("close", () => removeStream(stream))
188
+ response.on("drain", () => {
189
+ stream.blocked = false
190
+ while (streams.has(stream) && !stream.blocked && stream.queue.length) {
191
+ const next = stream.queue.shift()
192
+ stream.queuedBytes -= Buffer.byteLength(next)
193
+ write(stream, next)
194
+ }
195
+ })
196
+ write(stream, `: connected\nretry: ${LIVE_RECONNECT_MS}\n\n`)
197
+ for (const item of events) {
198
+ if (!streams.has(stream)) break
199
+ if (item.sequence > parsed.after && (!selected || selected === item.sessionID)) write(stream, item.frame)
200
+ }
201
+ if (streams.has(stream)) {
202
+ stream.heartbeat = setInterval(() => write(stream, ": heartbeat\n\n"), LIVE_HEARTBEAT_MS)
203
+ stream.heartbeat.unref()
204
+ }
205
+ return
206
+ }
207
+ const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions)?$/.exec(url.pathname)
208
+ if (!match) throw failure(404, "route_not_found")
209
+ if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]))
210
+ if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed")
211
+ if (!/^application\/json(?:;|$)/i.test(request.headers["content-type"] ?? "")) throw failure(415, "json_required")
212
+ const body = await readBody(request)
213
+ if (!body || !validBridgeID(body.requestId) || typeof body.prompt !== "string" ||
214
+ !body.prompt.trim() || body.prompt.length > 16_000 ||
215
+ Object.keys(body).some((key) => !["requestId", "prompt"].includes(key))) throw failure(400, "invalid_instruction")
216
+ await session(match[1])
217
+ if (response.headersSent || disposed) return
218
+ for (const [id, command] of commands) {
219
+ if (command.settled && Date.now() - command.created >= LIMITS.commandTTL) commands.delete(id)
220
+ }
221
+ const existing = commands.get(body.requestId)
222
+ if (existing) {
223
+ if (existing.sessionID !== match[1] || existing.prompt !== body.prompt) throw failure(409, "request_id_conflict")
224
+ await existing.task
225
+ return json(response, 200, existing.result)
226
+ }
227
+ // Reject at capacity instead of evicting a recent/in-flight request and
228
+ // executing its retry twice. Settled IDs expire only after 24 hours.
229
+ if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full")
230
+ const command = { sessionID: match[1], prompt: body.prompt, created: Date.now(), settled: false,
231
+ result: { requestId: body.requestId, sessionID: match[1], status: "accepted" }, task: undefined }
232
+ commands.set(body.requestId, command)
233
+ command.task = Promise.resolve().then(() => api.prompt(match[1], body.prompt))
234
+ .finally(() => { command.settled = true })
235
+ await command.task
236
+ // Keep the identity/failure cached even if the HTTP connection disappears:
237
+ // a client retry cannot re-submit a potentially accepted SDK request.
238
+ return json(response, 202, command.result)
239
+ } catch (error) {
240
+ json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" })
241
+ } finally {
242
+ active--
243
+ clearTimeout(timeout)
244
+ }
245
+ }
246
+ const server = createServer((request, response) => { void route(request, response) })
247
+ server.requestTimeout = 15_000
248
+ server.headersTimeout = 10_000
249
+ server.keepAliveTimeout = 5_000
250
+ await new Promise((resolve, reject) => {
251
+ server.once("error", reject)
252
+ server.listen(options.port ?? 43871, "127.0.0.1", () => { server.off("error", reject); resolve() })
253
+ })
254
+ server.on("error", () => {})
255
+ return {
256
+ handle, address: server.address(),
257
+ dispose() {
258
+ disposeTask ??= new Promise((resolve) => {
259
+ disposed = true
260
+ for (const cancel of lookupWaiters) cancel()
261
+ for (const stream of streams) { removeStream(stream); stream.response.destroy() }
262
+ server.close(() => { void connectionNotifications.dispose().then(resolve) })
263
+ server.closeAllConnections?.()
264
+ events.length = 0
265
+ states.clear()
266
+ commands.clear()
267
+ sanitizer.clear()
268
+ api.dispose?.()
269
+ })
270
+ return disposeTask
271
+ },
272
+ }
273
+ }