@remcp/runtime 0.2.78 → 0.2.79

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
@@ -2,11 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 0.2.78 — 2026-09-21
5
+ ## 0.2.79 — 2026-09-21
6
6
 
7
- - Native Wayland cursor telemetry no longer trusts stale XWayland `xdotool` coordinates; unavailable global cursor position is reported explicitly instead.
8
- - `computer_snapshot` degrades that capture to `cursor: {x:null,y:null}` with a non-fatal cursor error while preserving the rest of the snapshot.
9
- - Lockstep patch release with the ReMCP client/server at `0.2.78`.
7
+ - Explicit Wayland keyboard and key-by-key text input now focuses and verifies the requested target window before dispatch, with bounded direct window cycling and verified fallbacks.
8
+ - Native Wayland cursor telemetry fails closed instead of trusting stale XWayland `xdotool` coordinates; `computer_snapshot` keeps that capture non-fatal and structured.
9
+ - Regression coverage is headless-safe while still enforcing the snapshot normalization/error contract.
10
+ - Lockstep patch release with the ReMCP client/server at `0.2.79`.
10
11
 
11
12
  ## 0.2.77 — 2026-09-21
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.78",
3
+ "version": "0.2.79",
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",
@@ -455,7 +455,7 @@ const inputBackendSelector = {
455
455
  backend: e(['auto','x11','portal'], 'Input backend preference. auto uses proven local backends and already-authorized portal input; portal explicitly requests XDG RemoteDesktop consent on Wayland; x11 forces XWayland/xdotool only for operations where read-back testing shows it is reliable. GNOME Wayland keyboard/text deliberately reject x11.'),
456
456
  };
