opencode-rgbify-plugin 0.1.3 → 0.1.5

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.

package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "bun";
2
- import { existsSync, appendFileSync } from "node:fs";
2
+ import { existsSync, appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import path from "node:path";
5
+ import os from "node:os";
5
6
  const here = path.dirname(fileURLToPath(import.meta.url));
6
7
  const PLUGIN_ROOT = path.join(here, "..");
7
8
  const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py");
@@ -17,14 +18,36 @@ const VENV_PYTHON = IS_WINDOWS
17
18
  ? path.join(here, "..", ".venv", "Scripts", "python.exe")
18
19
  : path.join(here, "..", ".venv", "bin", "python");
19
20
  const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG;
20
- function debug(line) {
21
- if (!DEBUG_LOG)
21
+ // Debug logging must never run synchronously on opencode's stream fiber: the
22
+ // plugin's `event` hook is invoked inline in the awaited event listener chain
23
+ // (see mem:opencode/delta-control-flow), so a per-delta appendFileSync would
24
+ // stall the whole LLM stream. Batch lines and flush on a timer instead.
25
+ const debugLines = [];
26
+ let debugTimer = null;
27
+ function flushDebug() {
28
+ if (debugTimer !== null) {
29
+ clearTimeout(debugTimer);
30
+ debugTimer = null;
31
+ }
32
+ if (!DEBUG_LOG || debugLines.length === 0)
22
33
  return;
34
+ const batch = debugLines.splice(0);
23
35
  try {
24
- appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`);
36
+ appendFileSync(DEBUG_LOG, batch.join(""));
25
37
  }
26
38
  catch { }
27
39
  }
40
+ function debug(line) {
41
+ if (!DEBUG_LOG)
42
+ return;
43
+ debugLines.push(`${Date.now()} ${line}\n`);
44
+ if (debugTimer === null) {
45
+ debugTimer = setTimeout(() => {
46
+ debugTimer = null;
47
+ flushDebug();
48
+ }, 50);
49
+ }
50
+ }
28
51
  // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
29
52
  // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
30
53
  // the user, no root/admin. A shared promise guards against concurrent send()s
@@ -63,6 +86,32 @@ async function bootstrapPython() {
63
86
  function isEnabled() {
64
87
  return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true";
65
88
  }
89
+ // Desktop volume is a plain shared file the bridge's `watch_host_volume` task
90
+ // polls and applies live (works with the projector off). The /rgbify slash
91
+ // command just writes it. Path defaults to the opencode global state dir.
92
+ function stateDir() {
93
+ return (process.env.RGBIFY_STATE_DIR ||
94
+ path.join(os.homedir(), ".config", "opencode", "state"));
95
+ }
96
+ const VOLUME_FILE = path.join(stateDir(), "host-volume");
97
+ function readHostVolume() {
98
+ try {
99
+ const v = parseInt(readFileSync(VOLUME_FILE, "utf8").trim(), 10);
100
+ return Number.isFinite(v) ? Math.max(0, Math.min(10, v)) : 10;
101
+ }
102
+ catch {
103
+ return 10;
104
+ }
105
+ }
106
+ function writeHostVolume(v) {
107
+ try {
108
+ mkdirSync(stateDir(), { recursive: true });
109
+ writeFileSync(VOLUME_FILE, String(Math.max(0, Math.min(10, Math.round(v)))));
110
+ }
111
+ catch {
112
+ // Non-fatal: volume just won't persist.
113
+ }
114
+ }
66
115
  // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
67
116
  // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
68
117
  // wedge. History: sanitize() existed for the scrolling-display era, where tags
@@ -76,6 +125,50 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
76
125
  return {};
77
126
  let procPromise = null;
78
127
  const seenEventTypes = new Set();
128
+ // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
129
+ // stdin. pull() waits for a queued line and enqueues the oldest first (FIFO);
130
+ // every line is delivered in order — nothing is dropped or replaced. There is
131
+ // NO synchronous FileSink write/flush on opencode's event loop — that was the
132
+ // freeze (blocking the TUI whenever the pipe backed up under load).
133
+ let enqueueLine = null;
134
+ let pendingLine = null;
135
+ function makeStdinStream() {
136
+ const lines = [];
137
+ let wake = null;
138
+ let closed = false;
139
+ const enc = new TextEncoder();
140
+ const stream = new ReadableStream({
141
+ async pull(controller) {
142
+ while (lines.length === 0 && !closed) {
143
+ await new Promise((resolve) => {
144
+ wake = resolve;
145
+ });
146
+ }
147
+ if (closed)
148
+ return;
149
+ controller.enqueue(enc.encode(lines.shift() + "\n"));
150
+ },
151
+ cancel() {
152
+ closed = true;
153
+ if (wake) {
154
+ const w = wake;
155
+ wake = null;
156
+ w();
157
+ }
158
+ },
159
+ });
160
+ enqueueLine = (line) => {
161
+ if (closed)
162
+ return;
163
+ lines.push(line);
164
+ if (wake) {
165
+ const w = wake;
166
+ wake = null;
167
+ w();
168
+ }
169
+ };
170
+ return stream;
171
+ }
79
172
  function startBridge() {
80
173
  if (procPromise)
81
174
  return procPromise;
@@ -84,7 +177,7 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
84
177
  if (!python)
85
178
  throw new Error("bridge deps not installed (install.sh failed)");
86
179
  const proc = spawn([python, BRIDGE], {
87
- stdin: "pipe",
180
+ stdin: makeStdinStream(),
88
181
  stdout: "pipe",
89
182
  stderr: "pipe",
90
183
  // Skip BLE discovery (a ~5s scan) on every connect: default to the
@@ -101,18 +194,25 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
101
194
  // `.finally` so a dead bridge is replaced by the next send().
102
195
  void proc.exited.finally(() => {
103
196
  procPromise = null;
197
+ enqueueLine = null;
198
+ pendingLine = null;
104
199
  });
200
+ // Flush any line that arrived before the bridge was up (single pending slot).
201
+ if (pendingLine !== null) {
202
+ enqueueLine?.(pendingLine);
203
+ pendingLine = null;
204
+ }
105
205
  return proc;
106
206
  })();
107
207
  return procPromise;
108
208
  }
109
209
  // 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
210
+ // emits deltas in bursts of tiny fragments (~4 chars); per-delta sends would
211
+ // produce fragmented notes under the bridge's ACK gate. Instead, accumulate
112
212
  // text and emit a full TAIL_CHARS message whenever the buffer fills — or
113
213
  // 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.
214
+ // when printing stops. The bridge writes one line per ACK (gated), so during
215
+ // continuous printing the notes play back-to-back. Firmware cap is MAX_TEXT.
116
216
  const TAIL_CHARS = 8;
117
217
  const FLUSH_MS = 150;
118
218
  let buf = "";
@@ -129,24 +229,22 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
129
229
  }
130
230
  function writeLine(line) {
131
231
  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}` },
232
+ if (enqueueLine) {
233
+ enqueueLine(line);
234
+ }
235
+ else {
236
+ pendingLine = line;
237
+ }
238
+ // Keep the hot path free of spawn work: the bridge is started once at
239
+ // plugin init (and respawned only after a death clears procPromise), so a
240
+ // steady stream of deltas never re-enters startBridge().
241
+ if (procPromise === null) {
242
+ startBridge().catch(async (err) => {
243
+ await client.app.log({
244
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
245
+ });
148
246
  });
149
- });
247
+ }
150
248
  }
