- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
0.1.0 was staged but never approved, so the per-location connection fix ships as the first released version. Assisted-by: claude-opus-5 |
||
| .forgejo/workflows | ||
| scripts | ||
| src | ||
| test | ||
| .gitignore | ||
| bun.lock | ||
| CHANGELOG.md | ||
| index.ts | ||
| LICENSE | ||
| opencode.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.scripts.json | ||
opencode-openrgb
Ambient status lighting for opencode, driven by OpenRGB.
Your RGB hardware mirrors what opencode is doing: amber while the agent works, violet when it is waiting on a permission prompt, red when something fails. When everything goes idle, your original lighting is restored exactly as it was.
There are no model-facing tools. The plugin never lets the LLM touch your lights — it only reacts to session state.
| State | Meaning | Default |
|---|---|---|
busy |
at least one session is working | #ff8c00 |
retry |
a request is being retried | #ffd000 |
permission |
a permission prompt is waiting for you | #b400ff |
error |
a session errored (decays after 10s) | #ff0000 |
idle |
nothing running | restore |
Any state can instead be set to "baseline", meaning your own lighting,
breathing. See Pulsing your own color.
State is aggregated across all sessions, so subagent and task work keeps the
lights busy until everything is genuinely finished. When several conditions hold
at once, the highest priority wins: error > permission > retry > busy >
idle.
Requirements
- OpenRGB running with its SDK server enabled (Settings → General → Enable SDK
Server), listening on
127.0.0.1:6742by default - opencode 2.0+
Protocol versions 1–5 are supported; the client negotiates min(server, 5).
This plugin targets the opencode 2 plugin API and does not load under 1.x.
Install
opencode plugin add @tiuxo/opencode-openrgb
The plugin only loads for projects whose config declares it. For ambient
lighting everywhere, declare it globally in
~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["@tiuxo/opencode-openrgb"]
}
Config is hot-reloaded, so the plugin activates on save. Use /reload to force
it.
From source
Clone the repository and point package at the directory, using an absolute
path — plugin paths resolve relative to the config file that declares them:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{ "package": "/absolute/path/to/opencode-openrgb" }
]
}
Point it at the repository directory, not a file inside it: opencode skips configured plugin paths that are not directories.
To check what the plugin can see before enabling it:
bun run probe
Options
Every field is optional. Invalid values log a warning and fall back to the default rather than failing to load.
{
"package": "@tiuxo/opencode-openrgb",
"options": {
"enabled": true,
"host": "127.0.0.1",
"port": 6742,
"colors": {
// A hex/named color, or "baseline" to breathe your own lighting.
"busy": "#ff8c00",
"retry": "#ffd000",
"permission": "#b400ff",
"error": "#ff0000"
},
// "restore" puts back the lighting captured at startup.
// A color instead holds that color while idle. "off" goes dark.
"idle": "restore",
"brightness": 1.0, // 0..1, applied in software
"pulse": false, // breathing effect while busy
"pulseFps": 15,
"debounceMs": 120, // coalesce bursts of events
"restoreDelayMs": 400, // extra settle time before handing control back
"devices": {
"include": ["motherboard", "dram", "gpu", "cooler", "case", "ledstrip"],
"exclude": []
},
"debug": false
}
}
Colors accept #rgb, #rrggbb, rgb(r, g, b), and names like amber,
violet, off.
Pulsing your own color
If you would rather not have a status color replace your carefully chosen
lighting, set a state to "baseline":
{ "colors": { "busy": "baseline" } }
The device then breathes its own color while the agent works — whatever the lighting was when the plugin connected — so your OpenRGB profile keeps its identity and only its intensity changes. This is per LED, so a profile using several colors keeps that pattern intact and simply pulses.
A baseline state always pulses, regardless of the pulse option: a static
baseline would be pixel-identical to idle and would tell you nothing.
"current" and "profile" are accepted as aliases. Note that "gold" is a
named color, not the baseline — use "baseline" if you mean "whatever is
already there".
Device targeting
include and exclude entries match either a device type
(motherboard, gpu, keyboard, …) or a glob against the device name
(ASUS*, *X670E*, *). exclude wins over include.
The default targets interior/ambient hardware only. Keyboards, mice, keypads and accessories are deliberately left alone: their lighting usually conveys something already — an Elgato Stream Deck's button icons, for instance — and hijacking it by default would be obnoxious. Opt in explicitly if you want it:
"devices": { "include": ["*"], "exclude": ["accessory"] }
Scripts
| Command | What it does |
|---|---|
bun run probe |
Read-only dump of devices, zones, modes; shows what would be targeted |
bun run selftest |
Writes colors, reads them back from the server, asserts, then restores |
bun run simulate (--pulse, --hold <ms>) |
Drives the real plugin through every state, so you can preview your palette |
bun test |
Tests for the codec, client, state machine and option parsing |
bun run typecheck |
tsc --noEmit over src, scripts and tests |
selftest and simulate change your lighting while they run and restore it
afterwards.
How it works
The OpenRGB SDK is a binary TCP protocol. This plugin implements the parts it needs directly, with no runtime dependencies.
A few details that are easy to get wrong, all verified against the OpenRGB
sources and confirmed against real hardware by scripts/selftest.ts:
RGBColoris0x00BBGGRR, so the little-endian byte order is R, G, B, 0- In
UPDATELEDSandUPDATEMODE, the leadingdata_sizefield counts itself, and equals the packet header's size field - Below protocol 6,
mode.valueis a device-internal field that must be round-tripped back to the server verbatim when updating a mode - Protocol 5 has no ACK packets, so writes are fire-and-forget
Per-LED color only applies in a direct/custom mode, so the plugin switches the
device into its Direct mode if it is not already there, and switches back on
restore. Brightness is applied by scaling RGB in software, because Direct
modes commonly report no hardware brightness range at all.
Robustness is a hard requirement: the plugin must never break opencode. If OpenRGB is not running, is restarted, or misbehaves, every operation degrades to a silent no-op and the client reconnects in the background with backoff.
Events
The plugin subscribes to exactly the events that can change the lighting, so it stays off the firehose of streaming text and step events:
| Event | Effect |
|---|---|
session.status |
busy / retry / idle |
session.idle |
session is finished |
session.retry.scheduled |
a retry is pending |
session.execution.failed |
raises the error state (10s hold) |
permission.asked |
a prompt is waiting for you |
permission.replied |
clears that prompt |
session.deleted |
forgets the session entirely |
Restoring on exit
Under opencode 2 the plugin restores from the cleanup function returned by
setup, which runs when the plugin unloads. That is an ordinary async context,
so queued socket writes reach OpenRGB normally.
The synchronous write path remains for abrupt teardown. socket.write only
enqueues data, and if the event loop stops before it flushes, the restore
silently never reaches OpenRGB and the lights stay stuck on the status color —
and the next startup then snapshots that leftover color as the "baseline",
losing the real one. The restore path therefore writes straight to the socket's
file descriptor when it cannot rely on the loop running again. This reaches for
private API (socket._handle.fd), so every failure path falls back to a normal
queued write. The plugin also warns at startup if the lighting it captured is
uniformly one of its own status colors, a strong hint that a previous run died
without restoring.
Limitations
- Protocol 6 is not implemented. It lands with OpenRGB 1.0 final and changes device addressing to unique IDs. Decoders are version-guarded so the upgrade is contained, but until then the client negotiates down to 5.
- Restore is a startup snapshot. If you change your lighting in the OpenRGB GUI mid-session, the plugin will revert it at the next idle. Restart opencode to re-snapshot.
- A hard kill can still leave the last color showing.
SIGKILLor power loss gives the plugin no chance to restore. Graceful exits are handled (see below). - One instance per server. opencode loads the plugin once per location, but the hardware is global, so every instance shares one refcounted controller; the last one to unload restores the lighting. Separate opencode processes would still compete for the hardware.
Development
bun install
bun test
bun run typecheck
bun run simulate # drive real hardware through every state
The plugin ships as TypeScript source and opencode loads it directly, so there is no build step.
The repo's own opencode.json wires the plugin locally with debug: true, so
running opencode inside this directory loads the working copy.
The entry point (src/index.ts) stays thin: it defines the plugin, resolves
options, and pumps the event subscription. The logic lives in
src/controller.ts so it is directly importable and testable without a plugin
host — see test/plugin.test.ts, which drives the real entry point against a
mock OpenRGB server.
The root index.ts
opencode resolves a local directory plugin by trying <dir>/server and
<dir>/index, without consulting package.json main/exports. A plugin
whose only entrypoint is src/index.ts resolves to nothing and is dropped
silently, with nothing logged.
Installs by package name do use exports, so the root index.ts is a
re-export and both resolution paths land on it.
@opencode/plugin is imported for types only, so the published package has no
runtime dependencies.
Changelog
See CHANGELOG.md.
License
BSD-3-Clause