diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 976ca325..207ab70e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,6 +10,11 @@ "name": "ruview", "source": "./plugins/ruview", "description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification" + }, + { + "name": "ruview-live", + "source": "./harness/ruview/mod", + "description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)." } ] } diff --git a/docs/adr/ADR-377-ruview-live-claude-code-mod.md b/docs/adr/ADR-377-ruview-live-claude-code-mod.md new file mode 100644 index 00000000..88a9f28e --- /dev/null +++ b/docs/adr/ADR-377-ruview-live-claude-code-mod.md @@ -0,0 +1,101 @@ +# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview` + +- **Status**: proposed +- **Date**: 2026-10-01 +- **Deciders**: rUv (requested integration into the npm package); RuView maintainers +- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority +- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI), + ADR-376 (umbrella package); Claude Code mods (function hooks): the + claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest + analogue) + +## Context + +Claude Code mods are plugins whose behaviour is a function-hooks module: one +`register(on, options)` that hooks engine events as `($, e, next)`. They can: +- register slash commands; +- open panes beside the transcript and draw them with the surface's elements; +- set a status line; +- run processes through `$.process.run`. + +That fits RuView's goal of putting the live state of the sensors where an +operator or agent already works. + +RuView already has: +- a read-only CLI with `--json` results (ADR-373, ADR-375); +- an MCP server and the ChatGPT/MCP Apps console; +- a plugin marketplace in this repository. + +## Decision + +1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged + files `mod/.claude-plugin/`, `mod/hooks/`). + - It is listed in the repository marketplace as `ruview-live@ruview`, with + source `./harness/ruview/mod`. + - One copy serves both installs: the npm package and the marketplace. +2. **Behaviour.** + - **`/ruview`** toggles a pane (`ruview-live`). While it is open, a + `$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only + capture. + - **`/ruview refresh`** runs one capture and returns the summary. + - **`/ruview off`** closes the pane. + - **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the + optional ESPHome radar (presence, distance, device-reported vitals), and + alerts, including heartbeat-only boards with the CSI-buffer-starvation + hint. It has Refresh (`r`) and Close (`c`) buttons. + - **The status line** is set on every refresh. +3. **Data comes only from the harness CLI beside the mod.** + - The mod runs `node /../bin/cli.js esp32 … --json` and + `mmwave --source esphome … --json`, as fixed argv arrays, through + `$.process.run`. So the pane shows what the tested tools return. + - Mods may import nothing but their own files and `claude-code` (the + validator refuses `node:url`). The CLI is therefore located through + `$.plugin.root`. +4. **Least authority.** + - The mod never runs a write verb (flash, calibrate, train). A unit test + pins the argv. + - The radar host is validated before use. + - Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s. +5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed + path, the `claude --plugin-dir` command, the marketplace commands, usage, + settings, and the early-access note. + +## Evidence + +All on Claude Code 2.1.286, Windows 11 x64. +- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists + hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`, + `ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`, + `$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`. + `claude plugin validate .` passes for the marketplace. +- **Engine tests:** `claude plugin test harness/ruview/mod`, with + `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive + runs. They check: + - `/ruview` opens the pane and runs the exact read-only argv; + - the status reads `RuView · 2 nodes · 1 alert`; + - the timer refreshes while open and **never after close**; + - an honest `no_packets` status. +- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node: + settings bounds, host validation, argv, failure parsing, model/status, pane + drawing and button wiring, and the no-imports rule. +- **Real engine, headless (MEASURED):** + `claude --plugin-dir ./mod -p "/ruview refresh"` answered + `RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost` + set to the live Seeed MR60BHA2 kit, it answered + `RuView · 0 nodes · radar clear · 1 alert`. +- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path + unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude + Code prompt. + +## Consequences + +- An operator sees node and radar health beside the conversation without + asking for it. Agents see the same facts through MCP. +- **Mods are early access.** The API may change between Claude Code releases, + and hooks modules load only where function hooks are enabled. The mod is + therefore optional, and nothing else in the package depends on it. +- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's + access. It is read-only by construction, and the README says to install it + only from a trusted source. +- A refresh binds UDP 5005 for the capture window. While the sensing server + holds the port, the pane reports `port_in_use` instead of reading. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9a2abad5..391f9666 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,6 +2,7 @@ Latest proposed decisions: +- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md) - [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md) - [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md) - [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md) diff --git a/harness/ruview-umbrella/README.md b/harness/ruview-umbrella/README.md index 87dc0c2b..8b24bea0 100644 --- a/harness/ruview-umbrella/README.md +++ b/harness/ruview-umbrella/README.md @@ -44,6 +44,8 @@ npx ruview kernel selftest A terminal gets a formatted view; pipes and `--json` get JSON. +In **Claude Code**, the bundled `ruview-live` mod puts a live sensing pane beside the conversation (`/ruview`). Run `npx ruview mod` to see how to load it (mods are early access). + ## Agents: MCP, ChatGPT and MCP Apps One server exposes the harness tools (`ruview_*`) and the Homecore tools diff --git a/harness/ruview-umbrella/package.json b/harness/ruview-umbrella/package.json index 6e9059f4..fb34bf1c 100644 --- a/harness/ruview-umbrella/package.json +++ b/harness/ruview-umbrella/package.json @@ -1,6 +1,6 @@ { "name": "ruview", - "version": "0.8.0", + "version": "0.9.0", "description": "RuView in one install: the WiFi-sensing operator harness (ESP32/Realtek CSI, 60/24 GHz radar incl. ESPHome kits, LiDAR, flashing, training, doctor, MCP + ChatGPT/MCP Apps console), the Homecore developer metaharness, and the WASM vitals kernel. One CLI and one MCP server.", "type": "module", "bin": { @@ -21,7 +21,7 @@ "test": "node --test test/*.test.mjs" }, "dependencies": { - "@ruvnet/ruview": "0.8.0", + "@ruvnet/ruview": "0.9.0", "@ruvnet/ruview-kernel": "0.1.0", "homecore": "0.1.0" }, diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 87aa939e..9fa7dbfd 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -3,7 +3,7 @@ "generator": "RuView metaharness provenance v2", "template": "vertical:ruview", "name": "@ruvnet/ruview", - "version": "0.8.0", + "version": "0.9.0", "hosts": [ "claude-code", "codex" @@ -23,7 +23,7 @@ ".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f", "CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d", "LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06", - "README.md": "cdce362447f71e79667d8293569ba63ec251e15010aa3d01958f66fbe3bb54ec", + "README.md": "20df33af2fcd83481321d963b76169b863c6a8acb1d7281e9f40169786169a87", "bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b", "brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572", @@ -32,7 +32,7 @@ "flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214", "flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab", "flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0", - "package.json": "ab768d4e10a55867c2f68d4fd465cd820fb4bc22e432f4a5e67b21024ceabf2e", + "package.json": "c725457bc2d96c7305dcc8f11cd61cb2ef20d3fbe0baa5c9a376bc9897eb8641", "scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565", "scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b", "scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10", @@ -45,7 +45,7 @@ "skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76", "src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6", "src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f", - "src/cli.js": "6c20dc30212bf90c4c3ee21e18a8c1eeacaa1271372f623ff805ba6399676af4", + "src/cli.js": "2401106a97509da8dcbf7e002195328868aa2b18f69cbc02ddaaf1cad95b4388", "src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d", "src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af", "src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629", @@ -75,7 +75,7 @@ "src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956", "src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462" }, - "filesDigest": "9298db21d45a44b918e7ef61e1459a6250ec61ad49873ddc8609ede4dc08aa5e", + "filesDigest": "d7989b453bdabbff37ef3b2382192c36b9513544ab593ffa9da97ba18dc7f003", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index b89d1f8b..cdf854fb 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -6ad86ea903c6c131f134f11f5a51f38cbe2c352d7682911cd2a5587f92d54e16 manifest.json +b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json diff --git a/harness/ruview/README.md b/harness/ruview/README.md index 144194f0..ddc2676f 100644 --- a/harness/ruview/README.md +++ b/harness/ruview/README.md @@ -242,6 +242,44 @@ The bundled `.claude/settings.json` registers the `ruview` MCP server (`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run `npx @ruvnet/ruview install --host claude-code`. +## Claude Code mod: live sensing pane (ADR-377) + +This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviour is a function-hooks module. `/ruview` opens a pane beside the transcript, refreshed on a timer, with: +- your CSI nodes (ESP32, Realtek): rate, loss, RSSI and CSI shape; +- an optional ESPHome radar kit: presence, distance, and device-reported heart/breathing; +- alerts, such as a board that sends heartbeats but no CSI. + +It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`. + +```bash +npx @ruvnet/ruview mod # where the mod is in this install, and how to load it +claude --plugin-dir "" # try it for one session +# or from the RuView marketplace: +# /plugin marketplace add ruvnet/RuView +# /plugin install ruview-live@ruview +``` + +In Claude Code: + +| Command | What it does | +|---|---| +| `/ruview` | Open or close the pane | +| `/ruview refresh` | Run one capture now; the result goes in the status line | +| `/ruview off` | Close the pane | + +**Settings** (`claude plugin configure ruview-live`): +- `udpPort` (default 5005); +- `radarHost`, an ESPHome kit on your private network; +- `refreshSeconds` (default 15, minimum 5); +- `captureSeconds` (default 3). + +**How it reads:** +- The mod only runs this package's CLI, for read-only `esp32` and `mmwave --source esphome` captures, with `--json`, so the pane shows exactly what those tools return. +- It never flashes, provisions or writes to a device. +- Like every mod, it runs inside Claude Code with Claude Code's access, so install it only from a source you trust. + +Mods are early access in Claude Code. If `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. + ## ChatGPT and MCP Apps (ADR-375) `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a diff --git a/harness/ruview/mod/.claude-plugin/plugin.json b/harness/ruview/mod/.claude-plugin/plugin.json new file mode 100644 index 00000000..752240aa --- /dev/null +++ b/harness/ruview/mod/.claude-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "ruview-live", + "version": "0.1.0", + "description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with your CSI nodes (ESP32, Realtek) and 60 GHz radar (raw or ESPHome kits): rates, loss, RSSI, presence, device-reported vitals and alerts such as heartbeat-only boards, refreshed on a timer, plus a one-line status. Reads only; runs the @ruvnet/ruview CLI it ships beside.", + "author": { + "name": "ruvnet", + "url": "https://github.com/ruvnet/RuView" + }, + "homepage": "https://github.com/ruvnet/RuView", + "license": "MIT", + "userConfig": { + "udpPort": { + "type": "number", + "title": "UDP port", + "description": "Port your CSI nodes stream to (the sensing server's default is 5005). The pane listens briefly on each refresh; it reports port_in_use when the sensing server holds it.", + "default": 5005 + }, + "radarHost": { + "type": "string", + "title": "Radar kit host", + "description": "IP or hostname of an ESPHome radar kit (e.g. a Seeed MR60BHA2) on your private network. Empty skips the radar.", + "default": "" + }, + "refreshSeconds": { + "type": "number", + "title": "Refresh interval (s)", + "description": "Seconds between refreshes while the pane is open (minimum 5).", + "default": 15 + }, + "captureSeconds": { + "type": "number", + "title": "Capture window (s)", + "description": "Seconds each refresh listens for node packets and radar states (1-10).", + "default": 3 + } + } +} diff --git a/harness/ruview/mod/hooks/hooks.json b/harness/ruview/mod/hooks/hooks.json new file mode 100644 index 00000000..79b6c334 --- /dev/null +++ b/harness/ruview/mod/hooks/hooks.json @@ -0,0 +1,4 @@ +{ + "description": "RuView live pane: /ruview and its Pane drawing, a timed refresh from the @ruvnet/ruview CLI (node capture and ESPHome radar, read-only), and a one-line status", + "modules": ["./register.mjs"] +} diff --git a/harness/ruview/mod/hooks/register.mjs b/harness/ruview/mod/hooks/register.mjs new file mode 100644 index 00000000..54413e2b --- /dev/null +++ b/harness/ruview/mod/hooks/register.mjs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview. +// +// `/ruview` opens a pane beside the transcript with the CSI nodes streaming to +// this machine and, optionally, an ESPHome radar kit; it refreshes on a timer +// and keeps a one-line status. Every reading comes from the @ruvnet/ruview CLI +// this mod ships beside (`--json`), so the pane shows exactly what the tested +// tools return. Read-only: it never flashes, provisions or writes to a device. + +export const PANE_ID = 'ruview-live'; +export const COMMAND = 'ruview'; +const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/; + +/** + * The harness CLI beside this mod: the plugin root is /mod, so the CLI is + * /bin/cli.js. Mods import nothing but their own files, so the engine's + * `$.plugin.root` locates it. + */ +export function cliPathOf(pluginRoot) { + return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`; +} + +/** Normalise plugin options (userConfig) into bounded settings. */ +export function settingsOf(options = {}) { + const num = (v, d, lo, hi) => { + const n = Number(v); + return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d; + }; + const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : ''; + return { + udpPort: num(options.udpPort, 5005, 1024, 65535), + radarHost: HOST_RE.test(host) ? host : '', + refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000, + captureSeconds: num(options.captureSeconds, 3, 1, 10), + }; +} + +/** argv for one capture and (optionally) one radar read. */ +export function commandsOf(settings) { + const s = String(settings.captureSeconds); + return { + capture: ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'], + radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null, + }; +} + +/** Parse a CLI run into its JSON result, or an honest failure. */ +export function resultOf(run) { + if (!run) return null; + try { + const parsed = JSON.parse(run.stdout); + if (parsed && typeof parsed === 'object') return parsed; + } catch { /* fall through */ } + return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) }; +} + +const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—'); +const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—'); + +/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */ +export function modelOf(capture, radar, at) { + const alerts = []; + if (capture && capture.ok === false) alerts.push({ level: capture.reason === 'no_packets' ? 'warn' : 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` }); + for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` }); + if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` }); + const nodes = (capture?.nodes || []).map((n) => ({ + label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`, + rate: `${fixed(n.csiRateHz)} Hz`, + loss: pct(n.csiLossFraction), + rssi: `${fixed(n.rssiMean)} dBm`, + shape: n.csi?.shape || '—', + lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05, + synthetic: Boolean(n.csi?.synthetic), + })); + const radarRow = radar && radar.ok !== false ? { + name: radar.device?.name || radar.host || 'radar', + present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5), + distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`, + heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`, + breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`, + } : null; + return { at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0, nodes, radar: radarRow, alerts }; +} + +/** One line for the status bar. */ +export function statusOf(model) { + if (!model) return 'RuView · starting'; + const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`]; + if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`); + if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`); + return parts.join(' · '); +} + +/** Draw the pane with the surface's own elements ({ Box, Text, Button }). */ +export function viewOf(ui, model, { refreshMs, busy, onRefresh, onClose }) { + const { Box, Text, Button } = ui; + const line = (children, props = {}) => Text({ ...props, wrap: 'truncate-end', children }); + const rows = []; + rows.push(line(model ? `updated ${new Date(model.at).toLocaleTimeString()} · every ${Math.round(refreshMs / 1000)}s · ${model.decoded}/${model.packets} packets decoded` : 'waiting for the first capture…', { dimColor: true })); + for (const a of model?.alerts || []) rows.push(Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` })); + if (model) { + rows.push(line('NODES', { bold: true })); + if (!model.nodes.length) rows.push(line(' none streaming to this machine', { dimColor: true })); + for (const n of model.nodes) { + rows.push(Box({ flexDirection: 'row', gap: 2, children: [ + line(n.label.padEnd(10)), line(n.rate.padStart(9)), + line(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : {}), line(n.rssi), line(n.shape, { dimColor: true }), + n.synthetic ? line('SYNTHETIC', { color: 'yellow' }) : null, + ].filter(Boolean) })); + } + if (model.radar) { + const r = model.radar; + rows.push(line(`RADAR ${r.name}`, { bold: true })); + rows.push(line(` presence ${r.present == null ? '—' : r.present ? '● detected' : '○ none'} · distance ${r.distance} · heart ${r.heart} · breathing ${r.breathing}`, r.present ? { color: 'green' } : {})); + rows.push(line(' device-reported values, not validated against a reference', { dimColor: true })); + } + } + rows.push(Box({ flexDirection: 'row', gap: 2, children: [ + Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh', hotkey: 'r', onPress: onRefresh }), + Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: onClose }), + ] })); + return Box({ flexDirection: 'column', gap: 0, padding: 1, children: rows }); +} + +/** + * The mod entry: `/ruview [off|refresh]`, the pane, the timer, the status. + * @param on the engine's registrar + * @param options this plugin's userConfig values + */ +export function register(on, options = {}) { + const settings = settingsOf(options); + const commands = commandsOf(settings); + let host = null; + let isOpen = false; + let busy = false; + let model = null; + let stopTimer = null; + let cliPath = null; + + const stop = () => { if (stopTimer) { stopTimer(); stopTimer = null; } }; + const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.()); + + async function runCli(args) { + if (!args) return null; + try { + return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 }); + } catch (error) { + return { exitCode: -1, stdout: '', stderr: String(error?.message || error) }; + } + } + + async function refresh() { + if (!host || busy) return; + busy = true; + host.invalidate(); + try { + const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]); + model = modelOf(resultOf(capture), resultOf(radar), host.now()); + host.status(statusOf(model)); + } finally { + busy = false; + host.invalidate(); + } + } + + async function open() { + await host.open({ id: PANE_ID, title: 'RuView', closeOnEscape: true }); + isOpen = true; + stop(); + stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); })); + void refresh(); + } + + async function close() { + stop(); + isOpen = false; + await host.close({ id: PANE_ID }).catch(() => undefined); + } + + on('session.start', async ($, e, next) => { + cliPath = cliPathOf($.plugin.root); + host = { + run: (argv, init) => $.process.run(argv, init), + every: (ms, fn) => $.clock.every(ms, fn), + now: () => $.clock.now(), + open: (pane) => $.ui.open(pane), + close: (pane) => $.ui.close(pane), + status: (text) => $.ui.status(text), + invalidate: () => $.ui.invalidate('ui.render'), + }; + await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes and radar, refreshed', argumentHint: '[off|refresh]' }).catch(() => undefined); + return next(e); + }); + + on('command.run', { command: COMMAND }, async ($, e, next) => { + if (!host) return next(e); + const arg = String(e.args || '').trim(); + if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; } + if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; } + if (isOpen) { await close(); return { text: 'RuView pane closed.' }; } + await open(); + return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; refreshing every ${Math.round(settings.refreshMs / 1000)}s.` }; + }); + + on('ui.render', { component: 'Pane' }, async ($, e, next) => { + if (e.requestId !== PANE_ID) return next(e); + const ui = await $.ui.resolve(e); + return viewOf(ui, model, { refreshMs: settings.refreshMs, busy, onRefresh: () => { void refresh(); }, onClose: () => { void close(); } }); + }); + + on('ui.close', { id: PANE_ID }, async ($, e, next) => { + stop(); + isOpen = false; + return next(e); + }); + + on('session.end', async ($, e, next) => { + stop(); + return next(e); + }); +} diff --git a/harness/ruview/mod/tests/register.test.ts b/harness/ruview/mod/tests/register.test.ts new file mode 100644 index 00000000..09772816 --- /dev/null +++ b/harness/ruview/mod/tests/register.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, mock, test, tier } from 'claude-code/testing' + +tier('user') + +const CAPTURE = { + ok: true, + packets: 120, + decodedPackets: 120, + heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }], + nodes: [ + { source: 'esp32', nodeId: 42, csiRateHz: 4.7, csiLossFraction: 0, rssiMean: -63, csi: { shape: '1x64' } }, + { source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52' } }, + ], +} + +describe('register', () => { + test('/ruview opens the pane, refreshes from the harness CLI, and stops on close', async ($, on) => { + const runs: (readonly string[])[] = [] + const opened: string[] = [] + const closed: string[] = [] + const statuses: (string | undefined)[] = [] + const clock = mock.clock(on) + on('session.start', ($, e) => ({ cwd: e.cwd })) + on('command.register', ($, e) => ({ value: { command: e.name } })) + on('process.run', ($, e) => { + runs.push(e.argv) + return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } } + }) + on('ui.open', ($, e) => { + opened.push(e.id) + return { value: undefined } + }) + on('ui.close', ($, e) => { + closed.push(e.id) + return { value: undefined } + }) + on('ui.status', ($, e) => { + statuses.push(e.text) + return { value: undefined } + }) + on('ui.invalidate', () => ({ value: undefined })) + + const settle = async () => { + for (let i = 0; i < 5; i++) await clock.settle() + } + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + const { text } = await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } }) + await settle() + + expect(text).toContain('RuView pane open') + expect(opened).toEqual(['ruview-live']) + expect(runs.length).toBeGreaterThanOrEqual(1) + const argv = runs[0] ?? [] + expect(argv[0]).toBe('node') + expect(String(argv[1])).toContain('bin/cli.js') + expect(argv.slice(2)).toEqual(['esp32', '--seconds', '3', '--udp-port', '5005', '--json']) + expect(statuses.at(-1)).toBe('RuView · 2 nodes · 1 alert') + + const beforeTicks = runs.length + for (let i = 0; i < 3; i++) { + clock.advance(15_000) + await settle() + } + expect(runs.length).toBeGreaterThan(beforeTicks) + + await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } }) + await settle() + expect(closed).toEqual(['ruview-live']) + const afterClose = runs.length + for (let i = 0; i < 4; i++) { + clock.advance(15_000) + await settle() + } + expect(runs.length).toBe(afterClose) + }) + + test('/ruview refresh with no nodes reports the honest failure in the status', async ($, on) => { + const statuses: (string | undefined)[] = [] + mock.clock(on) + on('session.start', ($, e) => ({ cwd: e.cwd })) + on('command.register', ($, e) => ({ value: { command: e.name } })) + on('process.run', () => ({ value: { exitCode: 1, stdout: JSON.stringify({ ok: false, reason: 'no_packets', packets: 0 }), stderr: '' } })) + on('ui.status', ($, e) => { + statuses.push(e.text) + return { value: undefined } + }) + on('ui.invalidate', () => ({ value: undefined })) + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } }) + + expect(text).toBe('RuView · 0 nodes · 1 alert') + expect(statuses.at(-1)).toBe('RuView · 0 nodes · 1 alert') + }) +}) diff --git a/harness/ruview/mod/tsconfig.json b/harness/ruview/mod/tsconfig.json new file mode 100644 index 00000000..8681ec01 --- /dev/null +++ b/harness/ruview/mod/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.claude-plugin/types/tsconfig.json" +} diff --git a/harness/ruview/package.json b/harness/ruview/package.json index c0770bde..0e58d084 100644 --- a/harness/ruview/package.json +++ b/harness/ruview/package.json @@ -1,6 +1,6 @@ { "name": "@ruvnet/ruview", - "version": "0.8.0", + "version": "0.9.0", "description": "RuView WiFi-sensing operator harness \u2014 CLI, MCP server, and SDK for host device access (ESP32 CSI nodes, 60/24 GHz mmWave, LiDAR), SSH remote hosts, diagnostics, verified ESP32 flashing, evidence-gated training, calibration, and Cognitum Spaces.", "type": "module", "bin": { @@ -39,6 +39,8 @@ "brain/", "flywheel/", "scripts/", + "mod/.claude-plugin/", + "mod/hooks/", "CLAUDE.md", "README.md", "LICENSE" diff --git a/harness/ruview/src/cli.js b/harness/ruview/src/cli.js index 30bd686b..d67ca5f3 100644 --- a/harness/ruview/src/cli.js +++ b/harness/ruview/src/cli.js @@ -201,6 +201,7 @@ Harness: structured diagnostics with fixes (ADR-372) skills list bundled skills skill print a skill playbook + mod [--json] where the ruview-live Claude Code mod is, and how to load it (ADR-377) mcp start run the ruview.* MCP server (stdio) mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL] MCP over HTTP for ChatGPT and remote clients (ADR-375); @@ -291,6 +292,23 @@ export async function run(args, opts = {}) { return 0; } case 'mcp': return mcp(rest, flags, opts.mcpHandler); + case 'mod': { + // The ruview-live Claude Code mod ships inside this package (ADR-377). + const dir = join(ROOT, 'mod'); + const info = { + ok: existsSync(join(dir, '.claude-plugin', 'plugin.json')), + plugin: 'ruview-live', + path: dir, + run: `claude --plugin-dir "${dir}"`, + marketplace: ['/plugin marketplace add ruvnet/RuView', '/plugin install ruview-live@ruview'], + usage: ['/ruview open or close the live pane', '/ruview refresh one capture now, result in the status line', '/ruview off close the pane'], + configure: 'claude plugin configure ruview-live (udpPort, radarHost, refreshSeconds, captureSeconds)', + note: 'Mods are early access in Claude Code. If /ruview is missing, start Claude Code with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.', + }; + if (flags.json === true || !process.stdout.isTTY) pjson(info); + else console.log([`ruview-live mod: ${info.path}`, ` try it: ${info.run}`, ` install: ${info.marketplace.join(' then ')}`, ...info.usage.map((u) => ` ${u}`), ` ${info.configure}`, ` ${info.note}`].join('\n')); + return info.ok ? 0 : 1; + } case 'agent': { if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo ]'); return 2; } const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]); diff --git a/harness/ruview/test/mod.test.mjs b/harness/ruview/test/mod.test.mjs new file mode 100644 index 00000000..52ff06fe --- /dev/null +++ b/harness/ruview/test/mod.test.mjs @@ -0,0 +1,83 @@ +// The ruview-live Claude Code mod (ADR-377): its pure parts run under plain +// Node; the engine-level behaviour is covered by `claude plugin test mod`. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { cliPathOf, commandsOf, modelOf, resultOf, settingsOf, statusOf, viewOf } from '../mod/hooks/register.mjs'; + +const capture = { + ok: true, packets: 120, decodedPackets: 118, + heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }], + nodes: [ + { source: 'esp32', nodeId: 42, csiRateHz: 4.73, csiLossFraction: 0, rssiMean: -63.1, csi: { shape: '1x64' } }, + { source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52', synthetic: true } }, + ], +}; +const radar = { ok: true, host: '192.168.1.102', device: { name: 'mr60-kit' }, presentNow: true, distanceCmMean: 40.2, heartBpmMean: 75.3, breathingBpmMean: 10.7 }; + +test('settings are bounded and radar hosts are validated', () => { + assert.deepEqual(settingsOf({}), { udpPort: 5005, radarHost: '', refreshMs: 15000, captureSeconds: 3 }); + const s = settingsOf({ udpPort: 80, radarHost: ' 192.168.1.102 ', refreshSeconds: 1, captureSeconds: 99 }); + assert.deepEqual(s, { udpPort: 1024, radarHost: '192.168.1.102', refreshMs: 5000, captureSeconds: 10 }); + assert.equal(settingsOf({ radarHost: 'evil host; rm -rf /' }).radarHost, ''); +}); + +test('commands are fixed argv arrays for read-only CLI verbs', () => { + const c = commandsOf(settingsOf({ radarHost: 'kit.local' })); + assert.deepEqual(c.capture, ['esp32', '--seconds', '3', '--udp-port', '5005', '--json']); + assert.deepEqual(c.radar, ['mmwave', '--source', 'esphome', '--host', 'kit.local', '--seconds', '3', '--json']); + assert.equal(commandsOf(settingsOf({})).radar, null); + for (const argv of [c.capture, c.radar]) assert.ok(!argv.some((a) => /flash|calibrate|train|confirm/.test(a)), 'never a write verb'); + assert.equal(cliPathOf('C:\\pkg\\mod\\'), 'C:\\pkg\\mod/../bin/cli.js'); + assert.equal(cliPathOf('/pkg/mod'), '/pkg/mod/../bin/cli.js'); +}); + +test('CLI output parses to its result or an honest failure', () => { + assert.deepEqual(resultOf({ exitCode: 0, stdout: '{"ok":true}', stderr: '' }), { ok: true }); + assert.deepEqual(resultOf({ exitCode: 1, stdout: '{"ok":false,"reason":"no_packets"}', stderr: '' }), { ok: false, reason: 'no_packets' }); + assert.equal(resultOf({ exitCode: 127, stdout: '', stderr: 'node: not found' }).reason, 'cli_error'); + assert.equal(resultOf(null), null); +}); + +test('model and status summarise nodes, radar and alerts', () => { + const m = modelOf(capture, radar, Date.UTC(2026, 9, 1, 12)); + assert.deepEqual(m.nodes.map((n) => [n.label, n.rate, n.loss, n.lossy, n.synthetic]), [['esp32 42', '4.7 Hz', '0.0%', false, false], ['realtek 3', '348.0 Hz', '8.0%', true, true]]); + assert.match(m.alerts[0].text, /192\.168\.1\.67 sends heartbeats but no CSI/); + assert.deepEqual([m.radar.present, m.radar.distance, m.radar.heart], [true, '40.2 cm', '75.3 bpm']); + assert.equal(statusOf(m), 'RuView · 2 nodes · radar present · 1 alert'); + const failed = modelOf({ ok: false, reason: 'port_in_use', remedy: 'stop the sensing server' }, { ok: false, reason: 'host_not_private', detail: '8.8.8.8' }, 0); + assert.equal(failed.alerts.length, 2); + assert.equal(statusOf(failed), 'RuView · 0 nodes · 2 alerts'); + assert.equal(statusOf(null), 'RuView · starting'); +}); + +test('the pane draws with the surface elements and wires both buttons', () => { + const el = (type) => (props) => ({ type, props }); + const presses = []; + const tree = viewOf({ Box: el('Box'), Text: el('Text'), Button: el('Button') }, modelOf(capture, radar, 0), { + refreshMs: 15000, busy: false, onRefresh: () => presses.push('refresh'), onClose: () => presses.push('close'), + }); + const flat = []; + const walk = (n) => { if (!n || typeof n !== 'object') return; flat.push(n); [].concat(n.props?.children ?? []).forEach(walk); }; + walk(tree); + const texts = flat.filter((n) => n.type === 'Text').map((n) => String(n.props.children)); + assert.ok(texts.some((t) => t.includes('heartbeats but no CSI'))); + assert.ok(texts.some((t) => t.startsWith('RADAR'))); + assert.ok(texts.some((t) => t.includes('not validated'))); + assert.ok(texts.some((t) => t === 'SYNTHETIC')); + const buttons = flat.filter((n) => n.type === 'Button'); + assert.deepEqual(buttons.map((b) => [b.props.key, b.props.hotkey]), [['refresh', 'r'], ['close', 'c']]); + buttons.forEach((b) => b.props.onPress()); + assert.deepEqual(presses, ['refresh', 'close']); + const waiting = viewOf({ Box: el('Box'), Text: el('Text'), Button: el('Button') }, null, { refreshMs: 15000, busy: true, onRefresh() {}, onClose() {} }); + assert.match(JSON.stringify(waiting), /waiting for the first capture/); +}); + +test('the mod manifest and hooks module are a valid plugin shape', () => { + const plugin = JSON.parse(readFileSync(new URL('../mod/.claude-plugin/plugin.json', import.meta.url), 'utf8')); + const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8')); + assert.equal(plugin.name, 'ruview-live'); + assert.deepEqual(hooks.modules, ['./register.mjs']); + const src = readFileSync(new URL('../mod/hooks/register.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(src, /^\s*import\s/m, 'a mod imports nothing but its own files'); +});