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/README.md +25 -11
- package/bridge/ble_bridge.py +334 -306
- package/dist/index.js +160 -28
- package/package.json +21 -1
- package/src/index.ts +159 -27
- package/bridge/__pycache__/ble_bridge.cpython-312.pyc +0 -0
- package/bridge/__pycache__/ble_bridge.cpython-313.pyc +0 -0
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
|
-
|
|
21
|
-
|
|
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,
|
|
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:
|
|
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 (
|
|
111
|
-
//
|
|
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
|
|
115
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
213
|
-
//
|
|
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
|
+
"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
|
-
|
|
24
|
-
|
|
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,
|
|
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:
|
|
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 (
|
|
117
|
-
//
|
|
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
|
|
121
|
-
//
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
224
|
-
//
|
|
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
|
|
Binary file
|
|
Binary file
|