feat(tools): add opencode ble uart bridge demo

(cherry picked from commit 0b07f41e5a)

Co-authored-by: Zhou Xiao <zhouxiao@espressif.com>
This commit is contained in:
Zhou Xiao
2026-05-09 10:52:10 +08:00
parent 540c12cd6b
commit f043ef20e7
11 changed files with 1508 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ BLE UART Bridge is a host-side utility for talking to ESP-IDF applications that
- [Core](#core)
- [Console](#console)
- [Daemon](#daemon)
- [Demos](#demos) - example integrations built on the daemon
- [Choosing Core, Console, or Daemon](#choosing-core-console-or-daemon)
- [Profile compatibility](#profile-compatibility)
- [Dependencies](#dependencies)
@@ -162,6 +163,8 @@ tools/ble/ble_uart_bridge/
│ ├── Quick-Start-BLE-UART-Daemon.md
│ ├── Profile-Compatibility.md
│ └── PORTING.md
├── demos/
│ └── opencode/
└── src/
├── core/
├── console/
@@ -225,6 +228,17 @@ The daemon protocol is intentionally small. It is not a full RPC framework. It d
By default, the daemon binds to `127.0.0.1`. Keep it on a loopback address unless you add your own network access control, because the daemon exposes unauthenticated HTTP endpoints that can send data to the BLE device.
## Demos
The `demos/` directory contains example integrations that build on BLE UART
Bridge components.
- [BLE UART Bridge Demo - OpenCode Integration](demos/opencode/README.md) shows
how an OpenCode plugin can forward session status and permission requests to a
BLE device through the daemon. It also includes a firmware-side protocol
reference for devices, such as the planned `esp-vocat` / MiaoBan (喵伴)
example in `esp-iot-solution`.
## Choosing Core, Console, or Daemon
| Component | Best for | Interface |
@@ -269,5 +283,6 @@ The tool depends on:
- [Quick-Start-BLE-UART-Console.md](docs/Quick-Start-BLE-UART-Console.md)
- [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md)
- [BLE UART Bridge Demo - OpenCode Integration](demos/opencode/README.md)
- [Profile-Compatibility.md](docs/Profile-Compatibility.md)
- [PORTING.md](docs/PORTING.md)

View File

@@ -0,0 +1,408 @@
# BLE UART Bridge Demo - OpenCode Integration
This demo sketches how to bridge OpenCode events to a BLE device through
`tools/ble/ble_uart_bridge`.
It is intentionally small and example-oriented. Both this OpenCode plugin and
the `ble_uart_bridge` daemon protocol are demos that show one possible local IPC
pattern; developers are encouraged to customize the plugin payloads, firmware UI,
device decisions, and daemon-side protocol handling for their own products.
## Table of contents
- [Goal](#goal)
- [Quick Start](#quick-start)
- [How it relates to BLE UART Bridge](#how-it-relates-to-ble-uart-bridge)
- [Daemon JSONL protocol summary](#daemon-jsonl-protocol-summary)
- [Files](#files)
- [Demo and customization notes](#demo-and-customization-notes)
- [Environment variables](#environment-variables)
- [Current assumptions](#current-assumptions)
- [Message routing](#message-routing)
- [Firmware protocol reference](#firmware-protocol-reference)
- [Plugin message: session status](#plugin-message-session-status)
- [Plugin message: permission cancel](#plugin-message-permission-cancel)
- [Plugin message: permission request](#plugin-message-permission-request)
- [Firmware UI sketch](#firmware-ui-sketch)
- [Safety defaults](#safety-defaults)
- [Open items](#open-items)
## Goal
Use an OpenCode plugin to:
- forward session status events to a BLE device;
- forward permission requests to a BLE device;
- receive `once` / `reject` decisions from the device;
- reply to OpenCode permission requests through the OpenCode SDK client.
```mermaid
flowchart LR
OC[OpenCode] -->|session.status| Plugin[OpenCode BLE plugin]
OC -->|permission.asked| Plugin
Plugin -->|POST /notify| Daemon[ble_uart_bridge daemon]
Plugin -->|POST /request| Daemon
Daemon -->|BLE NUS JSONL| Device[BLE device UI]
Device -->|once / reject| Daemon
Daemon -->|HTTP response| Plugin
Plugin -->|SDK permission reply| OC
```
## Quick Start
1. Prepare a BLE device firmware example.
The intended firmware companion is an `esp-vocat` example for the MiaoBan
(喵伴) device, planned for the `esp-iot-solution` repository. Until that
example is available, use any device that implements Nordic UART Service and
the JSONL request/response envelope described in
[Firmware protocol reference](#firmware-protocol-reference).
2. Install the bridge dependencies:
```bash
python -m pip install -r tools/ble/ble_uart_bridge/requirements.txt
```
3. Start the BLE UART daemon:
```bash
python tools/ble/ble_uart_bridge/main.py list-devices
python tools/ble/ble_uart_bridge/main.py daemon "<device_id>" --host 127.0.0.1 --port 8888
```
The plugin connects to `http://127.0.0.1:8888` by default. If the daemon uses
another endpoint, inject it with `OPENCODE_BLE_DAEMON_URL` before starting
OpenCode:
```bash
export OPENCODE_BLE_DAEMON_URL="http://127.0.0.1:9999"
```
4. Copy or symlink the plugin into an OpenCode plugin directory, keeping the
TypeScript files together in one subdirectory.
Project-level install, for one project only:
```bash
mkdir -p <proj-path>/.opencode/plugins/opencode-ble-uart-bridge
cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts <proj-path>/.opencode/plugins/opencode-ble-uart-bridge/
```
User-level install, for all projects that use this OpenCode user config:
```bash
mkdir -p ~/.config/opencode/plugins/opencode-ble-uart-bridge
cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts ~/.config/opencode/plugins/opencode-ble-uart-bridge/
```
5. Merge the relevant parts of `opencode.json.example` into your `opencode.json`.
For OpenCode plugin loading details, see the official
[OpenCode plugin documentation](https://opencode.ai/docs/en/plugins/).
Project-level `opencode.json` example:
```json
{
"plugin": [
".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}
```
User-level `~/.config/opencode/opencode.json` example:
```json
{
"plugin": [
"<home-path>/.config/opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}
```
- `plugin` tells OpenCode which plugin module to load when the session starts.
The official docs describe local plugin auto-loading from
`<proj-path>/.opencode/plugins/` and `~/.config/opencode/plugins/`. This
demo keeps the entry file and helper modules together in one subdirectory,
so `opencode.json.example` points directly to the entry TypeScript file.
For user-level installs, point this entry to the installed file under
`~/.config/opencode/plugins/opencode-ble-uart-bridge/`; use an absolute
home path if your OpenCode config loader does not expand `~`.
- `permission.edit: "ask"` makes OpenCode ask before using the `edit` tool.
Those permission prompts are what this plugin forwards to the BLE device as
`permission.request` messages.
6. Start OpenCode after the daemon is running.
OpenCode loads plugins during startup. This demo checks `GET /status` once
while loading; if the BLE daemon is not reachable, the plugin registers no
event handler for that OpenCode process. Start or restart OpenCode after the
daemon is available.
To verify the path, trigger an `edit` permission request. The BLE device
should receive a `permission.request` JSONL message and return `once` or
`reject`.
After the `esp-vocat` example is published in `esp-iot-solution`, this section
should be updated with the exact example path, build/flash commands, and any
MiaoBan-specific button or display behavior.
## How it relates to BLE UART Bridge
The OpenCode plugin does not talk to BLE directly. It sends local HTTP requests
to the BLE UART Bridge daemon, and the daemon keeps the BLE connection open for
the plugin:
- `POST /notify` sends fire-and-forget events, such as session status updates.
- `POST /request` sends request/response messages, such as permission prompts
that must wait for a device decision.
- `GET /status` can be used by tools to inspect daemon health and connection
state.
For the daemon itself, see:
- [BLE UART Bridge README](../../README.md)
- [BLE UART Daemon Quick Start](../../docs/Quick-Start-BLE-UART-Daemon.md)
### Daemon JSONL protocol summary
The daemon forwards HTTP messages over BLE UART as newline-delimited JSON
(JSONL). Every BLE message is one JSON object followed by a final `\n`. The
example daemon protocol is named `esp-jsonl-rpc-lite-v1`.
Host-to-device messages use a small envelope:
```json
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request"}}
```
Device-to-host responses echo the same `id` and return either `data` or an
`error`:
```json
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once"}}
```
The daemon envelope is only a demonstration protocol, not a complete RPC
framework. It is designed to be easy to inspect, easy to parse with firmware
JSON libraries such as `cJSON`, and easy to replace with an application-specific
protocol when needed. The OpenCode-specific payloads carried in the `data` field
are documented below in [Firmware protocol reference](#firmware-protocol-reference).
## Files
- `src/opencode-ble-uart-bridge.ts` — OpenCode plugin entry point using `/notify` for status and `/request` for permission decisions.
- `src/*.ts` helper modules — typed, commented demo code for payloads, BLE daemon transport, OpenCode replies, and permission queue handling.
- `opencode.json.example` — example OpenCode config to load the plugin and ask for permissions.
## Demo and customization notes
This directory is meant to be copied, modified, and used as a starting point:
- Change `src/permission-payload.ts` if the BLE device needs a different display
model for OpenCode permissions.
- Change the [Firmware protocol reference](#firmware-protocol-reference) and the
firmware parser together if you add more message kinds, decision types,
buttons, or display states.
- Set `OPENCODE_BLE_DAEMON_URL` if the daemon runs on a different local
host/port.
- Keep local security requirements in mind. The daemon defaults to
`127.0.0.1:8888` and exposes unauthenticated local HTTP endpoints; do not bind
it to a public interface without adding your own access control.
The current demo intentionally keeps the BLE device decision model simple:
permission requests can be approved once with `once` or denied with `reject`.
## Environment variables
- `OPENCODE_BLE_DAEMON_URL`: BLE daemon base URL. Defaults to
`http://127.0.0.1:8888`.
- `OPENCODE_BLE_DECISION_TIMEOUT_SECONDS`: permission decision timeout in
seconds. Defaults to `60`; set it to a positive number.
- `OPENCODE_BLE_DEBUG=1`: enables verbose local plugin logs.
## Current assumptions
- The BLE daemon endpoint is configured by `OPENCODE_BLE_DAEMON_URL`, defaulting
to `http://127.0.0.1:8888`.
- The BLE daemon supports both `POST /notify` and `POST /request`.
- The BLE device implements Nordic UART Service.
- The BLE device understands JSON messages described in
[Firmware protocol reference](#firmware-protocol-reference).
- Permission decisions from the current single-key device are: `once`, `reject`.
- Permission requests are queued so the BLE device only displays one active
prompt at a time.
- The plugin fills missing permission `type` / `title` / `metadata` fields before sending to the device.
- Permission metadata sent to BLE is compacted to one display field (`command`, `path`, `url`, or first string field) and truncated.
- Session status forwarding is best-effort and should not block OpenCode.
- If the BLE daemon cannot return a permission decision, the plugin replies `reject`.
## Message routing
- `session.status` uses `POST /notify` because it is telemetry and does not require a device response.
- `permission.request` uses `POST /request` because OpenCode must wait for the device's `once` / `reject` decision.
- `permission.cancel` uses `POST /notify` because it only tells the device to clear a pending permission UI.
- The plugin sends structured JSON objects as daemon `data`; it does not double-encode plugin payloads as JSON strings.
## Firmware protocol reference
The BLE UART Bridge daemon wraps plugin messages into JSONL over BLE UART. For
request/response RPC, `POST /request` sends a non-empty bridge request ID:
```json
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request"}}
```
The BLE device must reply with the same bridge request ID. Successful responses
can return structured JSON in `data`:
```json
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once"}}
```
For fire-and-forget telemetry, `POST /notify` sends an empty bridge request ID.
The BLE device should process the message and must not reply:
```json
{"v":1,"id":"","op":"session.status","data":{"v":1,"kind":"session.status"}}
```
Both directions are newline-delimited JSON. Plugin payloads are sent as
structured JSON objects in the daemon `data` field, not as JSON-encoded strings.
### Plugin message: session status
Sent through daemon `POST /notify` when OpenCode publishes `session.status`.
The daemon envelope uses `op: "session.status"` and `id: ""`.
```json
{
"v": 1,
"kind": "session.status",
"event_id": "evt_...",
"session_id": "ses_...",
"requires_reply": false,
"payload": {
"type": "busy"
}
}
```
Device response: none.
### Plugin message: permission cancel
Sent through daemon `POST /notify` when OpenCode reaches `session.status: idle`
while a BLE permission request is still pending on the device. This covers cases
where the same permission was answered from the OpenCode TUI instead of the BLE
device. The daemon envelope uses `op: "permission.cancel"` and `id: ""`.
```json
{
"v": 1,
"kind": "permission.cancel",
"event_id": "evt_...",
"session_id": "ses_...",
"requires_reply": false,
"payload": {
"reason": "opencode_state_changed"
}
}
```
Device response: none. The device should clear any pending permission UI and
must not emit a later reply for the cancelled request.
### Plugin message: permission request
Sent through daemon `POST /request` when OpenCode publishes `permission.asked`.
The daemon envelope uses `op: "permission.request"` and a non-empty request ID.
```json
{
"v": 1,
"kind": "permission.request",
"event_id": "evt_...",
"session_id": "ses_...",
"permission_id": "perm_...",
"requires_reply": true,
"payload": {
"id": "perm_...",
"sessionID": "ses_...",
"type": "bash",
"title": "Run command",
"metadata": {
"command": "git status"
}
}
}
```
Device response in the daemon JSONL envelope:
```json
{
"v": 1,
"id": "<bridge-request-id>",
"ok": true,
"data": {
"decision": "once",
"message": "Approved from BLE device"
}
}
```
Device error response in the daemon JSONL envelope:
```json
{
"v": 1,
"id": "<bridge-request-id>",
"ok": false,
"error": "device rejected permission"
}
```
The daemon turns this into an HTTP error for `/request`; the plugin fails closed
and replies `reject` to OpenCode.
Valid decisions:
- `once`
- `reject`
### Firmware UI sketch
For `session.status`:
- show `busy`, `idle`, or `retry`.
For `permission.request`:
- show permission type/title;
- show compact metadata such as command/path/url;
- expose two actions on the current single-key device: `Once` and `Reject`.
The OpenCode plugin normalizes optional permission fields before forwarding to
the device: missing `type` becomes `unknown`, missing `title` becomes
`Permission request`, and missing/non-string metadata becomes `{}`. Metadata is
compacted to one display field (`command`, `path`, `url`, or first string field)
and truncated before crossing BLE.
### Safety defaults
- If the device UI times out, return `reject`.
- If JSON parsing fails, return an error response.
- Keep displayed metadata short to avoid leaking large prompts or secrets.
## Open items
- Improve plugin startup UX when the daemon health/status check fails.
- Add an integration test with a mocked BLE daemon.

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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
}
}
},
}
}

View File

@@ -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"
}

View File

@@ -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),
},
}
}

View File

@@ -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"
}

View File

@@ -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
}