From ded47a188f80de75b8f638075eb83f2ebc295444 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 22 Oct 2026 18:10:20 +0100 Subject: [PATCH 1/2] Support OpenCode 2 and keep OpenCode 1.18.29+ OpenCode 2 does not run v1 plugin code. The plugin now has one default export with id and setup() for v2, and server() for v1. The named SidePulse export is gone, because a v1 loader can load it as a second instance. OpenCode 2 ends a turn with session.execution.succeeded, .failed or .interrupted, and sends no session.idle or session.error. Event data moved from properties to data. The self-check replays three traces captured from OpenCode 2.1.2. Closes #2 --- README.md | 22 +++++++--- package.json | 2 +- plugin.mjs | 111 ++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 100 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 47d1784..2ee75ca 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ Install the opencode-sidepulse plugin for me. 1. Find my opencode config. Prefer the global file at ~/.config/opencode/opencode.json or ~/.config/opencode/opencode.jsonc. Create the global file if neither exists, with only {"$schema": "https://opencode.ai/config.json"}. -2. Add the string "opencode-sidepulse" to the top-level "plugin" array. The value is an - array of strings. Create the array if it is absent. Keep every existing entry. +2. Add the string "opencode-sidepulse" to the top-level plugin array. The key is + "plugins" on OpenCode 2 and "plugin" on OpenCode 1. Run `opencode --version` to find + the version. Create the array if it is absent. Keep every existing entry. 3. Make sure that exactly one sidepulse entry loads. Check ~/.config/opencode/plugins/, ~/.config/opencode/plugin/, .opencode/plugins/ and .opencode/plugin/ for any file that writes to LEDS.LED. Delete any copy you find, @@ -37,7 +38,13 @@ Do not change any other config field. ### Option 2: edit the config yourself -Add the plugin to `opencode.json` or `~/.config/opencode/opencode.jsonc`: +On OpenCode 2, run this command: + +```sh +opencode plugin add opencode-sidepulse +``` + +On OpenCode 1, add the plugin to `opencode.json` or `~/.config/opencode/opencode.jsonc`: ```json { @@ -46,6 +53,8 @@ Add the plugin to `opencode.json` or `~/.config/opencode/opencode.jsonc`: } ``` +OpenCode 2 uses the key `plugins` in place of `plugin`. + opencode installs the package from npm at startup, so you do not run `npm install`. Quit opencode and start it again. opencode loads config once at startup. @@ -106,7 +115,8 @@ file write is the whole interface. ## Requirements - macOS. -- opencode. +- OpenCode 2, or OpenCode 1.18.29 or newer. Older OpenCode 1 releases cannot load the + plugin. Use `opencode-sidepulse@0.1.7` on those releases. - A SidePulse Pro or SidePulse Dot. The plugin needs no dependencies and no build step. It uses only Node standard library @@ -129,8 +139,8 @@ Without a clone, check that the published package imports: ```sh bunx --package opencode-sidepulse bun -e \ - 'import("opencode-sidepulse").then(m => console.log(Object.keys(m).join(",")))' -# SidePulse,default + 'import("opencode-sidepulse").then(m => console.log(Object.keys(m).join(","), m.default.id))' +# default opencode-sidepulse ``` That confirms the package only. Use a clone for the self-check and the demo. diff --git a/package.json b/package.json index 6db3eba..fc59fe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-sidepulse", - "version": "0.1.7", + "version": "0.2.0", "description": "Show opencode agent status on a SidePulse Pro or SidePulse Dot LED device.", "keywords": [ "opencode", diff --git a/plugin.mjs b/plugin.mjs index 0375857..b310e64 100644 --- a/plugin.mjs +++ b/plugin.mjs @@ -150,6 +150,11 @@ function signalForEvent(type, props) { if (type === "permission.updated" || type === "permission.asked") return "ask" if (type === "permission.replied") return "reply" if (type === "session.idle") return "idle" + // OpenCode v2 ends a turn with one of these three events, and sends no session.idle + // or session.error. + if (type === "session.execution.succeeded") return "idle" + if (type === "session.execution.failed") return "error" + if (type === "session.execution.interrupted") return "idle" if (type === "session.error") // A cancel is not a failure, so it must not go red. It gets no state of its own // beyond that: upstream sets IDLE_VISIBLE_SECONDS to 0, so an Idle / Ready status is @@ -230,14 +235,9 @@ async function report(signal, sessionID, cwd, tool) { } catch {} } -// ponytail: paint nothing on init. opencode disposes and re-initializes plugins during a -// session, so an init paint would stomp the live state back to idle. The device holds its -// last program anyway, and shows INIT.LED on power-up. -export const SidePulse = async ({ directory } = {}) => { - const cwd = directory ?? process.cwd() - - // The MacBook SD reader cuts power to the device after three minutes of inactivity. - // The app does its own keepalive, so only do it when the app is absent. +// The MacBook SD reader cuts power to the device after three minutes of inactivity. +// The app does its own keepalive, so only do it when the app is absent. +function keepalive() { const alive = setInterval(() => { if (socket()) return const dir = mount() @@ -249,29 +249,64 @@ export const SidePulse = async ({ directory } = {}) => { }, 60_000) // Do not hold the process open on exit. alive.unref?.() + return () => clearInterval(alive) +} - return { - dispose: async () => { - clearInterval(alive) - }, - "chat.message": async (input) => report("start", input.sessionID, cwd), - // A cancelled turn can still settle its in-flight tool, so drop tool events that - // arrive after the turn ended. A new turn re-arms the guard through chat.message, - // so nothing is lost. - "tool.execute.before": async (input) => - turnActive && report(signalForTool(input.tool, "before"), input.sessionID, cwd, input.tool), - "tool.execute.after": async (input) => - turnActive && report(signalForTool(input.tool, "after"), input.sessionID, cwd, input.tool), - event: async ({ event }) => { - const props = event.properties ?? {} - const signal = signalForEvent(event.type, props) - if (!signal) return - return report(signal, props.sessionID ?? props.session_id ?? "unknown", cwd) - }, - } +// A cancelled turn can still settle its in-flight tool, so drop tool events that arrive +// after the turn ended. A new turn re-arms the guard, so nothing is lost. +function onTool(phase, tool, sessionID, cwd) { + return turnActive && report(signalForTool(tool, phase), sessionID, cwd, tool) +} + +// v1 events carry their payload in `properties`, v2 events in `data`. +function onEvent(event, cwd) { + const props = event.properties ?? event.data ?? {} + const signal = signalForEvent(event.type, props) + if (!signal) return + return report(signal, props.sessionID ?? props.session_id ?? "unknown", cwd) +} + +// One default export: OpenCode v1 (1.18.29 and newer) calls server(), v2 calls setup(). +// No named export, because v1 can load it as a second instance that fights for the device. +// NOTE: no paint on init, because opencode re-initializes plugins during a session. +const plugin = { + id: "opencode-sidepulse", + + async server({ directory } = {}) { + const cwd = directory ?? process.cwd() + const stop = keepalive() + return { + dispose: async () => stop(), + "chat.message": async (input) => report("start", input.sessionID, cwd), + "tool.execute.before": async (input) => onTool("before", input.tool, input.sessionID, cwd), + "tool.execute.after": async (input) => onTool("after", input.tool, input.sessionID, cwd), + event: async ({ event }) => onEvent(event, cwd), + } + }, + + async setup(ctx) { + const cwd = ctx.location?.directory ?? process.cwd() + const stop = keepalive() + const controller = new AbortController() + await ctx.session.hook("prompt", (e) => report("start", e.sessionID, cwd)) + await ctx.tool.hook("execute.before", (e) => onTool("before", e.tool, e.sessionID, cwd)) + await ctx.tool.hook("execute.after", (e) => onTool("after", e.tool, e.sessionID, cwd)) + // The stream ends on abort. Swallow that, and any other stream error, so the status + // light can never break opencode. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) + await onEvent(event, cwd) + } catch {} + })() + return () => { + controller.abort() + stop() + } + }, } -export default SidePulse +export default plugin // Self-check. Run it with: node plugin.mjs (also works under bun) // --demo walk every LED state on the device, four seconds each @@ -376,6 +411,22 @@ if (selfCheck) { ["session.status", { status: { type: "busy" } }, "busy"], ["session.idle", {}, "done"], ], + // OpenCode 2.1.2 traces, captured with a logging probe plugin. + "v2 normal finish": [ + ["chat.message", null, "busy"], + ["session.execution.started", {}, "busy"], + ["session.execution.succeeded", {}, "done"], + ], + "v2 cancel": [ + ["chat.message", null, "busy"], + ["session.execution.started", {}, "busy"], + ["session.execution.interrupted", { reason: "user" }, "done"], + ], + "v2 failure": [ + ["chat.message", null, "busy"], + ["session.execution.started", {}, "busy"], + ["session.execution.failed", { error: { type: "provider.no-route" } }, "error"], + ], } for (const [name, steps] of Object.entries(traces)) { let state = null @@ -386,6 +437,10 @@ if (selfCheck) { } } + // v1 needs server(), and v2 needs id and setup(). + if (typeof plugin.id !== "string" || typeof plugin.server !== "function" || typeof plugin.setup !== "function") + throw new Error("default export needs id, server() and setup()") + for (const name of ["SidePulse", "SidePulsePro", "PulseDot"]) { if (!VOLUME.test(name)) throw new Error(`volume ${name} does not match ${VOLUME}`) } From 31ee9ac370a9a0047d35d17c87742277aa909026 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 22 Oct 2026 18:24:04 +0100 Subject: [PATCH 2/2] State the OpenCode 1 versions that load the plugin The plugin loads on OpenCode 1.17.20: server() runs once. The migration guide names 1.18.29 as the minimum, but that is more conservative than the loader. --- README.md | 4 ++-- plugin.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2ee75ca..3cf58a3 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ file write is the whole interface. ## Requirements - macOS. -- OpenCode 2, or OpenCode 1.18.29 or newer. Older OpenCode 1 releases cannot load the - plugin. Use `opencode-sidepulse@0.1.7` on those releases. +- OpenCode 2, or OpenCode 1.17.20 or newer. Older OpenCode 1 releases are not tested. If + the plugin does not load on one of them, use `opencode-sidepulse@0.1.7`. - A SidePulse Pro or SidePulse Dot. The plugin needs no dependencies and no build step. It uses only Node standard library diff --git a/plugin.mjs b/plugin.mjs index b310e64..a102c1d 100644 --- a/plugin.mjs +++ b/plugin.mjs @@ -266,7 +266,7 @@ function onEvent(event, cwd) { return report(signal, props.sessionID ?? props.session_id ?? "unknown", cwd) } -// One default export: OpenCode v1 (1.18.29 and newer) calls server(), v2 calls setup(). +// One default export: OpenCode v1 calls server(), v2 calls setup(). Tested on 1.17.20. // No named export, because v1 can load it as a second instance that fights for the device. // NOTE: no paint on init, because opencode re-initializes plugins during a session. const plugin = {