@remcp/runtime 0.2.200 → 0.2.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.200",
3
+ "version": "0.2.202",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,95 @@
1
+ // Linux exposes multiple independent hardware temperature channels through hwmon and
2
+ // thermal zones. Never stop at the first thermal zone: that hides real CPU/GPU/SSD data.
3
+ import { readFile as kernelRead, readdir as kernelList } from 'node:fs/promises';
4
+ import { nvidiaTemperatureReadings } from './nvidia-temperature.mjs';
5
+
6
+ const CHIP = /^[A-Za-z0-9_.+-]{1,32}$/;
7
+ const LABEL = /^[A-Za-z0-9_.:+#() -]{1,40}$/;
8
+ const SAFE_NAME = /^[A-Za-z0-9_.:+#() -]{1,60}$/;
9
+ const SENSOR_LIMIT = 64;
10
+
11
+ export function linuxTemperatureValue(raw) {
12
+ const input = String(raw || '').trim();
13
+ if (!/^-?\d{1,7}$/.test(input)) return null;
14
+ const celsius = Number(input) / 1000;
15
+ return Number.isFinite(celsius) && celsius >= -40 && celsius <= 125
16
+ ? Number(celsius.toFixed(1)) : null;
17
+ }
18
+
19
+ const priority = name => /^(?:k10temp|coretemp|zenpower|cpu)/i.test(name) ? 0
20
+ : /^(?:amdgpu|nvidia|gpu)/i.test(name) ? 1
21
+ : /^nvme/i.test(name) ? 2 : 3;
22
+
23
+ export async function linuxTemperatureReadings({
24
+ list = kernelList, read = kernelRead, nvidia = nvidiaTemperatureReadings,
25
+ } = {}) {
26
+ const entries = [];
27
+ const chips = [];
28
+ const seen = new Set();
29
+ const boundedFile = async name => {
30
+ const value = String(await read(name, 'utf8'));
31
+ return value.length <= 128 ? value.trim() : '';
32
+ };
33
+ const add = (name, value, chip = '') => {
34
+ if (value === null || !SAFE_NAME.test(name) || seen.has(name)) return;
35
+ entries.push({ name, celsius: value, chip });
36
+ seen.add(name);
37
+ };
38
+
39
+ try {
40
+ const dirs = (await list('/sys/class/hwmon'))
41
+ .filter(name => /^hwmon\d{1,3}$/.test(name)).slice(0, 32);
42
+ for (const dir of dirs) {
43
+ const base = '/sys/class/hwmon/' + dir;
44
+ let chip;
45
+ try { chip = await boundedFile(base + '/name'); } catch { continue; }
46
+ if (!CHIP.test(chip)) continue;
47
+ chips.push(chip);
48
+ let inputs = [];
49
+ try { inputs = (await list(base)).filter(name => /^temp(?:[1-9]|[1-9]\d)_input$/.test(name)).slice(0, 32); }
50
+ catch { continue; }
51
+ for (const file of inputs) {
52
+ const index = file.match(/^temp(\d+)_input$/)[1];
53
+ try {
54
+ const value = linuxTemperatureValue(await boundedFile(base + '/' + file));
55
+ let label = '';
56
+ try { label = await boundedFile(base + '/temp' + index + '_label'); } catch { /* Optional. */ }
57
+ if (!LABEL.test(label)) label = 'temp' + index;
58
+ const baseName = chip + ': ' + label;
59
+ // Multiple NVMe drives can share the same chip/label. Preserve each
60
+ // physical channel using its hwmon ID rather than dropping it.
61
+ add(seen.has(baseName) ? chip + '(' + dir + '): ' + label : baseName, value, chip);
62
+ } catch { /* Optional or inaccessible hardware channel. */ }
63
+ }
64
+ }
65
+ } catch { /* No hwmon inventory (VMs, containers, hardened hosts). */ }
66
+
67
+ // Thermal zones can expose additional independent channels. Drop an exact
68
+ // ACPI duplicate when hwmon already reports the same chip and temperature.
69
+ try {
70
+ const zones = (await list('/sys/class/thermal'))
71
+ .filter(name => /^thermal_zone\d{1,3}$/.test(name)).slice(0, 32);
72
+ for (const zone of zones) {
73
+ const base = '/sys/class/thermal/' + zone;
74
+ try {
75
+ const value = linuxTemperatureValue(await boundedFile(base + '/temp'));
76
+ const type = await boundedFile(base + '/type').catch(() => '');
77
+ const duplicate = CHIP.test(type) && entries.some(row =>
78
+ row.chip === type && value !== null && Math.abs(row.celsius - value) <= 0.1);
79
+ if (!duplicate) add('Thermal ' + zone.slice('thermal_zone'.length), value);
80
+ } catch { /* Optional or inaccessible thermal zone. */ }
81
+ }
82
+ } catch { /* No thermal zones. */ }
83
+ // The proprietary NVIDIA driver may not expose GPU temperatures via hwmon.
84
+ // Query its read-only tool using fixed arguments; missing drivers mean unknown.
85
+ const vendorGpu = await nvidia().catch(() => []);
86
+ for (const item of Array.isArray(vendorGpu) ? vendorGpu.slice(0, 16) : []) {
87
+ if (!/^NVIDIA GPU (?:[0-9]|1[0-5])$/.test(item?.name)) continue;
88
+ const value = typeof item.celsius === 'number' && Number.isFinite(item.celsius) &&
89
+ item.celsius >= 0 && item.celsius <= 125 ? item.celsius : null;
90
+ add(item.name, value, 'nvidia');
91
+ }
92
+ // Prefer measured CPU and GPU channels for the compact overview widget.
93
+ return entries.sort((a, b) => priority(a.chip) - priority(b.chip))
94
+ .slice(0, SENSOR_LIMIT).map(({ name, celsius }) => ({ name, celsius }));
95
+ }
@@ -0,0 +1,24 @@
1
+ // NVIDIA exposes GPU die temperatures through the read-only nvidia-smi CLI
2
+ // on Linux and Windows. Do not execute a shell or user-supplied arguments.
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const exec = promisify(execFile);
6
+
7
+ export function parseNvidiaTemperatures(stdout) {
8
+ return String(stdout || '').split(/\r?\n/).slice(0, 16).flatMap((line, index) => {
9
+ const value = line.trim();
10
+ if (!/^\d{1,3}(?:\.\d)?$/.test(value)) return [];
11
+ const celsius = Number(value);
12
+ return celsius >= 0 && celsius <= 125
13
+ ? [{ name: 'NVIDIA GPU ' + index, celsius }] : [];
14
+ });
15
+ }
16
+
17
+ export async function nvidiaTemperatureReadings({ run = exec } = {}) {
18
+ try {
19
+ const result = await run('nvidia-smi',
20
+ ['--query-gpu=temperature.gpu', '--format=csv,noheader,nounits'],
21
+ { windowsHide: true, timeout: 2500, maxBuffer: 4096 });
22
+ return parseNvidiaTemperatures(result.stdout);
23
+ } catch { return []; }
24
+ }
@@ -3,8 +3,12 @@
3
3
  import { readFile, readdir } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
+ import os from 'node:os';
6
7
  import process from 'node:process';
8
+ import { setTimeout as delay } from 'node:timers/promises';
7
9
  import { darwinTemperatureReadings } from './darwin-temperature.mjs';
10
+ import { linuxTemperatureReadings } from './linux-temperature.mjs';
11
+ import { windowsTemperatureReadings } from './windows-temperature.mjs';
8
12
 
9
13
  const run = promisify(execFile);
10
14
  const numeric = value => Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
@@ -25,6 +29,52 @@ export function parseNetworkCounters(input) {
25
29
  return interfaces;
26
30
  }
27
31
 
32
+ // netstat -ib has multiple address rows per interface on macOS. Only the
33
+ // <Link#N> row contains the canonical byte counters; never sum duplicates.
34
+ export function parseDarwinNetworkCounters(input, activeNames = null) {
35
+ const lines = String(input).split(/\r?\n/);
36
+ const header = lines[0]?.trim().split(/\s+/) ?? [];
37
+ const rxIndex = header.indexOf('Ibytes');
38
+ const txIndex = header.indexOf('Obytes');
39
+ if (rxIndex < 0 || txIndex < 0) return [];
40
+ const found = new Map();
41
+ for (const line of lines.slice(1, 160)) {
42
+ const parts = line.trim().split(/\s+/);
43
+ const name = parts[0];
44
+ if (!safeName(name) || !/^<Link#\d+>$/.test(parts[2]) ||
45
+ /^(?:lo\d*|awdl\d*|llw\d*|utun\d*|bridge\d*|gif\d*|stf\d*|ap\d*|anpi\d*)$/.test(name) ||
46
+ (activeNames && !activeNames.has(name))) continue;
47
+ const rx = Number(parts[rxIndex]);
48
+ const tx = Number(parts[txIndex]);
49
+ if (!numeric(rx) || !numeric(tx)) continue;
50
+ found.set(name, { name, rx, tx });
51
+ if (found.size >= 24) break;
52
+ }
53
+ return [...found.values()];
54
+ }
55
+
56
+ // Windows adapter statistics are real cumulative counters, not link-speed estimates.
57
+ export function parseWindowsNetworkCounters(input, activeNames = null) {
58
+ let parsed;
59
+ try { parsed = JSON.parse(String(input)); } catch { return []; }
60
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
61
+ const found = new Map();
62
+ for (const row of rows.slice(0, 128)) {
63
+ const name = row?.Name;
64
+ if (typeof name !== 'string' || !/^[\p{L}\p{N} _().:#-]{1,64}$/u.test(name) ||
65
+ (activeNames && !activeNames.has(name))) continue;
66
+ const parseCounter = value => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
67
+ ? value : typeof value === 'string' && /^\d{1,16}$/.test(value) && numeric(Number(value))
68
+ ? Number(value) : null;
69
+ const rx = parseCounter(row?.ReceivedBytes);
70
+ const tx = parseCounter(row?.SentBytes);
71
+ if (rx === null || tx === null) continue;
72
+ found.set(name, { name, rx, tx });
73
+ if (found.size >= 24) break;
74
+ }
75
+ return [...found.values()];
76
+ }
77
+
28
78
  export function calculateNetworkRates(current, previous, elapsedSeconds) {
29
79
  if (!Array.isArray(current) || !Array.isArray(previous) || !Number.isFinite(elapsedSeconds) ||
30
80
  elapsedSeconds < 1 || elapsedSeconds > 300) return null;
@@ -45,52 +95,71 @@ export function calculateNetworkRates(current, previous, elapsedSeconds) {
45
95
  }
46
96
 
47
97
  let lastNetwork = null;
48
- export async function networkRates() {
49
- if (process.platform !== 'linux') return null;
98
+ let pendingNetwork = null;
99
+
100
+ async function readNetworkCounters() {
101
+ if (process.platform === 'linux') {
102
+ const active = new Set(Object.entries(os.networkInterfaces())
103
+ .filter(([, addresses]) => addresses?.some(address => !address.internal))
104
+ .map(([name]) => name));
105
+ return parseNetworkCounters(await readFile('/proc/net/dev', 'utf8'))
106
+ .filter(row => active.has(row.name) && !/^(?:veth|docker|br-|virbr|tun|tap|wg|lo)/.test(row.name));
107
+ }
108
+ if (process.platform === 'darwin') {
109
+ const { stdout } = await run('/usr/sbin/netstat', ['-ibn'],
110
+ { timeout: 2500, maxBuffer: 128 * 1024 });
111
+ const active = new Set(Object.entries(os.networkInterfaces())
112
+ .filter(([, addresses]) => addresses?.some(address => !address.internal &&
113
+ !address.address.startsWith('fe80:'))).map(([name]) => name));
114
+ return parseDarwinNetworkCounters(stdout, active);
115
+ }
116
+ if (process.platform === 'win32') {
117
+ const script = 'Get-NetAdapterStatistics | Select-Object Name,ReceivedBytes,SentBytes | ConvertTo-Json -Compress';
118
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
119
+ { windowsHide: true, timeout: 5000, maxBuffer: 128 * 1024 });
120
+ const active = new Set(Object.entries(os.networkInterfaces())
121
+ .filter(([, addresses]) => addresses?.some(address => !address.internal))
122
+ .map(([name]) => name));
123
+ return parseWindowsNetworkCounters(stdout, active);
124
+ }
125
+ return [];
126
+ }
127
+
128
+ async function sampleNetworkRates() {
50
129
  try {
51
- const input = await readFile('/proc/net/dev', 'utf8');
52
- const current = parseNetworkCounters(input);
53
- const now = performance.now();
54
- const previous = lastNetwork;
130
+ let current = await readNetworkCounters();
131
+ let now = performance.now();
132
+ let previous = lastNetwork;
133
+ const elapsed = previous ? (now - previous.at) / 1000 : 0;
134
+ // The first diagnostics request must return a measured rate, not null.
135
+ // Bootstrap from two real counters; never manufacture a zero-rate sample.
136
+ if (elapsed < 1 || elapsed > 300) {
137
+ previous = { at: now, interfaces: current };
138
+ await delay(1100);
139
+ current = await readNetworkCounters();
140
+ now = performance.now();
141
+ }
55
142
  lastNetwork = { at: now, interfaces: current };
56
- return previous ? calculateNetworkRates(current, previous.interfaces, (now - previous.at) / 1000) : null;
143
+ return calculateNetworkRates(current, previous.interfaces, (now - previous.at) / 1000);
57
144
  } catch {
58
145
  lastNetwork = null;
59
146
  return null;
60
147
  }
61
148
  }
62
149
 
150
+ export async function networkRates() {
151
+ if (!['linux', 'darwin', 'win32'].includes(process.platform)) return null;
152
+ if (pendingNetwork) return pendingNetwork;
153
+ pendingNetwork = sampleNetworkRates();
154
+ try { return await pendingNetwork; }
155
+ finally { pendingNetwork = null; }
156
+ }
157
+
63
158
  export async function thermalReadings() {
64
159
  if (process.platform === 'darwin') return darwinTemperatureReadings();
65
- if (process.platform !== 'linux') return [];
66
- const sensors = [];
67
- // Linux thermal zones expose millidegrees Celsius; only fixed kernel paths are read.
68
- try {
69
- const names = (await readdir('/sys/class/thermal')).filter(name => /^thermal_zone\d{1,3}$/.test(name)).slice(0, 16);
70
- for (const name of names) {
71
- try {
72
- const value = Number((await readFile('/sys/class/thermal/' + name + '/temp', 'utf8')).trim()) / 1000;
73
- if (Number.isFinite(value) && value >= -40 && value <= 125)
74
- sensors.push({ name: 'Thermal ' + name.slice(12), celsius: Number(value.toFixed(1)) });
75
- } catch { /* Sensor is optional or permissions deny reading it. */ }
76
- }
77
- } catch { /* No thermal zone inventory. */ }
78
- // hwmon is common on desktop hardware without usable thermal zones.
79
- if (!sensors.length) {
80
- try {
81
- const chips = (await readdir('/sys/class/hwmon')).filter(name => /^hwmon\d{1,3}$/.test(name)).slice(0, 12);
82
- for (const chip of chips) {
83
- for (let index = 1; index <= 4 && sensors.length < 12; index++) {
84
- try {
85
- const value = Number((await readFile('/sys/class/hwmon/' + chip + '/temp' + index + '_input', 'utf8')).trim()) / 1000;
86
- if (Number.isFinite(value) && value >= -40 && value <= 125)
87
- sensors.push({ name: 'Sensor ' + (sensors.length + 1), celsius: Number(value.toFixed(1)) });
88
- } catch { /* Not every chip exposes every temperature index. */ }
89
- }
90
- }
91
- } catch { /* No readable hwmon inventory. */ }
92
- }
93
- return sensors.slice(0, 12);
160
+ if (process.platform === 'linux') return linuxTemperatureReadings();
161
+ if (process.platform === 'win32') return windowsTemperatureReadings();
162
+ return [];
94
163
  }
95
164
 
96
165
  export function parseDiskVolumes(input) {
@@ -0,0 +1,76 @@
1
+ // Optional read-only Windows temperature sources. Windows has no universal
2
+ // built-in API for CPU/GPU/SSD temperatures: report only exposed real sensors.
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { nvidiaTemperatureReadings } from './nvidia-temperature.mjs';
6
+ export { parseNvidiaTemperatures } from './nvidia-temperature.mjs';
7
+
8
+ const exec = promisify(execFile);
9
+ const MAX_SENSORS = 64;
10
+ const CLEAN_LABEL = /^[A-Za-z0-9_.:+#() -]{1,60}$/;
11
+
12
+ // Fixed PowerShell source, with no user-supplied script text or arguments.
13
+ export const WINDOWS_TEMPERATURE_SCRIPT = [
14
+ "$ErrorActionPreference = 'Stop'",
15
+ "$rows = [System.Collections.Generic.List[object]]::new()",
16
+ "foreach ($namespace in @('root\\LibreHardwareMonitor','root\\OpenHardwareMonitor')) {",
17
+ " try {",
18
+ " $sensors = @(Get-CimInstance -Namespace $namespace -ClassName Sensor -ErrorAction Stop)",
19
+ " foreach ($sensor in $sensors) {",
20
+ " if ($sensor.SensorType -ne 'Temperature') { continue }",
21
+ " $rows.Add(@{ name = ('Hardware: ' + [string]$sensor.Name); celsius = $sensor.Value })",
22
+ " if ($rows.Count -ge 64) { break }",
23
+ " }",
24
+ " if ($rows.Count -gt 0) { break }",
25
+ " } catch { }",
26
+ "}",
27
+ "try {",
28
+ " $zones = @(Get-CimInstance -Namespace 'root\\wmi' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction Stop)",
29
+ " $index = 0",
30
+ " foreach ($zone in $zones) {",
31
+ " $raw = [double]$zone.CurrentTemperature",
32
+ " $rows.Add(@{ name = ('ACPI Thermal ' + $index); celsius = (($raw / 10) - 273.15) })",
33
+ " $index += 1",
34
+ " if ($rows.Count -ge 64) { break }",
35
+ " }",
36
+ "} catch { }",
37
+ "ConvertTo-Json -InputObject @($rows.ToArray()) -Compress -Depth 3",
38
+ ].join('\n');
39
+
40
+ export function parseWindowsTemperatureReadings(stdout) {
41
+ let values;
42
+ try { values = JSON.parse(String(stdout || '')); } catch { return []; }
43
+ if (!Array.isArray(values)) values = values && typeof values === 'object' ? [values] : [];
44
+ const sensors = [];
45
+ const names = new Set();
46
+ for (const raw of values.slice(0, 128)) {
47
+ const baseName = String(raw?.name ?? '').trim();
48
+ const value = raw?.celsius;
49
+ if (!CLEAN_LABEL.test(baseName) || typeof value !== 'number' ||
50
+ !Number.isFinite(value) || value < -40 || value > 125) continue;
51
+ // Separate GPUs/cores can expose identical labels through the same WMI
52
+ // provider; retain every valid channel with a deterministic index.
53
+ let name = baseName;
54
+ if (names.has(name)) name = baseName + ' (' + (sensors.length + 1) + ')';
55
+ if (!CLEAN_LABEL.test(name) || names.has(name)) continue;
56
+ names.add(name);
57
+ sensors.push({ name, celsius: Number(value.toFixed(1)) });
58
+ if (sensors.length >= MAX_SENSORS) break;
59
+ }
60
+ return sensors;
61
+ }
62
+
63
+ export async function windowsTemperatureReadings({ run = exec } = {}) {
64
+ const [cim, nvidia] = await Promise.all([
65
+ run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive',
66
+ '-Command', WINDOWS_TEMPERATURE_SCRIPT], {
67
+ windowsHide: true, timeout: 5500, maxBuffer: 32768,
68
+ }).then(result => parseWindowsTemperatureReadings(result.stdout)).catch(() => []),
69
+ nvidiaTemperatureReadings({ run }),
70
+ ]);
71
+ const names = new Set(cim.map(item => item.name));
72
+ for (const item of nvidia) if (!names.has(item.name) && cim.length < MAX_SENSORS) {
73
+ cim.push(item); names.add(item.name);
74
+ }
75
+ return cim;
76
+ }