@remcp/runtime 0.2.134 → 0.2.136

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.136 — 2026-10-02
4
+
5
+ - `installed_apps` now normalizes Windows registry, macOS `system_profiler`, `dpkg-query`, and `rpm` inventories into one structured `data[]` contract with stable app name/version/publisher/path fields.
6
+ - Stable top-level `backend`, `count`, `returned`, and `truncated` metadata make result limits explicit and remove platform-specific text parsing from callers.
7
+ - Name filtering and de-duplication are shared across platforms. Windows no longer interpolates the filter into PowerShell.
8
+ - Public release CI exercises the live native inventory on Linux, macOS, and Windows. Tool names remain unchanged at 83; the output-schema digest changes intentionally.
9
+
10
+ ## 0.2.135 — 2026-10-02
11
+
12
+ - Linux `audio` status is normalized across `wpctl`, `pactl`, and `amixer` into structured `volume`, `muted`, `action`, and `backend` fields; mutating audio actions re-read and return the verified state.
13
+ - Windows Core Audio and macOS audio responses expose the same `action`/`backend` fields.
14
+ - The additive `backend` output-schema field changes the tool-surface digest without changing tool names or the 83-tool count.
15
+
3
16
  ## 0.2.134 — 2026-10-02
4
17
 
5
18
  - `power_action delay_seconds` is bounded to 0–90 seconds in schema and runtime validation so a delayed destructive call cannot intentionally outlive the hosted ~110-second device-call lifetime.
package/README.md CHANGED
@@ -49,7 +49,7 @@ support and emits `notifications/tools/list_changed` when that capability set ch
49
49
  | Runtime | `get_system_info`, `get_runtime_info`, `get_runtime_stats`, `set_config_value` |
50
50
  | Computer use | `computer_snapshot`, `computer_action`, `list_windows`, `window_action`, `launch_app`, `ui_snapshot`, `ui_find`, `ui_action`, `type_text`, `keyboard`, `pointer`, `drag_drop`, `scroll`, `wait_for_ui`, `clipboard`, `display_inventory`, `screenshot_region`, `open_path`, `reveal_path`, `notification` |
51
51
  | Browser | `browser_tabs`, `browser_navigate`, `browser_snapshot`, `browser_find`, `browser_action`, `browser_wait`, `browser_evaluate` |
52
- | Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` |
52
+ | Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` — `audio` returns stable structured `volume`, `muted`, `action`, and `backend` fields across supported OS backends |
53
53
  | Documents | `read_document`, `edit_spreadsheet`, `edit_document`, `pdf_action` |
54
54
 
55
55
  The hosted ReMCP endpoint does not append those 83 granular operation names to its discovery list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.134",
