@remcp/runtime 0.2.226 → 0.2.228

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.226",
3
+ "version": "0.2.228",
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",
@@ -177,7 +177,7 @@ export const extendedToolDefinitions = [
177
177
  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),
178
178
  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']),
179
179
  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']),
180
- 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']),
180
+ 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. Omit the extension to accept the native format. GNOME may produce .webm or .mp4; other backends produce .mp4. An explicit extension must match the recorded format.') }), mutating, diagnosticHandlers.record_screen, ['record']),
181
181
 
182
182
  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']),
183
183
  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','ooxml']),
@@ -340,21 +340,31 @@ export async function uiSnapshot(args = {}) {
340
340
  ? args.active_only === true
341
341
  : (!explicitlyScoped && args.scope !== 'desktop');
342
342
  let effective = { ...args, active_only:activeOnly };
343
- let raw = await adapter.uiSnapshot(effective);
343
+ // For wait_for_ui, share one deadline across native capture and Linux retries.
344
+ const deadline = args.timeout_ms != null && Number.isFinite(Number(args.timeout_ms))
345
+ ? Date.now() + clamp(args.timeout_ms, 30_000, 100, 120_000) : null;
346
+ const canRetry = () => deadline === null || Date.now() + 100 < deadline;
347
+ const capture = () => adapter.uiSnapshot({
348
+ ...effective,
349
+ ...(deadline === null ? {} : { timeout_ms:Math.max(100, deadline - Date.now()) }),
350
+ });
351
+ let raw = await capture();
344
352
  let value = resultValue(raw);
345
353
  const requestedDepth = Number(effective.max_depth);
346
354
  const isEmptySnapshot = payload => Array.isArray(payload)
347
355
  ? payload.length === 0
348
356
  : Array.isArray(payload?.nodes) && payload.nodes.length === 0;
349
- if (process.platform === 'linux' && isEmptySnapshot(value)) {
350
- // GNOME/AT-SPI can transiently report an empty tree. Retry once at the requested
351
- // depth before reducing traversal depth, so valid deep trees are not discarded.
352
- await sleep(40);
353
- raw = await adapter.uiSnapshot(effective);
354
- value = resultValue(raw);
355
- if (isEmptySnapshot(value) && Number.isFinite(requestedDepth) && requestedDepth > 12) {
357
+ if (process.platform === 'linux' && isEmptySnapshot(value) && canRetry()) {
358
+ // GNOME/AT-SPI can transiently report an empty tree, but retries must
359
+ // never start after the caller's remaining wait budget is exhausted.
360
+ await sleep(deadline === null ? 40 : Math.min(40, Math.max(0, deadline - Date.now() - 100)));
361
+ if (canRetry()) {
362
+ raw = await capture();
363
+ value = resultValue(raw);
364
+ }
365
+ if (isEmptySnapshot(value) && Number.isFinite(requestedDepth) && requestedDepth > 12 && canRetry()) {
356
366
  effective = { ...effective, max_depth:12 };
357
- raw = await adapter.uiSnapshot(effective);
367
+ raw = await capture();
358
368
  value = resultValue(raw);
359
369
  }
360
370
  }
@@ -113,7 +113,9 @@ export async function windowAction(args) {
113
113
  return text(`Window action ${action} completed.`);
114
114
  }
115
115
 
116
- export function pyAtSpiPrelude() {
116
+ export function pyAtSpiPrelude({ pid = 0, app = '' } = {}) {
117
+ const scopedPid = Number.isInteger(Number(pid)) ? Number(pid) : 0;
118
+ const scopedApp = JSON.stringify(optionalString(app) || '');
117
119
  return `import json,sys,math
118
120
  try:
119
121
  import pyatspi
@@ -188,10 +190,13 @@ def _proc(e):
188
190
  except: pass
189
191
  return 0
190
192
  q=[]
193
+ scope_pid=${scopedPid};scope_app=${scopedApp}
191
194
  for app in desktop:
192
195
  try:
193
- app_name=app.name or ""
194
196
  pid=_proc(app)
197
+ if scope_pid and pid!=scope_pid: continue
198
+ app_name=app.name or ""
199
+ if scope_app and scope_app.lower() not in app_name.lower(): continue
195
200
  active=False
196
201
  try:
197
202
  active=bool(app.getState().contains(pyatspi.STATE_ACTIVE))
@@ -216,7 +221,7 @@ export async function uiSnapshot(args = {}) {
216
221
  const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
217
222
  const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
218
223
  const activeOnly = args.active_only === true;
219
- const script = pyAtSpiPrelude() + `
224
+ const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
220
225
  want_app=${wantedApp};want_pid=${wantedPid};want_window=${wantedWindow};want_active=${activeOnly ? 'True' : 'False'}
221
226
  roots=[]
222
227
  for root,app_name,pid,active in q:
@@ -265,7 +270,7 @@ while queue and len(out)<${maxNodes}:
265
270
  for i in range(e.childCount):queue.append((e.getChildAtIndex(i),d+1,app_name,pid,active,window_name,node_path+"."+str(i)))
266
271
  except:pass
267
272
  print(json.dumps(out))`;
268
- const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout: 30_000, allowFailure: true });
273
+ const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout:clamp(args.timeout_ms, 30_000, 100, 30_000), allowFailure:true });
269
274
  let parsed = null;
270
275
  try { parsed = JSON.parse(result.stdout || ''); } catch {}
271
276
  if (Array.isArray(parsed)) return jsonResult({ platform: 'linux', count: parsed.length, nodes: parsed });
@@ -288,7 +293,7 @@ export async function uiAction(args = {}) {
288
293
  const wantedName = JSON.stringify(optionalString(args.name) || '');
289
294
  const wantedRole = JSON.stringify(optionalString(args.role) || '');
290
295
  const value = JSON.stringify(String(args.value ?? ''));
291
- const script = pyAtSpiPrelude() + `
296
+ const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
292
297
  want_index=${legacyIndex};want_pid=${wantedPid};want_path=${JSON.stringify(stablePath)}
293
298
  want_app=${wantedApp};want_window=${wantedWindow};want_name=${wantedName};want_role=${wantedRole};action=${JSON.stringify(action)};value=${value}
294
299
  queue=[];seen=0;target=None;target_meta={}
@@ -67,7 +67,7 @@ export async function nativeWaylandAccessibilityAction(row, actionNames) {
67
67
  if (!Number.isInteger(pid) || pid <= 0 || !Array.isArray(actionNames) || !actionNames.length) {
68
68
  return { applied:false, available:[] };
69
69
  }
70
- const script = pyAtSpiPrelude() + `
70
+ const script = pyAtSpiPrelude({ pid }) + `
71
71
  want_pid=${pid};want_window=${JSON.stringify(String(row.title || ''))};wanted_actions=${JSON.stringify(actionNames)}
72
72
  target=None
73
73
  for root,app_name,pid,active in q:
@@ -52,6 +52,7 @@ export async function uiMatches(args = {}) {
52
52
  ...(resolved.app ? { app: resolved.app } : {}),
53
53
  ...((resolved.window_title || resolved.windowTitle) ? { window_title: resolved.window_title || resolved.windowTitle } : {}),
54
54
  ...(resolved.browser_dom === true ? { browser_dom:true } : {}),
55
+ ...(resolved.timeout_ms != null ? { timeout_ms:resolved.timeout_ms } : {}),
55
56
  });
56
57
  const payload = resultValue(result) || {};
57
58
  const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
@@ -108,6 +109,7 @@ export async function uiConditionMatches(args, condition) {
108
109
  ...(args.app ? { app:args.app } : {}),
109
110
  ...((args.window_title || args.windowTitle) ? { window_title:args.window_title || args.windowTitle } : {}),
110
111
  ...(args.browser_dom === true ? { browser_dom:true } : {}),
112
+ ...(args.timeout_ms != null ? { timeout_ms:args.timeout_ms } : {}),
111
113
  })) || {};
112
114
  nodes = Array.isArray(snapshot) ? snapshot : snapshot.nodes || [];
113
115
  }
@@ -142,8 +144,27 @@ export async function waitForUi(args = {}) {
142
144
  if (['element_exists','element_enabled'].includes(condition) && !hasSelector) throw new Error(`wait_for_ui condition=${condition} requires a semantic target`);
143
145
  if (condition === 'active_window' && !(args.text || args.name || args.window_title || args.windowTitle)) throw new Error('wait_for_ui condition=active_window requires text, name, or window_title');
144
146
  if (['present','absent'].includes(state) && !hasSelector) throw new Error(`wait_for_ui state=${state} requires a semantic target`);
145
- const deadline = Date.now() + timeoutMs;
146
147
  const startedAt = Date.now();
148
+ const deadline = startedAt + timeoutMs;
149
+ const expired = Symbol('wait_for_ui deadline');
150
+ // Resolve snapshot labels once, before invalidation; subsequent reads use the
151
+ // stable semantic locator rather than a label from a discarded tree.
152
+ const target = args.label != null ? { ...withResolvedUiLabel(args), label:undefined } : args;
153
+ const probe = async read => {
154
+ const remaining = deadline - Date.now();
155
+ if (remaining <= 0) return expired;
156
+ try {
157
+ const value = await read({ ...target, timeout_ms:Math.max(100, remaining) });
158
+ return Date.now() >= deadline ? expired : value;
159
+ } catch (error) {
160
+ if (Date.now() >= deadline) return expired;
161
+ throw error;
162
+ }
163
+ };
164
+ const pause = async () => {
165
+ const remaining = deadline - Date.now();
166
+ if (remaining > 0) await sleep(Math.min(pollMs, remaining));
167
+ };
147
168
  let attempts = 0;
148
169
 
149
170
  if (condition) {
@@ -152,7 +173,9 @@ export async function waitForUi(args = {}) {
152
173
  attempts += 1;
153
174
  // A wait must observe fresh state, not repeatedly query the same 5-second cache.
154
175
  invalidateUiSnapshotCache();
155
- evaluation = await uiConditionMatches(args, condition);
176
+ const observed = await probe(options => uiConditionMatches(options, condition));
177
+ if (observed === expired) break;
178
+ evaluation = observed;
156
179
  if (evaluation.matched) {
157
180
  return jsonResult({
158
181
  condition,
@@ -164,7 +187,7 @@ export async function waitForUi(args = {}) {
164
187
  ...(evaluation.detail ? { detail:evaluation.detail } : {}),
165
188
  });
166
189
  }
167
- await sleep(pollMs);
190
+ await pause();
168
191
  } while (Date.now() < deadline);
169
192
  return jsonResult({
170
193
  condition,
@@ -178,13 +201,20 @@ export async function waitForUi(args = {}) {
178
201
  }
179
202
 
180
203
  invalidateUiSnapshotCache();
181
- const baseline = state === 'changed' ? await uiMatches(args) : [];
204
+ const first = state === 'changed' ? await probe(uiMatches) : [];
205
+ const baseline = first === expired ? [] : first;
206
+ if (first === expired) {
207
+ return jsonResult({ state:'timeout', wanted:state, matched:0, attempts,
208
+ elapsed_ms:Date.now() - startedAt, nodes:[], before:[] });
209
+ }
182
210
  const baselineFingerprint = state === 'changed' ? uiFingerprint(baseline) : null;
183
211
  let nodes = baseline;
184
212
  do {
185
213
  attempts += 1;
186
214
  invalidateUiSnapshotCache();
187
- nodes = await uiMatches(args);
215
+ const observed = await probe(uiMatches);
216
+ if (observed === expired) break;
217
+ nodes = observed;
188
218
  const changed = state === 'changed' && uiFingerprint(nodes) !== baselineFingerprint;
189
219
  if ((state === 'present' && nodes.length) || (state === 'absent' && !nodes.length) || changed) {
190
220
  return jsonResult({
@@ -196,7 +226,7 @@ export async function waitForUi(args = {}) {
196
226
  ...(state === 'changed' ? { before: baseline } : {}),
197
227
  });
198
228
  }
199
- await sleep(pollMs);
229
+ await pause();
200
230
  } while (Date.now() < deadline);
201
231
  return jsonResult({ state:'timeout', wanted:state, matched:nodes.length, attempts, elapsed_ms:Date.now() - startedAt, nodes, ...(state === 'changed' ? { before:baseline } : {}) });
202
232
  }
@@ -253,11 +253,26 @@ print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
253
253
  async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
254
254
  const helper = path.join(stagingDirectory, 'gnome-screencast.js');
255
255
  await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
256
- await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
256
+ const result = await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
257
257
  label:'GNOME Shell screen recording',
258
258
  timeout:(seconds + 20) * 1000,
259
259
  maxBuffer:2 * 1024 * 1024,
260
260
  });
261
+ let report;
262
+ try { report = JSON.parse(result.stdout.trim()); } catch {}
263
+ const output = report?.destination;
264
+ // GNOME selects the container and may replace the template extension. Accept
265
+ // only the exact recording stem in our private staging directory, never an
266
+ // arbitrary path returned by the desktop service.
267
+ if (report?.stopped !== true || typeof output !== 'string' || !path.isAbsolute(output)
268
+ || path.dirname(output) !== stagingDirectory
269
+ || path.parse(output).name !== path.parse(staged).name
270
+ || !['.mp4','.webm'].includes(path.extname(output))) {
271
+ throw new Error('GNOME returned an invalid screen recording staging path or format');
272
+ }
273
+ const info = await lstat(output);
274
+ if (!info.isFile() || !info.size) throw new Error('GNOME screen recording is not a non-empty regular file');
275
+ return { path:output, format:path.extname(output).slice(1) };
261
276
  }
262
277
 
263
278
  export function validateRecordingDestinationFormat(destination, format) {
@@ -328,21 +343,27 @@ export async function recordScreen(args) {
328
343
  unavailable('Screen recording', 'ffmpeg is required on X11');
329
344
  }
330
345
 
331
- const format = backend === 'gnome-shell' ? 'webm' : 'mp4';
346
+ let format = backend === 'gnome-shell' ? 'webm' : 'mp4';
332
347
  const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
333
348
  if (requestedDestination) {
334
- validateRecordingDestinationFormat(requestedDestination, format);
349
+ if (backend !== 'gnome-shell') validateRecordingDestinationFormat(requestedDestination, format);
350
+ else if (path.extname(requestedDestination) && !['.mp4','.webm'].includes(path.extname(requestedDestination).toLowerCase())) {
351
+ throw new Error('GNOME screen recording destination must use .mp4, .webm or omit the extension');
352
+ }
335
353
  const existing = await lstat(requestedDestination).catch(() => null);
336
354
  if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
337
355
  if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
338
356
  }
339
357
 
340
358
  const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
341
- const staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
359
+ let staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
342
360
  let keepStaging = false;
343
361
  try {
344
362
  if (backend === 'gnome-shell') {
345
- await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
363
+ const recorded = await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
364
+ staged = recorded.path;
365
+ format = recorded.format;
366
+ validateRecordingDestinationFormat(requestedDestination, format);
346
367
  } else if (backend === 'wf-recorder') {
347
368
  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 });
348
369
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);