feat(ruview): ruview-live Claude Code mod shipped in the npm package (ADR-377)

- mod/: a Claude Code mod (function hooks). /ruview opens a pane beside
  the transcript with CSI nodes (rate, loss, RSSI, shape, SYNTHETIC) and an
  optional ESPHome radar (presence, distance, device-reported vitals),
  alerts (heartbeat-only boards + buffer-starvation hint), Refresh/Close
  buttons and a one-line status; refreshed on $.clock.every.
- Reads only through the harness CLI beside it (fixed read-only argv,
  --json) located via $.plugin.root (mods may import nothing else).
- Packaged in @ruvnet/ruview (mod/.claude-plugin, mod/hooks), listed in the
  repo marketplace as ruview-live@ruview; `ruview mod` prints how to load it.
- @ruvnet/ruview 0.9.0; umbrella ruview 0.9.0 (still unpublished).

Verified: claude plugin validate (mod + marketplace); claude plugin test
2/2 x3 runs; harness 172 tests + 89 security; real Claude Code headless
`/ruview refresh` from an installed tarball and with the live radar kit.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-10-01 17:12:53 -04:00
parent 4128661789
commit 093e3bbeae
16 changed files with 620 additions and 9 deletions

View file

@ -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)."
}
]
}

View file

@ -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 <plugin root>/../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.

View file

@ -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)

View file

@ -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

View file

@ -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"
},

View file

@ -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": {

View file

@ -1 +1 @@
6ad86ea903c6c131f134f11f5a51f38cbe2c352d7682911cd2a5587f92d54e16 manifest.json
b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json

View file

@ -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 "<path printed above>" # 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

View file

@ -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
}
}
}

View file

@ -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"]
}

View file

@ -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 <pkg>/mod, so the CLI is
* <pkg>/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);
});
}

View file

@ -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')
})
})

View file

@ -0,0 +1,3 @@
{
"extends": "./.claude-plugin/types/tsconfig.json"
}

View file

@ -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"

View file

@ -201,6 +201,7 @@ Harness:
structured diagnostics with fixes (ADR-372)
skills list bundled skills
skill <name> 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 <dir>]'); return 2; }
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);

View file

@ -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');
});