@remcp/runtime 0.2.119 → 0.2.121

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.121 — 2026-09-29
4
+
5
+ - record_screen gains a GNOME Shell Screencast backend for GNOME Wayland, so a native GNOME desktop no longer needs wf-recorder merely to advertise and perform bounded screen recording. The backend keeps start/stop on one D-Bus connection and produces WebM.
6
+ - Capability discovery probes ScreencastSupported fail-closed, caches the result for 15 seconds, and coalesces concurrent probes before tools/list_changed can expose the capability. Existing wf-recorder, X11 ffmpeg, AVFoundation and gdigrab paths remain available.
7
+ - Explicit recording destinations must use an extension matching the actual container (.webm for native GNOME Wayland, .mp4 for the other current backends).
8
+
3
9
  ## 0.2.116 — 2026-09-29
4
10
 
5
11
  - Linux Wayland `list_windows` stops its AT-SPI inventory traversal at top-level windows instead of walking descendant controls. On the verified ASUS desktop this kept the same 13 stable window records while cutting the call from roughly 7.2 seconds to roughly 2.1 seconds; `computer_snapshot` first-look latency fell from 7.50 seconds to 2.45 seconds in the same state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.119",
3
+ "version": "0.2.121",
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",
@@ -9,7 +9,7 @@ import { isWaylandSession } from '../screenshot-portal.mjs';
9
9
  import { waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
10
10
  import { commandExists, runFile } from './common.mjs';
11
11
  import { desktopHandlers } from './desktop.mjs';
12
- import { diagnosticHandlers, recordScreenAvailable } from './diagnostics.mjs';
12
+ import { diagnosticHandlers, gnomeScreencastSupported, recordScreenAvailable } from './diagnostics.mjs';
13
13
  import { documentHandlers } from './documents.mjs';
14
14
 
15
15
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -566,7 +566,7 @@ export const extendedToolDefinitions = [
566
566
  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),
567
567
  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']),
568
568
  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']),
569
- 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.') }), mutating, diagnosticHandlers.record_screen, ['record']),
569
+ 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']),
570
570
 
571
571
  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']),
572
572
  define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents']),
@@ -604,6 +604,7 @@ export async function capabilitySnapshot() {
604
604
  const linux = process.platform === 'linux';
605
605
  const pyAtSpi = linux ? await linuxHasPyAtSpi() : false;
606
606
  const wayland = linux && isWaylandSession();
607
+ const gnomeRecord = wayland ? await gnomeScreencastSupported() : false;
607
608
  const portalInput = wayland && waylandPortalCandidate();
608
609
  const x11Input = linux && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
609
610
  const browserCdp = browserRemoteEnabled() && await browserControlAvailable(undefined, 500);
@@ -626,7 +627,7 @@ export async function capabilitySnapshot() {
626
627
  apps: win || mac || commandExists('dpkg-query') || commandExists('rpm'),
627
628
  audio: win || mac || commandExists('wpctl') || commandExists('pactl') || commandExists('amixer'),
628
629
  power: win || mac || commandExists('systemctl') || commandExists('loginctl'),
629
- record: recordScreenAvailable({ platform:process.platform, wayland }),
630
+ record: recordScreenAvailable({ platform:process.platform, wayland, gnomeSupported:gnomeRecord }),
630
631
  ooxml: linux && (commandExists('zip') && commandExists('unzip')),
631
632
  };
632
633
  }
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { constants, existsSync } from 'node:fs';
8
- import { lstat, mkdtemp, open, rename, rm, stat, unlink } from 'node:fs/promises';
8
+ import { lstat, mkdtemp, open, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
9
9
  import { assertAllowedCommand } from '../policy.mjs';
10
10
  import { isWaylandSession } from '../screenshot-portal.mjs';
11
11
  import { openDirectoryPath } from '../tools/files.mjs';
@@ -282,21 +282,228 @@ async function avfoundationScreenInput(ffmpeg) {
282
282
  return index;
283
283
  }
284
284
 
285
- export function recordScreenAvailable({
285
+ export function gnomeScreencastCandidate({
286
+ platform = process.platform,
287
+ wayland = platform === 'linux' ? isWaylandSession() : false,
288
+ desktop = process.env.XDG_CURRENT_DESKTOP || '',
289
+ gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
290
+ sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
291
+ commandExistsFn = commandExists,
292
+ } = {}) {
293
+ const gnomeSession = /gnome/i.test(String(desktop)) || Boolean(String(gnomeSessionMode).trim());
294
+ return platform === 'linux'
295
+ && wayland
296
+ && gnomeSession
297
+ && Boolean(String(sessionBus).trim())
298
+ && commandExistsFn('gjs');
299
+ }
300
+
301
+ export function recordScreenBackend({
286
302
  platform = process.platform,
287
303
  wayland = platform === 'linux' ? isWaylandSession() : false,
288
304
  display = process.env.DISPLAY || '',
305
+ desktop = process.env.XDG_CURRENT_DESKTOP || '',
306
+ gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
307
+ sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
308
+ gnomeSupported = false,
289
309
  commandExistsFn = commandExists,
290
310
  existsSyncFn = existsSync,
291
311
  } = {}) {
292
312
  if (platform === 'linux') {
293
- if (wayland) return commandExistsFn('wf-recorder') && commandExistsFn('timeout');
294
- return Boolean(String(display).trim()) && commandExistsFn('ffmpeg');
313
+ if (wayland) {
314
+ if (gnomeSupported && gnomeScreencastCandidate({
315
+ platform,
316
+ wayland,
317
+ desktop,
318
+ gnomeSessionMode,
319
+ sessionBus,
320
+ commandExistsFn,
321
+ })) return 'gnome-shell';
322
+ if (commandExistsFn('wf-recorder') && commandExistsFn('timeout')) return 'wf-recorder';
323
+ return null;
324
+ }
325
+ return Boolean(String(display).trim()) && commandExistsFn('ffmpeg') ? 'ffmpeg-x11' : null;
326
+ }
327
+ if (platform === 'darwin') {
328
+ return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-avfoundation' : null;
295
329
  }
296
- if (platform === 'darwin' || platform === 'win32') {
297
- return Boolean(resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }));
330
+ if (platform === 'win32') {
331
+ return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-gdigrab' : null;
332
+ }
333
+ return null;
334
+ }
335
+
336
+ export function recordScreenAvailable({
337
+ platform = process.platform,
338
+ wayland = platform === 'linux' ? isWaylandSession() : false,
339
+ display = process.env.DISPLAY || '',
340
+ desktop = process.env.XDG_CURRENT_DESKTOP || '',
341
+ gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
342
+ sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
343
+ gnomeSupported = false,
344
+ commandExistsFn = commandExists,
345
+ existsSyncFn = existsSync,
346
+ } = {}) {
347
+ return Boolean(recordScreenBackend({
348
+ platform,
349
+ wayland,
350
+ display,
351
+ desktop,
352
+ gnomeSessionMode,
353
+ sessionBus,
354
+ gnomeSupported,
355
+ commandExistsFn,
356
+ existsSyncFn,
357
+ }));
358
+ }
359
+
360
+ const GNOME_SCREENCAST_SUPPORT_HELPER = `const {Gio, GLib} = imports.gi;
361
+ const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
362
+ const value = bus.call_sync(
363
+ 'org.gnome.Shell.Screencast',
364
+ '/org/gnome/Shell/Screencast',
365
+ 'org.freedesktop.DBus.Properties',
366
+ 'Get',
367
+ new GLib.Variant('(ss)', ['org.gnome.Shell.Screencast', 'ScreencastSupported']),
368
+ new GLib.VariantType('(v)'),
369
+ Gio.DBusCallFlags.NONE,
370
+ 3000,
371
+ null
372
+ ).deepUnpack();
373
+ print(value[0].deepUnpack() ? 'true' : 'false');
374
+ `;
375
+
376
+ let gnomeScreencastSupportCache = { key:'', value:false, expiresAt:0 };
377
+ let gnomeScreencastSupportProbe = { key:'', promise:null };
378
+
379
+ export async function gnomeScreencastSupported({
380
+ platform = process.platform,
381
+ wayland = platform === 'linux' ? isWaylandSession() : false,
382
+ desktop = process.env.XDG_CURRENT_DESKTOP || '',
383
+ gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
384
+ sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
385
+ commandExistsFn = commandExists,
386
+ runFileFn = runFile,
387
+ cacheMs = 15_000,
388
+ nowFn = Date.now,
389
+ } = {}) {
390
+ if (!gnomeScreencastCandidate({
391
+ platform,
392
+ wayland,
393
+ desktop,
394
+ gnomeSessionMode,
395
+ sessionBus,
396
+ commandExistsFn,
397
+ })) return false;
398
+
399
+ const key = [platform, wayland ? 'wayland' : '', desktop, gnomeSessionMode, sessionBus].join('|');
400
+ const now = nowFn();
401
+ if (cacheMs > 0 && gnomeScreencastSupportCache.key === key && gnomeScreencastSupportCache.expiresAt > now) {
402
+ return gnomeScreencastSupportCache.value;
403
+ }
404
+ if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise) {
405
+ return gnomeScreencastSupportProbe.promise;
406
+ }
407
+
408
+ const promise = (async () => {
409
+ let value = false;
410
+ try {
411
+ const result = await runFileFn('gjs', ['-c', GNOME_SCREENCAST_SUPPORT_HELPER], {
412
+ label:'GNOME Shell screencast capability probe',
413
+ timeout:5000,
414
+ maxBuffer:64 * 1024,
415
+ allowFailure:true,
416
+ });
417
+ value = Number(result.code) === 0 && String(result.stdout || '').trim() === 'true';
418
+ } catch {
419
+ value = false;
420
+ }
421
+ gnomeScreencastSupportCache = {
422
+ key,
423
+ value,
424
+ expiresAt:nowFn() + Math.max(0, cacheMs),
425
+ };
426
+ return value;
427
+ })();
428
+
429
+ gnomeScreencastSupportProbe = { key, promise };
430
+ try {
431
+ return await promise;
432
+ } finally {
433
+ if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise === promise) {
434
+ gnomeScreencastSupportProbe = { key:'', promise:null };
435
+ }
436
+ }
437
+ }
438
+
439
+ const GNOME_SCREENCAST_HELPER = `const {Gio, GLib} = imports.gi;
440
+ const [destination, secondsRaw, fpsRaw] = ARGV;
441
+ const seconds = Number(secondsRaw);
442
+ const fps = Number(fpsRaw);
443
+ const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
444
+ const destinationName = 'org.gnome.Shell.Screencast';
445
+ const objectPath = '/org/gnome/Shell/Screencast';
446
+ const interfaceName = 'org.gnome.Shell.Screencast';
447
+ const property = bus.call_sync(
448
+ destinationName,
449
+ objectPath,
450
+ 'org.freedesktop.DBus.Properties',
451
+ 'Get',
452
+ new GLib.Variant('(ss)', [interfaceName, 'ScreencastSupported']),
453
+ new GLib.VariantType('(v)'),
454
+ Gio.DBusCallFlags.NONE,
455
+ 5000,
456
+ null
457
+ ).deepUnpack();
458
+ if (!property[0].deepUnpack()) throw new Error('GNOME Shell reports ScreencastSupported=false');
459
+ const options = {
460
+ 'draw-cursor': new GLib.Variant('b', true),
461
+ 'framerate': new GLib.Variant('i', fps),
462
+ };
463
+ const started = bus.call_sync(
464
+ destinationName,
465
+ objectPath,
466
+ interfaceName,
467
+ 'Screencast',
468
+ new GLib.Variant('(sa{sv})', [destination, options]),
469
+ new GLib.VariantType('(bs)'),
470
+ Gio.DBusCallFlags.NONE,
471
+ 10000,
472
+ null
473
+ ).deepUnpack();
474
+ if (!started[0]) throw new Error('GNOME Shell refused to start screencast');
475
+ GLib.usleep(Math.round(seconds * 1000000));
476
+ const stopped = bus.call_sync(
477
+ destinationName,
478
+ objectPath,
479
+ interfaceName,
480
+ 'StopScreencast',
481
+ null,
482
+ new GLib.VariantType('(b)'),
483
+ Gio.DBusCallFlags.NONE,
484
+ 10000,
485
+ null
486
+ ).deepUnpack();
487
+ if (!stopped[0]) throw new Error('GNOME Shell did not stop screencast cleanly');
488
+ print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
489
+ `;
490
+
491
+ async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
492
+ const helper = path.join(stagingDirectory, 'gnome-screencast.js');
493
+ await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
494
+ await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
495
+ label:'GNOME Shell screen recording',
496
+ timeout:(seconds + 20) * 1000,
497
+ maxBuffer:2 * 1024 * 1024,
498
+ });
499
+ }
500
+
501
+ export function validateRecordingDestinationFormat(destination, format) {
502
+ if (!destination) return;
503
+ const extension = path.extname(destination).slice(1).toLowerCase();
504
+ if (extension && extension !== format) {
505
+ throw new Error(`Screen recording backend produces .${format}; destination must use .${format} or omit the extension`);
298
506
  }
299
- return false;
300
507
  }
