mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
feat(tools): add opencode ble uart bridge demo
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user