opencode-rgbify-plugin 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of opencode-rgbify-plugin might be problematic. Click here for more details.

@@ -0,0 +1,68 @@
1
+ # Self-sufficient Windows installer for the RGBify bridge. Installs EVERYTHING
2
+ # the bridge depends on, user-level, no admin:
3
+ # uv — Python/venv manager (installed into ~\.local\bin if missing)
4
+ # Python — uv-managed interpreter
5
+ # bleak — BLE connection to the projector (required)
6
+ # miniaudio — host auralizer (bundles its own native lib; cross-platform)
7
+ #
8
+ # Idempotent: re-running is a cheap no-op (each step skips if already done).
9
+ #
10
+ # Optional: pass a target plugin root as $1 to build the venv elsewhere (e.g.
11
+ # after the plugin is copied into ~/.config/opencode). Defaults to this repo.
12
+ param([string]$PluginRoot)
13
+
14
+ $ErrorActionPreference = "Stop"
15
+
16
+ if (-not $PluginRoot) {
17
+ $PluginRoot = Join-Path $PSScriptRoot ".."
18
+ }
19
+ $PluginRoot = (Resolve-Path $PluginRoot).Path
20
+ $VenvDir = Join-Path $PluginRoot ".venv"
21
+ $VenvPy = Join-Path $VenvDir "Scripts\python.exe"
22
+
23
+ function Say($msg) { Write-Host "== $msg" }
24
+
25
+ # ── 1 — uv (installs Python + manages the venv, no admin needed) ──
26
+ if (Get-Command uv -ErrorAction SilentlyContinue) {
27
+ Say "uv already installed: $(uv --version)"
28
+ } else {
29
+ Say "Installing uv (user-level)..."
30
+ irm https://astral.sh/uv/install.ps1 | iex
31
+ # uv installs into ~\.local\bin; add to this process's PATH
32
+ $env:PATH = "$env:USERPROFILE\.local\bin;$env:PATH"
33
+ if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
34
+ throw "uv install failed"
35
+ }
36
+ Say "uv installed: $(uv --version)"
37
+ }
38
+
39
+ # ── 2 — Python (uv-managed, user-level, no admin) ──
40
+ $PyVer = if ($env:RGBIFY_PYTHON) { $env:RGBIFY_PYTHON } else { "3.12" }
41
+ if (Test-Path $VenvPy) {
42
+ Say "venv already present: $VenvDir"
43
+ } else {
44
+ Say "Ensuring Python $PyVer via uv..."
45
+ uv python install $PyVer | Out-Null
46
+ Say "Creating venv with uv at $VenvDir"
47
+ uv venv --python $PyVer $VenvDir
48
+ }
49
+
50
+ # ── 3 — Install deps ──
51
+ Say "Installing bridge deps into venv..."
52
+ uv pip install --python $VenvPy bleak miniaudio
53
+
54
+ # ── 4 — Verify imports (-B: no bytecode cache, keeps the tree clean) ──
55
+ & $VenvPy -B -c "import bleak" 2>$null
56
+ if ($LASTEXITCODE -eq 0) {
57
+ Say "bleak OK"
58
+ } else {
59
+ throw "bleak failed to import"
60
+ }
61
+ & $VenvPy -B -c "import miniaudio" 2>$null
62
+ if ($LASTEXITCODE -eq 0) {
63
+ Say "miniaudio OK"
64
+ } else {
65
+ Write-Host "WARN: miniaudio unavailable - host auralizer disabled, projector still works"
66
+ }
67
+
68
+ Say "Done. Bridge venv ready at $VenvDir"
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # Self-sufficient installer for the RGBify bridge. Installs EVERYTHING the
3
+ # bridge depends on, user-level, no root:
4
+ # uv — Python/venv manager (installed into ~/.local/bin if missing)
5
+ # Python — uv-managed interpreter (into ~/.local/share/uv/python)
6
+ # bleak — BLE connection to the projector (required)
7
+ # miniaudio — host auralizer (bundles its own native lib; cross-platform)
8
+ #
9
+ # Idempotent: re-running is a cheap no-op (each step skips if already done).
10
+ #
11
+ # Optional: pass a target plugin root as $1 to build the venv elsewhere (e.g.
12
+ # after the plugin is copied into ~/.config/opencode). Defaults to this repo.
13
+ set -euo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ PLUGIN_ROOT="${1:-$(cd "$SCRIPT_DIR/.." && pwd)}"
17
+ VENV_DIR="$PLUGIN_ROOT/.venv"
18
+ VENV_PY="$VENV_DIR/bin/python"
19
+
20
+ # UV is installed to ~/.local/bin; make sure it's on PATH for this shell even
21
+ # if it was just installed by this script.
22
+ export PATH="$HOME/.local/bin:$PATH"
23
+
24
+ say() { echo "== $*"; }
25
+
26
+ # ── 1 — uv (installs Python + manages the venv, no root needed) ──
27
+ if command -v uv >/dev/null 2>&1; then
28
+ say "uv already installed: $(uv --version)"
29
+ else
30
+ say "Installing uv (user-level)..."
31
+ curl -LsSf https://astral.sh/uv/install.sh | sh
32
+ command -v uv >/dev/null 2>&1 || {
33
+ echo "ERROR: uv install failed" >&2
34
+ exit 1
35
+ }
36
+ say "uv installed: $(uv --version)"
37
+ fi
38
+
39
+ # ── 2 — Python (uv-managed, into ~/.local/share/uv/python, no root) ──
40
+ PYVER="${RGBIFY_PYTHON:-3.12}"
41
+ if "$VENV_PY" -c 'import sys; sys.exit(0)' >/dev/null 2>&1; then
42
+ say "venv already present: $VENV_DIR"
43
+ else
44
+ say "Ensuring Python $PYVER via uv..."
45
+ uv python install "$PYVER" || true # no-op if uv already has it
46
+ say "Creating venv with uv at $VENV_DIR"
47
+ uv venv --python "$PYVER" "$VENV_DIR"
48
+ fi
49
+
50
+ # ── 3 — Install deps ──
51
+ say "Installing bridge deps into venv..."
52
+ uv pip install --python "$VENV_PY" bleak miniaudio
53
+
54
+ # ── 4 — Verify imports (-B: no bytecode cache, keeps the tree clean) ──
55
+ if "$VENV_PY" -B -c 'import bleak' 2>/dev/null; then
56
+ say "bleak OK"
57
+ else
58
+ echo "ERROR: bleak failed to import — check the install above" >&2
59
+ exit 1
60
+ fi
61
+ if "$VENV_PY" -B -c 'import miniaudio' 2>/dev/null; then
62
+ say "miniaudio OK"
63
+ else
64
+ echo "WARN: miniaudio unavailable — host auralizer disabled, projector still works" >&2
65
+ fi
66
+
67
+ say "Done. Bridge venv ready at $VENV_DIR"
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const RGBifyProjectorPlugin: Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,231 @@
1
+ import { spawn } from "bun";
2
+ import { existsSync, appendFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+ const PLUGIN_ROOT = path.join(here, "..");
7
+ const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py");
8
+ // Cross-platform bootstrap: uv lays out the venv differently on Windows
9
+ // (.venv\Scripts\python.exe vs .venv/bin/python) and there is no bash by
10
+ // default, so both the interpreter path and the installer script are chosen
11
+ // by platform.
12
+ const IS_WINDOWS = process.platform === "win32";
13
+ const BRIDGE_INSTALL = IS_WINDOWS
14
+ ? path.join(here, "..", "bridge", "install.ps1")
15
+ : path.join(here, "..", "bridge", "install.sh");
16
+ const VENV_PYTHON = IS_WINDOWS
17
+ ? path.join(here, "..", ".venv", "Scripts", "python.exe")
18
+ : path.join(here, "..", ".venv", "bin", "python");
19
+ const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG;
20
+ function debug(line) {
21
+ if (!DEBUG_LOG)
22
+ return;
23
+ try {
24
+ appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`);
25
+ }
26
+ catch { }
27
+ }
28
+ // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
29
+ // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
30
+ // the user, no root/admin. A shared promise guards against concurrent send()s
31
+ // triggering a duplicate install. Re-running the installer is idempotent.
32
+ let bootstrapPromise = null;
33
+ async function bootstrapPython() {
34
+ if (existsSync(VENV_PYTHON))
35
+ return VENV_PYTHON;
36
+ if (bootstrapPromise)
37
+ return bootstrapPromise;
38
+ bootstrapPromise = (async () => {
39
+ debug("bootstrap: bridge venv missing, running installer");
40
+ try {
41
+ // On Windows there's no bash by default; run the .ps1 via powershell.
42
+ const args = IS_WINDOWS
43
+ ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BRIDGE_INSTALL, PLUGIN_ROOT]
44
+ : ["bash", BRIDGE_INSTALL, PLUGIN_ROOT];
45
+ const proc = spawn(args, {
46
+ stdin: "pipe",
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+ void proc.stdout?.pipeTo(new WritableStream({ write() { } }));
51
+ void proc.stderr?.pipeTo(new WritableStream({ write() { } }));
52
+ const exitCode = await proc.exited;
53
+ debug(`bootstrap: installer exited ${exitCode}`);
54
+ return existsSync(VENV_PYTHON) ? VENV_PYTHON : null;
55
+ }
56
+ catch (err) {
57
+ debug(`bootstrap: installer failed: ${err}`);
58
+ return null;
59
+ }
60
+ })();
61
+ return bootstrapPromise;
62
+ }
63
+ function isEnabled() {
64
+ return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true";
65
+ }
66
+ // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
67
+ // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
68
+ // wedge. History: sanitize() existed for the scrolling-display era, where tags
69
+ // in the text would scroll across the 8x8 matrix; the firmware now blits one
70
+ // char at a time, so tags are harmless. Worse, the old tag-swallowing state
71
+ // machine could stick on an unbalanced `<` and silently discard entire streams
72
+ // for minutes — the long-standing "both auralizers go silent" bug. Raw is both
73
+ // simpler and poison-proof.
74
+ export const RGBifyProjectorPlugin = async ({ client }) => {
75
+ if (!isEnabled())
76
+ return {};
77
+ let procPromise = null;
78
+ const seenEventTypes = new Set();
79
+ function startBridge() {
80
+ if (procPromise)
81
+ return procPromise;
82
+ procPromise = (async () => {
83
+ const python = await bootstrapPython();
84
+ if (!python)
85
+ throw new Error("bridge deps not installed (install.sh failed)");
86
+ const proc = spawn([python, BRIDGE], {
87
+ stdin: "pipe",
88
+ stdout: "pipe",
89
+ stderr: "pipe",
90
+ // Skip BLE discovery (a ~5s scan) on every connect: default to the
91
+ // projector's fixed address unless the user overrides it.
92
+ env: {
93
+ ...process.env,
94
+ RGBIFY_PROJECTOR_ADDR: process.env.RGBIFY_PROJECTOR_ADDR || "40:91:51:AB:50:CE",
95
+ },
96
+ });
97
+ void proc.stdout?.pipeTo(new WritableStream({ write() { } }));
98
+ void proc.stderr?.pipeTo(new WritableStream({ write() { } }));
99
+ // proc.exited RESOLVES (with the exit code) on exit — it does NOT reject,
100
+ // so a `.catch` would never fire and the bridge would never respawn. Use
101
+ // `.finally` so a dead bridge is replaced by the next send().
102
+ void proc.exited.finally(() => {
103
+ procPromise = null;
104
+ });
105
+ return proc;
106
+ })();
107
+ return procPromise;
108
+ }
109
+ // Delivery: raw delta text is COALESCED into full-length messages. opencode
110
+ // emits deltas in bursts of tiny fragments (avg ~4 chars); per-delta sends
111
+ // produced 140ms notes separated by 600-800ms holes. Instead, accumulate
112
+ // text and emit a full TAIL_CHARS message whenever the buffer fills — or
113
+ // after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
114
+ // when printing stops. The bridge chains whatever is queued straight after
115
+ // each ACK, so during continuous printing the notes play back-to-back.
116
+ const TAIL_CHARS = 8;
117
+ const FLUSH_MS = 150;
118
+ let buf = "";
119
+ let flushTimer = null;
120
+ function flushBuf() {
121
+ if (flushTimer) {
122
+ clearTimeout(flushTimer);
123
+ flushTimer = null;
124
+ }
125
+ const line = buf.slice(-TAIL_CHARS);
126
+ buf = "";
127
+ if (line)
128
+ writeLine(line);
129
+ }
130
+ function writeLine(line) {
131
+ debug(`send len=${line.length}`);
132
+ startBridge()
133
+ .then((proc) => {
134
+ // We always spawn the bridge with stdin: "pipe", so it's a FileSink.
135
+ const stdin = proc.stdin;
136
+ stdin.write(line + "\n");
137
+ // Bun's FileSink buffers writes; without flush() the bridge receives
138
+ // them in delayed bursts, desyncing the projector/host from opencode.
139
+ const r = stdin.flush();
140
+ if (r && typeof r.then === "function") {
141
+ ;
142
+ r.catch(() => { });
143
+ }
144
+ })
145
+ .catch(async (err) => {
146
+ await client.app.log({
147
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
148
+ });
149
+ });
150
+ }
151
+ function send(text) {
152
+ // Raw text, coalesced — no sanitization (see the note above).
153
+ buf += text;
154
+ if (buf.length >= TAIL_CHARS) {
155
+ flushBuf();
156
+ return;
157
+ }
158
+ if (!flushTimer)
159
+ flushTimer = setTimeout(flushBuf, FLUSH_MS);
160
+ }
161
+ startBridge().catch(async (err) => {
162
+ await client.app.log({
163
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
164
+ });
165
+ });
166
+ return {
167
+ event: async ({ event }) => {
168
+ // The installed @opencode-ai/plugin types (v1.17.0) don't yet declare the
169
+ // token-level delta events we consume, so treat the event loosely here.
170
+ const t = event.type;
171
+ if (!seenEventTypes.has(t)) {
172
+ seenEventTypes.add(t);
173
+ debug(`event type=${t}`);
174
+ }
175
+ // Token-level streaming deltas — fire per-token as the LLM streams,
176
+ // before opencode renders the accumulated part. Small, so the projector
177
+ // keeps up and stays in sync with the session.
178
+ if (t === "message.part.delta") {
179
+ const p = event.properties;
180
+ // Only stream text/reasoning deltas. Tool-part deltas carry opencode
181
+ // internal tool-call JSON (messageID/callID/...) that would render as
182
+ // garbage on the projector; tool lifecycle is signalled separately.
183
+ if (p?.field === "text" || p?.field === "reasoning") {
184
+ if (typeof p.delta === "string" && p.delta) {
185
+ send(p.delta);
186
+ }
187
+ }
188
+ return;
189
+ }
190
+ if (t === "session.next.text.delta" || t === "session.next.reasoning.delta") {
191
+ const p = event.properties;
192
+ if (typeof p?.delta === "string" && p.delta) {
193
+ send(p.delta);
194
+ }
195
+ return;
196
+ }
197
+ },
198
+ "chat.message": async (_input, output) => {
199
+ for (const part of output.parts) {
200
+ if (part.type !== "text")
201
+ continue;
202
+ send(part.text);
203
+ }
204
+ },
205
+ "tool.execute.before": async (input) => {
206
+ send("tool IN");
207
+ },
208
+ "tool.execute.after": async () => {
209
+ send("tool out");
210
+ },
211
+ // When opencode shuts down, kill the bridge so it doesn't linger as an
212
+ // orphan holding the projector connection. Closing its stdin would also do
213
+ // it (the bridge exits on EOF), but kill is immediate and explicit.
214
+ dispose: async () => {
215
+ // Drop any pending coalesced text — the session is over.
216
+ if (flushTimer) {
217
+ clearTimeout(flushTimer);
218
+ flushTimer = null;
219
+ }
220
+ buf = "";
221
+ if (procPromise) {
222
+ try {
223
+ const proc = await procPromise;
224
+ proc.kill();
225
+ }
226
+ catch { }
227
+ procPromise = null;
228
+ }
229
+ },
230
+ };
231
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "opencode-rgbify-plugin",
3
+ "version": "0.1.3",
4
+ "description": "opencode plugin: stream chat text deltas to an RGBify 8x8 projector over BLE",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "bridge",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "peerDependencies": {
25
+ "@opencode-ai/plugin": "*"
26
+ },
27
+ "devDependencies": {
28
+ "@opencode-ai/plugin": "^1.17.0",
29
+ "@types/bun": "^1.4.0",
30
+ "@types/node": "^26.3.0",
31
+ "typescript": "^5.9.0"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,241 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { spawn } from "bun"
3
+ import { existsSync, appendFileSync } from "node:fs"
4
+ import { fileURLToPath } from "node:url"
5
+ import path from "node:path"
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url))
8
+ const PLUGIN_ROOT = path.join(here, "..")
9
+ const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py")
10
+ // Cross-platform bootstrap: uv lays out the venv differently on Windows
11
+ // (.venv\Scripts\python.exe vs .venv/bin/python) and there is no bash by
12
+ // default, so both the interpreter path and the installer script are chosen
13
+ // by platform.
14
+ const IS_WINDOWS = process.platform === "win32"
15
+ const BRIDGE_INSTALL = IS_WINDOWS
16
+ ? path.join(here, "..", "bridge", "install.ps1")
17
+ : path.join(here, "..", "bridge", "install.sh")
18
+ const VENV_PYTHON = IS_WINDOWS
19
+ ? path.join(here, "..", ".venv", "Scripts", "python.exe")
20
+ : path.join(here, "..", ".venv", "bin", "python")
21
+ const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG
22
+
23
+ function debug(line: string) {
24
+ if (!DEBUG_LOG) return
25
+ try {
26
+ appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`)
27
+ } catch {}
28
+ }
29
+
30
+ // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
31
+ // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
32
+ // the user, no root/admin. A shared promise guards against concurrent send()s
33
+ // triggering a duplicate install. Re-running the installer is idempotent.
34
+ let bootstrapPromise: Promise<string | null> | null = null
35
+
36
+ async function bootstrapPython(): Promise<string | null> {
37
+ if (existsSync(VENV_PYTHON)) return VENV_PYTHON
38
+ if (bootstrapPromise) return bootstrapPromise
39
+ bootstrapPromise = (async () => {
40
+ debug("bootstrap: bridge venv missing, running installer")
41
+ try {
42
+ // On Windows there's no bash by default; run the .ps1 via powershell.
43
+ const args = IS_WINDOWS
44
+ ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BRIDGE_INSTALL, PLUGIN_ROOT]
45
+ : ["bash", BRIDGE_INSTALL, PLUGIN_ROOT]
46
+ const proc = spawn(args, {
47
+ stdin: "pipe",
48
+ stdout: "pipe",
49
+ stderr: "pipe",
50
+ })
51
+ void proc.stdout?.pipeTo(new WritableStream({ write() {} }))
52
+ void proc.stderr?.pipeTo(new WritableStream({ write() {} }))
53
+ const exitCode = await proc.exited
54
+ debug(`bootstrap: installer exited ${exitCode}`)
55
+ return existsSync(VENV_PYTHON) ? VENV_PYTHON : null
56
+ } catch (err) {
57
+ debug(`bootstrap: installer failed: ${err}`)
58
+ return null
59
+ }
60
+ })()
61
+ return bootstrapPromise
62
+ }
63
+
64
+ function isEnabled(): boolean {
65
+ return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true"
66
+ }
67
+
68
+ // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
69
+ // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
70
+ // wedge. History: sanitize() existed for the scrolling-display era, where tags
71
+ // in the text would scroll across the 8x8 matrix; the firmware now blits one
72
+ // char at a time, so tags are harmless. Worse, the old tag-swallowing state
73
+ // machine could stick on an unbalanced `<` and silently discard entire streams
74
+ // for minutes — the long-standing "both auralizers go silent" bug. Raw is both
75
+ // simpler and poison-proof.
76
+
77
+ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
78
+ if (!isEnabled()) return {}
79
+
80
+ let procPromise: Promise<ReturnType<typeof spawn>> | null = null
81
+ const seenEventTypes = new Set<string>()
82
+
83
+ function startBridge(): Promise<ReturnType<typeof spawn>> {
84
+ if (procPromise) return procPromise
85
+ procPromise = (async () => {
86
+ const python = await bootstrapPython()
87
+ if (!python) throw new Error("bridge deps not installed (install.sh failed)")
88
+ const proc = spawn([python, BRIDGE], {
89
+ stdin: "pipe",
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ // Skip BLE discovery (a ~5s scan) on every connect: default to the
93
+ // projector's fixed address unless the user overrides it.
94
+ env: {
95
+ ...process.env,
96
+ RGBIFY_PROJECTOR_ADDR:
97
+ process.env.RGBIFY_PROJECTOR_ADDR || "40:91:51:AB:50:CE",
98
+ },
99
+ })
100
+ void proc.stdout?.pipeTo(new WritableStream({ write() {} }))
101
+ void proc.stderr?.pipeTo(
102
+ new WritableStream({ write() {} }),
103
+ )
104
+ // proc.exited RESOLVES (with the exit code) on exit — it does NOT reject,
105
+ // so a `.catch` would never fire and the bridge would never respawn. Use
106
+ // `.finally` so a dead bridge is replaced by the next send().
107
+ void proc.exited.finally(() => {
108
+ procPromise = null
109
+ })
110
+ return proc
111
+ })()
112
+ return procPromise
113
+ }
114
+
115
+ // Delivery: raw delta text is COALESCED into full-length messages. opencode
116
+ // emits deltas in bursts of tiny fragments (avg ~4 chars); per-delta sends
117
+ // produced 140ms notes separated by 600-800ms holes. Instead, accumulate
118
+ // text and emit a full TAIL_CHARS message whenever the buffer fills — or
119
+ // after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
120
+ // when printing stops. The bridge chains whatever is queued straight after
121
+ // each ACK, so during continuous printing the notes play back-to-back.
122
+ const TAIL_CHARS = 8
123
+ const FLUSH_MS = 150
124
+ let buf = ""
125
+ let flushTimer: ReturnType<typeof setTimeout> | null = null
126
+
127
+ function flushBuf() {
128
+ if (flushTimer) {
129
+ clearTimeout(flushTimer)
130
+ flushTimer = null
131
+ }
132
+ const line = buf.slice(-TAIL_CHARS)
133
+ buf = ""
134
+ if (line) writeLine(line)
135
+ }
136
+
137
+ function writeLine(line: string) {
138
+ debug(`send len=${line.length}`)
139
+ startBridge()
140
+ .then((proc) => {
141
+ // We always spawn the bridge with stdin: "pipe", so it's a FileSink.
142
+ const stdin = proc.stdin as unknown as {
143
+ write(s: string): void
144
+ flush(): number | Promise<number>
145
+ }
146
+ stdin.write(line + "\n")
147
+ // Bun's FileSink buffers writes; without flush() the bridge receives
148
+ // them in delayed bursts, desyncing the projector/host from opencode.
149
+ const r = stdin.flush()
150
+ if (r && typeof (r as Promise<number>).then === "function") {
151
+ ;(r as Promise<number>).catch(() => {})
152
+ }
153
+ })
154
+ .catch(async (err) => {
155
+ await client.app.log({
156
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
157
+ })
158
+ })
159
+ }
160
+
161
+ function send(text: string) {
162
+ // Raw text, coalesced — no sanitization (see the note above).
163
+ buf += text
164
+ if (buf.length >= TAIL_CHARS) {
165
+ flushBuf()
166
+ return
167
+ }
168
+ if (!flushTimer) flushTimer = setTimeout(flushBuf, FLUSH_MS)
169
+ }
170
+
171
+ startBridge().catch(async (err) => {
172
+ await client.app.log({
173
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
174
+ })
175
+ })
176
+
177
+ return {
178
+ event: async ({ event }) => {
179
+ // The installed @opencode-ai/plugin types (v1.17.0) don't yet declare the
180
+ // token-level delta events we consume, so treat the event loosely here.
181
+ const t = (event as { type: string }).type
182
+ if (!seenEventTypes.has(t)) {
183
+ seenEventTypes.add(t)
184
+ debug(`event type=${t}`)
185
+ }
186
+
187
+ // Token-level streaming deltas — fire per-token as the LLM streams,
188
+ // before opencode renders the accumulated part. Small, so the projector
189
+ // keeps up and stays in sync with the session.
190
+ if (t === "message.part.delta") {
191
+ const p = (event as { properties?: { field?: string; delta?: string } }).properties
192
+ // Only stream text/reasoning deltas. Tool-part deltas carry opencode
193
+ // internal tool-call JSON (messageID/callID/...) that would render as
194
+ // garbage on the projector; tool lifecycle is signalled separately.
195
+ if (p?.field === "text" || p?.field === "reasoning") {
196
+ if (typeof p.delta === "string" && p.delta) {
197
+ send(p.delta)
198
+ }
199
+ }
200
+ return
201
+ }
202
+ if (t === "session.next.text.delta" || t === "session.next.reasoning.delta") {
203
+ const p = (event as { properties?: { delta?: string } }).properties
204
+ if (typeof p?.delta === "string" && p.delta) {
205
+ send(p.delta)
206
+ }
207
+ return
208
+ }
209
+ },
210
+ "chat.message": async (_input, output) => {
211
+ for (const part of output.parts) {
212
+ if (part.type !== "text") continue
213
+ send(part.text)
214
+ }
215
+ },
216
+ "tool.execute.before": async (input) => {
217
+ send("tool IN")
218
+ },
219
+ "tool.execute.after": async () => {
220
+ send("tool out")
221
+ },
222
+ // When opencode shuts down, kill the bridge so it doesn't linger as an
223
+ // orphan holding the projector connection. Closing its stdin would also do
224
+ // it (the bridge exits on EOF), but kill is immediate and explicit.
225
+ dispose: async () => {
226
+ // Drop any pending coalesced text — the session is over.
227
+ if (flushTimer) {
228
+ clearTimeout(flushTimer)
229
+ flushTimer = null
230
+ }
231
+ buf = ""
232
+ if (procPromise) {
233
+ try {
234
+ const proc = await procPromise
235
+ proc.kill()
236
+ } catch {}
237
+ procPromise = null
238
+ }
239
+ },
240
+ }
241
+ }