151
249
  function send(text) {
152
250
  // Raw text, coalesced — no sanitization (see the note above).
@@ -195,6 +293,36 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
195
293
  return;
196
294
  }
197
295
  },
296
+ // /rgbify volume <0-10>: the server plugin handles it directly (no model
297
+ // needed to act) — writes host-volume, which the bridge's watch_host_volume
298
+ // task applies live. The command still flows through the normal command
299
+ // pipeline, so we replace the parts with a crisp confirmation for the model
300
+ // to echo. In-place mutation: opencode passes the `parts` array by reference
301
+ // (plugin.trigger), so clearing + pushing takes effect on the prompt.
302
+ "command.execute.before": async (input, output) => {
303
+ if (input.command !== "rgbify")
304
+ return;
305
+ const args = (input.arguments || "").trim();
306
+ const base = {
307
+ id: `rgbify-${Date.now()}`,
308
+ sessionID: input.sessionID,
309
+ messageID: input.sessionID,
310
+ };
311
+ const m = args.match(/^volume\s+([0-9]+)$/);
312
+ output.parts.length = 0;
313
+ if (m) {
314
+ const v = Math.max(0, Math.min(10, parseInt(m[1], 10)));
315
+ writeHostVolume(v);
316
+ output.parts.push({ ...base, type: "text", text: `RGBify desktop volume set to ${v}.` });
317
+ }
318
+ else {
319
+ output.parts.push({
320
+ ...base,
321
+ type: "text",
322
+ text: `RGBify desktop volume is ${readHostVolume()}. Usage: /rgbify volume <0-10>.`,
323
+ });
324
+ }
325
+ },
198
326
  "chat.message": async (_input, output) => {
199
327
  for (const part of output.parts) {
200
328
  if (part.type !== "text")
@@ -209,8 +337,9 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
209
337
  send("tool out");
210
338
  },
211
339
  // 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.
340
+ // orphan. The bridge exits WITHOUT a BLE disconnect (bluetoothd keeps the
341
+ // shared link), so the RGBify website stays connected. Closing stdin would
342
+ // also do it (the bridge exits on EOF), but kill is immediate and explicit.
214
343
  dispose: async () => {
215
344
  // Drop any pending coalesced text — the session is over.
216
345
  if (flushTimer) {
@@ -218,6 +347,9 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
218
347
  flushTimer = null;
219
348
  }
220
349
  buf = "";
350
+ enqueueLine = null;
351
+ pendingLine = null;
352
+ flushDebug();
221
353
  if (procPromise) {
222
354
  try {
223
355
  const proc = await procPromise;
package/package.json CHANGED
@@ -1,7 +1,27 @@
1
1
  {
2
2
  "name": "opencode-rgbify-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "opencode plugin: stream chat text deltas to an RGBify 8x8 projector over BLE",
5
+ "keywords": [
6
+ "opencode",
7
+ "plugin",
8
+ "rgbify",
9
+ "ble",
10
+ "bluetooth",
11
+ "led",
12
+ "matrix",
13
+ "esp32",
14
+ "auralizer",
15
+ "sonification"
16
+ ],
17
+ "homepage": "https://github.com/dcerisano/opencode-rgbify-plugin",
18
+ "bugs": {
19
+ "url": "https://github.com/dcerisano/opencode-rgbify-plugin/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/dcerisano/opencode-rgbify-plugin.git"
24
+ },
5
25
  "type": "module",
6
26
  "license": "MIT",
7
27
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { spawn } from "bun"
3
- import { existsSync, appendFileSync } from "node:fs"
3
+ import { existsSync, appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import path from "node:path"
6
+ import os from "node:os"
6
7
 
7
8
  const here = path.dirname(fileURLToPath(import.meta.url))
8
9
  const PLUGIN_ROOT = path.join(here, "..")
@@ -20,13 +21,36 @@ const VENV_PYTHON = IS_WINDOWS
20
21
  : path.join(here, "..", ".venv", "bin", "python")
21
22
  const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG
22
23
 
23
- function debug(line: string) {
24
- if (!DEBUG_LOG) return
24
+ // Debug logging must never run synchronously on opencode's stream fiber: the
25
+ // plugin's `event` hook is invoked inline in the awaited event listener chain
26
+ // (see mem:opencode/delta-control-flow), so a per-delta appendFileSync would
27
+ // stall the whole LLM stream. Batch lines and flush on a timer instead.
28
+ const debugLines: string[] = []
29
+ let debugTimer: ReturnType<typeof setTimeout> | null = null
30
+
31
+ function flushDebug() {
32
+ if (debugTimer !== null) {
33
+ clearTimeout(debugTimer)
34
+ debugTimer = null
35
+ }
36
+ if (!DEBUG_LOG || debugLines.length === 0) return
37
+ const batch = debugLines.splice(0)
25
38
  try {
26
- appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`)
39
+ appendFileSync(DEBUG_LOG, batch.join(""))
27
40
  } catch {}
28
41
  }
29
42
 
43
+ function debug(line: string) {
44
+ if (!DEBUG_LOG) return
45
+ debugLines.push(`${Date.now()} ${line}\n`)
46
+ if (debugTimer === null) {
47
+ debugTimer = setTimeout(() => {
48
+ debugTimer = null
49
+ flushDebug()
50
+ }, 50)
51
+ }
52
+ }
53
+
30
54
  // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
31
55
  // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
32
56
  // the user, no root/admin. A shared promise guards against concurrent send()s
@@ -65,6 +89,36 @@ function isEnabled(): boolean {
65
89
  return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true"
66
90
  }
67
91
 
92
+ // Desktop volume is a plain shared file the bridge's `watch_host_volume` task
93
+ // polls and applies live (works with the projector off). The /rgbify slash
94
+ // command just writes it. Path defaults to the opencode global state dir.
95
+ function stateDir(): string {
96
+ return (
97
+ process.env.RGBIFY_STATE_DIR ||
98
+ path.join(os.homedir(), ".config", "opencode", "state")
99
+ )
100
+ }
101
+
102
+ const VOLUME_FILE = path.join(stateDir(), "host-volume")
103
+
104
+ function readHostVolume(): number {
105
+ try {
106
+ const v = parseInt(readFileSync(VOLUME_FILE, "utf8").trim(), 10)
107
+ return Number.isFinite(v) ? Math.max(0, Math.min(10, v)) : 10
108
+ } catch {
109
+ return 10
110
+ }
111
+ }
112
+
113
+ function writeHostVolume(v: number): void {
114
+ try {
115
+ mkdirSync(stateDir(), { recursive: true })
116
+ writeFileSync(VOLUME_FILE, String(Math.max(0, Math.min(10, Math.round(v)))))
117
+ } catch {
118
+ // Non-fatal: volume just won't persist.
119
+ }
120
+ }
121
+
68
122
  // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
69
123
  // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
70
124
  // wedge. History: sanitize() existed for the scrolling-display era, where tags
@@ -80,13 +134,57 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
80
134
  let procPromise: Promise<ReturnType<typeof spawn>> | null = null
81
135
  const seenEventTypes = new Set<string>()
82
136
 
137
+ // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
138
+ // stdin. pull() waits for a queued line and enqueues the oldest first (FIFO);
139
+ // every line is delivered in order — nothing is dropped or replaced. There is
140
+ // NO synchronous FileSink write/flush on opencode's event loop — that was the
141
+ // freeze (blocking the TUI whenever the pipe backed up under load).
142
+ let enqueueLine: ((line: string) => void) | null = null
143
+ let pendingLine: string | null = null
144
+
145
+ function makeStdinStream(): ReadableStream<Uint8Array> {
146
+ const lines: string[] = []
147
+ let wake: (() => void) | null = null
148
+ let closed = false
149
+ const enc = new TextEncoder()
150
+ const stream = new ReadableStream<Uint8Array>({
151
+ async pull(controller) {
152
+ while (lines.length === 0 && !closed) {
153
+ await new Promise<void>((resolve) => {
154
+ wake = resolve
155
+ })
156
+ }
157
+ if (closed) return
158
+ controller.enqueue(enc.encode(lines.shift() + "\n"))
159
+ },
160
+ cancel() {
161
+ closed = true
162
+ if (wake) {
163
+ const w = wake
164
+ wake = null
165
+ w()
166
+ }
167
+ },
168
+ })
169
+ enqueueLine = (line: string) => {
170
+ if (closed) return
171
+ lines.push(line)
172
+ if (wake) {
173
+ const w = wake
174
+ wake = null
175
+ w()
176
+ }
177
+ }
178
+ return stream
179
+ }
180
+
83
181
  function startBridge(): Promise<ReturnType<typeof spawn>> {
84
182
  if (procPromise) return procPromise
85
183
  procPromise = (async () => {
86
184
  const python = await bootstrapPython()
87
185
  if (!python) throw new Error("bridge deps not installed (install.sh failed)")
88
186
  const proc = spawn([python, BRIDGE], {
89
- stdin: "pipe",
187
+ stdin: makeStdinStream(),
90
188
  stdout: "pipe",
91
189
  stderr: "pipe",
92
190
  // Skip BLE discovery (a ~5s scan) on every connect: default to the
@@ -106,19 +204,26 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
106
204
  // `.finally` so a dead bridge is replaced by the next send().
107
205
  void proc.exited.finally(() => {
108
206
  procPromise = null
207
+ enqueueLine = null
208
+ pendingLine = null
109
209
  })
210
+ // Flush any line that arrived before the bridge was up (single pending slot).
211
+ if (pendingLine !== null) {
212
+ enqueueLine?.(pendingLine)
213
+ pendingLine = null
214
+ }
110
215
  return proc
111
216
  })()
112
217
  return procPromise
113
218
  }
114
219
 
115
220
  // 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
221
+ // emits deltas in bursts of tiny fragments (~4 chars); per-delta sends would
222
+ // produce fragmented notes under the bridge's ACK gate. Instead, accumulate
118
223
  // text and emit a full TAIL_CHARS message whenever the buffer fills — or
119
224
  // 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.
225
+ // when printing stops. The bridge writes one line per ACK (gated), so during
226
+ // continuous printing the notes play back-to-back. Firmware cap is MAX_TEXT.
122
227
  const TAIL_CHARS = 8
123
228
  const FLUSH_MS = 150
124
229
  let buf = ""
@@ -136,26 +241,21 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
136
241
 
137
242
  function writeLine(line: string) {
138
243
  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) => {
244
+ if (enqueueLine) {
245
+ enqueueLine(line)
246
+ } else {
247
+ pendingLine = line
248
+ }
249
+ // Keep the hot path free of spawn work: the bridge is started once at
250
+ // plugin init (and respawned only after a death clears procPromise), so a
251
+ // steady stream of deltas never re-enters startBridge().
252
+ if (procPromise === null) {
253
+ startBridge().catch(async (err) => {
155
254
  await client.app.log({
156
255
  body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
157
256
  })
158
257
  })
258
+ }
159
259
  }
160
260
 
161
261
  function send(text: string) {
@@ -207,6 +307,34 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
207
307
  return
208
308
  }
209
309
  },
310
+ // /rgbify volume <0-10>: the server plugin handles it directly (no model
311
+ // needed to act) — writes host-volume, which the bridge's watch_host_volume
312
+ // task applies live. The command still flows through the normal command
313
+ // pipeline, so we replace the parts with a crisp confirmation for the model
314
+ // to echo. In-place mutation: opencode passes the `parts` array by reference
315
+ // (plugin.trigger), so clearing + pushing takes effect on the prompt.
316
+ "command.execute.before": async (input, output) => {
317
+ if (input.command !== "rgbify") return
318
+ const args = (input.arguments || "").trim()
319
+ const base = {
320
+ id: `rgbify-${Date.now()}`,
321
+ sessionID: input.sessionID,
322
+ messageID: input.sessionID,
323
+ }
324
+ const m = args.match(/^volume\s+([0-9]+)$/)
325
+ output.parts.length = 0
326
+ if (m) {
327
+ const v = Math.max(0, Math.min(10, parseInt(m[1], 10)))
328
+ writeHostVolume(v)
329
+ output.parts.push({ ...base, type: "text", text: `RGBify desktop volume set to ${v}.` })
330
+ } else {
331
+ output.parts.push({
332
+ ...base,
333
+ type: "text",
334
+ text: `RGBify desktop volume is ${readHostVolume()}. Usage: /rgbify volume <0-10>.`,
335
+ })
336
+ }
337
+ },
210
338
  "chat.message": async (_input, output) => {
211
339
  for (const part of output.parts) {
212
340
  if (part.type !== "text") continue
@@ -220,8 +348,9 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
220
348
  send("tool out")
221
349
  },
222
350
  // 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.
351
+ // orphan. The bridge exits WITHOUT a BLE disconnect (bluetoothd keeps the
352
+ // shared link), so the RGBify website stays connected. Closing stdin would
353
+ // also do it (the bridge exits on EOF), but kill is immediate and explicit.
225
354
  dispose: async () => {
226
355
  // Drop any pending coalesced text — the session is over.
227
356
  if (flushTimer) {
@@ -229,6 +358,9 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
229
358
  flushTimer = null
230
359
  }
231
360
  buf = ""
361
+ enqueueLine = null
362
+ pendingLine = null
363
+ flushDebug()
232
364
  if (procPromise) {
233
365
  try {
234
366
  const proc = await procPromise