opencode-rgbify-plugin 0.1.3 → 0.1.4

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.
package/README.md CHANGED
@@ -16,18 +16,20 @@ opencode events ──► src/index.ts (plugin) ──► raw tail ──► lin
16
16
  ```
17
17
 
18
18
  - **`src/index.ts`** — the opencode plugin. Spawns the Python bridge and
19
- forwards each delta's last 8 chars as a newline-delimited line. **No
20
- sanitization**: both auralizers treat any out-of-range byte as a rest, so
21
- raw text is safe. (Sanitization existed for the scrolling-display era, where
19
+ forwards each delta's tail (last `TAIL_CHARS=4` chars) as a newline-delimited
20
+ line. **No sanitization**: both auralizers treat any out-of-range byte as a
21
+ rest, so raw text is safe. (Sanitization existed for the scrolling-display era, where
22
22
  tags in the text would scroll across the matrix; the firmware now blits one
23
23
  char at a time, so tags are harmless — and the old tag-swallowing state
24
24
  machine could stick on an unbalanced `<` and silently discard whole streams.
25
25
  That was the long-standing "auralizers go silent" bug.)
26
- - **`bridge/ble_bridge.py`** — the BLE bridge. Reads lines from stdin, chunks
27
- them to the negotiated MTU, writes them to the projector's `TEXT_BRIDGE`
28
- characteristic, and auralizes each char on the host at the firmware's native
29
- cadence (one ~33ms note per char, log-scale frequency table identical to the
30
- firmware).
26
+ - **`bridge/ble_bridge.py`** — the BLE bridge. Reads lines from stdin and
27
+ writes each to the projector's `TEXT_BRIDGE` characteristic as a
28
+ write-without-response (fire-and-forget, no ACK wait), and auralizes each
29
+ char on the host at the firmware's native cadence (one ~33ms note per char,
30
+ log-scale frequency table identical to the firmware). The plugin feeds stdin
31
+ via a `ReadableStream` with latest-wins semantics, so opencode never blocks
32
+ on the bridge and no line ever queues up.
31
33
 
32
34
  ### Interrupt semantics
33
35
 
@@ -83,9 +83,9 @@ SCAN_TIMEOUT = 5.0
83
83
  # small instead of adding a fixed multi-second delay on every reconnect.
84
84
  CONNECT_DELAY = 1.0
85
85
 
86
- # Max time a single write ACK may take. The firmware holds the ACK until a
87
- # whole message plays (~8 chars * 33ms = ~264ms), so this must be comfortably
88
- # above that but still short enough that a wedged link is torn down quickly.
86
+ # Max time a single write may take. Writes are write-without-response
87
+ # (fire-and-forget), so they normally resolve in milliseconds; this is only a
88
+ # guard against a wedged BlueZ DBus call so a stalled link is torn down quickly.
89
89
  WRITE_TIMEOUT = 3.0
90
90
 
91
91
  # Fallback VOLUME poll interval (seconds). Notifications from the device are
@@ -97,16 +97,6 @@ VOL_POLL_SEC = 2.0
97
97
  # freq = -1021 + c*37 Hz for non-space chars, whitespace = rest, volume 0-10).
98
98
  SAMPLE_RATE = 44100
99
99
  NOTE_SEC = 1.0 / 30
100
- # Adaptive host pacing: the firmware's REAL per-char time is slower than its
101
- # nominal 30fps under load (measured ACKs of ~43ms/char vs 33ms nominal), so a
102
- # fixed 33ms host note runs out before the next ACK-gated dispatch — a small
103
- # periodic silence, only present while plugged in. The BLE loop measures
104
- # ack_ms/char after every write and EMA-smooths it into this variable;
105
- # host_auralize synthesizes at that pace so host buffers last exactly as long
106
- # as the ACK window. Clamped to sane bounds; falls back to nominal when cold.
107
- NOTE_SEC_MIN = 0.020
108
- NOTE_SEC_MAX = 0.080
109
- ACK_PACE_EMA = 0.3
110
100
  # Host note amplitude as a fraction of full-scale int16. The firmware drives a
111
101
  # piezo at resonance (loud); the host speaker at 0.05 was nearly inaudible, at
112
102
  # 0.4 it masked the piezo — 0.3 rebalances the mix.
@@ -493,12 +483,6 @@ async def main() -> None:
493
483
  except (NotImplementedError, RuntimeError):
494
484
  pass
495
485
 
496
- lines: asyncio.Queue = asyncio.Queue()
497
- # Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
498
- # ACK time per char after every write and EMA-smooths it here;
499
- # host_auralize synthesizes at this pace so host buffers last exactly as
500
- # long as the ACK window — no periodic silence while plugged in.
501
- ack_pace_sec = NOTE_SEC
502
486
  # Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
503
487
  # most recent line, so a newer line interrupts (replaces) the previous one.
504
488
  host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
@@ -510,7 +494,20 @@ async def main() -> None:
510
494
 
511
495
  auralizer = HostAuralizer()
512
496
 
497
+ def push_latest(q: asyncio.Queue, line: str) -> None:
498
+ try:
499
+ q.put_nowait(line)
500
+ except asyncio.QueueFull:
501
+ q.get_nowait()
502
+ q.put_nowait(line)
503
+
513
504
  async def read_stdin() -> None:
505
+ # Latest-line fan-out, no intermediary queue. While connected, a line
506
+ # goes to ble_line only — the BLE loop hands every written chunk back to
507
+ # host_line so the host plays EXACTLY the bytes the projector receives
508
+ # (char-perfect sync). While disconnected, lines go to host_line only,
509
+ # so the host keeps auralizing always-on and nothing accumulates for
510
+ # replay on reconnect.
514
511
  while True:
515
512
  raw = await reader.readline()
516
513
  if not raw:
@@ -520,24 +517,6 @@ async def main() -> None:
520
517
  return
521
518
  line = raw.decode("utf-8", "replace").rstrip("\n")
522
519
  dbg(f"in len={len(line)}")
523
- await lines.put(line)
524
-
525
- def push_latest(q: asyncio.Queue, line: str) -> None:
526
- try:
527
- q.put_nowait(line)
528
- except asyncio.QueueFull:
529
- q.get_nowait()
530
- q.put_nowait(line)
531
-
532
- async def broadcast() -> None:
533
- # Fan every line out as the LATEST line. While the projector is
534
- # connected, feed only ble_line — the BLE loop hands every chunk it
535
- # writes back to host_line, so the host auralizer plays EXACTLY the
536
- # bytes the projector receives (char-perfect sync). While disconnected,
537
- # raw lines go to host_line only, so the host keeps auralizing
538
- # always-on and nothing accumulates for replay on reconnect.
539
- while not stop_event.is_set():
540
- line = await lines.get()
541
520
  if connected:
542
521
  push_latest(ble_line, line)
543
522
  else:
@@ -562,7 +541,7 @@ async def main() -> None:
562
541
  if not line:
563
542
  continue
564
543
  auralizer.play_note(
565
- synth_message(line, load_volume(), ack_pace_sec)
544
+ synth_message(line, load_volume())
566
545
  )
567
546
  finally:
568
547
  auralizer.stop()
@@ -581,12 +560,11 @@ async def main() -> None:
581
560
  return
582
561
 
583
562
  asyncio.create_task(read_stdin())
584
- asyncio.create_task(broadcast())
585
563
  asyncio.create_task(watch_parent())
586
564
  host_task = asyncio.create_task(host_auralize())
587
565
 
588
566
  async def ble_loop() -> None:
589
- nonlocal connected, ack_pace_sec
567
+ nonlocal connected
590
568
  while not stop_event.is_set():
591
569
  try:
592
570
  addr = await discover_address(override)
@@ -619,8 +597,8 @@ async def main() -> None:
619
597
  # clients (bridge AND the RGBify website) share ONE BlueZ
620
598
  # ACL link, so a device-wide disconnect ejected the
621
599
  # website every time a bridge went away. Stale writes
622
- # can't survive anymore anyway — flow-control ACK means
623
- # every write is fully played before its response.
600
+ # can't survive anyway — the firmware interrupts on every
601
+ # write, so there's nothing queued to replay.
624
602
 
625
603
  async with BleakClient(addr, disconnected_callback=on_disconnect) as client:
626
604
  # Clear anything that slipped in before the flag flipped, so
@@ -686,15 +664,12 @@ async def main() -> None:
686
664
  apply_volume(value[0], "poll", False)
687
665
  except Exception as e:
688
666
  dbg(f"vol poll err {e}")
689
- # WHOLE-MESSAGE DELIVERY, ACK-GATED: each line (the last
690
- # 8 chars of a coalesced delta burst) is written in ONE
691
- # write with response=True. The firmware holds the ACK
692
- # until every char has been played, so the write resolves
693
- # exactly when playback finishes nothing can buffer in
694
- # either BLE stack. Lines that arrive DURING playback are
695
- # NOT discarded: they chain immediately after the ACK, so
696
- # notes play back-to-back for as long as text keeps
697
- # streaming.
667
+ # FIRE-AND-FORGET DELIVERY: each line (the last 8 chars of
668
+ # a delta) is written with response=False. The firmware's
669
+ # TEXT_BRIDGE onWrite resets its playback position on every
670
+ # write, so the newest message interrupts the previous one
671
+ # immediately no ACK to wait for, no serialization, no
672
+ # backlog on either side.
698
673
  while True:
699
674
  kind, text = await wait_line_or_stop(
700
675
  ble_line, stop_event, IDLE_CHECK_MS
@@ -711,51 +686,33 @@ async def main() -> None:
711
686
  if not text:
712
687
  continue
713
688
  # SYNC: hand the message to the host auralizer NOW, so
714
- # it starts playing the same notes at the same moment
715
- # the projector does — NOT after the ACK (which would
716
- # put the host one message behind).
689
+ # it starts the same notes at the same moment the
690
+ # projector does.
717
691
  push_latest(host_line, text)
718
692
  dbg(f"host play len={len(text)}")
719
- t_write = time.monotonic()
720
693
  try:
721
694
  await asyncio.wait_for(
722
695
  client.write_gatt_char(
723
696
  TEXT_BRIDGE_UUID, text.encode("utf-8"),
724
- response=True,
697
+ response=False,
725
698
  ),
726
699
  timeout=WRITE_TIMEOUT,
727
700
  )
728
701
  except asyncio.TimeoutError:
729
- # Firmware wedged / connection stalled: drop the
730
- # link and reconnect rather than freeze both sinks.
702
+ # BlueZ wedged: drop the link and reconnect rather
703
+ # than freeze both sinks.
731
704
  dbg("write TIMEOUT")
732
- raise ConnectionError("write ACK timed out")
705
+ raise ConnectionError("write timed out")
733
706
  except Exception as e:
734
707
  # Transient/failed write: move on; the next delta
735
708
  # starts fresh.
736
709
  dbg(f"write err {e}")
737
710
  continue
738
- ack_ms = (time.monotonic() - t_write) * 1000.0
739
- dbg(f"ack {ack_ms:.0f}ms")
740
- # Adaptive host pacing: learn the firmware's REAL
741
- # per-char playback time from this ACK so host buffers
742
- # last exactly as long as the ACK window.
743
- inst = min(NOTE_SEC_MAX, max(
744
- NOTE_SEC_MIN, (ack_ms / 1000.0) / max(1, len(text))
745
- ))
746
- ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
747
711
  print("ok", flush=True)
748
712
  # Fallback volume poll (rate-limited to VOL_POLL_SEC):
749
713
  # catches webapp changes even if a notification is
750
714
  # lost. Cheap monotonic-time check when under rate.
751
715
  await poll_volume()
752
- # NO flush here: anything that arrived during playback
753
- # stays queued and plays immediately on the next loop
754
- # iteration — notes chain back-to-back for as long as
755
- # deltas keep coming ("keep playing until the next
756
- # delta arrives"). The maxsize-1 queue bounds lag at
757
- # ~one extra message; when deltas stop, the last one
758
- # plays out and the auralizers go quiet.
759
716
  except Exception as e:
760
717
  connected = False
761
718
  dbg(f"loop err {e}")
package/dist/index.js CHANGED
@@ -76,6 +76,52 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
76
76
  return {};
77
77
  let procPromise = null;
78
78
  const seenEventTypes = new Set();
79
+ // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
80
+ // stdin. pull() waits for the newest line and enqueues it; a newer line
81
+ // replaces an un-consumed one (latest wins, no backlog). There is NO
82
+ // synchronous FileSink write/flush on opencode's event loop — that was the
83
+ // freeze (blocking the TUI whenever the pipe backed up under load).
84
+ let enqueueLine = null;
85
+ let pendingLine = null;
86
+ function makeStdinStream() {
87
+ let latest = null;
88
+ let wake = null;
89
+ let closed = false;
90
+ const enc = new TextEncoder();
91
+ const stream = new ReadableStream({
92
+ async pull(controller) {
93
+ while (latest === null && !closed) {
94
+ await new Promise((resolve) => {
95
+ wake = resolve;
96
+ });
97
+ }
98
+ if (closed)
99
+ return;
100
+ const line = latest;
101
+ latest = null;
102
+ controller.enqueue(enc.encode(line + "\n"));
103
+ },
104
+ cancel() {
105
+ closed = true;
106
+ if (wake) {
107
+ const w = wake;
108
+ wake = null;
109
+ w();
110
+ }
111
+ },
112
+ });
113
+ enqueueLine = (line) => {
114
+ if (closed)
115
+ return;
116
+ latest = line;
117
+ if (wake) {
118
+ const w = wake;
119
+ wake = null;
120
+ w();
121
+ }
122
+ };
123
+ return stream;
124
+ }
79
125
  function startBridge() {
80
126
  if (procPromise)
81
127
  return procPromise;
@@ -84,7 +130,7 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
84
130
  if (!python)
85
131
  throw new Error("bridge deps not installed (install.sh failed)");
86
132
  const proc = spawn([python, BRIDGE], {
87
- stdin: "pipe",
133
+ stdin: makeStdinStream(),
88
134
  stdout: "pipe",
89
135
  stderr: "pipe",
90
136
  // Skip BLE discovery (a ~5s scan) on every connect: default to the
@@ -101,62 +147,44 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
101
147
  // `.finally` so a dead bridge is replaced by the next send().
102
148
  void proc.exited.finally(() => {
103
149
  procPromise = null;
150
+ enqueueLine = null;
151
+ pendingLine = null;
104
152
  });
153
+ // Flush any line that arrived before the bridge was up (latest only).
154
+ if (pendingLine !== null) {
155
+ enqueueLine?.(pendingLine);
156
+ pendingLine = null;
157
+ }
105
158
  return proc;
106
159
  })();
107
160
  return procPromise;
108
161
  }
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
- }
162
+ // Delivery: fire-and-forget. Each delta's tail goes straight to the bridge
163
+ // no coalescing, no accumulation, no timer. The bridge keeps only the latest
164
+ // line, so the last delta is the only delta (no backlog). Keep SMALL: the
165
+ // bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
166
+ // so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
167
+ // Firmware cap is MAX_TEXT=256.
168
+ const TAIL_CHARS = 4;
130
169
  function writeLine(line) {
131
170
  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) => {
171
+ if (enqueueLine) {
172
+ enqueueLine(line);
173
+ }
174
+ else {
175
+ pendingLine = line;
176
+ }
177
+ startBridge().catch(async (err) => {
146
178
  await client.app.log({
147
179
  body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
148
180
  });
149
181
  });
150
182
  }
151
183
  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);
184
+ // Raw text, tail-capped — no sanitization (see the note above).
185
+ const line = text.slice(-TAIL_CHARS);
186
+ if (line)
187
+ writeLine(line);
160
188
  }
161
189
  startBridge().catch(async (err) => {
162
190
  await client.app.log({
@@ -212,12 +240,9 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
212
240
  // orphan holding the projector connection. Closing its stdin would also do
213
241
  // it (the bridge exits on EOF), but kill is immediate and explicit.
214
242
  dispose: async () => {
215
- // Drop any pending coalesced text — the session is over.
216
- if (flushTimer) {
217
- clearTimeout(flushTimer);
218
- flushTimer = null;
219
- }
220
- buf = "";
243
+ // Drop any pending line — the session is over.
244
+ enqueueLine = null;
245
+ pendingLine = null;
221
246
  if (procPromise) {
222
247
  try {
223
248
  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.4",
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
@@ -80,13 +80,59 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
80
80
  let procPromise: Promise<ReturnType<typeof spawn>> | null = null
81
81
  const seenEventTypes = new Set<string>()
82
82
 
83
+ // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
84
+ // stdin. pull() waits for the newest line and enqueues it; a newer line
85
+ // replaces an un-consumed one (latest wins, no backlog). There is NO
86
+ // synchronous FileSink write/flush on opencode's event loop — that was the
87
+ // freeze (blocking the TUI whenever the pipe backed up under load).
88
+ let enqueueLine: ((line: string) => void) | null = null
89
+ let pendingLine: string | null = null
90
+
91
+ function makeStdinStream(): ReadableStream<Uint8Array> {
92
+ let latest: string | null = null
93
+ let wake: (() => void) | null = null
94
+ let closed = false
95
+ const enc = new TextEncoder()
96
+ const stream = new ReadableStream<Uint8Array>({
97
+ async pull(controller) {
98
+ while (latest === null && !closed) {
99
+ await new Promise<void>((resolve) => {
100
+ wake = resolve
101
+ })
102
+ }
103
+ if (closed) return
104
+ const line = latest
105
+ latest = null
106
+ controller.enqueue(enc.encode(line + "\n"))
107
+ },
108
+ cancel() {
109
+ closed = true
110
+ if (wake) {
111
+ const w = wake
112
+ wake = null
113
+ w()
114
+ }
115
+ },
116
+ })
117
+ enqueueLine = (line: string) => {
118
+ if (closed) return
119
+ latest = line
120
+ if (wake) {
121
+ const w = wake
122
+ wake = null
123
+ w()
124
+ }
125
+ }
126
+ return stream
127
+ }
128
+
83
129
  function startBridge(): Promise<ReturnType<typeof spawn>> {
84
130
  if (procPromise) return procPromise
85
131
  procPromise = (async () => {
86
132
  const python = await bootstrapPython()
87
133
  if (!python) throw new Error("bridge deps not installed (install.sh failed)")
88
134
  const proc = spawn([python, BRIDGE], {
89
- stdin: "pipe",
135
+ stdin: makeStdinStream(),
90
136
  stdout: "pipe",
91
137
  stderr: "pipe",
92
138
  // Skip BLE discovery (a ~5s scan) on every connect: default to the
@@ -106,66 +152,45 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
106
152
  // `.finally` so a dead bridge is replaced by the next send().
107
153
  void proc.exited.finally(() => {
108
154
  procPromise = null
155
+ enqueueLine = null
156
+ pendingLine = null
109
157
  })
158
+ // Flush any line that arrived before the bridge was up (latest only).
159
+ if (pendingLine !== null) {
160
+ enqueueLine?.(pendingLine)
161
+ pendingLine = null
162
+ }
110
163
  return proc
111
164
  })()
112
165
  return procPromise
113
166
  }
114
167
 
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
- }
168
+ // Delivery: fire-and-forget. Each delta's tail goes straight to the bridge
169
+ // no coalescing, no accumulation, no timer. The bridge keeps only the latest
170
+ // line, so the last delta is the only delta (no backlog). Keep SMALL: the
171
+ // bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
172
+ // so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
173
+ // Firmware cap is MAX_TEXT=256.
174
+ const TAIL_CHARS = 4
136
175
 
137
176
  function writeLine(line: string) {
138
177
  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
- })
178
+ if (enqueueLine) {
179
+ enqueueLine(line)
180
+ } else {
181
+ pendingLine = line
182
+ }
183
+ startBridge().catch(async (err) => {
184
+ await client.app.log({
185
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
158
186
  })
187
+ })
159
188
  }
160
189
 
161
190
  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)
191
+ // Raw text, tail-capped — no sanitization (see the note above).
192
+ const line = text.slice(-TAIL_CHARS)
193
+ if (line) writeLine(line)
169
194
  }
170
195
 
171
196
  startBridge().catch(async (err) => {
@@ -223,12 +248,9 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
223
248
  // orphan holding the projector connection. Closing its stdin would also do
224
249
  // it (the bridge exits on EOF), but kill is immediate and explicit.
225
250
  dispose: async () => {
226
- // Drop any pending coalesced text — the session is over.
227
- if (flushTimer) {
228
- clearTimeout(flushTimer)
229
- flushTimer = null
230
- }
231
- buf = ""
251
+ // Drop any pending line — the session is over.
252
+ enqueueLine = null
253
+ pendingLine = null
232
254
  if (procPromise) {
233
255
  try {
234
256
  const proc = await procPromise