@remcp/runtime 0.2.100 → 0.2.102

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.102 — 2026-09-22
4
+
5
+ - `runWithInput` installs a stdin error handler before writing input, so an early child pipe close is returned as a `ToolError` instead of an uncaught `EPIPE` that can terminate the runtime.
6
+ - A failed input pipe now terminates the child before rejecting, preventing a rejected call from leaving an orphan process behind.
7
+ - Empty input closes stdin without a zero-byte write, reducing races with short-lived subprocesses.
8
+ - Added a deterministic closed-stdin regression and stress coverage; runtime tool names and schemas are unchanged.
9
+
10
+ ## 0.2.101 — 2026-09-22
11
+
12
+ - macOS `record_screen` resolves FFmpeg from standard Homebrew locations when launchd's minimal `PATH` cannot see it.
13
+ - AVFoundation screen capture now enumerates devices and selects the actual `Capture screen` index instead of assuming input 1.
14
+ - Capability discovery stays conservative: no recorder backend means no `record_screen`; Linux Wayland still requires `wf-recorder`.
15
+ - Added regression coverage for Homebrew discovery and real-world AVFoundation ordering where input 1 is OBS Virtual Camera and the screen is input 3.
16
+
3
17
  ## 0.2.100 — 2026-09-22
4
18
 
5
19
  - Wayland portal-backed region screenshots clip compositor window bounds to the visible virtual desktop before FFmpeg cropping.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
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",
@@ -98,7 +98,14 @@ export async function runWithInput(file, args = [], input = '', options = {}) {
98
98
  if (code === 0 || options.allowFailure) finish(resolve, { stdout, stderr, code });
99
99
  else finish(reject, new ToolError(`${options.label || file} failed: ${stderr.trim() || `exit ${code}`}`));
100
100
  });
101
- child.stdin.end(String(input));
101
+ child.stdin.on('error', error => {
102
+ if (settled) return;
103
+ child.kill('SIGKILL');
104
+ finish(reject, new ToolError(`${options.label || file} input failed: ${error.message}`));
105
+ });
106
+ const inputText = String(input);
107
+ if (inputText.length) child.stdin.end(inputText);
108
+ else child.stdin.end();
102
109
  });
103
110
  }
104
111
 
@@ -3,6 +3,7 @@ import net from 'node:net';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
+ import { existsSync } from 'node:fs';
6
7
  import { stat } from 'node:fs/promises';
7
8
  import { assertAllowedCommand } from '../policy.mjs';
8
9
  import { isWaylandSession } from '../screenshot-portal.mjs';
@@ -245,17 +246,54 @@ export async function powerAction(args) {
245
246
  return text(`${policy.note ? `${policy.note}\n` : ''}Power action ${action} requested.`);
246
247
  }
247
248
 
249
+ export function resolveRecordScreenFfmpeg({
250
+ platform = process.platform,
251
+ commandExistsFn = commandExists,
252
+ existsSyncFn = existsSync,
253
+ } = {}) {
254
+ if (commandExistsFn('ffmpeg')) return 'ffmpeg';
255
+ if (platform === 'darwin') {
256
+ for (const candidate of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']) {
257
+ if (existsSyncFn(candidate)) return candidate;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ export function parseAvfoundationScreenInput(output) {
264
+ for (const line of String(output || '').split(/\r?\n/)) {
265
+ const match = line.match(/\[(\d+)\]\s+Capture screen(?:\s+\d+)?\s*$/i);
266
+ if (match) return match[1];
267
+ }
268
+ return null;
269
+ }
270
+
271
+ async function avfoundationScreenInput(ffmpeg) {
272
+ const result = await runFile(ffmpeg, [
273
+ '-hide_banner',
274
+ '-f','avfoundation',
275
+ '-list_devices','true',
276
+ '-i','',
277
+ ], { label:'AVFoundation device inventory', timeout:10_000, allowFailure:true });
278
+ const index = parseAvfoundationScreenInput(`${result.stderr || ''}\n${result.stdout || ''}`);
279
+ if (!index) unavailable('Screen recording', 'ffmpeg could not find an AVFoundation Capture screen input');
280
+ return index;
281
+ }
282
+
248
283
  export function recordScreenAvailable({
249
284
  platform = process.platform,
250
285
  wayland = platform === 'linux' ? isWaylandSession() : false,
251
286
  display = process.env.DISPLAY || '',
252
287
  commandExistsFn = commandExists,
288
+ existsSyncFn = existsSync,
253
289
  } = {}) {
254
290
  if (platform === 'linux') {
255
291
  if (wayland) return commandExistsFn('wf-recorder') && commandExistsFn('timeout');
256
292
  return Boolean(String(display).trim()) && commandExistsFn('ffmpeg');
257
293
  }
258
- if (platform === 'darwin' || platform === 'win32') return commandExistsFn('ffmpeg');
294
+ if (platform === 'darwin' || platform === 'win32') {
295
+ return Boolean(resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }));
296
+ }
259
297
  return false;
260
298
  }
261
299
 
@@ -268,22 +306,26 @@ export async function recordScreen(args) {
268
306
  const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
269
307
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
270
308
  } else {
309
+ const ffmpeg = resolveRecordScreenFfmpeg();
271
310
  if (wayland) {
272
311
  unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
273
312
  }
274
313
  if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
275
314
  unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
276
315
  }
277
- if (!commandExists('ffmpeg')) {
316
+ if (!ffmpeg) {
278
317
  if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
279
318
  if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
280
319
  unavailable('Screen recording', 'ffmpeg is required on X11');
281
320
  }
282
321
  let argv;
283
322
  if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
284
- else if (process.platform === 'darwin') argv=['-y','-f','avfoundation','-framerate',String(fps),'-i','1:none','-t',String(seconds),'-pix_fmt','yuv420p',destination];
323
+ else if (process.platform === 'darwin') {
324
+ const input = await avfoundationScreenInput(ffmpeg);
325
+ argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
326
+ }
285
327
  else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
286
- await runFile('ffmpeg', argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
328
+ await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
287
329
  }
288
330
  const info = await stat(destination);
289
331
  return jsonResult({ path:destination, bytes:info.size, duration_seconds:seconds, format:path.extname(destination).slice(1) || 'mp4' });