Add Spiker:bit EMG + servo support and refactor DataFlow serial onto a transport abstraction [CLUE-567] - #2929
Add Spiker:bit EMG + servo support and refactor DataFlow serial onto a transport abstraction [CLUE-567]#2929scytacki wants to merge 44 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-567] Split into spikerbit-device.ts (no library import, tested with a fake IMicrobitUsbConnection) and spikerbit-connection.ts (the sole static importer of the ESM-only @microbit/microbit-connection library) so Jest never has to load the library. Added tsconfig paths entries for the library's usb/universal-hex exports subpaths, since this repo's classic TS moduleResolution doesn't read package "exports" maps (same workaround already used for @concord-consortium/codap-formulas).
…-567] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .ts is MakeCode source (paste target for makecode.microbit.org); the firmware dir is excluded from tsconfig/eslint since it uses MakeCode-only globals. The compiled universal .hex is still authored/committed separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The micro:bit firmware pads each serial line with spaces before the \r\n (e.g. "emg:57 \r\n"), so both parseArduinoSerialData and detectSpikerbitVersion require [ \t]* before the line ending. The Arduino's unpadded lines still match exactly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Universal micro:bit hex (V1+V2) built from spikerbit-clue-v1.ts. Bench-verified: EMG streams into the sensor node, the Servo output drives the servo, and the version query is detected (no reflash on reconnect). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…E-567] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v2 MakeCode source disables writeLine's 32-byte space padding and moves
the "?" version reply onto the forever-loop fiber so it can't interleave with
the emg stream. Re-export the compiled hex and bump kSpikerbitFirmwareVersion
to 2 to match.
Rename the firmware artifacts to spikerbit-clue.{ts,hex} (the -v1 suffix was
redundant with the versioned banner) and update the README and import.
Add a Jest consistency test that guards against the source VERSION,
kSpikerbitFirmwareVersion, and the committed hex drifting out of sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pxt-spikerbit extension's musclePowerSignal() subtracts a fixed 580 noise floor and half-wave rectifies, but the board's resting baseline is ~511, so moderate contractions never clear the floor and read as ~0 — only hard, rail-to-rail flexes register. Recorded raw-vs-envelope data on a micro:bit V2 confirmed this. v3 computes the envelope itself: track the DC baseline and full-wave rectify around it (envelope = EMA(|raw - baseline|)). The EMA coefficients are derived from the measured per-sample dt so the smoothing time constant is fixed regardless of sample rate. We no longer call the extension at all — its startMuscleRecording() only drove P8=0/P9=0 to select EMG mode, which we now do ourselves, reading the raw signal on P1. Servo confirmed on P0. Bench-validated on hardware: tracks moderate flexes the old envelope missed. Bump kSpikerbitFirmwareVersion to 3 and re-export the hex. Add the two MakeCode bench-test programs used to diagnose and validate this, and update the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a hardware-reference section to the firmware README from the Backyard Brains schematic: the micro:bit pin usage (P0 servo, P1 EMG via JP2, P2 free, P8/P9 mode-select), the servo connector wiring, and the 2xAA → VIN → PAM2401 boost power chain. Records why battery voltage can't be read off any rail (all regulated, including the servo's +5V V+) and how to monitor it instead — a divider from raw VIN into the free P2 analog pin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the spikerbit-clue-v1.{ts,hex} → spikerbit-clue.{ts,hex} rename.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
connectAndStream previously flashed only when the version query got no reply, so a board running an older firmware version was left as-is. Now it also flashes when the reported version is below kSpikerbitFirmwareVersion, so connecting a board running old firmware auto-updates it. Update the device tests: the fake connection now reports a configurable version, the up-to-date cases report the current version, and a new test covers an out-of-date board triggering a flash. Update the design spec (detection step and the former "no version-based re-flash" limitation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
] @microbit/microbit-connection ships a CommonJS build (resolved via its package exports "require" condition), so a plain static import works in both Jest 30 and webpack — the ESM/dynamic-import rationale was incorrect. Import it statically instead of via dynamic import(); this moves ~51 KB solely into the already-lazy Dataflow webpack chunk (+2.7%) with nothing added to initial load, and keeps spikerbit-connection.ts as a thin typed wrapper. Also from the maintainability review: - rename SerialDevice.hasPort() to hasWebSerialPort() so callers don't confuse it with the any-transport isConnected() (the Spiker:bit uses WebUSB, no port). - document that the firmware-consistency test only checks the embedded version string, not that the hex was compiled from the source (test header + README). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…UE-567] - Replace the `as unknown as IMicrobitUsbConnection` cast in createSpikerbitConnection with a plain typed return; the library's MicrobitUSBConnection is structurally assignable to our interface, so the compiler now catches API drift instead of the cast silently erasing it. Correct the interface's comment (the library isn't ESM-only). - Initialize SerialDevice.spikerbitConnected in the constructor and drop the now- redundant `=== true` guard in isConnected(). - Record two known limitations in the design spec's future-work section: the half-wired connect menu (duplicate options, no click-outside dismissal) and the missing listener cleanup / in-flight connect guard in the Spiker:bit connect path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…WebSerialTransport [CLUE-567] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-567-microbit-support-2
|
| Run status |
|
| Run duration | 03m 32s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2929 +/- ##
===========================================
- Coverage 86.10% 69.29% -16.82%
===========================================
Files 933 933
Lines 53246 53377 +131
Branches 14076 14107 +31
===========================================
- Hits 45847 36987 -8860
- Misses 7383 16358 +8975
- Partials 16 32 +16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MicrobitUsbTransport now owns the WebUSB connection's inbound events (serialdata -> onData, status -> onDisconnect), making it a full IDeviceTransport peer of WebSerialTransport. SpikerbitDevice keeps only connect-time version detection: it points transport.onData at a version-only handler during connect, then setActiveDevice repoints it at SerialDevice.receive (spikerbit maps to the arduino protocol -> parseArduinoSerialData). Disconnect now flows through transport.onDisconnect, gaining the stale-transport identity guard. Inbound is now unified across all devices; update the hardware data-flow doc and regenerate the inbound diagram. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…LUE-567] Rename the two wire protocols after their format rather than a device, now that they no longer map one-to-one to hardware (the Spiker:bit speaks the "arduino" protocol): - protocol values "arduino" -> "keyValue", "microbit" -> "radioHub" - channel field deviceFamily -> protocol (it was always a protocol tag) - parsers parseArduinoSerialData -> parseKeyValueData, handleArduinoStreamObj -> handleKeyValueStreamObj, handleMicroBitStreamObj -> handleRadioHubStreamObj Device identity (SerialDevice.deviceFamily: arduino/microbit/spikerbit) is unchanged; only the protocol concept is renamed. No persisted data uses these strings, so no migration is needed. Consolidate the hardware data-flow documentation into src/plugins/dataflow-tool/serial.md: fold in the still-relevant intent from the old serial.md (connect flow, unfiltered device chooser rationale, message reconstitution, connection-status tracking) and give the Spiker:bit version check/flash its own section. Move the diagrams' SVGs alongside the doc, drop the now-empty docs/dataflow-hardware.md, and link to serial.md from docs/dataflow.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold the ambient "*.hex" string-import declaration into src/typings.d.ts alongside the existing *.png/*.svg asset declarations, rather than keeping a standalone hex.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the comment as JSDoc and correct two overstatements: the connect event is what the browser fires for already-authorized devices (not Arduino-specific), and the disconnect handler does real teardown (guarded against killing a WebUSB session), not symmetry decoration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…E-567] The property's only purpose is deciding whether the connect-button UI can trust the browser connect/disconnect events to reflect physical-connection state; name it for that and note it only applies to the Web Serial path (stays false for a WebUSB/Spiker:bit connection). Also guard the connect path on a resolved deviceFamily instead of a non-null assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cted [CLUE-567] The class was renamed from a port-specific check to isConnected(), which is true for any active transport (including the portless WebUSB Spiker:bit), so name it for connection state rather than a port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ambient *.hex declaration now lives in src/typings.d.ts, and the hex is committed, so drop the "until this file is committed the build will fail" caveat in favor of the evergreen build/type-resolution facts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The servo pin is confirmed to be P0 (already used in AnalogPin.P0), so the "confirm P0 vs P8 before shipping" note is resolved. Comment-only change; no behavior change and no hex re-export needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing reads channel.hubId (the hubIds in live-output/serial are the micro:bit relay hub letter, a different value), so drop the field and its assignments across the arduino, micro:bit, virtual, and simulated channels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sensor-select label was `hubName:type` (e.g. "Arduino:emg-reading"),
which mislabels a Spiker:bit's reused EMG channel as "Arduino". Switch to
the channel's displayName ("EMG", "Temperature A", ...), which stays unique
per option and is device-neutral. That was hubName's only reader, so drop
the field like hubId.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…E-567] The sensor-missing prompt is now "Connect a device for live <sensor>" for every serial device (not "Connect Arduino"/"Connect micro:bit"), so update the 12 hardcoded labels in the "verify sensor select" assertion to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the EMG design doc to describe the whole PR's architecture as the final result — Spiker:bit support plus the device-capability model and transport abstraction — with a rationale section documenting why the final design was chosen over the alternatives it replaced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds Spiker:bit (micro:bit expansion board) EMG input + servo output support to the DataFlow tile, while refactoring all DataFlow “serial hardware” communication to use a device-capability model and a transport abstraction so Web Serial (Arduino/radio-hub micro:bit) and WebUSB (Spiker:bit) can share the same inbound/outbound data path.
Changes:
- Introduces a transport abstraction (
IDeviceTransport) with concrete Web Serial (WebSerialTransport) and micro:bit WebUSB (MicrobitUsbTransport) implementations, and makesSerialDevicetransport-agnostic. - Adds device capabilities (
kDeviceCapabilities) to decouple device identity from wire protocol and drive output gating (“live/unsupported/no-device”). - Bundles and documents Spiker:bit firmware (
.hexasasset/source), adds consistency tests, and updates UI/docs/tests accordingly.
Reviewed changes
Copilot reviewed 37 out of 43 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| webpack.config.js | Adds webpack handling to import .hex firmware as source text. |
| tsconfig.json | Adds path aliases for microbit-connection subpaths; excludes MakeCode firmware sources from TS compilation. |
| src/typings.d.ts | Declares *.hex module typing for firmware imports. |
| src/plugins/dataflow/nodes/sensor-node.ts | Makes missing-sensor messaging device-neutral; uses channel display names for labels. |
| src/plugins/dataflow/nodes/rete-manager.tsx | Switches “connected” logic to isConnected() and uses protocol satisfaction vs device literal comparisons. |
| src/plugins/dataflow/nodes/live-output-node.ts | Routes output behavior via protocol/capabilities and adds output gating states/options. |
| src/plugins/dataflow/model/utilities/virtual-channel.ts | Removes now-unused hub identity fields from virtual channels. |
| src/plugins/dataflow/model/utilities/simulated-channel.ts | Removes now-unused hub identity fields from simulated channels. |
| src/plugins/dataflow/model/utilities/node.ts | Adds output gating helpers (outputGateState, unsupportedOutputOption). |
| src/plugins/dataflow/model/utilities/node-output-gating.test.ts | Adds unit coverage for output gating helpers. |
| src/plugins/dataflow/model/utilities/device-capabilities.ts | Introduces device capability map and helper predicates (protocol/display/output support). |
| src/plugins/dataflow/model/utilities/device-capabilities.test.ts | Adds unit tests for capability predicates. |
| src/plugins/dataflow/model/utilities/channel.ts | Replaces channel device-family tagging with protocol tagging; updates serial channel definitions. |
| src/plugins/dataflow/firmware/spikerbit-envelope-test.ts | Adds bench-only MakeCode program for envelope/rate validation (excluded from TS build). |
| src/plugins/dataflow/firmware/spikerbit-emg-test.ts | Adds bench-only MakeCode program for raw-vs-envelope diagnostics (excluded from TS build). |
| src/plugins/dataflow/firmware/spikerbit-clue.ts | Adds source-of-record MakeCode firmware for CLUE Spiker:bit (versioned banner + EMG/servo protocol). |
| src/plugins/dataflow/firmware/README.md | Documents firmware rebuild workflow and hardware notes. |
| src/plugins/dataflow/components/ui/dataflow-serial-connect-button.tsx | Adds connect menu to choose between Web Serial devices and Spiker:bit; updates connection-state styling hooks. |
| src/plugins/dataflow/components/ui/dataflow-program-topbar.tsx | Wires new onConnectDevice callback into topbar. |
| src/plugins/dataflow/components/ui/dataflow-program-topbar.scss | Styles the new connect dropdown menu and updates state class names. |
| src/plugins/dataflow/components/dataflow-program.tsx | Adds Spiker:bit connection/flash flow and moves Web Serial connect to WebSerialTransport. |
| src/plugins/dataflow-tool/serial.md | Consolidates/updates hardware connection documentation (inbound/outbound, transports, flashing, status). |
| src/plugins/dataflow-tool/images/dataflow-hardware-outbound.svg | Adds rendered diagram for outbound hardware flow. |
| src/plugins/dataflow-tool/images/dataflow-hardware-inbound.svg | Adds rendered diagram for inbound hardware flow. |
| src/models/stores/web-serial-transport.ts | Adds Web Serial transport implementation (open/write/read loop + connection event wiring). |
| src/models/stores/web-serial-transport.test.ts | Adds unit tests for WebSerialTransport lifecycle/read/write/disconnect behavior. |
| src/models/stores/stores.ts | Initializes global Web Serial connect/disconnect listeners via initWebSerialConnectionEvents. |
| src/models/stores/spikerbit-firmware-consistency.test.ts | Adds tests to keep firmware version source/hex/app constant in sync. |
| src/models/stores/spikerbit-device.ts | Adds Spiker:bit connect/version/flash state machine and WebUSB transport wrapper. |
| src/models/stores/spikerbit-device.test.ts | Adds unit tests for Spiker:bit connect/flash/streaming and disconnect behavior. |
| src/models/stores/spikerbit-connection.ts | Adds typed wrapper around microbit-connection’s USB + universal-hex helpers. |
| src/models/stores/serial.ts | Refactors SerialDevice to be transport-agnostic and to route inbound by protocol. |
| src/models/stores/serial-transport.test.ts | Adds tests for transport routing, stale-transport guards, and protocol routing. |
| src/models/stores/serial-protocol.ts | Extracts key-value parser and Spiker:bit version banner detection. |
| src/models/stores/serial-protocol.test.ts | Adds unit tests for key-value parsing and version detection behavior. |
| src/models/stores/device-transport.ts | Introduces IDeviceTransport interface contract. |
| package.json | Adds @microbit/microbit-connection dependency; adds .hex to Jest file mocks. |
| package-lock.json | Locks dependency graph changes for microbit-connection and related packages. |
| docs/superpowers/specs/2026-07-17-spikerbit-emg-dataflow-design.md | Adds design spec documenting architecture decisions and rationale. |
| docs/dataflow.md | Links to the new serial hardware documentation. |
| cypress/e2e/functional/tile_tests/dataflow_tool_spec.js | Updates expected sensor dropdown labels to the new generic “Connect a device…” wording. |
| .eslintrc.js | Excludes MakeCode firmware directory from ESLint checks. |
Comments suppressed due to low confidence (1)
src/plugins/dataflow/model/utilities/channel.ts:149
lastMessageRecievedAtis misspelled, so relay-info channels never initializelastMessageReceivedAt. This can keep channels from timing out/missing as intended.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| serialConnected: null, | ||
| deviceFamily: "microbit", | ||
| protocol: "radioHub", | ||
| lastMessageRecievedAt: Date.now() |
| private async readWithTimeout( | ||
| streamReader: ReadableStreamDefaultReader<Uint8Array> | ||
| ): Promise<{ value: string | undefined; done: boolean; timedOut: boolean }> { | ||
| const timeoutPromise = new Promise<{ value: undefined; done: false; timedOut: true }>((resolve) => { | ||
| setTimeout(() => resolve({ value: undefined, done: false, timedOut: true }), this.READ_TIMEOUT_MS); | ||
| }); | ||
| const readPromise = streamReader.read().then(({ value, done }) => ({ | ||
| value: value ? textDecoder.decode(value) : undefined, | ||
| done, | ||
| timedOut: false | ||
| })); | ||
| return Promise.race([readPromise, timeoutPromise]); | ||
| } |
| private async closePort(streamReader?: ReadableStreamDefaultReader<Uint8Array>) { | ||
| if (!this.port) return; | ||
| try { | ||
| if (streamReader) { | ||
| try { await streamReader.cancel(); } catch (e) { /* reader may already be released */ } | ||
| try { streamReader.releaseLock(); } catch (e) { /* lock may already be released */ } | ||
| } | ||
| if (this.writer) { | ||
| try { await this.writer.close(); } catch (e) { /* writer may already be closed */ } | ||
| } | ||
| await this.port.close(); | ||
| } catch (e) { | ||
| console.error("Error closing port:", e); | ||
| } | ||
| } |
| public setActiveDevice(deviceFamily: string, transport: IDeviceTransport, channels: NodeChannelInfo[]){ | ||
| this.activeTransport = transport; | ||
| this.deviceFamily = deviceFamily; | ||
| this.channels = channels; | ||
| transport.onData = (chunk: string) => { if (this.activeTransport === transport) this.receive(chunk); }; | ||
| transport.onDisconnect = () => { if (this.activeTransport === transport) this.clearActiveDevice(); }; | ||
| this.updateConnectionInfo(Date.now(), "connect"); |
Overview
Base:
master. Two things developed together:deviceFamily === "arduino"literals and a single hard-wired Web Serial path with a device-capability model and a transport abstraction.The two are intertwined by design: the refactor is what lets the Spiker:bit sit cleanly alongside the existing Arduino and radio-hub micro:bit instead of impersonating one of them. Review the two together.
The
@microbit/microbit-connectiondependency (and the committed compiled firmware.hex) account for nearly all of the +19k line count; the reviewable source change is ~40 files.New hardware: Spiker:bit EMG + servo
spikerbit-connection.tsis a thin typed wrapper around@microbit/microbit-connection;MicrobitUsbTransport(inspikerbit-device.ts) is theIDeviceTransportover that connection.SpikerbitDevicedrives connect → version check → auto-flash the bundledCLUE-SPIKERBITfirmware when the board is running no known/current version → stream. The USB connection stays Connected across a flash (DAPLink untouched), so no reconnect dance.spikerbit-emg-test.ts,spikerbit-envelope-test.ts) plus the compiledspikerbit-clue.hex, imported as a string (hex.d.ts,webpack.config.js,tsconfig.json).spikerbit-firmware-consistency.test.tsguards the version constant against the source. The EMG envelope is computed in firmware (v3).Serial device refactor
Device-capability model
device-capabilities.tsmaps device identity (arduino/microbit/spikerbit) to{ protocol, displayName, outputs }, cleanly separating device identity from a channel's wire-protocol tag (channel.protocol).keyValue(labeledchannelId:numberlines — Arduino and Spiker:bit) andradioHub(multi-hub radio framing — the radio-hub micro:bit).channelSatisfiedBy,deviceProtocol, and a three-state output gate (live/unsupported/no-device) viaoutputGateState. As a result the Spiker:bit correctly shows the Grabber output as unsupported (it has a servo, not the Backyard Brains gripper), and the sensor "missing" prompt is the generic "Connect a device for live EMG".Transport abstraction
IDeviceTransport—write/closeplus an inbound surface (onData/onDisconnect).WebSerialTransport(web-serial-transport.ts) owns the entire Web Serial world: the port, writer,open(), the read loop with reopen/timeout recovery, USB-descriptor device identification, and thenavigator.serialconnect/disconnect listeners (initWebSerialConnectionEvents, wired once at store creation instores.ts).MicrobitUsbTransportis its WebUSB peer.SerialDeviceis now transport-agnostic — it holds the active transport + channels,writeLine → activeTransport.write, and a centralreceive(chunk)that routes by protocol to the shared parser (parseKeyValueData/handleRadioHubStreamObj).navigator.serialno longer appears inserial.ts. Stale-transport callbacks are identity-guarded, so a slow old read loop or an unrelated disconnect can't tear down a newer connection.transport.onData → SerialDevice.receive, Spiker:bit included; its only device-specific inbound step is connect-time version detection, which is not part of the steady-state data path.Behavior changes
Docs
src/plugins/dataflow-tool/serial.mdconsolidates the hardware data-flow documentation: the object map, inbound/outbound Mermaid diagrams (with pre-rendered SVGs), the connect flow + unfiltered-chooser rationale, the Spiker:bit version-check/flash sequence, message reconstitution, and connection-status tracking. Linked fromdocs/dataflow.md.docs/superpowers/specs/2026-07-17-spikerbit-emg-dataflow-design.md; firmware build notes insrc/plugins/dataflow/firmware/README.md.Testing
WebSerialTransportlifecycle (identity/knownBoard resolution, write framing + closed no-op, chunk delivery → timeout → give-up →onDisconnect), centralreceive()routing + stale-transport identity guard,keyValueline parsing, Spiker:bit connect/flash/inbound-through-receive, and firmware-version consistency.check:typesandlint:buildclean.Notes for reviewers (deferred minors — safe to defer, called out so they aren't mistaken for oversights)
IDeviceTransport.close()is defined but not yet wired to a programmatic disconnect path (disconnect currently comes from the transports' own lifecycle events).SerialDevice.receive()silently no-ops on an unrecognized/undefined protocol — e.g. a late chunk arriving just after disconnect.!!coercion onisConnected()remains inlive-output-node.ts(harmless —isConnected()returns a clean boolean after the extraction).🤖 Generated with Claude Code