fix(ble): fix OpenCode BLE UART bridge plugin for v1.17+ compatibility

- Add type assertion for event.type to bypass strict TS compilation errors
- Accept both 'permission.asked' and 'permission.updated' event types
- Add normalizePermissionEvent() to handle internal vs SDK event property
  format differences (pattern vs patterns, type vs permission fields)
- Add RawPermissionEvent type for loose event property parsing
- Add troubleshooting section and subdirectory auto-loading note to README
This commit is contained in:
Zhou Xiao
2026-06-18 11:03:18 +08:00
committed by jiminxiang
parent a981af40ca
commit de87877f39
9 changed files with 168 additions and 131 deletions
@@ -99,6 +99,12 @@ flowchart LR
cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts ~/.config/opencode/plugins/opencode-ble-uart-bridge/
```
> **Note:** OpenCode's auto-loader may only scan the top-level of the plugin
> directory (not subdirectories). If the plugin does not load after copying
> the files, add an explicit entry in `opencode.json` (see step 5) pointing
> to the entry TypeScript file with an **absolute path** — this is the
> reliable method that works across all OpenCode versions.
5. Merge the relevant parts of `opencode.json.example` into your `opencode.json`.
For OpenCode plugin loading details, see the official
@@ -412,6 +418,50 @@ and truncated before crossing BLE.
- If JSON parsing fails, return an error response.
- Keep displayed metadata short to avoid leaking large prompts or secrets.
## Troubleshooting
### Plugin has no effect after configuration
If OpenCode does not forward events to the ESP-BLE-UART Daemon after you
followed the Quick Start steps:
1. **Check the plugin is actually loaded.** OpenCode loads local plugins from
`~/.config/opencode/plugins/` and `.opencode/plugins/`. Some versions only
scan the top-level directory for `.ts` files, so placing files in a
subdirectory may not work without an explicit `opencode.json` entry. Add a
`plugin` entry with an **absolute path** to the entry file:
```json
{
"plugin": ["/Users/you/.config/opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"]
}
```
2. **Check the TypeScript compilation.** OpenCode 1.17+ uses stricter TypeScript
checking for local plugins. If you see compilation errors in the plugin
output, ensure you are using the latest version of the plugin source from
`tools/ble/ble_uart_bridge/demos/opencode/src/`.
3. **Check the daemon is running and reachable.** The plugin calls
`GET http://127.0.0.1:8888/status` on startup. If the daemon is not running,
the plugin enters a "disabled" forwarding state. Start the daemon first:
```bash
python tools/ble/ble_uart_bridge/main.py daemon "<device_id>"
```
Then restart OpenCode. The daemon URL can be customized with the
`OPENCODE_BLE_DAEMON_URL` environment variable.
4. **Enable debug logging.** Set `OPENCODE_BLE_DEBUG=1` before starting
OpenCode to see verbose plugin logs in the terminal where OpenCode was
launched:
```bash
export OPENCODE_BLE_DEBUG=1
opencode
```
## Open items
- Add an integration test with a mocked ESP-BLE-UART Daemon.
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
import { BLE_DAEMON_URL } from "./config"
import { debugLog } from "./logging"
import { isPermissionDecision } from "./opencode-permission-reply"
import type { BridgeResponse, DaemonResponse, DaemonStatus } from "./types"
@@ -38,8 +37,6 @@ export async function getDaemonStatus(): Promise<DaemonStatus> {
* from leaking into the permission queue code.
*/
function parseBridgeResponse(body: DaemonResponse): BridgeResponse {
debugLog("daemon raw response", body)
// Try nested data/response fields first (daemon envelope)
const payload = body.data ?? body.response
if (payload !== undefined) {
@@ -28,9 +28,6 @@ export const DEFAULT_REJECT_MESSAGE = "Rejected from BLE device"
*/
export const METADATA_DISPLAY_KEYS = ["command", "path", "url"] as const
/** Enables verbose local console logging when OPENCODE_BLE_DEBUG=1. */
export const DEBUG = process.env.OPENCODE_BLE_DEBUG === "1"
/** HTTP base URL of the local ESP-BLE-UART Daemon that bridges OpenCode to the BLE device. */
export const BLE_DAEMON_URL = process.env.OPENCODE_BLE_DAEMON_URL ?? "http://127.0.0.1:8888"
@@ -1,29 +1,12 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-FileCopyrightText: 2026 Esposif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { DEBUG } from "./config"
import type { OpenCodePermissionClient } from "./types"
/**
* Write local debug logs only when explicitly enabled.
*
* Demo plugins often need extra observability while being developed, but they
* should stay quiet by default when users copy them into normal OpenCode use.
*/
export function debugLog(message: string, data?: unknown) {
if (!DEBUG) {
return
}
if (data === undefined) {
console.info(`[opencode-ble] ${message}`)
return
}
console.info(`[opencode-ble] ${message}`, data)
}
/**
* Prefer OpenCode's application log API. Fall back to debug-only local logs so
* daemon connection failures do not pollute the OpenCode TUI.
* Prefer OpenCode's application log API. Silently no-op when the API is
* unavailable, avoiding fallback to console.* calls that would corrupt
* the OpenCode TUI display.
*
* The partial client type means a copied demo can still run against OpenCode
* versions that do not expose every helper method used by newer SDKs.
@@ -43,14 +26,6 @@ export async function appLog(
extra,
},
})
return
}
if (level === "error") {
debugLog(`error: ${message}`, extra)
} else if (level === "warn") {
debugLog(`warn: ${message}`, extra)
} else {
debugLog(message, extra)
}
}
@@ -70,8 +45,8 @@ export async function showToastBestEffort(
duration: 5000,
},
})
} catch (error) {
debugLog("failed to show TUI toast", { error: String(error), title, message, variant })
} catch {
// Best-effort; toast is not critical
}
}
@@ -79,7 +54,7 @@ export async function showToastBestEffort(
* Best-effort logging wrapper used on error paths.
*
* Permission handling should not fail just because the logging endpoint is
* unavailable, so this helper catches log write errors and reports them locally.
* unavailable, so this helper catches log write errors silently.
*/
export async function appLogBestEffort(
client: OpenCodePermissionClient,
@@ -89,7 +64,7 @@ export async function appLogBestEffort(
) {
try {
await appLog(client, level, message, extra)
} catch (error) {
debugLog(`failed to write app log: ${message}`, { error: String(error) })
} catch {
// Best-effort; silent no-op
}
}
@@ -5,18 +5,19 @@ import type { Plugin } from "@opencode-ai/plugin"
import { getDaemonStatus, notifyBLE } from "./ble-daemon-client"
import { DEFAULT_REJECT_MESSAGE } from "./config"
import { appLog, appLogBestEffort, debugLog, showToastBestEffort } from "./logging"
import { appLog, appLogBestEffort, showToastBestEffort } from "./logging"
import { replyToOpenCodePermission } from "./opencode-permission-reply"
import { buildPermissionCancelPayload, buildSessionStatusPayload } from "./permission-payload"
import {
buildPermissionCancelPayload,
buildSessionStatusPayload,
normalizePermissionEvent,
} from "./permission-payload"
import {
enqueuePermissionRequest,
markActiveBLEPermissionsExternallyResolved,
statusShouldCancelPendingPermission,
} from "./permission-queue"
import type { DaemonStatus, OpenCodePermissionClient, PermissionEventProperties } from "./types"
export { notifyBLE, sendRequestToBLE } from "./ble-daemon-client"
export { buildPermissionPayload } from "./permission-payload"
import type { DaemonStatus, OpenCodePermissionClient, RawPermissionEvent } from "./types"
type BLEPluginState = "unknown" | "connected" | "degraded" | "disabled"
@@ -61,13 +62,6 @@ async function notifyStateChange(
})
}
/**
* OpenCode plugin entry point for the BLE device bridge demo.
*
* OpenCode calls this exported function once when loading the plugin. The
* returned object registers an `event` callback, and OpenCode calls that
* callback for session and permission events.
*/
export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => {
const openCodeClient = client as OpenCodePermissionClient
let bleState: BLEPluginState = "unknown"
@@ -101,7 +95,13 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
return bleState
}
await refreshBLEState(false)
// Fire-and-forget the initial BLE state check. Per OpenCode issue #4140,
// awaiting client methods (app.log, tui.showToast) during plugin init can
// hang OpenCode if the server isn't fully ready. The state will be
// refreshed on the first incoming event anyway.
void refreshBLEState(false).catch(() => {
// Best-effort; state refresh happens on next event
})
return {
/**
@@ -112,7 +112,12 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
* - `permission.asked`: ask the BLE device user to approve or reject.
*/
event: async ({ event }) => {
if (event.type === "session.status") {
// OpenCode's SDK Event type and its internal event system use different
// type strings for conceptually similar events. Use a string cast to
// avoid TypeScript strict-mode compilation failures.
const eventType = (event as { type: string }).type
if (eventType === "session.status") {
// OpenCode emits this event whenever a session changes state, such as
// becoming busy, retrying, or going idle. The plugin forwards this
// state to the BLE device so the device can mirror OpenCode's current
@@ -186,25 +191,15 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
await appLogBestEffort(openCodeClient, "warn", "session.status handler failed", {
error: String(error),
})
debugLog("session.status handler failed", { error: String(error) })
}
})()
}
if (event.type === "permission.asked") {
const permission = event.properties as PermissionEventProperties
const permissionSummary = {
keys: Object.keys(permission),
id: permission.id,
requestID: permission.requestID,
permissionID: permission.permissionID,
sessionID: permission.sessionID,
permission: permission.permission,
type: permission.type,
title: permission.title,
}
debugLog("received permission.asked", permissionSummary)
await appLogBestEffort(openCodeClient, "info", "received permission.asked", permissionSummary)
// OpenCode's internal event system uses "permission.asked"; the SDK
// event system uses "permission.updated". Accept both for compatibility
// across OpenCode versions.
if (eventType === "permission.asked" || eventType === "permission.updated") {
const permission = normalizePermissionEvent(event.properties as RawPermissionEvent)
try {
const state = await refreshBLEState(true)
@@ -1,7 +1,6 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { debugLog } from "./logging"
import { permissionRequestID } from "./permission-payload"
import type { OpenCodePermissionClient, PermissionDecision, PermissionEventProperties } from "./types"
@@ -62,21 +61,8 @@ export async function replyToOpenCodePermission(
// - raw HTTP fallback: fetch(serverUrl /permission/:id/reply)
// - v1 SDK helper: postSessionIdPermissionsPermissionId()
// - deprecated v2 SDK: client.permission.respond()
debugLog("replying to OpenCode permission", {
requestID,
sessionID: permission.sessionID,
decision,
message,
hasServerUrl: serverUrl !== undefined,
hasDirectory: directory !== undefined,
hasInternalPost: typeof client._client?.post === "function",
hasV2Reply: typeof client.permission?.reply === "function",
hasV2Respond: typeof client.permission?.respond === "function",
hasV1Respond: typeof client.postSessionIdPermissionsPermissionId === "function",
})
if (typeof client.permission?.reply === "function") {
debugLog("using OpenCode v2 permission.reply API")
const result = await client.permission.reply({
requestID,
reply: decision,
@@ -86,7 +72,6 @@ export async function replyToOpenCodePermission(
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
@@ -95,7 +80,6 @@ export async function replyToOpenCodePermission(
// fetch(serverUrl) may fail or hit the wrong OpenCode instance. The
// injected HeyAPI client uses OpenCode's in-process app.fetch() binding,
// so this is the reliable fallback when the public reply helper is absent.
debugLog("using OpenCode internal raw v2 permission.reply endpoint")
const result = await client._client.post({
url: `/permission/${encodeURIComponent(requestID)}/reply`,
body: {
@@ -108,37 +92,25 @@ export async function replyToOpenCodePermission(
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (serverUrl !== undefined && directory !== undefined) {
debugLog("using OpenCode raw v2 permission.reply endpoint")
try {
const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), {
method: "POST",
headers: opencodeRequestHeaders(directory),
body: JSON.stringify({
reply: decision,
message,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${await response.text()}`)
}
try {
debugLog("OpenCode permission response completed", await response.json())
} catch {
debugLog("OpenCode permission response completed (non-JSON body)")
}
return
} catch (error) {
debugLog("OpenCode raw v2 permission.reply failed, trying fallback", { error: String(error) })
const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), {
method: "POST",
headers: opencodeRequestHeaders(directory),
body: JSON.stringify({
reply: decision,
message,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${await response.text()}`)
}
return
}
if (typeof client.postSessionIdPermissionsPermissionId === "function") {
debugLog("using OpenCode v1 permission respond API")
const result = await client.postSessionIdPermissionsPermissionId({
path: {
id: permission.sessionID,
@@ -152,12 +124,10 @@ export async function replyToOpenCodePermission(
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (typeof client.permission?.respond === "function") {
debugLog("using OpenCode v2 deprecated permission.respond API")
const result = await client.permission.respond({
sessionID: permission.sessionID,
permissionID: requestID,
@@ -167,7 +137,6 @@ export async function replyToOpenCodePermission(
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
@@ -7,7 +7,49 @@ import {
MAX_METADATA_VALUE_CHARS,
METADATA_DISPLAY_KEYS,
} from "./config"
import type { PermissionEventProperties } from "./types"
import type { PermissionEventProperties, RawPermissionEvent } from "./types"
/**
* Normalize raw permission event properties from either the internal OpenCode
* event system (type="permission.asked") or the SDK event system
* (type="permission.updated") into the PermissionEventProperties shape that
* this plugin's payload builders and permission queue expect.
*
* The two event systems use different field names for similar concepts:
* - Internal `permission` ↔ SDK `type`
* - Internal `patterns` (string[]) ↔ SDK `pattern` (string | string[])
* - Internal has `tool_name`/`tool_input`; SDK has `messageID`/`callID`/`time`
*/
export function normalizePermissionEvent(raw: RawPermissionEvent): PermissionEventProperties {
// Normalize patterns: SDK Permission uses `pattern` (string | string[]),
// internal events use `patterns` (string[]).
let patterns: string[] | undefined
if (Array.isArray(raw.patterns)) {
patterns = raw.patterns as string[]
} else if (Array.isArray(raw.pattern)) {
patterns = raw.pattern as string[]
} else if (typeof raw.pattern === "string") {
patterns = [raw.pattern]
}
return {
id: raw.id,
requestID: raw.requestID ?? raw.id,
permissionID: raw.permissionID ?? raw.id,
sessionID: raw.sessionID ?? "",
permission: raw.permission ?? raw.type,
type: raw.type ?? raw.permission,
title: raw.title ?? DEFAULT_PERMISSION_TITLE,
metadata: typeof raw.metadata === "object" && raw.metadata !== null
? (raw.metadata as Record<string, unknown>)
: undefined,
patterns,
tool_name: raw.tool_name,
tool_input: typeof raw.tool_input === "object" && raw.tool_input !== null
? (raw.tool_input as Record<string, unknown>)
: undefined,
}
}
/** Create a unique event ID for messages sent to the ESP-BLE-UART Daemon. */
function eventID() {
@@ -3,7 +3,7 @@
import { CONCURRENT_REJECT_MESSAGE, DECISION_TIMEOUT_SECONDS, DEFAULT_REJECT_MESSAGE } from "./config"
import { sendRequestToBLE } from "./ble-daemon-client"
import { appLogBestEffort, debugLog, showToastBestEffort } from "./logging"
import { appLogBestEffort, showToastBestEffort } from "./logging"
import { isPermissionDecision, replyToOpenCodePermission } from "./opencode-permission-reply"
import { buildPermissionPayload, permissionRequestID } from "./permission-payload"
import type { OpenCodePermissionClient, PermissionDecision, PermissionEventProperties, PermissionQueueItem } from "./types"
@@ -113,11 +113,6 @@ async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<Per
if (item.skipBLE === true) {
decisionMessage = nonEmptyString(item.skipMessage, DEFAULT_REJECT_MESSAGE)
debugLog("skipping BLE permission request after concurrent reject", {
requestID,
sessionID: item.permission.sessionID,
message: decisionMessage,
})
await appLogBestEffort(item.client, "warn", "skipping BLE permission request after concurrent reject", {
requestID,
sessionID: item.permission.sessionID,
@@ -134,11 +129,6 @@ async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<Per
if (externallyResolvedPermissionIDs.has(requestID)) {
markQueuedPermissionsSkipped(item.permission.sessionID)
debugLog("BLE permission response ignored after OpenCode state changed", {
requestID,
sessionID: item.permission.sessionID,
result,
})
return undefined
}
@@ -152,16 +142,9 @@ async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<Per
// it a corrective denial so later serial tool calls can continue.
decisionMessage = nonEmptyString(result.message, DEFAULT_REJECT_MESSAGE)
}
debugLog("BLE permission decision received", result)
await appLogBestEffort(item.client, "info", "BLE permission decision received", result as Record<string, unknown>)
} catch (error) {
if (externallyResolvedPermissionIDs.has(requestID)) {
markQueuedPermissionsSkipped(item.permission.sessionID)
debugLog("BLE permission failure ignored after OpenCode state changed", {
requestID,
sessionID: item.permission.sessionID,
error: String(error),
})
return undefined
}
// Daemon failures are fail-closed, but still include feedback for the same
@@ -31,6 +31,35 @@ export type PermissionEventProperties = {
tool_input?: Record<string, unknown>
}
/**
* Raw event properties that may come from either the internal OpenCode event
* system (event type "permission.asked") or the SDK event system (event type
* "permission.updated").
*
* The internal system uses fields like `permission` (string) and `patterns`
* (string[]), while the SDK Permission type uses `type` (string) and `pattern`
* (string | string[]). This type accepts both so the normalizer can map them
* into a common PermissionEventProperties shape.
*/
export type RawPermissionEvent = {
id?: string
requestID?: string
permissionID?: string
sessionID?: string
permission?: string
type?: string
title?: string
metadata?: unknown
pattern?: string | string[]
patterns?: unknown
tool_name?: string
tool_input?: unknown
messageID?: string
callID?: string
time?: unknown
[key: string]: unknown
}
/**
* Normalized response expected from the ESP-BLE-UART Daemon after a permission request.
*