No description
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian Clemens 1ba4c1cd40
All checks were successful
CI / check (push) Successful in 1m53s
Publish / publish (push) Successful in 1m58s
Release v0.1.1
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
2026-11-10 20:31:40 +09:00
.forgejo/workflows Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
scripts Rename to opencode-openrgb and port to the opencode V2 plugin API 2026-11-10 20:06:06 +09:00
src Share one controller across plugin instances 2026-11-10 20:26:27 +09:00
test Share one controller across plugin instances 2026-11-10 20:26:27 +09:00
.gitignore Initial commit: openrgb-opencode as written for opencode V1 2026-11-10 20:00:14 +09:00
bun.lock Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
CHANGELOG.md Release v0.1.1 2026-11-10 20:31:40 +09:00
index.ts Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
LICENSE Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
opencode.json Share one controller across plugin instances 2026-11-10 20:26:27 +09:00
package.json Release v0.1.1 2026-11-10 20:31:40 +09:00
README.md Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
tsconfig.json Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00
tsconfig.scripts.json Prepare for publishing as @tiuxo/opencode-openrgb 2026-11-10 20:12:53 +09:00

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:6742 by 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:

  • RGBColor is 0x00BBGGRR, so the little-endian byte order is R, G, B, 0
  • In UPDATELEDS and UPDATEMODE, the leading data_size field counts itself, and equals the packet header's size field
  • Below protocol 6, mode.value is 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. SIGKILL or 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