3
+ "version": "0.2.136",
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",
@@ -212,9 +212,21 @@ const OUTPUT_FIELDS = Object.freeze({
212
212
  service: { data:outputArray(outputObject('Service record.')), action:outputString(), name:outputString(), text:outputString() },
213
213
  event_log: { text:outputString('Bounded log output.'), data:outputArray(outputObject()) },
214
214
  network: { hostname:outputString(), interfaces:outputObject(), dns:outputArray(outputString()), servers:outputArray(outputString()), host:outputString(), port:outputNumber(), ok:outputBoolean(), latency_ms:outputNumber(), interface_count:outputNumber(), interface_count_returned:outputNumber(), address_count:outputNumber(), address_count_returned:outputNumber(), interfaces_truncated:outputBoolean(), addresses_truncated:outputBoolean(), data:outputArray(outputObject()) },
215
- installed_apps: { data:outputArray(outputObject('Installed app/package with name/version/path where available.')) },
215
+ installed_apps: {
216
+ data:outputArray(outputRecord({
217
+ name:outputString('Installed application/package name.'),
218
+ version:{ type:['string','null'], description:'Installed version when available.' },
219
+ publisher:{ type:['string','null'], description:'Publisher/maintainer/vendor when available.' },
220
+ path:{ type:['string','null'], description:'Installation path when available.' },
221
+ signed_by:outputString('Optional macOS signing identity summary.'),
222
+ }, 'Normalized installed app/package record.')),
223
+ backend:outputString('Native inventory backend used on this platform.'),
224
+ count:outputNumber('Total matching records before the requested limit.'),
225
+ returned:outputNumber('Number of records returned in data.'),
226
+ truncated:outputBoolean('True when count is larger than returned because of the requested limit.'),
227
+ },
216
228
  environment: { platform:outputString(), arch:outputString(), hostname:outputString(), release:outputString(), shell:outputString(), path:outputString(), node:outputString(), env:outputObject() },
217
- audio: { volume:outputNumber(), muted:outputBoolean(), action:outputString(), text:outputString() },
229
+ audio: { volume:outputNumber(), muted:outputBoolean(), action:outputString(), backend:outputString(), text:outputString() },
218
230
  power_action: { text:outputString(), action:outputString() },
219
231
  record_screen: { path:outputString(), bytes:outputNumber(), duration_seconds:outputNumber(), format:outputString() },
220
232
  read_document: { text:outputString(), path:outputString(), data:{}, sheets:outputArray(outputObject()), sheet:outputString(), rows:outputArray({}), cells:outputArray({}) },
@@ -218,26 +218,90 @@ export async function networkTool(args) {
218
218
  unavailable('Listener inventory', 'ss or netstat is required');
219
219
  }
220
220
 
221
+ function installedAppString(value) {
222
+ const normalized = value == null ? '' : String(value).trim();
223
+ return normalized || null;
224
+ }
225
+
226
+ export function normalizeInstalledApp(row = {}) {
227
+ const name = installedAppString(row.name ?? row.DisplayName ?? row._name);
228
+ if (!name) return null;
229
+ const signedBy = installedAppString(row.signed_by);
230
+ return {
231
+ name,
232
+ version:installedAppString(row.version ?? row.DisplayVersion),
233
+ publisher:installedAppString(row.publisher ?? row.Publisher),
234
+ path:installedAppString(row.path ?? row.InstallLocation),
235
+ ...(signedBy ? { signed_by:signedBy } : {}),
236
+ };
237
+ }
238
+
239
+ export function parseInstalledAppsTsv(output) {
240
+ return String(output || '').split(/\r?\n/).filter(Boolean).map(line => {
241
+ const [name = '', version = '', publisher = '', pathValue = ''] = line.split('\t');
242
+ return normalizeInstalledApp({ name, version, publisher, path:pathValue });
243
+ }).filter(Boolean);
244
+ }
245
+
246
+ export function installedAppsPayload(rows, { backend, filter = null, limit = 1000 } = {}) {
247
+ const wanted = optionalString(filter)?.toLowerCase() || null;
248
+ const boundedLimit = clamp(limit, 1000, 1, 10_000);
249
+ const seen = new Set();
250
+ const normalized = [];
251
+ for (const raw of Array.isArray(rows) ? rows : (rows ? [rows] : [])) {
252
+ const app = normalizeInstalledApp(raw);
253
+ if (!app) continue;
254
+ if (wanted && !app.name.toLowerCase().includes(wanted)) continue;
255
+ const key = [app.name.toLowerCase(), app.version || '', app.path || ''].join('\u0000');
256
+ if (seen.has(key)) continue;
257
+ seen.add(key);
258
+ normalized.push(app);
259
+ }
260
+ normalized.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity:'base' })
261
+ || String(left.version || '').localeCompare(String(right.version || ''), undefined, { sensitivity:'base' }));
262
+ const data = normalized.slice(0, boundedLimit);
263
+ return {
264
+ data,
265
+ backend:String(backend || 'unknown'),
266
+ count:normalized.length,
267
+ returned:data.length,
268
+ truncated:normalized.length > data.length,
269
+ };
270
+ }
271
+
272
+ export function windowsInstalledAppsPowerShell() {
273
+ return [
274
+ "$ErrorActionPreference='Stop'",
275
+ "$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')",
276
+ "$items=foreach($path in $paths){Get-ItemProperty -Path $path -ErrorAction SilentlyContinue}",
277
+ "$items|Where-Object{$_.DisplayName}|ForEach-Object{[PSCustomObject]@{name=[string]$_.DisplayName;version=[string]$_.DisplayVersion;publisher=[string]$_.Publisher;path=[string]$_.InstallLocation}}|ConvertTo-Json -Compress",
278
+ ].join(';');
279
+ }
280
+
221
281
  export async function installedApps(args) {
222
282
  const limit = clamp(args.limit, 1000, 1, 10_000);
223
283
  const filter = optionalString(args.filter);
224
284
  if (process.platform === 'win32') {
225
- const where = filter ? ` | Where-Object { $_.DisplayName -like '*${escapePowerShellSingle(filter)}*' }` : '';
226
- const script = `$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*');Get-ItemProperty $paths -ErrorAction SilentlyContinue${where}|Where-Object{$_.DisplayName}|Select-Object -First ${limit} DisplayName,DisplayVersion,Publisher,InstallLocation|Sort-Object DisplayName -Unique|ConvertTo-Json -Compress`;
227
- return text((await runPowerShell(script, { label:'installed apps', timeout:30_000 })).stdout.trim() || '[]');
285
+ const { stdout } = await runPowerShell(windowsInstalledAppsPowerShell(), { label:'installed apps', timeout:30_000, maxBuffer:32*1024*1024 });
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(stdout.trim() || '[]');
289
+ } catch {
290
+ throw new Error('Windows installed-app inventory returned invalid JSON');
291
+ }
292
+ return jsonResult(installedAppsPayload(parsed, { backend:'windows-registry', filter, limit }));
228
293
  }
