@remcp/runtime 0.2.133 → 0.2.135

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.135 — 2026-10-02
4
+
5
+ - 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.
6
+ - Windows Core Audio and macOS audio responses expose the same `action`/`backend` fields.
7
+ - The additive `backend` output-schema field changes the tool-surface digest without changing tool names or the 83-tool count.
8
+
9
+ ## 0.2.134 — 2026-10-02
10
+
11
+ - `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.
12
+ - Delayed power actions use the propagated MCP AbortSignal; cancellation clears the timer and stops before any OS lock/sleep/restart/shutdown command is sent.
13
+ - A second cancellation check runs immediately before platform dispatch. Regression tests cover pre-cancelled, mid-delay-cancelled, and schema-bound behavior.
14
+ - Runtime tool names remain unchanged; this is a safety/correctness tightening of the existing power tool.
15
+
3
16
  ## 0.2.133 — 2026-10-02
4
17
 
5
18
  - Explicit `computer_action type target=window` now keeps top-level window selectors separate from semantic element selectors; `window_id` is never rewritten into the UI-element `id` before `type_text`.
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.133",
3
+ "version": "0.2.135",
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",
@@ -214,7 +214,7 @@ const OUTPUT_FIELDS = Object.freeze({
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
215
  installed_apps: { data:outputArray(outputObject('Installed app/package with name/version/path where available.')) },
216
216
  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() },
217
+ audio: { volume:outputNumber(), muted:outputBoolean(), action:outputString(), backend:outputString(), text:outputString() },
218
218
  power_action: { text:outputString(), action:outputString() },
219
219
  record_screen: { path:outputString(), bytes:outputNumber(), duration_seconds:outputNumber(), format:outputString() },
220
220
  read_document: { text:outputString(), path:outputString(), data:{}, sheets:outputArray(outputObject()), sheet:outputString(), rows:outputArray({}), cells:outputArray({}) },
@@ -656,7 +656,7 @@ export const extendedToolDefinitions = [
656
656
  define('installed_apps', 'Installed applications', 'Inventory installed applications or packages with versions and publishers where available using native package/application inventories.', o({ filter:s('Name substring.'), limit:n('Maximum results; default 1000.') }), readOnly, diagnosticHandlers.installed_apps, ['apps']),
657
657
  define('environment', 'Runtime environment', 'Report platform, architecture, Node version, working/home/temp paths, shell, PATH, and optionally environment variables. Environment variables are opt-in and secret-looking or credential-bearing values are masked.', o({ include_env:b('Include sanitized environment variables; default false.') }), readOnly, diagnosticHandlers.environment),
658
658
  define('audio', 'Audio control', 'Read or change default output volume or mute state using native operating-system audio controls when available.', o({ action:e(['status','set_volume','mute','unmute']), volume:n('0..100 for set_volume.') }, ['action']), mutatingNonDestructive, diagnosticHandlers.audio, ['audio']),
659
- define('power_action', 'Power action', 'Lock, sleep, restart, or shut down the paired computer using native operating-system power facilities. The action passes through ReMCP command policy.', o({ action:e(['lock','sleep','restart','shutdown']), delay_seconds:n('Optional delay, maximum 3600 seconds.') }, ['action']), mutating, diagnosticHandlers.power_action, ['power']),
659
+ define('power_action', 'Power action', 'Lock, sleep, restart, or shut down the paired computer using native operating-system power facilities. The action passes through ReMCP command policy. Delayed actions are cancelled if the client stops waiting.', o({ action:e(['lock','sleep','restart','shutdown']), delay_seconds:{ type:'number', minimum:0, maximum:90, description:'Optional delay in seconds, maximum 90. Longer delays are rejected because hosted calls expire before they can complete safely.' } }, ['action']), mutating, diagnosticHandlers.power_action, ['power']),
660
660
  define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path. If an extension is supplied, use .webm for native GNOME Wayland recording and .mp4 for the other recorder backends.') }), mutating, diagnosticHandlers.record_screen, ['record']),
661
661
 
662
662
  define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document, ['documents']),
@@ -9,7 +9,7 @@ import { lstat, mkdtemp, open, rename, rm, stat, unlink, writeFile } from 'node:
9
9
  import { assertAllowedCommand } from '../policy.mjs';
10
10
  import { isWaylandSession } from '../screenshot-portal.mjs';
11
11
  import { openDirectoryPath } from '../tools/files.mjs';
12
- import { resolveSafePath, text } from '../util.mjs';
12
+ import { resolveSafePath, text, throwIfCancelled, ToolError } from '../util.mjs';
13
13
  import {
14
14
  clamp,
15
15
  commandExists,
@@ -367,52 +367,130 @@ export function windowsAudioPowerShell(action = 'status', volume = 50, { compile
367
367
  ].filter(Boolean).join('\n');
368
368
  }
369
369
 
370
+ export function parseWpctlAudioStatus(output) {
371
+ const match = String(output || '').match(/\bVolume:\s*([0-9]+(?:\.[0-9]+)?)(?:\s+\[(MUTED)\])?/i);
372
+ if (!match) throw new Error('wpctl returned an unrecognized volume status');
373
+ const scalar = Number(match[1]);
374
+ if (!Number.isFinite(scalar)) throw new Error('wpctl returned a non-numeric volume');
375
+ return { volume:Math.round(Math.max(0, Math.min(1, scalar)) * 100), muted:Boolean(match[2]) };
376
+ }
377
+
378
+ export function parsePactlAudioStatus(volumeOutput, muteOutput) {
379
+ const volumeMatch = String(volumeOutput || '').match(/\b(\d{1,3})%/);
380
+ const muteMatch = String(muteOutput || '').match(/\bMute:\s*(yes|no)\b/i);
381
+ if (!volumeMatch || !muteMatch) throw new Error('pactl returned an unrecognized volume/mute status');
382
+ return {
383
+ volume:Math.max(0, Math.min(100, Number(volumeMatch[1]))),
384
+ muted:muteMatch[1].toLowerCase() === 'yes',
385
+ };
386
+ }
387
+
388
+ export function parseAmixerAudioStatus(output) {
389
+ const rendered = String(output || '');
390
+ const volumeMatch = rendered.match(/\[(\d{1,3})%\]/);
391
+ const states = [...rendered.matchAll(/\[(on|off)\]/gi)].map(match => match[1].toLowerCase());
392
+ if (!volumeMatch || !states.length) throw new Error('amixer returned an unrecognized Master status');
393
+ return {
394
+ volume:Math.max(0, Math.min(100, Number(volumeMatch[1]))),
395
+ muted:states.every(state => state === 'off'),
396
+ };
397
+ }
398
+
399
+ async function linuxAudioStatus(backend) {
400
+ if (backend === 'wpctl') {
401
+ const { stdout } = await runFile('wpctl', ['get-volume','@DEFAULT_AUDIO_SINK@'], { label:'audio status' });
402
+ return parseWpctlAudioStatus(stdout);
403
+ }
404
+ if (backend === 'pactl') {
405
+ const volumeResult = await runFile('pactl', ['get-sink-volume','@DEFAULT_SINK@'], { label:'audio status' });
406
+ const muteResult = await runFile('pactl', ['get-sink-mute','@DEFAULT_SINK@'], { label:'audio status' });
407
+ return parsePactlAudioStatus(volumeResult.stdout, muteResult.stdout);
408
+ }
409
+ const { stdout } = await runFile('amixer', ['get','Master'], { label:'audio status' });
410
+ return parseAmixerAudioStatus(stdout);
411
+ }
412
+
370
413
  export async function audioTool(args) {
371
414
  const action = requireEnum(args.action || 'status', 'action', ['status','set_volume','mute','unmute']);
372
415
  const volume = clamp(args.volume, 50, 0, 100);
373
416
  if (process.platform === 'darwin') {
374
- if (action === 'status') {
417
+ const readState = async () => {
375
418
  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' });
419
+ const [v,m] = stdout.trim().split('\t');
420
+ return { volume:Number(v), muted:m === 'true' };
421
+ };
422
+ if (action !== 'status') {
423
+ 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
424
  }
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' });
425
+ return jsonResult({ ...(await readState()), action, backend:'osascript' });
380
426
  }
381
427
  if (process.platform === 'win32') {
382
428
  if (!commandExists('powershell.exe') && !process.env.SystemRoot) unavailable('Windows audio control');
383
429
  const { stdout } = await runPowerShell(windowsAudioPowerShell(action, volume), { label:'Windows CoreAudio control', timeout:30_000 });
384
430
  const rendered = stdout.trim();
385
431
  try {
386
- return jsonResult(JSON.parse(rendered));
432
+ return jsonResult({ ...JSON.parse(rendered), action, backend:'coreaudio' });
387
433
  } catch {
388
434
  throw new Error(`Windows CoreAudio returned an invalid state payload: ${rendered || '(empty)'}`);
389
435
  }
390
436
  }
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.`);
437
+
438
+ const backend = commandExists('wpctl') ? 'wpctl' : commandExists('pactl') ? 'pactl' : commandExists('amixer') ? 'amixer' : null;
439
+ if (!backend) unavailable('Audio control', 'wpctl, pactl or amixer is required on Linux');
440
+
441
+ if (action !== 'status') {
442
+ if (backend === 'wpctl') {
443
+ if (action === 'set_volume') await runFile('wpctl', ['set-volume','@DEFAULT_AUDIO_SINK@',`${volume}%`], { label:'audio control' });
444
+ else await runFile('wpctl', ['set-mute','@DEFAULT_AUDIO_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
445
+ } else if (backend === 'pactl') {
446
+ if (action === 'set_volume') await runFile('pactl', ['set-sink-volume','@DEFAULT_SINK@',`${volume}%`], { label:'audio control' });
447
+ else await runFile('pactl', ['set-sink-mute','@DEFAULT_SINK@',action === 'mute' ? '1' : '0'], { label:'audio control' });
448
+ } else {
449
+ await runFile('amixer', action === 'set_volume' ? ['set','Master',`${volume}%`] : ['set','Master',action === 'mute' ? 'mute' : 'unmute'], { label:'audio control' });
450
+ }
402
451
  }
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.`);
452
+ return jsonResult({ ...(await linuxAudioStatus(backend)), action, backend });
453
+ }
454
+
455
+ export const POWER_ACTION_MAX_DELAY_SECONDS = 90;
456
+
457
+ export function normalizePowerActionDelay(value) {
458
+ if (value === undefined || value === null || value === '') return 0;
459
+ const delay = Number(value);
460
+ if (!Number.isFinite(delay) || delay < 0 || delay > POWER_ACTION_MAX_DELAY_SECONDS) {
461
+ throw new Error(`delay_seconds must be between 0 and ${POWER_ACTION_MAX_DELAY_SECONDS}`);
407
462
  }
408
- unavailable('Audio control', 'wpctl, pactl or amixer is required on Linux');
463
+ return delay;
464
+ }
465
+
466
+ export async function waitForPowerActionDelay(delaySeconds, signal) {
467
+ throwIfCancelled(signal);
468
+ if (delaySeconds <= 0) return;
469
+
470
+ await new Promise((resolve, reject) => {
471
+ let settled = false;
472
+ const finish = (fn, value) => {
473
+ if (settled) return;
474
+ settled = true;
475
+ clearTimeout(timer);
476
+ signal?.removeEventListener?.('abort', onAbort);
477
+ fn(value);
478
+ };
479
+ const onAbort = () => finish(reject, new ToolError('Cancelled by the client; power action was not executed.'));
480
+ const timer = setTimeout(() => finish(resolve), delaySeconds * 1000);
481
+ if (signal?.aborted) onAbort();
482
+ else signal?.addEventListener?.('abort', onAbort, { once:true });
483
+ });
484
+
485
+ throwIfCancelled(signal);
409
486
  }
410
487
 
411
- export async function powerAction(args) {
488
+ export async function powerAction(args, extra = {}) {
412
489
  const action = requireEnum(args.action, 'action', ['lock','sleep','restart','shutdown']);
413
490
  const policy = assertAllowedCommand(action === 'restart' ? 'reboot' : action === 'shutdown' ? 'shutdown' : action);
414
- const delay = clamp(args.delay_seconds, 0, 0, 3600);
415
- if (delay) await new Promise(resolve => setTimeout(resolve, delay * 1000));
491
+ const delay = normalizePowerActionDelay(args.delay_seconds);
492
+ await waitForPowerActionDelay(delay, extra.signal);
493
+ throwIfCancelled(extra.signal);
416
494
  if (process.platform === 'win32') {
417
495
  if (action === 'lock') await runPowerShell('rundll32.exe user32.dll,LockWorkStation', { label:'lock workstation' });
418
496
  else if (action === 'sleep') await runPowerShell('rundll32.exe powrprof.dll,SetSuspendState 0,1,0', { label:'sleep workstation' });