backeer-opencode 0.4.0 → 0.5.2
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 +35 -0
- package/README.md +57 -3
- package/bin/backeer-opencode-mcp.js +9 -0
- package/dist/backeer-permission.js +619 -53
- package/docs/live-control.md +80 -0
- package/package.json +10 -6
- package/src/config.js +4 -0
- package/src/live-bridge-client.js +52 -0
- package/src/live-bridge-config.js +48 -0
- package/src/live-bridge-events.js +80 -0
- package/src/live-bridge.js +273 -0
- package/src/live-connection.js +50 -0
- package/src/live-control-client.js +372 -0
- package/src/live-control-mcp.js +79 -0
- package/src/opencode-v2-client.js +2 -0
- package/src/opencode-v2.js +2 -4
- package/src/plugin.js +54 -46
- package/src/updates.js +1 -1
|
@@ -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.
|
|
4
|
-
"description": "Backeer
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
@@ -51,7 +53,9 @@
|
|
|
51
53
|
"access": "public"
|
|
52
54
|
},
|
|
53
55
|
"dependencies": {
|
|
54
|
-
"@
|
|
56
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
57
|
+
"@opencode/client": "2.0.26",
|
|
58
|
+
"zod": "4.6.7"
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|
|
57
61
|
"@eslint/js": "^9.39.5",
|
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
|
),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { postJsonWithRetry } from "./http.js"
|
|
3
|
+
|
|
4
|
+
export const LIVE_RECONNECT_MS = 3000
|
|
5
|
+
export const LIVE_HEARTBEAT_MS = 15000
|
|
6
|
+
export const LIVE_STALE_MS = 45000
|
|
7
|
+
|
|
8
|
+
// Publish only aggregate link transitions, never prompts, file paths or output.
|
|
9
|
+
// Keep notification delivery off the SSE/event-processing path.
|
|
10
|
+
export function createLiveConnectionNotifier(config, reporter) {
|
|
11
|
+
const epoch = randomUUID()
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
let last, sequence = 0, pending, task
|
|
14
|
+
async function drain() {
|
|
15
|
+
while (pending && !controller.signal.aborted) {
|
|
16
|
+
const event = pending
|
|
17
|
+
pending = undefined
|
|
18
|
+
try {
|
|
19
|
+
await postJsonWithRetry(config, config.eventsEndpoint, event, { signal: controller.signal })
|
|
20
|
+
} catch {
|
|
21
|
+
if (!controller.signal.aborted) reporter?.error("Live connection notification could not be sent")
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
update(connected) {
|
|
27
|
+
if (controller.signal.aborted || connected === last) return
|
|
28
|
+
const initial = last === undefined
|
|
29
|
+
last = connected
|
|
30
|
+
if (initial || !config.token || !config.eventsEndpoint) return
|
|
31
|
+
// If delivery is slow, keep only the latest undelivered state; no growing queue.
|
|
32
|
+
pending = {
|
|
33
|
+
type: "application", severity: connected ? "info" : "warning",
|
|
34
|
+
title: connected ? "OpenCode bağlantısı yeniden kuruldu" : "OpenCode bağlantısı kesildi",
|
|
35
|
+
body: connected
|
|
36
|
+
? "Canlı takip yeniden bağlandı. Sunucudaki görev otomatik olarak yeniden başlatılmadı."
|
|
37
|
+
: "Canlı takip bağlantısı kesildi. Yeniden bağlanma her 3 saniyede denenecek; sunucudaki görev durdurulmadı.",
|
|
38
|
+
payload: [{ key: "Connection", value: connected ? "reconnected" : "disconnected" }],
|
|
39
|
+
externalId: `opencode:live:${epoch}:${++sequence}`,
|
|
40
|
+
timestamp: new Date().toISOString(), notify: true, direction: "in",
|
|
41
|
+
}
|
|
42
|
+
task ??= drain().finally(() => { task = undefined })
|
|
43
|
+
},
|
|
44
|
+
async dispose() {
|
|
45
|
+
controller.abort()
|
|
46
|
+
pending = undefined
|
|
47
|
+
await task
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|