229
294
  if (process.platform === 'darwin') {
230
295
  const parsed = JSON.parse((await runFile('/usr/sbin/system_profiler', ['SPApplicationsDataType','-json'], { label:'installed apps', timeout:60_000, maxBuffer:64*1024*1024 })).stdout);
231
- const apps = (parsed.SPApplicationsDataType || []).filter(app => !filter || String(app._name || '').toLowerCase().includes(filter.toLowerCase())).slice(0, limit);
232
- return jsonResult(apps.map(app => ({ name:app._name, version:app.version || null, path:app.path || null, signed_by:app.signed_by || null })));
296
+ return jsonResult(installedAppsPayload(parsed.SPApplicationsDataType || [], { backend:'system_profiler', filter, limit }));
233
297
  }
234
298
  if (commandExists('dpkg-query')) {
235
299
  const { stdout } = await runFile('dpkg-query', ['-W','-f=${binary:Package}\t${Version}\t${Maintainer}\n'], { label:'installed apps', maxBuffer:32*1024*1024 });
236
- return text(stdout.split('\n').filter(Boolean).filter(line => !filter || line.toLowerCase().includes(filter.toLowerCase())).slice(0, limit).join('\n'));
300
+ return jsonResult(installedAppsPayload(parseInstalledAppsTsv(stdout), { backend:'dpkg-query', filter, limit }));
237
301
  }
238
302
  if (commandExists('rpm')) {
239
303
  const { stdout } = await runFile('rpm', ['-qa','--qf','%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\n'], { label:'installed apps', maxBuffer:32*1024*1024 });
240
- return text(stdout.split('\n').filter(Boolean).filter(line => !filter || line.toLowerCase().includes(filter.toLowerCase())).slice(0, limit).join('\n'));
304
+ return jsonResult(installedAppsPayload(parseInstalledAppsTsv(stdout), { backend:'rpm', filter, limit }));
241
305
  }
242
306
  unavailable('Installed application inventory', 'dpkg-query or rpm is required');
243
307
  }
@@ -367,45 +431,89 @@ export function windowsAudioPowerShell(action = 'status', volume = 50, { compile
367
431
  ].filter(Boolean).join('\n');
368
432
  }
369
433
 