457
457
  const inputWindowSelector = {
458
- window_id: s('Optional target window id returned by list_windows. Useful for deterministic XWayland keyboard input on Wayland.'),
458
+ window_id: s('Optional target window id returned by list_windows. On Wayland the target is focused and verified before keyboard/text input is dispatched.'),
459
459
  pid: n('Optional target process id.'),
460
460
  app: s('Optional target application/process name substring.'),
461
461
  title: s('Optional target window title substring.'),
@@ -538,8 +538,8 @@ export const extendedToolDefinitions = [
538
538
  define('ui_snapshot', 'UI accessibility snapshot', 'Read the active desktop accessibility/UI Automation tree with compact labels, semantic hierarchy, bounds, state and suggested actions. Default scope is the active application/window; scope=desktop requests the full desktop tree. Explicit pid/app/window_title scopes override the active default. browser_dom asks the OS accessibility provider for the web-document subtree when available.', o({ scope:e(['active','desktop'],'Default active. Use desktop only when cross-application discovery is required.'), pid:n('Optional owning process id scope.'), app:s('Optional application/process name substring scope.'), window_title:s('Optional top-level accessibility window/frame title scope.'), browser_dom:b('Prefer the native browser document accessibility subtree instead of browser chrome when supported.'), semantic_tree:b('Include compact semantic hierarchy text; default true.'), max_nodes:n('Maximum nodes; default 500, maximum 5000.'), max_depth:n('Maximum traversal depth; default 8, maximum 32.') }), readOnlyLive, desktopHandlers.ui_snapshot, ['ui']),
539
539
  define('ui_find', 'Find UI elements', 'Find native accessible desktop elements by label/id/name/role/AutomationId. Reuses the latest five-second semantic snapshot for fast repeated targeting; refresh=true forces a new OS accessibility capture.', o({ ...uiSelector, refresh:b('Force a fresh accessibility capture instead of the short-lived snapshot cache.'), limit:n('Maximum matches; default 20.'), max_nodes:n(), max_depth:n() }), readOnlyLive, desktopHandlers.ui_find, ['ui']),
540
540
  define('ui_action', 'Act on UI element', 'Act on a native accessibility/UI Automation element by id, label or semantic selector. Supports invoke/focus/value/toggle/selection, expand-collapse, range values and scroll-into-view before falling back to coordinates.', o({ action:e(['click','invoke','focus','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection']), ...uiSelector, value:{description:'New text or numeric range value.'} }, ['action']), openMutating, desktopHandlers.ui_action, ['ui']),
541
- define('type_text', 'Type text', 'Enter Unicode text into a native semantic accessibility target or focused native control. Auto prefers verified accessibility editing; explicit semantic targets fail safely when the platform rejects the write instead of claiming an unverified paste. Use browser_action for Chromium page DOM. Clipboard/keys fallbacks remain available for focused/window-level input when appropriate.', o({ text:s('Text to enter.'), method:e(['auto','accessibility','clipboard','keys']), clear:b('Clear existing text before input when keyboard/paste fallback is used.'), caret_position:e(['start','idle','end']), press_enter:b('Press Enter after typing.'), delay_ms:n('Per-key delay for method=keys.'), ...uiSelector, window_id:s('Optional XWayland target window id for clipboard/keys methods.'), title:s('Optional target window title substring for clipboard/keys methods.'), ...inputBackendSelector }, ['text']), openMutating, desktopHandlers.type_text, ['input']),
542
- define('keyboard', 'Keyboard shortcut', 'Send a keyboard shortcut or key press to the active desktop application or an explicitly selected window. On Wayland deterministic XWayland input should use window_id/pid/app/title; backend=portal explicitly requests the consent-backed XDG RemoteDesktop path.', o({ shortcut:s(), key:s(), keys:{ type:'array', items:{type:'string'} }, ...inputWindowSelector, ...inputBackendSelector }), openMutating, desktopHandlers.keyboard, ['input']),
541
+ define('type_text', 'Type text', 'Enter Unicode text into a native semantic accessibility target or focused native control. Auto prefers verified accessibility editing; explicit semantic targets fail safely when the platform rejects the write instead of claiming an unverified paste. Use browser_action for Chromium page DOM. Clipboard/keys fallbacks remain available for focused/window-level input when appropriate, and explicit Wayland window targets are focused and verified before dispatch.', o({ text:s('Text to enter.'), method:e(['auto','accessibility','clipboard','keys'], 'Default auto prefers verified accessibility editing and falls back to clipboard paste. keys sends physical key events and therefore follows the active keyboard layout.'), clear:b('Clear existing text before input when keyboard/paste fallback is used.'), caret_position:e(['start','idle','end']), press_enter:b('Press Enter after typing.'), delay_ms:n('Per-key delay for method=keys.'), ...uiSelector, window_id:s('Optional target window id; the window is focused before auto/accessibility/clipboard/keys input and Wayland verifies focus before dispatch.'), title:s('Optional target window title substring; focused before input when no semantic element target is supplied.'), ...inputBackendSelector }, ['text']), openMutating, desktopHandlers.type_text, ['input']),
542
+ define('keyboard', 'Keyboard shortcut', 'Send a keyboard shortcut or key press to the active desktop application or an explicitly selected window. On Wayland, window_id/pid/app/title targets are focused and verified before input is dispatched; backend=portal explicitly requests the consent-backed XDG RemoteDesktop path.', o({ shortcut:s(), key:s(), keys:{ type:'array', items:{type:'string'} }, ...inputWindowSelector, ...inputBackendSelector }), openMutating, desktopHandlers.keyboard, ['input']),
543
543
  define('pointer', 'Pointer action', 'Move the pointer or perform click, double-click, right-click, mouse-down, or mouse-up at desktop screen coordinates. Wayland supports XWayland fallback and an explicitly consented XDG RemoteDesktop backend.', o({ action:e(['move','click','double_click','right_click','down','up']), x:n(), y:n(), button:e(['left','right','middle']), ...inputBackendSelector }, ['action']), openMutating, desktopHandlers.pointer, ['pointer']),
544
544
  define('drag_drop', 'Drag and drop', 'Drag between desktop coordinates or semantic UI element IDs returned by ui_snapshot/ui_find. Element IDs are resolved to current accessibility bounds before input. GNOME Wayland requires the consent-backed Remote Desktop portal for real drag delivery; unreliable XTEST/xdotool drag is rejected.', o({ from_x:n(), from_y:n(), to_x:n(), to_y:n(), from_id:s('Source UI element id; use instead of from_x/from_y.'), to_id:s('Destination UI element id; use instead of to_x/to_y.'), button:e(['left','right','middle']), hold_ms:n(), duration_ms:n(), ...inputBackendSelector }), openMutating, desktopHandlers.drag_drop, ['drag']),
545
545
  define('scroll', 'Scroll', 'Scroll vertically or horizontally using native wheel input. Target a semantic UI element/label or x/y first so the wheel is delivered to the intended region; direction+wheel_times is a convenience over normalized deltas.', o({ delta_x:n('Horizontal delta; positive means right.'), delta_y:n('Vertical delta; positive means down.'), delta:n('Alias for delta_y.'), direction:e(['up','down','left','right']), wheel_times:n('Number of 120-unit wheel steps when direction is used; 1..50.'), x:n(), y:n(), ...uiSelector, ...inputBackendSelector }), mutatingNonDestructive, desktopHandlers.scroll, ['pointer']),
@@ -252,6 +252,36 @@ async function focusInputWindow(windowId) {
252
252
  if (result.code === 0) await new Promise(resolve => setTimeout(resolve, 40));
253
253
  }
254
254
 
255
+ function explicitInputWindowSelector(args = {}) {
256
+ const dedicatedWindowId = optionalString(args.window_id || args.windowId);
257
+ const dedicatedTitle = optionalString(args.title);
258
+ const hasSemanticElementTarget = Boolean(
259
+ optionalString(args.id)
260
+ || optionalString(args.name)
261
+ || optionalString(args.role)
262
+ || optionalString(args.automation_id || args.automationId)
263
+ );
264
+ if (!dedicatedWindowId && !dedicatedTitle && hasSemanticElementTarget) return null;
265
+ const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
266
+ const app = optionalString(args.app);
267
+ const scopedTitle = optionalString(args.window_title || args.windowTitle);
268
+ if (!dedicatedWindowId && !dedicatedTitle && pid == null && !app && !scopedTitle) return null;
269
+ return {
270
+ ...(dedicatedWindowId ? { id:dedicatedWindowId } : {}),
271
+ ...(pid != null ? { pid } : {}),
272
+ ...(app ? { app } : {}),
273
+ ...((dedicatedTitle || scopedTitle) ? { title:dedicatedTitle || scopedTitle } : {}),
274
+ };
275
+ }
276
+
277
+ async function focusExplicitWaylandInputTarget(args = {}) {
278
+ const selector = explicitInputWindowSelector(args);
279
+ if (!selector) return null;
280
+ const row = await windowMatch(selector);
281
+ await windowAction({ action:'focus', id:row.id, backend:inputBackend(args) });
282
+ return row;
283
+ }
284
+
255
285
  async function nativeWaylandWindowAction(row, action, args) {
256
286
  const backend = inputBackend(args);
257
287
  if (backend === 'x11') unavailable('Native Wayland window action', 'use backend=portal for compositor-managed windows');
@@ -350,23 +380,31 @@ export async function windowAction(args) {
350
380
  if (focusState.exact) return text('Window action focus already matched the target window.');
351
381
 
352
382
  const rows = JSON.parse((await listWindows()).content?.[0]?.text || '[]');
353
- const appCycles = Math.min(30, Math.max(4, new Set(rows.map(item => Number(item.pid) || String(item.app || ''))).size + 2));
383
+ const windowCycles = Math.min(60, Math.max(8, rows.length + 4));
384
+ for (let index = 0; index < windowCycles; index += 1) {
385
+ await portalShortcut('ALT+ESC', portalOptions);
386
+ await new Promise(resolve => setTimeout(resolve, 140));
387
+ focusState = await targetAccessibilityFocus(row);
388
+ if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Esc.');
389
+ }
390
+
391
+ const appCycles = Math.min(12, Math.max(3, new Set(rows.map(item => Number(item.pid) || String(item.app || ''))).size + 1));
354
392
  for (let index = 0; index < appCycles; index += 1) {
355
393
  await portalShortcut('ALT+TAB', portalOptions);
356
- await new Promise(resolve => setTimeout(resolve, 120));
394
+ await new Promise(resolve => setTimeout(resolve, 160));
357
395
  focusState = await targetAccessibilityFocus(row);
358
- if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Tab.');
396
+ if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Tab fallback.');
359
397
  if (focusState.active) {
360
398
  const sameAppWindows = rows.filter(item => Number(item.pid) === Number(row.pid)).length;
361
- for (let windowIndex = 0; windowIndex < Math.min(12, Math.max(2, sameAppWindows + 2)); windowIndex += 1) {
399
+ for (let windowIndex = 0; windowIndex < Math.min(8, Math.max(2, sameAppWindows + 1)); windowIndex += 1) {
362
400
  await portalShortcut('ALT+`', portalOptions);
363
- await new Promise(resolve => setTimeout(resolve, 100));
401
+ await new Promise(resolve => setTimeout(resolve, 120));
364
402
  focusState = await targetAccessibilityFocus(row);
365
- if (focusState.exact) return text('Window action focus completed via verified Wayland window cycling.');
403
+ if (focusState.exact) return text('Window action focus completed via verified Wayland same-app fallback.');
366
404
  }
367
405
  }
368
406
  }
369
- throw new Error(`Could not verify focus for ${row.title || row.app || row.id} after cycling Wayland applications`);
407
+ throw new Error(`Could not verify focus for ${row.title || row.app || row.id} after bounded Wayland window cycling`);
370
408
  }
371
409
  if (!commandExists('wmctrl')) unavailable('Window focus', 'install wmctrl');
372
410
  await runFile('wmctrl', ['-ia', id], { label: 'window focus' });
@@ -805,6 +843,7 @@ export async function keyboard(args = {}) {
805
843
  return argv;
806
844
  };
807
845
  if (isWaylandSession()) {
846
+ await focusExplicitWaylandInputTarget(args);
808
847
  if (backend !== 'portal' && commandExists('wtype')) {
809
848
  const result = await runFile('wtype', wtypeArgs(), { label: 'keyboard', allowFailure: true, timeout: 5000 });
810
849
  if (result.code === 0) return text(`Sent ${shortcut} via wtype.`);
@@ -842,6 +881,7 @@ export async function typeTextKeys(value, args = {}) {
842
881
  const delay = clamp(args.delay_ms, 1, 0, 1000);
843
882
  const backend = inputBackend(args);
844
883
  if (isWaylandSession()) {
884
+ await focusExplicitWaylandInputTarget(args);
845
885
  if (backend !== 'portal' && commandExists('wtype')) {
846
886
  const result = await runFile('wtype', ['-d', String(delay), '--', String(value)], { label: 'type text', timeout: 30_000, allowFailure: true });
847
887
  if (result.code === 0) return { ...result, backend: 'wtype' };
@@ -618,6 +618,23 @@ export async function waitForUi(args = {}) {
618
618
  return jsonResult({ state:'timeout', wanted:state, matched:nodes.length, attempts, elapsed_ms:Date.now() - startedAt, nodes, ...(state === 'changed' ? { before:baseline } : {}) });
619
619
  }
620
620
 
621
+ function explicitTypeTextWindowTarget(args = {}, hasElementSelector = false) {
622
+ const windowId = optionalString(args.window_id || args.windowId);
623
+ const title = optionalString(args.title);
624
+ if (!windowId && !title && hasElementSelector) return null;
625
+ const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
626
+ const app = optionalString(args.app);
627
+ const scopedTitle = optionalString(args.window_title || args.windowTitle);
628
+ if (!windowId && !title && pid == null && !app && !scopedTitle) return null;
629
+ return {
630
+ ...(windowId ? { id:windowId } : {}),
631
+ ...(pid != null ? { pid } : {}),
632
+ ...(app ? { app } : {}),
633
+ ...((title || scopedTitle) ? { title:title || scopedTitle } : {}),
634
+ ...(args.backend ? { backend:args.backend } : {}),
635
+ };
636
+ }
637
+
621
638
  export async function typeText(args = {}) {
622
639
  const value = String(args.text ?? '');
623
640
  const method = requireEnum(args.method || 'auto', 'method', ['auto','accessibility','clipboard','keys']);
@@ -626,6 +643,8 @@ export async function typeText(args = {}) {
626
643
  optionalString(resolvedArgs.id) || optionalString(resolvedArgs.name) || optionalString(resolvedArgs.role) ||
627
644
  optionalString(resolvedArgs.automation_id || resolvedArgs.automationId)
628
645
  );
646
+ const explicitWindowTarget = explicitTypeTextWindowTarget(resolvedArgs, hasElementSelector);
647
+ if (explicitWindowTarget) await windowAction({ action:'focus', ...explicitWindowTarget });
629
648
  const clear = args.clear === true;
630
649
  const pressEnter = args.press_enter === true;
631
650
  const caret = requireEnum(args.caret_position || 'idle', 'caret_position', ['start','idle','end']);