@remcp/runtime 0.2.110 → 0.2.112

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.112 — 2026-09-25
4
+
5
+ - Linux EIS absolute pointer motion converts desktop-wide coordinates into the selected libei region before dispatch and keeps the current pointer device when it remains valid, eliminating accepted-but-undelivered absolute moves.
6
+ - Wayland portal drag-and-drop uses one region-safe target transition while pressed, then reasserts the final target before release so `pointerdown → pointermove → pointerup` remains intact.
7
+ - The live computer-use smoke is serialized per user to prevent concurrent desktop-input races; the final ASUS GNOME Wayland run passed 82/82 checks. Runtime tool names and schemas are unchanged.
8
+
9
+ ## 0.2.111 — 2026-09-25
10
+
11
+ - `read_document` reads `.xlsx` files again while `allowedRoots` confinement is enabled: the reader no longer re-enters its own temp staging path through the allowlist.
12
+ - Linux window inventory refreshes X11 rows after the AT-SPI walk, window selectors resolve X11/XWayland targets without a full semantic rebuild, and `window_action focus` verifies direct XWayland activation before falling back to portal cycling. Runtime tool names and schemas are unchanged.
13
+
3
14
  ## 0.2.110 — 2026-09-25
4
15
 
5
16
  - Bulk handlers (`read_multiple_files`, `read_files`, `replace_in_files`, `write_files`, `copy_paths`, `move_paths`, `delete_paths`, `create_archive`) and the directory walk now honour client cancellation between items, so an aborted call stops at the next item boundary instead of finishing on the device; single-file and external-tool (`tar`/`unzip`) operations remain atomic and are not interrupted mid-step.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.110",
3
+ "version": "0.2.112",
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",
@@ -55,7 +55,7 @@ async function x11PixelBounds(id) {
55
55
  }
56
56
 
57
57
  export async function listWindows() {
58
- const wmRows = commandExists('wmctrl')
58
+ let wmRows = commandExists('wmctrl')
59
59
  ? parseWindows((await runFile('wmctrl', ['-lpGx'], { label: 'window inventory', allowFailure:true, timeout:3000 })).stdout)
60
60
  : [];
61
61
  if (!isWaylandSession()) {
@@ -85,6 +85,15 @@ export async function listWindows() {
85
85
  });
86
86
  } catch {}
87
87
 
88
+ // AT-SPI enumeration can take several seconds on a busy desktop. Refresh the cheap
89
+ // X11 inventory afterwards so an XWayland window that appeared while AT-SPI was
90
+ // walking the tree is merged with its semantic window instead of being misclassified
91
+ // as native Wayland for the rest of this call.
92
+ if (commandExists('wmctrl')) {
93
+ const refreshedWmRows = parseWindows((await runFile('wmctrl', ['-lpGx'], { label:'window inventory refresh', allowFailure:true, timeout:3000 })).stdout);
94
+ if (refreshedWmRows.length) wmRows = refreshedWmRows;
95
+ }
96
+
88
97
  const pixelBounds = new Map();
89
98
  if (commandExists('xwininfo')) {
90
99
  // wmctrl geometry on Mutter/XWayland is decoration-shifted and cannot be used for pixel crops.
@@ -151,18 +160,50 @@ export async function listWindows() {
151
160
  return jsonResult(rows);
152
161
  }
153
162
 
163
+ function windowSelector(args = {}) {
164
+ return {
165
+ id: optionalString(args.id || args.window_id || args.windowId),
166
+ pid: Number.isInteger(Number(args.pid)) ? Number(args.pid) : null,
167
+ app: optionalString(args.app),
168
+ title: optionalString(args.title || args.window_title || args.windowTitle),
169
+ };
170
+ }
171
+
172
+ function windowMatchesSelector(item, selector) {
173
+ return (!selector.id || item.id === selector.id || item.wm_id === selector.id || item.ui_id === selector.id)
174
+ && (selector.pid == null || Number(item.pid) === selector.pid)
175
+ && (!selector.app || String(item.app || '').toLowerCase().includes(selector.app.toLowerCase()))
176
+ && (!selector.title || String(item.title || '').toLowerCase().includes(selector.title.toLowerCase()));
177
+ }
178
+
179
+ async function fastX11WindowMatch(args) {
180
+ if (!commandExists('wmctrl')) return null;
181
+ const selector = windowSelector(args);
182
+ const rows = parseWindows((await runFile('wmctrl', ['-lpGx'], {
183
+ label:'window match',
184
+ allowFailure:true,
185
+ timeout:2000,
186
+ })).stdout);
187
+ const row = rows.find(item => windowMatchesSelector({ ...item, wm_id:item.id, ui_id:null }, selector));
188
+ if (!row) return null;
189
+ return {
190
+ ...row,
191
+ wm_id:row.id,
192
+ ui_id:null,
193
+ backend:isWaylandSession() ? 'xwayland' : 'x11',
194
+ };
195
+ }
196
+
154
197
  async function windowMatch(args) {
198
+ // Most desktop actions target an X11/XWayland window. Resolve that cheap native id
199
+ // first instead of rebuilding the entire AT-SPI inventory before every action.
200
+ // Native Wayland windows have no wmctrl row and fall through to the semantic inventory.
201
+ const fast = await fastX11WindowMatch(args);
202
+ if (fast) return fast;
203
+
155
204
  const rows = JSON.parse((await listWindows()).content[0].text);
156
- const id = optionalString(args.id || args.window_id || args.windowId);
157
- const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
158
- const app = optionalString(args.app);
159
- const title = optionalString(args.title || args.window_title || args.windowTitle);
160
- const row = rows.find(item =>
161
- (!id || item.id === id || item.wm_id === id || item.ui_id === id)
162
- && (pid == null || item.pid === pid)
163
- && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
164
- && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
165
- );
205
+ const selector = windowSelector(args);
206
+ const row = rows.find(item => windowMatchesSelector(item, selector));
166
207
  if (!row) throw new Error('No matching window found');
167
208
  return row;
168
209
  }
@@ -502,7 +543,24 @@ export async function windowAction(args) {
502
543
  if (focusState.exact) return text('Window action focus completed via verified AT-SPI focus.');
503
544
  } catch {}
504
545
  }
