feat(ble): add vibe_indicator device support to OpenCode bridge

Detect and drive the display-only ble_uart_vibe_indicator sample alongside
the interactive MiaoBan companion device, and let multiple OpenCode instances
each bind their own indicator channel.

- plugin: after connect, probe the device over the daemon's generic /request
  path and classify it as vibe_indicator / generic / unknown — a vibe_indicator
  answers the indicator_count capability query, a 502 marks a generic device,
  and a transport failure stays unknown for retry on a later refresh. The daemon
  stays a generic transport and is unchanged; detection is a demo concern.
- plugin: route by device_type. The vibe_indicator mirrors OpenCode activity
  as four lamp states on its bound channel — executing (green blink),
  success (green solid), waiting-for-user (yellow solid, on permission
  prompts, decision left to the TUI), and error (red solid, on session.error
  and the following idle). Other devices keep the existing session.status /
  permission round-trip.
- plugin: add indicator_bind_channel / indicator_unbind_channel /
  indicator_show_binding tools. Each channel has at most one live owner:
  binding a channel owned by another running instance fails (force to take
  over), and stale claims are reclaimed via process-liveness checks. The
  per-directory binding is persisted and re-claimed across restarts
  (OPENCODE_BLE_BINDING_FILE).
- docs: document device detection, lamp effects, and channel binding.
This commit is contained in:
jiminxiang
2026-06-18 17:12:36 +08:00
parent de87877f39
commit 114fd4a9bc
7 changed files with 1010 additions and 23 deletions
@@ -0,0 +1,183 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
/**
* Persistent, exclusive store for indicator channel bindings, keyed by project
* directory.
*
* Each independent OpenCode instance runs in a project directory, so storing
* `directory -> { channel, pid }` lets a manual binding survive a restart while
* also enforcing that a channel has at most one live owner. The file is a small
* JSON object: `{ "<directory>": { "channel": N, "pid": P }, ... }`.
*
* Ownership is resolved with a liveness check: a claim whose owning process is
* gone is treated as stale and reclaimed automatically, so closing an instance
* frees its channel. Writes go through a temp file + rename so a partial write
* can never corrupt the shared file. Writes happen on startup auto-selection and
* on explicit bind/unbind; cross-instance write contention is low, and a
* concurrent claim race is possible but acceptable for this demo.
*/
type Binding = { channel: number; pid: number }
const DEFAULT_BINDING_FILE = join(homedir(), ".ble_uart_bridge", "indicator-bindings.json")
/** Location of the bindings file; override with `OPENCODE_BLE_BINDING_FILE`. */
function bindingFilePath(): string {
return process.env.OPENCODE_BLE_BINDING_FILE ?? DEFAULT_BINDING_FILE
}
/** Whether a process id belongs to a live process on this machine. */
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
// Signal 0 performs existence/permission checking without sending a signal.
process.kill(pid, 0)
return true
} catch (error) {
// EPERM means the process exists but is owned by another user.
return (error as { code?: string }).code === "EPERM"
}
}
async function readBindings(): Promise<Record<string, Binding>> {
try {
const raw = await readFile(bindingFilePath(), "utf8")
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object") {
const result: Record<string, Binding> = {}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
// Current format: { channel, pid }. Tolerate the legacy format where the
// value was a bare channel number (treated as having no live owner).
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
result[key] = { channel: value, pid: 0 }
} else if (value && typeof value === "object") {
const channel = (value as Record<string, unknown>).channel
const pid = (value as Record<string, unknown>).pid
if (typeof channel === "number" && Number.isInteger(channel) && channel >= 0) {
result[key] = { channel, pid: typeof pid === "number" ? pid : 0 }
}
}
}
return result
}
} catch {
// Missing file or invalid JSON: treat as an empty store.
}
return {}
}
async function writeBindings(bindings: Record<string, Binding>): Promise<void> {
const file = bindingFilePath()
const tmp = `${file}.${process.pid}.tmp`
await mkdir(dirname(file), { recursive: true })
await writeFile(tmp, `${JSON.stringify(bindings, null, 2)}\n`, "utf8")
await rename(tmp, file)
}
export type ClaimResult =
| { ok: true; channel: number }
| { ok: false; conflictDirectory: string; conflictPid: number }
/** Return the channel saved for a directory, or undefined if none is stored. */
export async function loadChannelForDirectory(directory: string | undefined): Promise<number | undefined> {
if (!directory) {
return undefined
}
const bindings = await readBindings()
return bindings[directory]?.channel
}
/**
* Claim a channel for a directory, enforcing one live owner per channel.
*
* Fails if another directory currently owns the channel and its owning process
* is still alive: a channel held by a running instance cannot be taken over.
* Stale claims (owner process gone) are reclaimed automatically. On success the
* directory's own previous claim (if any, on a different channel) is replaced,
* freeing that channel.
*/
export async function claimChannelForDirectory(
directory: string | undefined,
channel: number,
): Promise<ClaimResult> {
if (!directory) {
return { ok: true, channel }
}
const bindings = await readBindings()
for (const [otherDir, binding] of Object.entries(bindings)) {
if (otherDir === directory || binding.channel !== channel) {
continue
}
if (isProcessAlive(binding.pid)) {
return { ok: false, conflictDirectory: otherDir, conflictPid: binding.pid }
}
// Stale claim (owner process gone): release the other directory's claim.
delete bindings[otherDir]
}
bindings[directory] = { channel, pid: process.pid }
await writeBindings(bindings)
return { ok: true, channel }
}
/**
* Auto-select and claim the lowest-numbered free channel for a directory.
*
* A channel is "free" when no *other* directory with a live owning process holds
* it (channels held by dead processes are reclaimable, hence free). Returns the
* claimed channel, or `null` when every channel in `0..count-1` is occupied by a
* live instance — the caller should then treat the instance as unbound
* ("dangling"). `claimChannelForDirectory` re-reads the store before writing, so
* a channel lost to a concurrent claim is skipped and the next one is tried.
*/
export async function pickAndClaimFreeChannel(
directory: string | undefined,
count: number,
): Promise<number | null> {
if (count <= 0) {
return null
}
if (!directory) {
// No persistence without a directory key; default to the first channel.
return 0
}
const bindings = await readBindings()
const taken = new Set<number>()
for (const [otherDir, binding] of Object.entries(bindings)) {
if (otherDir !== directory && isProcessAlive(binding.pid)) {
taken.add(binding.channel)
}
}
for (let channel = 0; channel < count; channel++) {
if (taken.has(channel)) {
continue
}
const result = await claimChannelForDirectory(directory, channel)
if (result.ok) {
return channel
}
// Lost a concurrent race for this channel; try the next one.
taken.add(channel)
}
return null
}
/** Release any channel claimed by a directory (best-effort). */
export async function releaseChannelForDirectory(directory: string | undefined): Promise<void> {
if (!directory) {
return
}
const bindings = await readBindings()
if (bindings[directory]) {
delete bindings[directory]
await writeBindings(bindings)
}
}
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { BLE_DAEMON_URL } from "./config"
import type { DaemonResponse } from "./types"
/**
* Application-type detection for the connected ESP-BLE-UART device.
*
* The transport layer (NUS-over-BLE JSONL) is identical for every ESP-BLE-UART
* device, so neither the daemon nor this plugin can tell a `vibe_indicator`
* apart from any other device by connection alone. This detection is a demo
* concern — the daemon is a generic transport — so the probe lives here in the
* plugin: we send the `vibe_indicator` capability query and classify the device
* from its reply.
*
* - A well-formed `{"count": N}` reply with `N >= 1` identifies a `vibe_indicator`.
* - A protocol or application rejection (daemon HTTP 502) identifies a generic
* ESP-BLE-UART device that does not understand the indicator protocol.
* - A write failure, timeout, or unreachable daemon leaves the type `unknown`,
* so the caller can retry on a later refresh.
*/
export type DeviceType = "vibe_indicator" | "generic" | "unknown"
export type DeviceProbeResult = {
deviceType: DeviceType
/** Number of indicator channels, only present for a `vibe_indicator`. */
indicatorCount?: number
}
/** JSONL envelope `op` and capability query the indicator firmware answers. */
const PROBE_OP = "command"
const PROBE_DATA = { cmd: "query", type: "indicator_count" }
const PROBE_TIMEOUT_SECONDS = 5
/**
* Probe the connected device and classify its application type.
*
* This goes straight to the daemon's `/request` path (rather than
* `sendRequestToBLE`) because it needs the raw device payload (`{count}`) and
* the daemon's HTTP status to distinguish a rejection (generic device) from a
* transport failure (unknown), neither of which the permission-oriented response
* parser preserves.
*/
export async function probeDeviceType(): Promise<DeviceProbeResult> {
let response: Response
try {
response = await fetch(`${BLE_DAEMON_URL}/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ op: PROBE_OP, data: PROBE_DATA, timeout: PROBE_TIMEOUT_SECONDS }),
})
} catch {
// Daemon unreachable: cannot determine the type.
return { deviceType: "unknown" }
}
if (!response.ok) {
// 502: the device rejected the indicator probe (protocol/application error)
// → a generic ESP-BLE-UART device.
// 503 (write failed) / 504 (timeout) / anything else: transport could not
// deliver the probe, so the type stays unknown and is retried later.
return { deviceType: response.status === 502 ? "generic" : "unknown" }
}
try {
const body = (await response.json()) as DaemonResponse
const payload = body.data
const count =
payload && typeof payload === "object" ? (payload as Record<string, unknown>).count : undefined
// Reject booleans (typeof boolean !== "number") and non-integers, matching the
// firmware's integer indicator count. Require at least one channel.
if (typeof count === "number" && Number.isInteger(count) && count >= 1) {
return { deviceType: "vibe_indicator", indicatorCount: count }
}
// Answered the probe without a usable indicator count: treat as a generic device.
return { deviceType: "generic" }
} catch {
return { deviceType: "unknown" }
}
}
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { sendRequestToBLE } from "./ble-daemon-client"
/**
* Signal-light control for the `ble_uart_vibe_indicator` sample device.
*
* Unlike the interactive companion device, the vibe_indicator is display-only:
* its protocol (see the sample's `json_format.md`) exposes only `query` and
* `control` commands and has no way to return a permission decision. This module
* maps OpenCode activity onto the device's lamps.
*
* Every command must travel over the daemon's `/request` path
* (`sendRequestToBLE`), never `/notify`: the notify path sends an empty `id`,
* which the indicator firmware rejects with `id_not_specified`.
*/
/** JSONL envelope `op` the indicator firmware accepts. */
const INDICATOR_OP = "command"
/** Lamp ids within an indicator group: red / yellow / green GPIO. */
const LIGHT_RED = 0
const LIGHT_YELLOW = 1
const LIGHT_GREEN = 2
// Lamp actions defined by the sample protocol: 0 off, 1 on, 2 slow blink
// (~1 Hz), 3 fast blink (~3 Hz). Only the actions used by the state mapping
// below are bound to names.
const ACTION_OFF = 0
const ACTION_ON = 1
const ACTION_SLOW_BLINK = 2
/** Indicator control commands echo quickly; keep the request timeout short. */
const INDICATOR_REQUEST_TIMEOUT_SECONDS = 5
type LampCommand = { indicator_id: number; light_id: number; light_action: number }
/**
* High-level indicator states mirrored on one channel's lamps:
*
* - `executing` → green blink (work in progress)
* - `success` → green solid (finished without error)
* - `waiting` → yellow solid (waiting for user feedback, e.g. a permission)
* - `error` → red solid (the session errored out)
*/
export type IndicatorState = "executing" | "success" | "waiting" | "error"
function lamp(channel: number, lightId: number, lightAction: number): LampCommand {
return { indicator_id: channel, light_id: lightId, light_action: lightAction }
}
/**
* Map a high-level indicator state onto the three lamps (red/yellow/green) of
* one channel. Every state drives all three lamps so the previous one is always
* cleared.
*/
function lampCommandsForState(channel: number, state: IndicatorState): LampCommand[] {
switch (state) {
case "executing":
// Green blink — work in progress.
return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_SLOW_BLINK)]
case "waiting":
// Yellow solid — waiting for user feedback.
return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_ON), lamp(channel, LIGHT_GREEN, ACTION_OFF)]
case "error":
// Red solid — the session errored out.
return [lamp(channel, LIGHT_RED, ACTION_ON), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_OFF)]
case "success":
default:
// Green solid — finished without error.
return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_ON)]
}
}
async function sendControl(payload: LampCommand[]): Promise<void> {
// The device echoes the payload on success; the response is intentionally
// ignored. Errors propagate so callers can keep this best-effort.
await sendRequestToBLE(INDICATOR_OP, { cmd: "control", payload }, INDICATOR_REQUEST_TIMEOUT_SECONDS)
}
/** Mirror a high-level indicator state onto one channel's lamps. */
export async function sendIndicatorState(channel: number, state: IndicatorState): Promise<void> {
await sendControl(lampCommandsForState(channel, state))
}
/**
* Turn off all three lamps (red/yellow/green) of one channel.
*
* Used when an instance stops driving a channel — on unbind, or when rebinding
* to a different channel — so the channel it left behind does not keep a stale
* lamp lit (e.g. a green blink from the previous binding).
*/
export async function clearIndicatorChannel(channel: number): Promise<void> {
await sendControl([
lamp(channel, LIGHT_RED, ACTION_OFF),
lamp(channel, LIGHT_YELLOW, ACTION_OFF),
lamp(channel, LIGHT_GREEN, ACTION_OFF),
])
}
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2026 Esposif Systems (Shanghai) CO LTD
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import type { OpenCodePermissionClient } from "./types"
@@ -1,10 +1,18 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import type { Plugin } from "@opencode-ai/plugin"
import { type Plugin, tool } from "@opencode-ai/plugin"
import { getDaemonStatus, notifyBLE } from "./ble-daemon-client"
import {
claimChannelForDirectory,
loadChannelForDirectory,
pickAndClaimFreeChannel,
releaseChannelForDirectory,
} from "./binding-store"
import { DEFAULT_REJECT_MESSAGE } from "./config"
import { type DeviceType, probeDeviceType } from "./device-detection"
import { clearIndicatorChannel, sendIndicatorState, type IndicatorState } from "./indicator-control"
import { appLog, appLogBestEffort, showToastBestEffort } from "./logging"
import { replyToOpenCodePermission } from "./opencode-permission-reply"
import {
@@ -21,6 +29,18 @@ import type { DaemonStatus, OpenCodePermissionClient, RawPermissionEvent } from
type BLEPluginState = "unknown" | "connected" | "degraded" | "disabled"
/**
* Number of consecutive inconclusive ("unknown") device-type probes tolerated
* for one connection before the device is assumed to be a generic companion
* device. The probe classifies a device as generic only when its firmware
* actively rejects the indicator query (daemon HTTP 502). A companion device
* whose firmware silently ignores the query times out (HTTP 504) and would
* otherwise stay "pending" forever — permanently suppressing session-status
* forwarding and the BLE permission round-trip. Falling back to generic after a
* bounded number of retries re-enables those paths.
*/
const MAX_DEVICE_TYPE_PROBE_ATTEMPTS = 3
function stateFromStatus(status: DaemonStatus): BLEPluginState {
if (status.daemon_state === "exiting") {
return "disabled"
@@ -65,9 +85,110 @@ async function notifyStateChange(
export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => {
const openCodeClient = client as OpenCodePermissionClient
let bleState: BLEPluginState = "unknown"
// Device application type, detected by probing the firmware (see
// ./device-detection). A "vibe_indicator" is a display-only device that
// mirrors OpenCode activity on lamps and cannot return a permission decision;
// everything else is treated as the interactive companion device that
// understands the full permission protocol.
let bleDeviceType: DeviceType = "unknown"
// Number of indicator channels (groups) the device exposes, from the probe.
let indicatorCount: number | undefined
// Whether the device application type has been conclusively detected for the
// current connection session, so the probe runs at most once while connected
// (cleared on disconnect so a new or reconnected device is probed again). An
// inconclusive "unknown" result leaves this false so a later refresh retries
// while still connected.
let deviceTypeProbed = false
// Guard against overlapping refreshes probing the device at the same time.
let deviceTypeProbing = false
// Consecutive inconclusive probes for the current connection. Drives the
// generic fallback after MAX_DEVICE_TYPE_PROBE_ATTEMPTS; reset on disconnect.
let deviceTypeProbeAttempts = 0
// Indicator channel this OpenCode instance drives, or null when no channel is
// bound ("dangling": every channel is taken by another live instance, so lamp
// updates are skipped until a channel is bound). Resolved once the device type
// and channel count are known — a saved binding is re-claimed, otherwise the
// lowest free channel is auto-selected. Each independent instance therefore
// gets its own channel without fighting over one.
let instanceChannel: number | null = null
// Whether channel resolution has run for the connected indicator device, so it
// happens at most once and an explicit bind/unbind is not overridden.
let channelResolved = false
// Whether the current activity errored. Set on `session.error`, so the
// following `idle` shows red (error exit) instead of green (success); cleared
// when new work starts (`busy`).
let indicatorErrorActive = false
// Latest lamp state to apply once a channel is bound (covers auto-bind I/O race).
let pendingIndicatorState: IndicatorState | null = null
let indicatorLampChain = Promise.resolve()
let bleStateRefreshGeneration = 0
const connectedSessionNotifications = new Set<string>()
function isIndicatorDevice(): boolean {
return bleDeviceType === "vibe_indicator"
}
function isDeviceTypePending(): boolean {
return bleDeviceType === "unknown" && !deviceTypeProbed
}
// Run lamp work serialized on the single indicator chain and return a promise
// that resolves when *this* work item completes. All lamp writes — event
// driven (session.status / session.error / permission) and tool/lifecycle
// driven (bind / unbind / dispose) — must go through here so they apply in a
// deterministic order and never race to leave the lamp in the wrong state.
function runIndicatorLampWork(work: () => Promise<void>): Promise<void> {
const result = indicatorLampChain.then(work)
// Keep the queue alive after a failed update so later work still runs.
indicatorLampChain = result.then(
() => {},
() => {},
)
return result
}
function enqueueIndicatorLampWork(work: () => Promise<void>): void {
void runIndicatorLampWork(work).catch(() => {
// Fire-and-forget; failures are already logged by the work itself.
})
}
async function driveIndicatorState(state: IndicatorState): Promise<void> {
if (instanceChannel === null) {
pendingIndicatorState = state
return
}
try {
await sendIndicatorState(instanceChannel, state)
} catch (error) {
await appLogBestEffort(openCodeClient, "warn", "Failed to update indicator lamps", {
error: String(error),
})
await refreshBLEState(false)
}
}
// Flush the latest pending lamp state once a channel is bound. Enqueued on the
// shared chain so it cannot overtake or be overtaken by other lamp work.
function replayPendingIndicatorState(): void {
enqueueIndicatorLampWork(async () => {
if (instanceChannel === null || pendingIndicatorState === null) {
return
}
const state = pendingIndicatorState
pendingIndicatorState = null
try {
await sendIndicatorState(instanceChannel, state)
} catch (error) {
pendingIndicatorState = state
await appLogBestEffort(openCodeClient, "warn", "Failed to replay pending indicator state", {
error: String(error),
})
await refreshBLEState(false)
}
})
}
async function refreshBLEState(notifyConnected: boolean): Promise<BLEPluginState> {
const generation = ++bleStateRefreshGeneration
try {
@@ -77,7 +198,27 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
}
const nextState = stateFromStatus(status)
const shouldNotify = nextState !== bleState && (nextState !== "connected" || notifyConnected)
if (bleState === "connected" && nextState !== "connected") {
deviceTypeProbed = false
deviceTypeProbeAttempts = 0
bleDeviceType = "unknown"
indicatorCount = undefined
channelResolved = false
indicatorErrorActive = false
instanceChannel = null
pendingIndicatorState = null
}
bleState = nextState
// Detect the device application type the first time we observe a connected
// device, before notifying or driving lamps, so callers that await this
// refresh (event handlers, indicator tools) see the resolved type instead
// of racing the probe. Awaited here is safe: the init call site wraps this
// refresh in `void`, so plugin init stays non-blocking, and a connected
// device answers the probe quickly. deviceTypeProbed makes a conclusive
// result stick while an inconclusive one is retried on a later refresh.
if (nextState === "connected" && !deviceTypeProbed) {
await detectDeviceType()
}
if (shouldNotify) {
await notifyStateChange(openCodeClient, nextState, status)
}
@@ -95,6 +236,117 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
return bleState
}
// Probe the connected device to classify its application type (see
// ./device-detection), then — for a vibe_indicator — resolve which channel
// this instance drives. Detection is a demo concern, so it lives in the
// plugin rather than the generic transport daemon. An inconclusive result
// (write failed / timed out / daemon unreachable) leaves deviceTypeProbed
// false so a later refresh retries while the device stays connected. A
// concurrent in-flight guard keeps overlapping refreshes from probing twice.
async function detectDeviceType(): Promise<void> {
if (deviceTypeProbed || deviceTypeProbing) {
return
}
// Snapshot the refresh generation so a probe result from a connection that
// has since dropped (or been replaced by a reconnect) is discarded instead
// of being written onto the new/disconnected state. The probe itself can
// take seconds, during which another refresh may reset the device state.
const generation = bleStateRefreshGeneration
deviceTypeProbing = true
try {
const result = await probeDeviceType()
if (generation !== bleStateRefreshGeneration || bleState !== "connected") {
// The connection changed under us while probing; drop the stale result.
return
}
if (result.deviceType === "unknown") {
deviceTypeProbeAttempts += 1
if (deviceTypeProbeAttempts < MAX_DEVICE_TYPE_PROBE_ATTEMPTS) {
// Inconclusive; leave unprobed so a later refresh retries.
return
}
// Retries exhausted: assume a generic device that does not speak the
// indicator protocol, so the companion permission/status path is
// re-enabled instead of staying pending forever.
deviceTypeProbed = true
bleDeviceType = "generic"
indicatorCount = undefined
await appLogBestEffort(
openCodeClient,
"warn",
`Device type probe inconclusive after ${deviceTypeProbeAttempts} attempts; treating device as generic.`,
{},
)
return
}
deviceTypeProbed = true
bleDeviceType = result.deviceType
indicatorCount = result.indicatorCount
if (isIndicatorDevice() && !channelResolved && indicatorCount !== undefined) {
await resolveInstanceChannel()
}
} finally {
deviceTypeProbing = false
}
}
// Resolve which indicator channel this instance drives, once the device type
// and channel count are known (triggered from refreshBLEState). Order:
// 1. Re-claim the saved binding for this directory if it is still free.
// If a live instance has taken it, report the conflict and stay unbound
// (no silent re-selection — the user decides via indicator_bind_channel).
// 2. Otherwise auto-select and claim the lowest free channel.
// 3. If every channel is taken, stay unbound ("dangling") and warn.
// Runs at most once per connected indicator device; never blocks init.
async function resolveInstanceChannel(): Promise<void> {
const count = indicatorCount
if (channelResolved || !isIndicatorDevice() || count === undefined) {
return
}
channelResolved = true
try {
const saved = await loadChannelForDirectory(directory)
if (saved !== undefined) {
const result = await claimChannelForDirectory(directory, saved)
if (result.ok) {
instanceChannel = saved
replayPendingIndicatorState()
return
}
instanceChannel = null
await reportDanglingChannel(
`Saved indicator channel ${saved} is in use by another running OpenCode instance ` +
`(pid ${result.conflictPid}). This instance is unbound; bind a free channel with the ` +
`indicator_bind_channel tool.`,
)
return
}
const picked = await pickAndClaimFreeChannel(directory, count)
if (picked !== null) {
instanceChannel = picked
await appLogBestEffort(openCodeClient, "info", `Auto-bound to indicator channel ${picked} (of ${count}).`, {})
replayPendingIndicatorState()
return
}
instanceChannel = null
await reportDanglingChannel(
`All ${count} indicator channel(s) are in use by other running OpenCode instances. ` +
`This instance is unbound; free a channel or bind one explicitly with indicator_bind_channel.`,
)
} catch (error) {
// File I/O failure: leave the instance unbound rather than risk a collision.
instanceChannel = null
await appLogBestEffort(openCodeClient, "warn", "Failed to resolve indicator channel binding", {
error: String(error),
})
}
}
async function reportDanglingChannel(message: string): Promise<void> {
await showToastBestEffort(openCodeClient, "warning", "OpenCode ESP-BLE-UART Bridge", message)
await appLogBestEffort(openCodeClient, "warn", message, {})
}
// Fire-and-forget the initial BLE state check. Per OpenCode issue #4140,
// awaiting client methods (app.log, tui.showToast) during plugin init can
// hang OpenCode if the server isn't fully ready. The state will be
@@ -104,6 +356,34 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
})
return {
/**
* Turn off this instance's indicator lamps when the plugin is disposed
* (OpenCode shutting down, or reloading the plugin), so an exited instance
* does not leave its channel's lamp lit. Best-effort: if the daemon or device
* is already gone the call simply fails and is ignored.
*
* Only this instance's own channel is cleared, not every channel, so other
* OpenCode instances sharing the device keep their lamps. The persisted
* channel binding is intentionally left in place: this directory re-claims the
* same channel on the next start, and the now-dead pid lets another instance
* reclaim it if needed.
*/
dispose: async () => {
if (instanceChannel === null || !isIndicatorDevice()) {
return
}
const channel = instanceChannel
// Serialized on the shared chain so the final clear is not overtaken by a
// lamp update still queued from a last-moment event.
await runIndicatorLampWork(async () => {
try {
await clearIndicatorChannel(channel)
} catch {
// Best-effort; nothing else to do while shutting down.
}
})
},
/**
* Handle OpenCode events that are relevant to the BLE device.
*
@@ -152,6 +432,34 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
return
}
// See the permission handler: only treat a pending probe as a reason to
// skip while the device is connected. While disconnected (degraded) the
// type stays pending until it reconnects, and forwarding the session
// status below is the BLE send that triggers the daemon's reconnect.
if (state === "connected" && isDeviceTypePending()) {
return
}
if (isIndicatorDevice()) {
// Display-only device: map the session status onto the lamps.
// - busy/retry → executing (green blink); new work clears any
// previous error.
// - idle → success (green solid), unless this activity errored, in
// which case keep error (red solid) until the next busy.
const statusType = properties.status.type
enqueueIndicatorLampWork(async () => {
let indicatorState: IndicatorState
if (statusType === "busy" || statusType === "retry") {
indicatorErrorActive = false
indicatorState = "executing"
} else {
indicatorState = indicatorErrorActive ? "error" : "success"
}
await driveIndicatorState(indicatorState)
})
return
}
if (
// If the session became idle while a BLE permission prompt is
// active, mark that prompt as externally resolved and ask the
@@ -195,6 +503,27 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
})()
}
if (eventType === "session.error") {
// Set synchronously so a concurrent session.status idle sees the error
// before lamp work is enqueued.
indicatorErrorActive = true
void (async () => {
try {
const state = await refreshBLEState(true)
if (state === "disabled" || !isIndicatorDevice()) {
return
}
enqueueIndicatorLampWork(async () => {
await driveIndicatorState("error")
})
} catch (error) {
await appLogBestEffort(openCodeClient, "warn", "session.error handler failed", {
error: String(error),
})
}
})()
}
// OpenCode's internal event system uses "permission.asked"; the SDK
// event system uses "permission.updated". Accept both for compatibility
// across OpenCode versions.
@@ -203,6 +532,30 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
try {
const state = await refreshBLEState(true)
if (isIndicatorDevice()) {
// The indicator cannot return a decision. Show "waiting for user
// feedback" (yellow solid, best-effort, non-blocking) and let the
// user answer in the OpenCode TUI. Do not reply to OpenCode here.
// Enqueued on the shared lamp chain so it cannot race a concurrent
// session.status update and leave the lamp in the wrong state.
if (state !== "disabled" && instanceChannel !== null) {
enqueueIndicatorLampWork(async () => {
await driveIndicatorState("waiting")
})
}
return
}
if (state === "connected" && isDeviceTypePending()) {
// Probe inconclusive on a *connected* device: do not route to the
// generic BLE permission path (the device may be a vibe_indicator).
// Answer in the OpenCode TUI. Gate this on "connected" only: when the
// device is disconnected (degraded) the type is reset to pending and is
// not re-probed until it reconnects, so falling through to
// enqueuePermissionRequest below is what triggers the daemon's
// reconnect-on-send. Blocking here would strand the user on a manual
// reconnect.
return
}
if (state === "disabled") {
await replyToOpenCodePermission(
openCodeClient,
@@ -226,5 +579,137 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
}
}
},
/**
* User-invokable tools for binding this OpenCode instance to an indicator
* channel. When several independent OpenCode instances share one indicator
* device, each instance can claim its own channel so their lamps do not
* collide. Ask the assistant, e.g. "bind the indicator to channel 1".
*/
tool: {
indicator_bind_channel: tool({
description:
"Bind this OpenCode instance's vibe_indicator lamps to a specific channel (indicator group, 0-based). " +
"Use when multiple OpenCode instances share one indicator device and each should drive its own channel. " +
"A channel can only be owned by one live instance; binding a channel already owned by another running " +
"instance fails (pick a free channel instead).",
args: {
channel: tool.schema.number(),
},
async execute(args) {
await refreshBLEState(false)
if (!isIndicatorDevice()) {
return "No vibe_indicator device is connected; channel binding only applies to indicator devices."
}
const count = indicatorCount
const channel = Math.trunc(args.channel)
// Validate the target channel BEFORE touching any state. If the channel
// count is not known yet, or the channel is out of range, refuse without
// moving the binding: otherwise an invalid channel would overwrite the
// current (valid) binding in the store and dark its real lamp, leaving
// this instance pointed at a non-existent channel. Refusing here keeps
// the existing binding fully intact — i.e. it falls back to it.
if (count === undefined) {
return "Indicator channel count is not known yet; please try binding again in a moment."
}
if (channel < 0 || channel >= count) {
const current = instanceChannel === null ? "none (unbound)" : `channel ${instanceChannel}`
return `Invalid channel ${channel}. Valid range is 0..${count - 1}. Keeping the current binding (${current}).`
}
// An explicit, valid bind is the user's decision; stop auto-resolution
// from overriding it later.
channelResolved = true
// Claim the channel exclusively (persisted). A channel owned by another
// live instance cannot be taken over.
let claimed = true
try {
const result = await claimChannelForDirectory(directory, channel)
if (!result.ok) {
return (
`Channel ${channel} is already bound by another running OpenCode instance ` +
`(pid ${result.conflictPid}, directory ${result.conflictDirectory}). ` +
`Pick a free channel.`
)
}
} catch {
// Filesystem error: keep the binding in memory only (no exclusivity
// guarantee and it will reset on restart).
claimed = false
}
const previousChannel = instanceChannel
instanceChannel = channel
// Serialize the rebind lamp work on the shared chain so it cannot race
// a concurrent event-driven update. Moving off a different channel:
// turn its lamps off so the channel we left behind does not keep a
// stale lamp lit (e.g. the previous channel's green blink). Then a
// best-effort visual confirmation: light the chosen channel green.
await runIndicatorLampWork(async () => {
if (previousChannel !== null && previousChannel !== channel) {
try {
await clearIndicatorChannel(previousChannel)
} catch {
// Ignore; the new channel binding below still applies.
}
}
try {
await sendIndicatorState(channel, "success")
} catch {
// Ignore; the binding still takes effect for later status updates.
}
})
replayPendingIndicatorState()
const note = claimed ? "" : " (could not be saved to disk; it will reset on restart)"
return `Bound this OpenCode instance to indicator channel ${channel} (of ${count})${note}.`
},
}),
indicator_unbind_channel: tool({
description:
"Release this OpenCode instance's indicator channel binding so another instance can claim it. " +
"This instance becomes unbound and stops driving any lamps until a channel is bound again.",
args: {},
async execute() {
try {
await releaseChannelForDirectory(directory)
} catch {
// Best-effort; the in-memory reset below still happens.
}
// Turn off the lamps we were driving so an unbound instance does not
// leave a stale lamp lit on the channel it just released. Serialized
// on the shared chain so it cannot race a concurrent lamp update.
const releasedChannel = instanceChannel
if (releasedChannel !== null) {
await runIndicatorLampWork(async () => {
try {
await clearIndicatorChannel(releasedChannel)
} catch {
// Best-effort; the in-memory reset below still happens.
}
})
}
// Stay unbound (do not auto-rebind) until the user binds again.
instanceChannel = null
pendingIndicatorState = null
channelResolved = true
return "Released the channel binding; this instance is now unbound and will not drive any indicator lamps until you bind a channel."
},
}),
indicator_show_binding: tool({
description:
"Show which vibe_indicator channel this OpenCode instance is currently bound to, and how many channels the device exposes.",
args: {},
async execute() {
await refreshBLEState(false)
const bound = instanceChannel === null ? "none (unbound)" : String(instanceChannel)
if (!isIndicatorDevice()) {
return `Bound channel: ${bound}. No vibe_indicator device is currently connected.`
}
const range = indicatorCount !== undefined ? `. Device channels: 0..${indicatorCount - 1}` : ""
if (instanceChannel === null) {
return `Bound channel: none (unbound/dangling)${range}.`
}
return `Bound channel: ${instanceChannel}${range}.`
},
}),
},
}
}
@@ -96,18 +96,22 @@ export async function replyToOpenCodePermission(
}
if (serverUrl !== undefined && directory !== undefined) {
const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), {
method: "POST",
headers: opencodeRequestHeaders(directory),
body: JSON.stringify({
reply: decision,
message,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${await response.text()}`)
try {
const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), {
method: "POST",
headers: opencodeRequestHeaders(directory),
body: JSON.stringify({
reply: decision,
message,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${await response.text()}`)
}
return
} catch {
// fall through to v1 / deprecated v2 probes
}
return
}
if (typeof client.postSessionIdPermissionsPermissionId === "function") {