@remcp/runtime 0.2.154 → 0.2.156

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.
@@ -0,0 +1,455 @@
1
+ import os from 'node:os';
2
+ import process from 'node:process';
3
+ import { fileToolHandlers } from '../tools/files.mjs';
4
+ import { liveConfig, runtimeConfig } from '../config.mjs';
5
+ import { assertAllowedCommand } from '../policy.mjs';
6
+ import { multi, resolveSafePath, text } from '../util.mjs';
7
+ import { clamp, desktopSessionAvailable, jsonResult, optionalString, requireEnum } from './common.mjs';
8
+ import * as linux from './desktop-linux.mjs';
9
+ import * as macos from './desktop-macos.mjs';
10
+ import * as windows from './desktop-windows.mjs';
11
+ import { browserAction, browserCapabilityAvailable, browserSnapshot } from './browser.mjs';
12
+ import { findOcrText, ocrImage } from './ocr.mjs';
13
+
14
+
15
+ import { uiMatches } from './desktop-ui-actions.mjs';
16
+
17
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
+ export const adapter = process.platform === 'win32' ? windows : process.platform === 'darwin' ? macos : linux;
19
+
20
+ export const UI_CACHE_TTL_MS = 5_000;
21
+ export let latestUiSnapshot = null;
22
+
23
+ // ES module imports are read-only bindings. All snapshot invalidation must be
24
+ // owned by this module so wait_for_ui can request a fresh tree safely.
25
+ export function invalidateUiSnapshotCache() {
26
+ latestUiSnapshot = null;
27
+ }
28
+
29
+ export function suggestedUiAction(node = {}) {
30
+ const role = String(node.role || '').toLowerCase();
31
+ const patterns = Array.isArray(node.patterns) ? node.patterns.map(value => String(value).toLowerCase()) : [];
32
+ if (patterns.includes('toggle') || /check|switch/.test(role)) return 'toggle';
33
+ if (patterns.includes('range_value') || /slider|spin/.test(role)) return 'set_range_value';
34
+ if (patterns.includes('selection_item') || /radio|listitem|treeitem|tabitem|option/.test(role)) return 'select';
35
+ if (patterns.includes('expand_collapse') || /combo|treeitem|menuitem/.test(role)) return 'expand';
36
+ if (patterns.includes('value') || /edit|textbox|text field|entry|search/.test(role)) return 'set_value';
37
+ if (patterns.includes('invoke') || /button|link|menuitem/.test(role)) return 'invoke';
38
+ if (node.scrollable === true || patterns.includes('scroll') || patterns.includes('scroll_item')) return 'scroll_into_view';
39
+ return 'click';
40
+ }
41
+
42
+ export function semanticTreeText(nodes = [], maxChars = 48_000) {
43
+ const lines = [];
44
+ let chars = 0;
45
+ for (const node of nodes) {
46
+ const depth = Math.max(0, Math.min(32, Number(node.depth) || 0));
47
+ const label = Number.isInteger(node.label) ? `#${node.label} ` : '';
48
+ const role = String(node.role || 'element');
49
+ const name = String(node.name || node.value || '').replace(/\s+/g, ' ').trim();
50
+ const states = [
51
+ node.focused === true ? 'focused' : '',
52
+ node.enabled === false ? 'disabled' : '',
53
+ node.offscreen === true ? 'offscreen' : '',
54
+ node.password === true ? 'password' : '',
55
+ node.toggle_state != null ? `toggle:${node.toggle_state}` : '',
56
+ node.expand_collapse_state ? `state:${node.expand_collapse_state}` : '',
57
+ node.range_value != null ? `value:${node.range_value}` : '',
58
+ ].filter(Boolean);
59
+ const line = `${' '.repeat(depth)}${label}${role}${name ? ` "${name.slice(0, 240)}"` : ''} [${node.suggested_action || suggestedUiAction(node)}]${states.length ? ` ${states.map(v => `[${v}]`).join(' ')}` : ''}`;
60
+ if (chars + line.length + (lines.length ? 1 : 0) > maxChars) {
61
+ lines.push('… semantic tree truncated …');
62
+ break;
63
+ }
64
+ lines.push(line);
65
+ chars += line.length + (lines.length > 1 ? 1 : 0);
66
+ }
67
+ return lines.join('\n');
68
+ }
69
+
70
+ export function enrichUiPayload(value, args = {}) {
71
+ const base = Array.isArray(value) ? { platform:process.platform, count:value.length, nodes:value } : { ...(value || {}) };
72
+ const source = Array.isArray(base.nodes) ? base.nodes : [];
73
+ let label = 0;
74
+ const nodes = source.map(node => {
75
+ const x = Number(node.x), y = Number(node.y), width = Number(node.width), height = Number(node.height);
76
+ const usableBounds = [x, y, width, height].every(Number.isFinite) && width > 0 && height > 0;
77
+ const actionable = usableBounds && node.offscreen !== true && (
78
+ node.enabled !== false || node.focused === true || String(node.name || '').trim() || String(node.role || '').trim()
79
+ );
80
+ const enriched = {
81
+ ...node,
82
+ ...(usableBounds ? { center_x:Math.round(x + width / 2), center_y:Math.round(y + height / 2) } : {}),
83
+ suggested_action:suggestedUiAction(node),
84
+ };
85
+ if (actionable) enriched.label = label++;
86
+ return enriched;
87
+ });
88
+ const payload = {
89
+ ...base,
90
+ count:Number(base.count) || nodes.length,
91
+ nodes,
92
+ label_count:label,
93
+ semantic_tree:args.semantic_tree === false ? undefined : semanticTreeText(nodes),
94
+ captured_at:new Date().toISOString(),
95
+ };
96
+ latestUiSnapshot = { at:Date.now(), payload, args:{ ...args } };
97
+ return payload;
98
+ }
99
+
100
+ export function cachedUiNodes(args = {}) {
101
+ if (!latestUiSnapshot || Date.now() - latestUiSnapshot.at > UI_CACHE_TTL_MS) return null;
102
+ const requestedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
103
+ const cachedNodes = latestUiSnapshot.payload?.nodes;
104
+ if (!Array.isArray(cachedNodes)) return null;
105
+ if (requestedPid != null && !cachedNodes.some(node => Number(node.pid) === requestedPid)) return null;
106
+ if (args.app && !cachedNodes.some(node => String(node.app || '').toLowerCase().includes(String(args.app).toLowerCase()))) return null;
107
+ if ((args.window_title || args.windowTitle) && !cachedNodes.some(node => String(node.window || node.window_title || '').toLowerCase().includes(String(args.window_title || args.windowTitle).toLowerCase()))) return null;
108
+ return cachedNodes;
109
+ }
110
+
111
+ export function resolveCachedLabel(value, field = 'label') {
112
+ if (value == null || value === '') return null;
113
+ const label = Number(value);
114
+ if (!Number.isInteger(label) || label < 0) throw new Error(`${field} must be a non-negative integer label returned by ui_snapshot/ui_find`);
115
+ if (!latestUiSnapshot || Date.now() - latestUiSnapshot.at > UI_CACHE_TTL_MS) {
116
+ throw new Error(`${field} refers to an expired UI snapshot; refresh ui_snapshot/ui_find before acting`);
117
+ }
118
+ const nodes = latestUiSnapshot.payload?.nodes;
119
+ if (!Array.isArray(nodes)) throw new Error(`${field} requires a prior ui_snapshot/ui_find on the current desktop state`);
120
+ const node = nodes.find(item => Number(item.label) === label);
121
+ if (!node) throw new Error(`${field} ${label} is not present in the latest UI snapshot; refresh ui_snapshot`);
122
+ return node;
123
+ }
124
+
125
+ export function withResolvedUiLabel(args = {}) {
126
+ const node = resolveCachedLabel(args.label);
127
+ if (!node) return args;
128
+ return {
129
+ ...args,
130
+ id:args.id || node.id,
131
+ pid:args.pid ?? node.pid,
132
+ app:args.app || node.app,
133
+ window_title:args.window_title || args.windowTitle || node.window || node.window_title,
134
+ name:args.name || (!node.id ? node.name : undefined),
135
+ role:args.role || (!node.id ? node.role : undefined),
136
+ };
137
+ }
138
+
139
+ export function resultValue(result) {
140
+ if (result?.structuredContent && !(Object.keys(result.structuredContent).length === 1 && typeof result.structuredContent.text === 'string')) {
141
+ if (Object.keys(result.structuredContent).length === 1 && Object.hasOwn(result.structuredContent, 'data')) return result.structuredContent.data;
142
+ return result.structuredContent;
143
+ }
144
+ const raw = result?.content?.find(part => part.type === 'text')?.text;
145
+ if (typeof raw !== 'string') return null;
146
+ try { return JSON.parse(raw); } catch { return raw; }
147
+ }
148
+
149
+ export function objectRecord(value) {
150
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
151
+ }
152
+
153
+ export function toolResultMessage(result) {
154
+ const text = result?.content?.find(part => part.type === 'text')?.text;
155
+ return String(text || 'tool returned an error result').slice(0, 2000);
156
+ }
157
+
158
+ export function snapshotError(source, settled) {
159
+ if (settled?.status === 'rejected') {
160
+ return { source, message:String(settled.reason?.message || settled.reason || 'capture rejected').slice(0, 2000) };
161
+ }
162
+ if (settled?.status === 'fulfilled' && settled.value?.isError === true) {
163
+ return { source, message:toolResultMessage(settled.value) };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ export function normalizeSnapshotErrors(errors) {
169
+ if (!Array.isArray(errors)) return [];
170
+ return errors.slice(0, 50).map((error, index) => {
171
+ if (objectRecord(error)) {
172
+ return {
173
+ ...error,
174
+ source:String(error.source || 'snapshot').slice(0, 120),
175
+ message:String(error.message || error.error || 'snapshot capture error').slice(0, 2000),
176
+ };
177
+ }
178
+ return { source:'snapshot', message:String(error ?? `snapshot error ${index + 1}`).slice(0, 2000) };
179
+ });
180
+ }
181
+
182
+ export function jsonBytes(value) {
183
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
184
+ }
185
+
186
+ export function boundedComputerSnapshot(payload) {
187
+ const normalized = {
188
+ ...payload,
189
+ active_window:objectRecord(payload?.active_window),
190
+ windows:Array.isArray(payload?.windows) ? payload.windows : [],
191
+ displays:Array.isArray(payload?.displays) ? payload.displays : [],
192
+ cursor:objectRecord(payload?.cursor) || { x:null, y:null },
193
+ ui:objectRecord(payload?.ui) || { platform:process.platform, count:0, nodes:[] },
194
+ clipboard:objectRecord(payload?.clipboard) || { available:false, length:0 },
195
+ errors:normalizeSnapshotErrors(payload?.errors),
196
+ };
197
+ const budget = Math.min(768 * 1024, Math.max(16 * 1024, Math.floor(Number(liveConfig('maxOutputBytes') || 0) * 0.35)));
198
+ const originalBytes = jsonBytes(normalized);
199
+ if (originalBytes <= budget) {
200
+ return { ...normalized, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
201
+ }
202
+
203
+ const bounded = {
204
+ ...normalized,
205
+ windows:[...normalized.windows],
206
+ errors:normalizeSnapshotErrors(normalized.errors),
207
+ ui:{
208
+ ...normalized.ui,
209
+ ...(Array.isArray(normalized.ui.nodes) ? { nodes:[...normalized.ui.nodes] } : {}),
210
+ },
211
+ browser:objectRecord(normalized.browser) ? {
212
+ ...normalized.browser,
213
+ ...(Array.isArray(normalized.browser.nodes) ? { nodes:[...normalized.browser.nodes] } : {}),
214
+ } : undefined,
215
+ ocr:objectRecord(normalized.ocr) ? {
216
+ ...normalized.ocr,
217
+ text:String(normalized.ocr.text || '').slice(0, 50_000),
218
+ ...(Array.isArray(normalized.ocr.words) ? { words:[...normalized.ocr.words] } : {}),
219
+ ...(Array.isArray(normalized.ocr.lines) ? { lines:[...normalized.ocr.lines] } : {}),
220
+ } : undefined,
221
+ snapshot_original_bytes:originalBytes,
222
+ snapshot_budget_bytes:budget,
223
+ snapshot_truncated:true,
224
+ };
225
+
226
+ const lists = [
227
+ [bounded.ocr, 'words', 50],
228
+ [bounded.ui, 'nodes', 100],
229
+ [bounded.browser, 'nodes', 100],
230
+ [bounded.visual, 'labeled_targets', 25],
231
+ [bounded.ocr, 'lines', 25],
232
+ [bounded, 'windows', 20],
233
+ ];
234
+ let guard = 0;
235
+ while (jsonBytes(bounded) > budget && guard < 32) {
236
+ guard += 1;
237
+ let changed = false;
238
+ for (const [container, key, floor] of lists) {
239
+ const values = container?.[key];
240
+ if (!Array.isArray(values) || values.length <= floor) continue;
241
+ const nextLength = Math.max(floor, Math.floor(values.length * 0.6));
242
+ container[key] = values.slice(0, nextLength);
243
+ container.truncated = true;
244
+ container[`returned_${key}`] = nextLength;
245
+ changed = true;
246
+ if (jsonBytes(bounded) <= budget) break;
247
+ }
248
+ if (!changed) break;
249
+ }
250
+
251
+ if (jsonBytes(bounded) > budget && bounded.ui && typeof bounded.ui.semantic_tree === 'string') {
252
+ bounded.ui.semantic_tree = bounded.ui.semantic_tree.slice(0, 8_000);
253
+ bounded.ui.semantic_tree_truncated = true;
254
+ }
255
+
256
+ if (jsonBytes(bounded) > budget) {
257
+ if (bounded.ui && typeof bounded.ui === 'object') {
258
+ bounded.ui = {
259
+ platform:bounded.ui.platform || null,
260
+ count:Number(bounded.ui.count) || (Array.isArray(bounded.ui.nodes) ? bounded.ui.nodes.length : 0),
261
+ nodes:[],
262
+ truncated:true,
263
+ reason:'snapshot payload budget',
264
+ };
265
+ }
266
+ if (bounded.browser && typeof bounded.browser === 'object') {
267
+ bounded.browser = {
268
+ target_id:bounded.browser.target_id || null,
269
+ title:String(bounded.browser.title || '').slice(0, 500),
270
+ url:String(bounded.browser.url || '').slice(0, 2000),
271
+ count:Number(bounded.browser.count) || (Array.isArray(bounded.browser.nodes) ? bounded.browser.nodes.length : 0),
272
+ nodes:[],
273
+ truncated:true,
274
+ reason:'snapshot payload budget',
275
+ };
276
+ }
277
+ if (bounded.ocr && typeof bounded.ocr === 'object') {
278
+ bounded.ocr = {
279
+ available:Boolean(bounded.ocr.available),
280
+ backend:bounded.ocr.backend || null,
281
+ language:bounded.ocr.language || null,
282
+ text:String(bounded.ocr.text || '').slice(0, 10_000),
283
+ count:Number(bounded.ocr.count) || 0,
284
+ total_words:Number(bounded.ocr.total_words) || 0,
285
+ words:[],
286
+ lines:[],
287
+ truncated:true,
288
+ reason:'snapshot payload budget',
289
+ };
290
+ }
291
+ if (Array.isArray(bounded.windows)) bounded.windows = bounded.windows.slice(0, 10);
292
+ }
293
+
294
+ bounded.snapshot_bytes = jsonBytes(bounded);
295
+ return bounded;
296
+ }
297
+
298
+ export function bestMonitor(window, displays) {
299
+ if (!Array.isArray(displays)) return null;
300
+ const wx1 = Number(window.x), wy1 = Number(window.y), ww = Number(window.width), wh = Number(window.height);
301
+ if (![wx1, wy1, ww, wh].every(Number.isFinite) || ww <= 0 || wh <= 0) return null;
302
+ const wx2 = wx1 + ww, wy2 = wy1 + wh;
303
+ let best = null;
304
+ let bestArea = 0;
305
+ for (let index = 0; index < displays.length; index += 1) {
306
+ const display = displays[index];
307
+ const dx1 = Number(display.x), dy1 = Number(display.y), dw = Number(display.width), dh = Number(display.height);
308
+ if (![dx1, dy1, dw, dh].every(Number.isFinite) || dw <= 0 || dh <= 0) continue;
309
+ const area = Math.max(0, Math.min(wx2, dx1 + dw) - Math.max(wx1, dx1)) * Math.max(0, Math.min(wy2, dy1 + dh) - Math.max(wy1, dy1));
310
+ if (area > bestArea) { bestArea = area; best = { index, ...display }; }
311
+ }
312
+ return best;
313
+ }
314
+
315
+ export async function listWindows(args = {}) {
316
+ const raw = await adapter.listWindows(args);
317
+ if (args.include_monitor === false) return raw;
318
+ const windows = resultValue(raw);
319
+ if (!Array.isArray(windows)) return raw;
320
+ let displays = null;
321
+ try { displays = resultValue(await adapter.displayInventory()); } catch {}
322
+ if (!Array.isArray(displays)) return jsonResult(windows.map(window => ({ ...window, monitor: window.monitor ?? null })));
323
+ return jsonResult(windows.map(window => {
324
+ const monitor = bestMonitor(window, displays);
325
+ return { ...window, monitor: monitor?.name ?? null, monitor_index: monitor?.index ?? null };
326
+ }));
327
+ }
328
+ export async function windowAction(args = {}) {
329
+ const hasTarget = Boolean(args.id || Number.isInteger(Number(args.pid)) || args.app || args.title);
330
+ if (!hasTarget) throw new Error('window_action requires id, pid, app, or title');
331
+ const action = optionalString(args.action);
332
+ if (action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('window_action move requires x and y');
333
+ if (action === 'resize' && ![args.width,args.height].every(value => Number.isFinite(Number(value)))) throw new Error('window_action resize requires width and height');
334
+ if (action === 'move_resize' && ![args.x,args.y,args.width,args.height].every(value => Number.isFinite(Number(value)))) throw new Error('window_action move_resize requires x, y, width and height');
335
+ return adapter.windowAction(args);
336
+ }
337
+ export async function uiSnapshot(args = {}) {
338
+ const explicitlyScoped = args.pid != null || args.app || args.window_title || args.windowTitle;
339
+ const activeOnly = args.active_only != null
340
+ ? args.active_only === true
341
+ : (!explicitlyScoped && args.scope !== 'desktop');
342
+ let effective = { ...args, active_only:activeOnly };
343
+ let raw = await adapter.uiSnapshot(effective);
344
+ let value = resultValue(raw);
345
+ const requestedDepth = Number(effective.max_depth);
346
+ const isEmptySnapshot = payload => Array.isArray(payload)
347
+ ? payload.length === 0
348
+ : 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) {
356
+ effective = { ...effective, max_depth:12 };
357
+ raw = await adapter.uiSnapshot(effective);
358
+ value = resultValue(raw);
359
+ }
360
+ }
361
+ return jsonResult(enrichUiPayload(value, effective));
362
+ }
363
+ export async function uiAction(args = {}) {
364
+ const resolved = withResolvedUiLabel(args);
365
+ const hasTarget = Boolean(resolved.id || resolved.label != null || resolved.name || resolved.role || resolved.automation_id || resolved.automationId);
366
+ if (!hasTarget) throw new Error('ui_action requires id, label, name, role, or automation_id');
367
+ const action = optionalString(resolved.action);
368
+ if (['set_value','set_range_value'].includes(action) && resolved.value == null) throw new Error(`ui_action ${action} requires value`);
369
+ return adapter.uiAction(resolved);
370
+ }
371
+ export async function keyboard(args = {}) { return adapter.keyboard(args); }
372
+ export async function pointer(args = {}) {
373
+ if (args.action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('pointer move requires x and y');
374
+ return adapter.pointer(args);
375
+ }
376
+ export async function clipboard(args = {}) { return adapter.clipboard(args); }
377
+ export async function displayInventory(args = {}) { return adapter.displayInventory(args); }
378
+ export async function cursorPosition(args = {}) { return typeof adapter.cursorPosition === 'function' ? adapter.cursorPosition(args) : jsonResult({ x:null, y:null }); }
379
+
380
+ export async function screenshotRegion(args = {}) {
381
+ let x = Number(args.x), y = Number(args.y), width = Number(args.width), height = Number(args.height);
382
+ const hasRect = [x, y, width, height].every(Number.isFinite) && width > 0 && height > 0;
383
+ if (!hasRect) {
384
+ const windowId = optionalString(args.window_id || args.id);
385
+ const app = optionalString(args.app);
386
+ const title = optionalString(args.title);
387
+ const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
388
+ if (windowId || app || title || pid != null) {
389
+ let row = null;
390
+ if (windowId && typeof adapter.resolveWindowTarget === 'function') {
391
+ row = resultValue(await adapter.resolveWindowTarget({
392
+ id:windowId,
393
+ ...(pid != null ? { pid } : {}),
394
+ ...(app ? { app } : {}),
395
+ ...(title ? { title } : {}),
396
+ }));
397
+ } else {
398
+ const rows = resultValue(await listWindows({ include_monitor:false }));
399
+ row = Array.isArray(rows) ? rows.find(item =>
400
+ (!windowId || item.id === windowId)
401
+ && (pid == null || Number(item.pid) === pid)
402
+ && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
403
+ && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
404
+ ) : null;
405
+ }
406
+ if (!row) throw new Error('No matching window found for screenshot_region');
407
+ x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
408
+ } else if (args.monitor != null || args.monitor_index != null) {
409
+ const displays = resultValue(await displayInventory());
410
+ if (!Array.isArray(displays)) throw new Error('This platform does not expose normalized monitor geometry for screenshot_region');
411
+ const index = Number.isInteger(Number(args.monitor_index)) ? Number(args.monitor_index) : null;
412
+ const monitor = optionalString(args.monitor);
413
+ const row = displays.find((item, itemIndex) =>
414
+ (index == null || itemIndex === index)
415
+ && (!monitor || (monitor.toLowerCase() === 'primary' ? Boolean(item.primary) : String(item.name || item.display_name || '').toLowerCase().includes(monitor.toLowerCase())))
416
+ );
417
+ if (!row) throw new Error('No matching monitor found for screenshot_region');
418
+ x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
419
+ }
420
+ }
421
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
422
+ throw new Error('Provide x/y/width/height, a window selector, or a monitor selector');
423
+ }
424
+ const padding = clamp(args.padding, 0, 0, 200);
425
+ if (padding) { x -= padding; y -= padding; width += padding * 2; height += padding * 2; }
426
+ return adapter.screenshotRegion({ ...args, x, y, width, height });
427
+ }
428
+ export async function notification(args = {}) { return adapter.notification(args); }
429
+ export async function scroll(args = {}) {
430
+ const direction = optionalString(args.direction);
431
+ const hasDelta = [args.delta_x,args.delta_y,args.delta].some(value => value != null && Number.isFinite(Number(value)));
432
+ if (!direction && !hasDelta) throw new Error('scroll requires direction or a delta');
433
+ const times = clamp(args.wheel_times, 1, 1, 50);
434
+ let deltaX = Number(args.delta_x || 0);
435
+ let deltaY = Number(args.delta_y ?? args.delta ?? 0);
436
+ if (direction) {
437
+ const normalized = requireEnum(direction, 'direction', ['up','down','left','right']);
438
+ if (normalized === 'up') deltaY = -120 * times;
439
+ if (normalized === 'down') deltaY = 120 * times;
440
+ if (normalized === 'left') deltaX = -120 * times;
441
+ if (normalized === 'right') deltaX = 120 * times;
442
+ }
443
+ const semanticTarget = args.label != null || args.id || args.name || args.role || args.automation_id || args.automationId;
444
+ if (semanticTarget) {
445
+ const nodes = await uiMatches({ ...args, limit:1, refresh:true });
446
+ const node = nodes[0];
447
+ if (!node) throw new Error('Scroll target UI element was not found');
448
+ const x = Number(node.center_x ?? (Number(node.x) + Number(node.width) / 2));
449
+ const y = Number(node.center_y ?? (Number(node.y) + Number(node.height) / 2));
450
+ if (Number.isFinite(x) && Number.isFinite(y)) await pointer({ action:'move', x:Math.round(x), y:Math.round(y), backend:args.backend });
451
+ } else if (Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y))) {
452
+ await pointer({ action:'move', x:Number(args.x), y:Number(args.y), backend:args.backend });
453
+ }
454
+ return adapter.scroll({ ...args, delta_x:deltaX, delta_y:deltaY });
455
+ }