505
- if (backend === 'x11') unavailable('Wayland window focus', 'AT-SPI focus did not take effect and X11 focus cannot target this Wayland window; use backend=portal');
546
+ // XWayland windows have a real X11 wm_id even inside a Wayland session. Prefer
547
+ // direct activation for those windows, verify the result through accessibility,
548
+ // and only fall back to compositor-level cycling if direct activation did not take.
549
+ // This avoids dozens of Alt+Esc/AT-SPI round trips on busy desktops.
550
+ if (row.wm_id && backend !== 'portal' && commandExists('wmctrl')) {
551
+ const activated = await runFile('wmctrl', ['-ia', row.wm_id], { label:'XWayland window focus', allowFailure:true, timeout:2000 });
552
+ if (activated.code === 0) {
553
+ if (commandExists('xdotool')) {
554
+ await runFile('xdotool', ['windowactivate','--sync',row.wm_id], { label:'XWayland window focus sync', allowFailure:true, timeout:3000 });
555
+ }
556
+ await new Promise(resolve => setTimeout(resolve, 80));
557
+ const focusState = await targetAccessibilityFocus(row);
558
+ if (focusState.exact) return text('Window action focus completed via verified XWayland activation.');
559
+ }
560
+ }
561
+ if (backend === 'x11') unavailable('Wayland window focus', row.wm_id
562
+ ? 'direct XWayland activation did not produce verifiable focus'
563
+ : 'the target is native Wayland and has no X11 window id; use backend=portal');
506
564
  if (!waylandPortalCandidate()) unavailable('Wayland window focus', 'no XDG RemoteDesktop portal is available');
507
565
  if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Wayland window focus');
508
566
  const portalOptions = { timeoutMs: backend === 'portal' ? 120_000 : 2500 };
@@ -1212,22 +1270,16 @@ export async function dragDrop(args = {}) {
1212
1270
  await portalPointerButton(button, true, portalOptions);
1213
1271
  await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 60, 5000)));
1214
1272
 
1215
- // A single absolute jump while the button is held can be coalesced by Mutter/
1216
- // XWayland and never become a target-visible pointermove. Send a short,
1217
- // human-like path so drag-aware toolkits observe movement before button-up.
1218
- const distance = Math.hypot(toX - fromX, toY - fromY);
1219
- const steps = Math.max(4, Math.min(20, Math.ceil(distance / 24)));
1220
- const durationMs = clamp(args.duration_ms, 160, 80, 5000);
1221
- const stepDelay = Math.max(8, Math.round(durationMs / steps));
1222
- for (let step = 1; step <= steps; step += 1) {
1223
- const ratio = step / steps;
1224
- await portalMoveTo(
1225
- fromX + (toX - fromX) * ratio,
1226
- fromY + (toY - fromY) * ratio,
1227
- portalOptions,
1228
- );
1229
- await new Promise(resolve => setTimeout(resolve, stepDelay));
1230
- }
1273
+ // Keep the pressed pointer on a single absolute-motion transition. Repeated
1274
+ // absolute moves can cross EIS region/device contexts and lose the logical
1275
+ // pressed state. The EIS helper converts the desktop target into region-local
1276
+ // coordinates, so one target move is sufficient and preserves buttons=1.
1277
+ await portalMoveTo(toX, toY, portalOptions);
1278
+ await new Promise(resolve => setTimeout(resolve, clamp(args.duration_ms, 160, 80, 5000)));
1279
+ // Reassert the final target immediately before release. On Mutter/XWayland this
1280
+ // mirrors a real pointer-up gesture and ensures the release is delivered as a
1281
+ // pointerup/mouseup at the dragged target instead of only clearing button state.
1282
+ await portalMoveTo(toX, toY, portalOptions);
1231
1283
  await new Promise(resolve => setTimeout(resolve, 40));