301
508
 
302
509
  async function openRecordingParent(destination) {
@@ -348,24 +555,37 @@ async function copyRecordingFile(source, destination) {
348
555
  export async function recordScreen(args) {
349
556
  const seconds = clamp(args.duration_seconds, 5, 1, 120);
350
557
  const fps = clamp(args.fps, 15, 1, 60);
558
+ const wayland = process.platform === 'linux' && isWaylandSession();
559
+ const gnomeSupported = wayland ? await gnomeScreencastSupported() : false;
560
+ const backend = recordScreenBackend({ platform:process.platform, wayland, gnomeSupported });
561
+ if (!backend) {
562
+ if (wayland) unavailable('Screen recording', 'GNOME Shell Screencast or wf-recorder with timeout is required on Wayland');
563
+ if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
564
+ if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
565
+ if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
566
+ unavailable('Screen recording', 'ffmpeg is required on X11');
567
+ }
568
+
569
+ const format = backend === 'gnome-shell' ? 'webm' : 'mp4';
351
570
  const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
352
571
  if (requestedDestination) {
572
+ validateRecordingDestinationFormat(requestedDestination, format);
353
573
  const existing = await lstat(requestedDestination).catch(() => null);
354
574
  if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
355
575
  if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
356
576
  }
577
+
357
578
  const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
358
- const staged = path.join(stagingDirectory, `capture-${randomUUID()}.mp4`);
359
- const wayland = process.platform === 'linux' && isWaylandSession();
579
+ const staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
360
580
  let keepStaging = false;
361
581
  try {
362
- if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
582
+ if (backend === 'gnome-shell') {
583
+ await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
584
+ } else if (backend === 'wf-recorder') {
363
585
  const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
364
586
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
365
587
  } else {
366
588
  const ffmpeg = resolveRecordScreenFfmpeg();
367
- if (wayland) unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
368
- if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
369
589
  if (!ffmpeg) {
370
590
  if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
371
591
  if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
@@ -386,7 +606,7 @@ export async function recordScreen(args) {
386
606
  const output = requestedDestination || staged;
387
607
  const info = await stat(output);
388
608
  keepStaging = !requestedDestination;
389
- return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format:path.extname(output).slice(1) || 'mp4' });
609
+ return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format });
390
610
  } finally {
391
611
  if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
392
612
  }