feat(tools): add opencode ble uart bridge demo

This commit is contained in:
Zhou Xiao
2026-05-08 11:31:51 +08:00
parent e068717331
commit 0b07f41e5a
11 changed files with 1508 additions and 0 deletions
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// 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 } from "./types"
/**
* Check whether the local BLE daemon is reachable.
*
* This probe is intentionally silent: OpenCode loads plugins during startup, so
* a missing optional daemon must not print fetch errors into the OpenCode UI.
*/
export async function isDaemonAvailable(): Promise<boolean> {
try {
const response = await fetch(`${BLE_DAEMON_URL}/status`)
return response.ok
} catch {
return false
}
}
/**
* Normalize the BLE daemon's response envelope into a permission response.
*
* The daemon supports both nested `data`/`response` envelopes and a direct
* top-level decision. Keeping that tolerance here prevents transport quirks
* 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) {
if (typeof payload === "string") {
return JSON.parse(payload) as BridgeResponse
}
if (payload && typeof payload === "object") {
return payload as BridgeResponse
}
}
// Fallback: daemon may have returned the decision at the top level
if (
body &&
typeof body === "object" &&
"decision" in body &&
isPermissionDecision((body as BridgeResponse).decision)
) {
return body as BridgeResponse
}
throw new Error(`BLE daemon returned an invalid response payload: ${JSON.stringify(body)}`)
}
/**
* Send a one-way notification to the BLE daemon.
*
* Use this for events such as session status updates or permission cancellation,
* where the BLE device should update its UI but OpenCode is not waiting for a
* user decision.
*/
export async function notifyBLE(op: string, data: unknown): Promise<void> {
const response = await fetch(`${BLE_DAEMON_URL}/notify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ op, data }),
})
if (!response.ok) {
throw new Error(`BLE daemon notify failed: HTTP ${response.status}`)
}
}
/**
* Send a request to the BLE daemon and wait for a structured response.
*
* Permission prompts use this path because OpenCode cannot continue until the
* BLE device returns a decision or the request times out.
*/
export async function sendRequestToBLE(op: string, data: unknown, timeoutSeconds: number): Promise<BridgeResponse> {
const response = await fetch(`${BLE_DAEMON_URL}/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
op,
data,
timeout: timeoutSeconds,
}),
})
if (!response.ok) {
throw new Error(`BLE daemon request failed: HTTP ${response.status}`)
}
return parseBridgeResponse((await response.json()) as DaemonResponse)
}
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
/** Maximum number of characters sent for one metadata value shown on the BLE device. */
export const MAX_METADATA_VALUE_CHARS = 512
/** Fallback display type when OpenCode does not provide a specific permission type. */
export const DEFAULT_PERMISSION_TYPE = "unknown"
/** Fallback title shown on the BLE device when the permission event has no title. */
export const DEFAULT_PERMISSION_TITLE = "Permission request"
/**
* Default rejection message sent back to OpenCode when the BLE path rejects or fails.
*
* Keep this message non-empty: OpenCode treats a bare reject as a hard
* PermissionRejectedError that can block the current assistant turn. A reject
* with feedback behaves like a corrective denial and lets later serial tool
* requests continue.
*/
export const DEFAULT_REJECT_MESSAGE = "Rejected from BLE device"
/**
* Metadata keys that are most useful on a small BLE screen.
*
* The bridge shows at most one metadata entry, so these keys are prioritized
* before falling back to the first available string metadata value.
*/
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 BLE 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"
/** Maximum time to wait for a user decision from the BLE device. */
export const DECISION_TIMEOUT_SECONDS = Number(process.env.OPENCODE_BLE_DECISION_TIMEOUT_SECONDS ?? "60")
/** Message used when later prompts are skipped after an earlier same-session reject. */
export const CONCURRENT_REJECT_MESSAGE = "Another concurrent permission request was rejected"
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2026 Espressif 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, with console fallback for older clients.
*
* 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.
*/
export async function appLog(
client: OpenCodePermissionClient,
level: "debug" | "info" | "warn" | "error",
message: string,
extra?: Record<string, unknown>,
) {
if (typeof client.app?.log === "function") {
await client.app.log({
body: {
service: "opencode-ble",
level,
message,
extra,
},
})
return
}
if (level === "error") {
console.error(`[opencode-ble] ${message}`, extra)
} else if (level === "warn") {
console.warn(`[opencode-ble] ${message}`, extra)
} else {
debugLog(message, extra)
}
}
/**
* 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.
*/
export async function appLogBestEffort(
client: OpenCodePermissionClient,
level: "debug" | "info" | "warn" | "error",
message: string,
extra?: Record<string, unknown>,
) {
try {
await appLog(client, level, message, extra)
} catch (error) {
console.warn(`[opencode-ble] failed to write app log: ${message}`, error)
}
}
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import type { Plugin } from "@opencode-ai/plugin"
import { isDaemonAvailable, notifyBLE } from "./ble-daemon-client"
import { appLog, appLogBestEffort, debugLog } from "./logging"
import { buildPermissionCancelPayload, buildSessionStatusPayload } from "./permission-payload"
import {
enqueuePermissionRequest,
markActiveBLEPermissionsExternallyResolved,
statusShouldCancelPendingPermission,
} from "./permission-queue"
import type { OpenCodePermissionClient, PermissionEventProperties } from "./types"
export { notifyBLE, sendRequestToBLE } from "./ble-daemon-client"
export { buildPermissionPayload } from "./permission-payload"
/**
* 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 }) => {
if (!(await isDaemonAvailable())) {
return {}
}
return {
/**
* Handle OpenCode events that are relevant to the BLE device.
*
* This demo listens for two event families:
* - `session.status`: mirror OpenCode activity on the BLE device.
* - `permission.asked`: ask the BLE device user to approve or reject.
*/
event: async ({ event }) => {
if (event.type === "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
// activity.
const properties = event.properties as {
sessionID: string
status: { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }
}
// Forwarding session status is best-effort background work. Do not
// await this async IIFE from the OpenCode event callback; otherwise a
// slow or unavailable BLE daemon could block OpenCode's own event loop.
void (async () => {
if (
// If the session became idle while a BLE permission prompt is
// active, mark that prompt as externally resolved and ask the BLE
// daemon to dismiss it. This prevents an old prompt from being
// answered after OpenCode no longer needs the decision. Only idle
// triggers this cancellation: busy/retry are normal activity
// transitions and should not dismiss an actively displayed prompt.
statusShouldCancelPendingPermission(properties.status) &&
markActiveBLEPermissionsExternallyResolved(properties.sessionID)
) {
try {
// OpenCode does not emit a permission-specific cancellation event
// when a user answers the same prompt in the TUI. This best-effort
// notification tells the daemon the BLE prompt is stale before the
// following idle status also clears the device UI.
await notifyBLE("permission.cancel", buildPermissionCancelPayload(properties.sessionID))
} catch (error) {
console.warn("Failed to cancel stale BLE permission on device", error)
}
}
try {
// Always forward the latest session status, even if there was no
// stale permission prompt to cancel. This keeps the BLE device's
// display synchronized with OpenCode.
await notifyBLE("session.status", buildSessionStatusPayload(properties.sessionID, properties.status))
} catch (error) {
console.warn("Failed to forward session status to BLE device", 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(client as OpenCodePermissionClient, "info", "received permission.asked", permissionSummary)
try {
// Permission handling is awaited because OpenCode needs an explicit
// reply before it can continue the tool or command that requested
// permission. The queue itself serializes BLE prompts.
await enqueuePermissionRequest(client as OpenCodePermissionClient, permission, serverUrl, directory)
} catch (error) {
await appLog(client as OpenCodePermissionClient, "error", "Failed to reply to OpenCode permission request", {
error: String(error),
})
throw error
}
}
},
}
}
@@ -0,0 +1,176 @@
// 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"
/** Extract SDK-style errors from methods that return error objects instead of throwing. */
function sdkResultError(result: unknown): unknown {
if (result && typeof result === "object" && "error" in result) {
return (result as { error?: unknown }).error
}
return undefined
}
/** Build Basic Auth headers for raw OpenCode server fallback calls when configured. */
function serverAuthHeaders(): Record<string, string> {
const password = process.env.OPENCODE_SERVER_PASSWORD
if (!password) {
return {}
}
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
return {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
}
}
/**
* Headers required by raw OpenCode HTTP calls.
*
* The `x-opencode-directory` header tells OpenCode which workspace directory the
* request belongs to, matching how the SDK normally scopes permission replies.
*/
function opencodeRequestHeaders(directory: string): Record<string, string> {
return {
"Content-Type": "application/json",
"x-opencode-directory": encodeURIComponent(directory),
...serverAuthHeaders(),
}
}
/**
* Reply to an OpenCode permission request using the best API available.
*
* The preferred path is the modern `permission.reply` API. The remaining paths
* keep this demo useful across OpenCode versions and environments where only a
* raw client, raw server URL, older v1 helper, or deprecated v2 helper exists.
*/
export async function replyToOpenCodePermission(
client: OpenCodePermissionClient,
permission: PermissionEventProperties,
decision: PermissionDecision,
message?: string,
serverUrl?: URL,
directory?: string,
) {
const requestID = permissionRequestID(permission)
// Probe reply APIs in compatibility order:
// - modern v2 SDK: client.permission.reply()
// - plugin-internal client: client._client.post()
// - 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,
message,
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (typeof client._client?.post === "function" && message !== undefined) {
// In TUI/plugin runtime, serverUrl can be a phantom URL: a plain
// 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: {
reply: decision,
message,
},
headers: { "Content-Type": "application/json" },
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (serverUrl !== undefined && directory !== undefined && message !== 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()}`)
}
debugLog("OpenCode permission response completed", await response.json())
return
} catch (error) {
debugLog("OpenCode raw v2 permission.reply failed, trying fallback", { error: String(error) })
}
}
if (typeof client.postSessionIdPermissionsPermissionId === "function") {
debugLog("using OpenCode v1 permission respond API")
const result = await client.postSessionIdPermissionsPermissionId({
path: {
id: permission.sessionID,
permissionID: requestID,
},
body: {
response: decision,
},
})
const error = sdkResultError(result)
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,
response: decision,
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
throw new Error("OpenCode client does not expose a permission reply API")
}
/** Runtime guard for permission decisions parsed from BLE daemon JSON. */
export function isPermissionDecision(value: unknown): value is PermissionDecision {
return value === "once" || value === "reject"
}
@@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import {
DEFAULT_PERMISSION_TITLE,
DEFAULT_PERMISSION_TYPE,
MAX_METADATA_VALUE_CHARS,
METADATA_DISPLAY_KEYS,
} from "./config"
import type { PermissionEventProperties } from "./types"
/** Create a unique event ID for messages sent to the BLE daemon. */
function eventID() {
return crypto.randomUUID()
}
/** Keep one displayed metadata value small enough for constrained BLE device UIs. */
function truncateForBLE(value: string): string {
return value.length > MAX_METADATA_VALUE_CHARS ? value.slice(0, MAX_METADATA_VALUE_CHARS) : value
}
/** Return the first non-empty string from several OpenCode event fields. */
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
return value
}
}
return undefined
}
/** Check whether compacted display metadata has at least one useful entry. */
function hasMetadata(metadata: Record<string, string>): boolean {
return Object.keys(metadata).length > 0
}
/**
* Pick one BLE-friendly metadata entry from a larger OpenCode metadata object.
*
* A permission event can include many nested fields, but a BLE device usually
* has limited display space. This helper prefers human-relevant keys and avoids
* sending large or non-string values to the daemon.
*/
function compactMetadataForBLE(metadata: Record<string, unknown> | undefined): Record<string, string> {
if (!metadata) {
return {}
}
for (const key of METADATA_DISPLAY_KEYS) {
const value = metadata[key]
if (typeof value === "string" && value.length > 0) {
return { [key]: truncateForBLE(value) }
}
}
for (const [key, value] of Object.entries(metadata)) {
if (typeof value === "string" && value.length > 0) {
return { [key]: truncateForBLE(value) }
}
}
return {}
}
/** Convert OpenCode permission fields into a short type label for the BLE UI. */
function displayTypeForBLE(permission: PermissionEventProperties): string {
return firstNonEmptyString(permission.type, permission.permission, permission.tool_name) ?? DEFAULT_PERMISSION_TYPE
}
/** Convert OpenCode permission fields into the main prompt title shown on BLE. */
function displayTitleForBLE(permission: PermissionEventProperties): string {
return (
firstNonEmptyString(permission.title, permission.tool_input?.description, permission.permission, permission.tool_name) ??
DEFAULT_PERMISSION_TITLE
)
}
/**
* Choose the single most useful metadata field for the BLE permission prompt.
*
* The order is: explicit permission metadata, tool input metadata, then the
* first command pattern. This keeps the device prompt concise while still
* showing the user why OpenCode is asking for permission.
*/
function displayMetadataForBLE(permission: PermissionEventProperties): Record<string, string> {
const metadata = compactMetadataForBLE(permission.metadata)
if (hasMetadata(metadata)) {
return metadata
}
const toolInput = compactMetadataForBLE(permission.tool_input)
if (hasMetadata(toolInput)) {
return toolInput
}
const pattern = permission.patterns?.find((value) => typeof value === "string" && value.length > 0)
if (pattern) {
return { command: truncateForBLE(pattern) }
}
return {}
}
/**
* Normalize the permission ID across OpenCode API versions.
*
* Different OpenCode events may call the same concept `id`, `requestID`, or
* `permissionID`. Reply code and BLE payloads should use one normalized value.
*/
export function permissionRequestID(permission: PermissionEventProperties): string {
const id = permission.id ?? permission.requestID ?? permission.permissionID
if (typeof id === "string" && id.length > 0) {
return id
}
throw new Error("OpenCode permission event is missing id/requestID/permissionID")
}
/**
* Build the protocol message sent to the BLE daemon whenever OpenCode's session
* state changes. This is a one-way notification, so the BLE device can update
* its UI but is not expected to send a reply.
*/
export function buildSessionStatusPayload(
sessionID: string,
status: { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number },
) {
return {
v: 1,
kind: "session.status",
event_id: eventID(),
session_id: sessionID,
requires_reply: false,
payload: status,
}
}
/**
* Tell the BLE daemon to dismiss any permission prompt for this session.
*
* This is used when OpenCode has already moved on, for example after the
* session becomes idle before the BLE device returns a decision.
*/
export function buildPermissionCancelPayload(sessionID: string) {
return {
v: 1,
kind: "permission.cancel",
event_id: eventID(),
session_id: sessionID,
requires_reply: false,
payload: {
reason: "opencode_state_changed",
},
}
}
/**
* Build the BLE permission prompt payload from an OpenCode permission event.
*
* This is the main protocol boundary between OpenCode and the BLE daemon. The
* outer fields describe routing and reply behavior; the nested `payload` fields
* are intentionally small and display-oriented for the device UI.
*/
export function buildPermissionPayload(permission: PermissionEventProperties, id = eventID()) {
const requestID = permissionRequestID(permission)
return {
v: 1,
kind: "permission.request",
event_id: id,
session_id: permission.sessionID,
permission_id: requestID,
requires_reply: true,
payload: {
id: requestID,
sessionID: permission.sessionID,
type: displayTypeForBLE(permission),
title: displayTitleForBLE(permission),
metadata: displayMetadataForBLE(permission),
},
}
}
@@ -0,0 +1,271 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { CONCURRENT_REJECT_MESSAGE, DECISION_TIMEOUT_SECONDS, DEFAULT_REJECT_MESSAGE } from "./config"
import { sendRequestToBLE } from "./ble-daemon-client"
import { appLogBestEffort, debugLog } from "./logging"
import { isPermissionDecision, replyToOpenCodePermission } from "./opencode-permission-reply"
import { buildPermissionPayload, permissionRequestID } from "./permission-payload"
import type { OpenCodePermissionClient, PermissionDecision, PermissionEventProperties, PermissionQueueItem } from "./types"
/**
* Queue permission prompts so the BLE device only asks one question at a time.
*
* This keeps the device UI simple and avoids racing multiple decisions for the
* same OpenCode session. It also mirrors OpenCode's behavior of cancelling
* other same-session pending permissions after one pending request is rejected.
*/
const permissionQueue: PermissionQueueItem[] = []
/** Prevents multiple asynchronous queue drainers from processing the same queue. */
let permissionQueueRunning = false
/**
* Tracks permission IDs currently waiting for a BLE device decision per session.
*
* The session.status handler uses this map to detect stale prompts when OpenCode
* becomes idle before the BLE device replies.
*/
const activeBLEPermissionsBySession = new Map<string, Set<string>>()
/**
* Permission IDs that OpenCode resolved outside the BLE decision path.
*
* This can happen when a user answers the same permission directly in the TUI
* while the BLE device is still showing the prompt. If the BLE device replies
* later, the queue ignores that reply instead of double-answering an already
* finished OpenCode request.
*/
const externallyResolvedPermissionIDs = new Set<string>()
/** Return a non-empty string or a fallback message for OpenCode replies. */
function nonEmptyString(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback
}
/** Mark a permission as actively displayed or pending on the BLE device. */
function beginActiveBLEPermission(permission: PermissionEventProperties) {
const requestID = permissionRequestID(permission)
const ids = activeBLEPermissionsBySession.get(permission.sessionID) ?? new Set<string>()
ids.add(requestID)
activeBLEPermissionsBySession.set(permission.sessionID, ids)
}
/** Remove a permission from the active BLE prompt tracking map. */
function endActiveBLEPermission(permission: PermissionEventProperties) {
const requestID = permissionRequestID(permission)
const ids = activeBLEPermissionsBySession.get(permission.sessionID)
if (ids === undefined) {
return
}
ids.delete(requestID)
if (ids.size === 0) {
activeBLEPermissionsBySession.delete(permission.sessionID)
}
}
/**
* Mark active prompts for a session as resolved by OpenCode state changes.
*
* The session.status handler calls this when a session becomes idle. Returning
* `true` tells the caller there was a prompt worth cancelling on the BLE device.
*/
export function markActiveBLEPermissionsExternallyResolved(sessionID: string): boolean {
const ids = activeBLEPermissionsBySession.get(sessionID)
if (ids === undefined || ids.size === 0) {
return false
}
for (const id of ids) {
externallyResolvedPermissionIDs.add(id)
}
return true
}
/**
* Skip queued prompts for a session after one prompt is rejected.
*
* OpenCode cancels other pending permission requests in the same session when
* one pending request is rejected. Because this demo serializes prompts before
* they reach BLE, later same-session items may still be waiting in this FIFO.
* Marking them as skipped rejects them back to OpenCode without showing stale
* prompts on the device during the next OpenCode turn.
*/
function markQueuedPermissionsSkipped(sessionID: string) {
for (const item of permissionQueue) {
if (item.permission.sessionID === sessionID && item.skipBLE !== true) {
item.skipBLE = true
item.skipMessage = CONCURRENT_REJECT_MESSAGE
}
}
}
/**
* Process one queued permission request from BLE prompt through OpenCode reply.
*
* The function defaults to rejection on timeout or daemon failure. That fail-safe
* behavior is important for a permission bridge: losing contact with the remote
* device should not grant access.
*/
async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<PermissionDecision | undefined> {
const requestID = permissionRequestID(item.permission)
let decision: PermissionDecision = "reject"
let decisionMessage: string | undefined
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,
message: decisionMessage,
})
} else {
try {
beginActiveBLEPermission(item.permission)
const result = await sendRequestToBLE(
"permission.request",
buildPermissionPayload(item.permission),
DECISION_TIMEOUT_SECONDS,
)
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
}
if (!isPermissionDecision(result.decision)) {
throw new Error(`Invalid BLE permission decision: ${String(result.decision)}`)
}
decision = result.decision
if (decision === "reject") {
// Always include a reject message. A bare OpenCode reject is treated as
// PermissionRejectedError and can block the current turn; feedback makes
// 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
// reason as device-side rejects: avoid turning a denial into a hard turn
// blocker when OpenCode can continue with later serial requests.
decisionMessage = DEFAULT_REJECT_MESSAGE
console.warn("Failed to get BLE permission decision, rejecting request", error)
await appLogBestEffort(item.client, "warn", "Failed to get BLE permission decision, rejecting request", {
error: String(error),
})
} finally {
endActiveBLEPermission(item.permission)
externallyResolvedPermissionIDs.delete(requestID)
}
}
if (decision === "reject") {
// After one reject, keep queued same-session prompts away from BLE. This
// matches OpenCode's concurrent-cancel semantics and prevents the device
// from showing permission requests that OpenCode has already invalidated.
markQueuedPermissionsSkipped(item.permission.sessionID)
}
try {
await replyToOpenCodePermission(
item.client,
item.permission,
decision,
decisionMessage,
item.serverUrl,
item.directory,
)
} catch (error) {
if (item.skipBLE === true) {
await appLogBestEffort(item.client, "warn", "Skipped queued permission was already resolved by OpenCode", {
requestID,
sessionID: item.permission.sessionID,
error: String(error),
})
return decision
}
throw error
}
return decision
}
/**
* Drain queued permission requests sequentially.
*
* A new drainer starts only when one is not already running. If new items arrive
* just as the loop exits, the `finally` block starts another pass.
*/
async function drainPermissionQueue() {
if (permissionQueueRunning) {
return
}
permissionQueueRunning = true
try {
while (permissionQueue.length > 0) {
const item = permissionQueue.shift()
if (item === undefined) {
continue
}
try {
await handlePermissionQueueItem(item)
item.resolve()
} catch (error) {
item.reject(error)
}
}
} finally {
permissionQueueRunning = false
if (permissionQueue.length > 0) {
void drainPermissionQueue()
}
}
}
/**
* Add a permission request to the serialized BLE decision queue.
*
* The returned Promise resolves only after the queue item has either replied to
* OpenCode or decided that OpenCode already resolved the request elsewhere.
*/
export function enqueuePermissionRequest(
client: OpenCodePermissionClient,
permission: PermissionEventProperties,
serverUrl?: URL,
directory?: string,
): Promise<void> {
return new Promise((resolve, reject) => {
permissionQueue.push({ client, permission, serverUrl, directory, resolve, reject })
void drainPermissionQueue()
})
}
/**
* Decide whether a session status update should cancel pending BLE prompts.
*
* An idle session means OpenCode is no longer actively waiting for the operation
* that originally caused the permission prompt. Any BLE prompt still shown for
* that session is therefore considered stale.
*/
export function statusShouldCancelPendingPermission(status: { type: "idle" | "busy" | "retry" }): boolean {
return status.type === "idle"
}
@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
/**
* The permission decisions this BLE demo accepts from the device.
*
* OpenCode may support additional replies such as "always", but this demo only
* exposes "once" and "reject" to keep the BLE device UI simple and to avoid
* accidentally granting broad long-lived permissions from a small remote UI.
*/
export type PermissionDecision = "once" | "reject"
/**
* Shape of the permission event fields this plugin reads from OpenCode.
*
* Several ID field names are optional because OpenCode permission events have
* changed across API versions. The bridge normalizes them before talking to the
* BLE daemon or replying back to OpenCode.
*/
export type PermissionEventProperties = {
id?: string
requestID?: string
permissionID?: string
sessionID: string
permission?: string
type?: string
title?: string
metadata?: Record<string, unknown>
patterns?: string[]
tool_name?: string
tool_input?: Record<string, unknown>
}
/**
* Normalized response expected from the BLE daemon after a permission request.
*
* `decision` is optional at the type level because the daemon response is parsed
* from JSON. Runtime code validates it before using it as a permission reply.
*/
export type BridgeResponse = {
decision?: PermissionDecision
message?: string
ok?: boolean
}
/**
* Envelope formats the BLE daemon may return.
*
* The daemon can wrap the actual payload in either `data` or `response`, and it
* may encode that payload as an object or a JSON string. Keeping this type loose
* lets the parser accept all supported daemon versions in one place.
*/
export type DaemonResponse = {
data?: unknown
response?: unknown
}
/**
* Partial OpenCode client surface used by this demo.
*
* The plugin intentionally models only the methods it calls instead of importing
* every generated SDK type. This makes the demo easier to read and allows it to
* support multiple OpenCode API versions and fallback paths.
*/
export type OpenCodePermissionClient = {
_client?: {
post?: (input: {
url: string
body: { reply: PermissionDecision; message?: string }
headers?: Record<string, string>
}) => Promise<unknown>
}
app?: {
log?: (input: {
body: {
service: string
level: "debug" | "info" | "warn" | "error"
message: string
extra?: Record<string, unknown>
}
}) => Promise<unknown>
}
permission?: {
reply?: (input: {
requestID: string
reply?: "once" | "always" | "reject"
message?: string
directory?: string
workspace?: string
}) => Promise<unknown>
respond?: (input: {
sessionID: string
permissionID: string
response?: "once" | "always" | "reject"
directory?: string
workspace?: string
}) => Promise<unknown>
}
postSessionIdPermissionsPermissionId?: (input: {
path: { id: string; permissionID: string }
body: { response: PermissionDecision }
}) => Promise<unknown>
}
/**
* Internal unit of work for the permission queue.
*
* Each item connects one OpenCode permission event to one BLE device decision,
* plus the Promise callbacks that let the plugin's event handler wait until the
* reply has been sent back to OpenCode.
*/
export type PermissionQueueItem = {
client: OpenCodePermissionClient
permission: PermissionEventProperties
serverUrl?: URL
directory?: string
skipBLE?: boolean
skipMessage?: string
resolve: () => void
reject: (error: unknown) => void
}