434
+ export function parseWpctlAudioStatus(output) {
435
+ const match = String(output || '').match(/\bVolume:\s*([0-9]+(?:\.[0-9]+)?)(?:\s+\[(MUTED)\])?/i);
436
+ if (!match) throw new Error('wpctl returned an unrecognized volume status');
437
+ const scalar = Number(match[1]);
438
+ if (!Number.isFinite(scalar)) throw new Error('wpctl returned a non-numeric volume');
439
+ return { volume:Math.round(Math.max(0, Math.min(1, scalar)) * 100), muted:Boolean(match[2]) };
440
+ }
441
+
442
+ export function parsePactlAudioStatus(volumeOutput, muteOutput) {
443
+ const volumeMatch = String(volumeOutput || '').match(/\b(\d{1,3})%/);
444
+ const muteMatch = String(muteOutput || '').match(/\bMute:\s*(yes|no)\b/i);
445
+ if (!volumeMatch || !muteMatch) throw new Error('pactl returned an unrecognized volume/mute status');
446
+ return {
447
+ volume:Math.max(0, Math.min(100, Number(volumeMatch[1]))),
448
+ muted:muteMatch[1].toLowerCase() === 'yes',
449
+ };
450
+ }
451
+
452
+ export function parseAmixerAudioStatus(output) {
453
+ const rendered = String(output || '');
454
+ const volumeMatch = rendered.match(/\[(\d{1,3})%\]/);
455
+ const states = [...rendered.matchAll(/\[(on|off)\]/gi)].map(match => match[1].toLowerCase());
456
+ if (!volumeMatch || !states.length) throw new Error('amixer returned an unrecognized Master status');
457
+ return {
458
+ volume:Math.max(0, Math.min(100, Number(volumeMatch[1]))),
459
+ muted:states.every(state => state === 'off'),
460
+ };
461
+ }
462
+
463
+ async function linuxAudioStatus(backend) {
464
+ if (backend === 'wpctl') {
465
+ const { stdout } = await runFile('wpctl', ['get-volume','@DEFAULT_AUDIO_SINK@'], { label:'audio status' });
466
+ return parseWpctlAudioStatus(stdout);
467
+ }
468
+ if (backend === 'pactl') {
469
+ const volumeResult = await runFile('pactl', ['get-sink-volume','@DEFAULT_SINK@'], { label:'audio status' });
470
+ const muteResult = await runFile('pactl', ['get-sink-mute','@DEFAULT_SINK@'], { label:'audio status' });
471
+ return parsePactlAudioStatus(volumeResult.stdout, muteResult.stdout);
472
+ }
473
+ const { stdout } = await runFile('amixer', ['get','Master'], { label:'audio status' });
474
+ return parseAmixerAudioStatus(stdout);
475
+ }
476
+
370
477
  export async function audioTool(args) {
371
478
  const action = requireEnum(args.action || 'status', 'action', ['status','set_volume','mute','unmute']);
372
479
  const volume = clamp(args.volume, 50, 0, 100);
373
480
  if (process.platform === 'darwin') {
374
- if (action === 'status') {
481
+ const readState = async () => {
375
482
  const { stdout } = await runOsa('set v to output volume of (get volume settings)\nset m to output muted of (get volume settings)\nreturn (v as text) & tab & (m as text)', { label:'audio status' });
376
- const [v,m] = stdout.trim().split('\t'); return jsonResult({ volume:Number(v), muted:m === 'true' });
483
+ const [v,m] = stdout.trim().split('\t');
484
+ return { volume:Number(v), muted:m === 'true' };
485
+ };
486
+ if (action !== 'status') {
487
+ await runOsa(action === 'set_volume' ? `set volume output volume ${volume}` : action === 'mute' ? 'set volume with output muted' : 'set volume without output muted', { label:'audio control' });
377
488
  }
378
- await runOsa(action === 'set_volume' ? `set volume output volume ${volume}` : action === 'mute' ? 'set volume with output muted' : 'set volume without output muted', { label:'audio control' });
379
- return audioTool({ action:'status' });
489
+ return jsonResult({ ...(await readState()), action, backend:'osascript' });
380
490
  }
381
491
  if (process.platform === 'win32') {
382
492
  if (!commandExists('powershell.exe') && !process.env.SystemRoot) unavailable('Windows audio control');
383
493
  const { stdout } = await runPowerShell(windowsAudioPowerShell(action, volume), { label:'Windows CoreAudio control', timeout:30_000 });
384
494
  const rendered = stdout.trim();
385
495
  try {
386
- return jsonResult(JSON.parse(rendered));
496
+ return jsonResult({ ...JSON.parse(rendered), action, backend:'coreaudio' });
387
497
  } catch {
388
498
  throw new Error(`Windows CoreAudio returned an invalid state payload: ${rendered || '(empty)'}`);
389
499
  }
390
500
  }
391
- if (commandExists('wpctl')) {
392
- if (action === 'status') return text((await runFile('wpctl', ['get-volume','@DEFAULT_AUDIO_SINK@'], { label:'audio status' })).stdout.trim());
393
- if (action === 'set_volume') await runFile('wpctl', ['set-volume','@DEFAULT_AUDIO_SINK@',`${volume}%`], { label:'audio control' });
394
- else await runFile('wpctl', ['set-mute','@DEFAULT_AUDIO_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
395
- return text(`Audio action ${action} completed.`);
396
- }
397
- if (commandExists('pactl')) {
398
- if (action === 'status') return text(`${(await runFile('pactl',['get-sink-volume','@DEFAULT_SINK@'],{label:'audio status'})).stdout}${(await runFile('pactl',['get-sink-mute','@DEFAULT_SINK@'],{label:'audio status'})).stdout}`.trim());
399
- if (action === 'set_volume') await runFile('pactl', ['set-sink-volume','@DEFAULT_SINK@',`${volume}%`], { label:'audio control' });
400
- else await runFile('pactl', ['set-sink-mute','@DEFAULT_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
401
- return text(`Audio action ${action} completed.`);
402
- }
403
- if (commandExists('amixer')) {
404
- if (action === 'status') return text((await runFile('amixer',['get','Master'],{label:'audio status'})).stdout);
405
- await runFile('amixer', action === 'set_volume' ? ['set','Master',`${volume}%`] : ['set','Master',action === 'mute' ? 'mute' : 'unmute'], { label:'audio control' });
406
- return text(`Audio action ${action} completed.`);
501
+
502
+ const backend = commandExists('wpctl') ? 'wpctl' : commandExists('pactl') ? 'pactl' : commandExists('amixer') ? 'amixer' : null;
503
+ if (!backend) unavailable('Audio control', 'wpctl, pactl or amixer is required on Linux');
504
+
505
+ if (action !== 'status') {
506
+ if (backend === 'wpctl') {
507
+ if (action === 'set_volume') await runFile('wpctl', ['set-volume','@DEFAULT_AUDIO_SINK@',`${volume}%`], { label:'audio control' });
508
+ else await runFile('wpctl', ['set-mute','@DEFAULT_AUDIO_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
509
+ } else if (backend === 'pactl') {
510
+ if (action === 'set_volume') await runFile('pactl', ['set-sink-volume','@DEFAULT_SINK@',`${volume}%`], { label:'audio control' });
511
+ else await runFile('pactl', ['set-sink-mute','@DEFAULT_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
512
+ } else {
513
+ await runFile('amixer', action === 'set_volume' ? ['set','Master',`${volume}%`] : ['set','Master',action === 'mute' ? 'mute' : 'unmute'], { label:'audio control' });
514
+ }
407
515
  }
408
- unavailable('Audio control', 'wpctl, pactl or amixer is required on Linux');
516
+ return jsonResult({ ...(await linuxAudioStatus(backend)), action, backend });
409
517
  }
410
518
 
411
519
  export const POWER_ACTION_MAX_DELAY_SECONDS = 90;