1232
1284
  await portalPointerButton(button, false, portalOptions);
1233
1285
  await new Promise(resolve => setTimeout(resolve, 80));
@@ -334,10 +334,12 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
334
334
  }
335
335
 
336
336
  async function readXlsx(filePath, args) {
337
- const source = await snapshotDocumentFile(filePath);
338
337
  const dir = await tempDir('remcp-xlsx-read-');
339
338
  try {
340
- await extractZip(source.path, dir);
339
+ // extractZip already snapshots the user-approved source into a private temp file
340
+ // before invoking unzip. A second snapshot here used to re-open ReMCP's own temp
341
+ // path through allowedRoots, so read_document failed whenever confinement was on.
342
+ await extractZip(filePath, dir);
341
343
  const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
342
344
  const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
343
345
  const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
@@ -347,7 +349,6 @@ async function readXlsx(filePath, args) {
347
349
  const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
348
350
  return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
349
351
  } finally {
350
- await removeTemp(source.dir);
351
352
  await removeTemp(dir);
352
353
  }
353
354
  }
@@ -216,6 +216,8 @@ class EiSender:
216
216
  self.lib.ei_device_has_capability.restype = ctypes.c_bool
217
217
  self.lib.ei_device_get_region_at.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
218
218
  self.lib.ei_device_get_region_at.restype = c_void_p
219
+ self.lib.ei_region_convert_point.argtypes = [c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
220
+ self.lib.ei_region_convert_point.restype = ctypes.c_bool
219
221
  self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
220
222
  self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
221
223
  self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
@@ -367,17 +369,46 @@ class EiSender:
367
369
  return state["ptr"]
368
370
  raise RuntimeError(f"no resumed EIS device with capability {cap}")
369
371
 
372
+ def _absolute_motion_for_device(self, device, x, y):
373
+ if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
374
+ return None
375
+ region = self.lib.ei_device_get_region_at(device, float(x), float(y))
376
+ if not region:
377
+ return None
378
+ motion_x = ctypes.c_double(float(x))
379
+ motion_y = ctypes.c_double(float(y))
380
+ if not self.lib.ei_region_convert_point(region, ctypes.byref(motion_x), ctypes.byref(motion_y)):
381
+ raise RuntimeError(f"failed to convert desktop coordinate ({x}, {y}) into the selected EIS region")
382
+ return motion_x.value, motion_y.value
383
+
370
384
  def device_for_absolute(self, x, y):
371
385
  candidates = []
386
+
387
+ # Preserve pointer identity across absolute motion whenever the current
388
+ # device still covers the destination. This is required for pointer
389
+ # capture during drags: switching EIS devices mid-gesture can drop the
390
+ # captured pointer before its button release is observed.
391
+ preferred = self.devices.get(self.pointer_device_key)
392
+ if preferred and preferred["resumed"] and preferred["emulating"]:
393
+ device = preferred["ptr"]
394
+ if self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
395
+ candidates.append(device)
396
+ converted = self._absolute_motion_for_device(device, x, y)
397
+ if converted is not None:
398
+ return device, converted[0], converted[1]
399
+
372
400
  for state in self.devices.values():
373
401
  if not state["resumed"] or not state["emulating"]:
374
402
  continue
375
403
  device = state["ptr"]
404
+ if preferred and self._device_key(device) == self.pointer_device_key:
405
+ continue
376
406
  if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
377
407
  continue
378
408
  candidates.append(device)
379
- if self.lib.ei_device_get_region_at(device, float(x), float(y)):
380
- return device
409
+ converted = self._absolute_motion_for_device(device, x, y)
410
+ if converted is not None:
411
+ return device, converted[0], converted[1]
381
412
  if candidates:
382
413
  raise RuntimeError(f"no absolute-pointer EIS region contains desktop coordinate ({x}, {y})")
383
414
  raise RuntimeError("no resumed EIS device with absolute-pointer capability")
@@ -424,8 +455,8 @@ class EiSender:
424
455
  elif op == "motion_absolute":
425
456
  x = float(payload["x"])
426
457
  y = float(payload["y"])
427
- device = self.device_for_absolute(x, y)
428
- self.lib.ei_device_pointer_motion_absolute(device, x, y)
458
+ device, motion_x, motion_y = self.device_for_absolute(x, y)
459
+ self.lib.ei_device_pointer_motion_absolute(device, motion_x, motion_y)
429
460
  self.remember_pointer(device, (x, y))
430
461
  self.frame(device)
431
462
  elif op == "button":