@remcp/runtime 0.2.50 → 0.2.52

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,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.52 — 2026-09-20
6
+
7
+ - `computer_snapshot.active_window` is nullable when native accessibility state cannot be matched to the platform's top-level window enumeration, preventing valid macOS snapshots from failing structured output validation.
8
+ - Lockstep patch release with the ReMCP client at `0.2.52`.
9
+
10
+ ## 0.2.51 — 2026-09-20
11
+
12
+ - Linux AT-SPI text/focus operations reject explicit false results, `type_text method=accessibility` remains accessibility-only, and semantic browser text failures direct callers to `browser_action` rather than reporting an unverified paste as success.
13
+ - GNOME Wayland drag requires the consent-backed Remote Desktop portal instead of unreliable XTEST/xdotool delivery.
14
+ - PDF `read_document` falls back from the built-in parser to local `pdftotext` when embedded fonts prevent direct decoding, while preserving the existing no-text error when neither path extracts content.
15
+ - Lockstep patch release with the ReMCP client at `0.2.51`.
16
+
5
17
  ## 0.2.50 — 2026-09-20
6
18
 
7
19
  - Lockstep release with the ReMCP client after the hosted MCP/plugin contract was hardened for current OpenAI/MCP Apps requirements.
package/README.md CHANGED
@@ -63,7 +63,7 @@ For frontend QA, use semantic data to operate the page and rendered pixels to ve
63
63
  `maxReadLines`, `maxBufferedLines`, and `maxOutputBytes`. Access roots, blocked commands,
64
64
  the command guardrail, shell, write limit, runtime name, and unrestricted mode stay with the person
65
65
  at the computer. DOCX/XLSX edits operate directly on OOXML, while structural PDF writes use existing
66
- system tools such as qpdf/poppler when present; ReMCP still does not bundle Chromium, Puppeteer,
66
+ system tools such as qpdf/poppler when present. PDF text reading uses the built-in parser first and can fall back to local `pdftotext` for embedded-font PDFs; ReMCP still does not bundle Chromium, Puppeteer,
67
67
  `sharp`, or `exceljs`.
68
68
 
69
69
  `--print-tools` prints the exact JSON contract (schemas and annotations) the runtime advertises, and
@@ -76,7 +76,7 @@ and the user's request is the authorization. ReMCP adds no per-tool approval pro
76
76
  and no dry-run detour unless you ask for one.
77
77
 
78
78
  Operating-system security boundaries still apply. macOS can require Accessibility/Screen Recording grants,
79
- and GNOME Wayland requires a one-time XDG RemoteDesktop consent before low-level keyboard/pointer input.
79
+ and GNOME Wayland requires a one-time XDG RemoteDesktop consent before low-level keyboard/pointer/drag input. XTEST/xdotool drag is deliberately not treated as reliable on GNOME Wayland.
80
80
  ReMCP does not bypass those controls: after GNOME grants access, the portal restore token is stored locally
81
81
  under the runtime config directory with mode `0600` and reused when the portal permits it. Semantic AT-SPI
82
82
  UI actions and the XDG Screenshot portal remain separate from that low-level input permission.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
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",
@@ -36,10 +36,10 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
36
36
  ui_snapshot: 'Use this to inspect native desktop Accessibility/UI Automation semantics for the active app before clicking or typing. Prefer scope=active; use scope=desktop only for cross-application discovery. For a Chromium web page with CDP available, prefer browser_snapshot for page DOM semantics.',
37
37
  ui_find: 'Use this after ui_snapshot when you need a small set of native UI elements by role/name/label/AutomationId. It requires an element criterion; use ui_snapshot, not an empty ui_find, to enumerate UI. Prefer returned id or compact label for subsequent ui_action; refresh only when the UI has changed.',
38
38
  ui_action: 'Use this to invoke, focus, edit, select, toggle, expand/collapse, range-edit or scroll a native accessible UI element. Prefer this over pointer clicks because semantic actions survive layout changes; fall back to pointer only when accessibility cannot perform the requested action.',
39
- type_text: 'Use this for reliable Unicode text entry into a native semantic target or focused control. Prefer method=auto so Accessibility or clipboard restoration preserves the requested characters regardless of keyboard layout. Use method=keys only for deliberate physical key-by-key input; printable output then follows the current keyboard layout on the computer. Use keyboard for shortcuts, navigation keys and control keys.',
39
+ type_text: 'Use this for reliable Unicode text entry into a native semantic target or focused native control. Prefer method=auto so Accessibility is used when the target supports verified semantic editing. For Chromium page DOM, use browser_action instead of forcing desktop accessibility text input; auto fails safely when a semantic element rejects the write rather than claiming an unverified paste. Use method=keys only for deliberate physical key-by-key input, and keyboard for shortcuts/navigation/control keys.',
40
40
  keyboard: 'Use this for shortcuts and individual key presses such as Ctrl/Cmd+C, Tab, Enter or Escape. Do not use it to enter normal prose when type_text is available. On Wayland auto uses an already-authorized portal; backend=portal explicitly requests consent.',
41
41
  pointer: 'Use this only when an action truly requires screen coordinates or a semantic UI/browser action is unavailable. Prefer ui_action for native controls and browser_action for web controls; pointer is the coordinate fallback for move/click/button-down/button-up.',
42
- drag_drop: 'Use this for a real drag gesture between two UI element ids or coordinates. Prefer element ids from ui_find/ui_snapshot because their current bounds are resolved at execution time; use coordinates only when no semantic endpoints exist.',
42
+ drag_drop: 'Use this for a real drag gesture between two UI element ids or coordinates. Prefer element ids from ui_find/ui_snapshot because their current bounds are resolved at execution time; use coordinates only when no semantic endpoints exist. On GNOME Wayland, real drag input requires the consent-backed Remote Desktop portal because XTEST/xdotool drag delivery is not reliable.',
43
43
  scroll: 'Use this for native wheel scrolling of a desktop region or semantic UI target. Prefer browser_action scroll_into_view for a known web element and ui_action scroll_into_view for a known accessible desktop element.',
44
44
  wait_for_ui: 'Use this after a desktop action to wait for an explicit native UI state or condition instead of sleeping or polling screenshots. Presence/absence waits need a semantic target; use state=changed for a broad tree change and set a bounded timeout.',
45
45
  clipboard: 'Use this for explicit text clipboard read/write/clear operations. Do not use clipboard as a typing workaround directly; type_text already uses safe clipboard paste with restoration when needed.',
@@ -63,7 +63,7 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
63
63
  audio: 'Use this to read or change the default output volume/mute state. Use action=status for inspection; set_volume requires a 0–100 value.',
64
64
  power_action: 'Use this only when the user explicitly asks to lock, sleep, restart or shut down the computer. It is intentionally guarded by device command policy because restart/shutdown can interrupt work.',
65
65
  record_screen: 'Use this for a short bounded screen recording when motion/timing is necessary to diagnose a UI issue. Prefer screenshot_region for static visual verification because it is cheaper and easier to inspect.',
66
- read_document: 'Use this to read structured content from PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON or XML without opening a desktop app. Prefer it over shell extraction or launching Office when the goal is document content.',
66
+ read_document: 'Use this to read structured content from PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON or XML without opening a desktop app. Prefer it over shell extraction or launching Office when the goal is document content. PDF reading uses the built-in parser first and may fall back to local pdftotext when embedded fonts prevent direct decoding.',
67
67
  edit_spreadsheet: 'Use this for direct XLSX cell/range/formula edits without launching Excel. Prefer explicit cells/ranges and write to output when preserving the original matters.',
68
68
  edit_document: 'Use this for structured DOCX paragraph edits without launching Word. It is best for replace/insert/delete paragraph operations; use native UI automation only when document formatting/layout interaction is required.',
69
69
  pdf_action: 'Use this for PDF metadata/annotations inspection or structural merge/split/page extraction. For reading PDF text use read_document; supply an output/output_dir for write actions when preserving originals matters.',
@@ -164,6 +164,7 @@ const outputString = description => ({ type:'string', ...(description ? { descri
164
164
  const outputNumber = description => ({ type:'number', ...(description ? { description } : {}) });
165
165
  const outputBoolean = description => ({ type:'boolean', ...(description ? { description } : {}) });
166
166
  const outputObject = description => ({ type:'object', additionalProperties:true, ...(description ? { description } : {}) });
167
+ const outputNullableObject = description => ({ type:['object','null'], additionalProperties:true, ...(description ? { description } : {}) });
167
168
  const outputRecord = (properties = {}, description) => ({ type:'object', properties, additionalProperties:true, ...(description ? { description } : {}) });
168
169
  const outputArray = (items = {}, description) => ({ type:'array', items, ...(description ? { description } : {}) });
169
170
 
@@ -172,7 +173,7 @@ const OUTPUT_FIELDS = Object.freeze({
172
173
  device:outputObject('Device/platform metadata for the captured computer state.'),
173
174
  windows:outputArray(outputObject(), 'Top-level windows with stable ids/PIDs/bounds when available.'),
174
175
  displays:outputArray(outputObject(), 'Display inventory used for virtual-desktop geometry.'),
175
- active_window:outputObject('Active top-level window when known.'),
176
+ active_window:outputNullableObject('Active top-level window when known; null when accessibility state cannot be joined to top-level window enumeration.'),
176
177
  ui:outputObject('Native accessibility snapshot and semantic tree.'),
177
178
  cursor:outputObject('Pointer position when available.'),
178
179
  clipboard:outputObject('Clipboard metadata only unless clipboard content was explicitly requested.'),
@@ -537,10 +538,10 @@ export const extendedToolDefinitions = [
537
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']),
538
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']),
539
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']),
540
- define('type_text', 'Type text', 'Enter Unicode text into a semantic accessibility target or focused control. Auto prefers accessibility editing, then reliable clipboard paste with previous clipboard restoration; keys remains available for control-character input.', 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']),
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']),
541
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']),
542
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']),
543
- 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 the center of their current accessibility bounds before the native pointer adapter runs.', 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']),
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']),
544
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']),
545
546
  define('wait_for_ui', 'Wait for UI', 'Wait on fresh native accessibility state without screenshot polling. Use state=present/absent/changed or condition=text_exists/active_window/element_exists/element_enabled/focused_element.', o({ state:e(['present','absent','changed'], 'changed captures the first matching semantic state and waits until it differs.'), condition:e(['text_exists','active_window','element_exists','element_enabled','focused_element']), text:s('Text used by text_exists or active_window.'), browser_dom:b('Use native browser document accessibility where supported.'), ...uiSelector, timeout_ms:n('Maximum wait, capped at 120000 ms.'), poll_ms:n('Polling interval, 50..5000 ms.') }), readOnlyLive, desktopHandlers.wait_for_ui, ['ui']),
546
547
  define('clipboard', 'Clipboard', 'Read, write, or clear the system text clipboard using native operating-system clipboard facilities.', o({ action:e(['read','write','clear']), text:s('Text to write.') }, ['action']), mutating, desktopHandlers.clipboard, ['clipboard']),
@@ -586,10 +586,14 @@ if target is None:raise Exception("UI element not found")
586
586
  if action in ("click","invoke","select","toggle"):
587
587
  target.queryAction().doAction(0)
588
588
  elif action=="focus":
589
- target.queryComponent().grabFocus()
589
+ if target.queryComponent().grabFocus() is False: raise Exception("focus accessibility action returned false")
590
590
  elif action=="set_value":
591
- try: target.queryEditableText().setTextContents(value)
592
- except: target.queryValue().currentValue=value
591
+ try:
592
+ applied=target.queryEditableText().setTextContents(value)
593
+ if applied is False: raise Exception("editable text write returned false")
594
+ except Exception as editable_error:
595
+ try: target.queryValue().currentValue=value
596
+ except Exception as value_error: raise Exception("set_value failed: editable=%s; value=%s"%(editable_error,value_error))
593
597
  elif action=="set_range_value":
594
598
  target.queryValue().currentValue=float(value)
595
599
  elif action in ("expand","collapse"):
@@ -620,7 +624,10 @@ print(json.dumps({"action":action,"name":target.name or "","role":target.getRole
620
624
  try { payload = JSON.parse(result.stdout || ''); } catch {}
621
625
  if (payload && !payload.error) return text(JSON.stringify(payload));
622
626
  if (payload?.error) unavailable('Linux accessibility action', payload.error);
623
- if (result.code !== 0) unavailable('Linux accessibility action', (result.stderr || result.stdout).trim());
627
+ if (result.code !== 0) {
628
+ const detail = String(result.stderr || result.stdout || 'AT-SPI action failed').trim().split(/\r?\n/).filter(Boolean).at(-1) || 'AT-SPI action failed';
629
+ unavailable('Linux accessibility action', detail);
630
+ }
624
631
  return text(result.stdout.trim());
625
632
  }
626
633
 
@@ -961,9 +968,11 @@ export async function dragDrop(args = {}) {
961
968
  return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdg-desktop-portal' });
962
969
  } catch {}
963
970
  }
964
- if (backend !== 'portal' && await runXdotool()) return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xwayland-xdotool' });
971
+ if (backend === 'x11') {
972
+ unavailable('Wayland drag and drop', 'XTEST/xdotool drag events are not reliably delivered through GNOME Wayland; use backend=portal');
973
+ }
965
974
  if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland drag and drop');
966
- unavailable('Wayland drag and drop', backend === 'x11' ? 'xdotool is unavailable' : 'no working drag backend is available');
975
+ unavailable('Wayland drag and drop', 'no working consent-backed drag backend is available');
967
976
  }
968
977
  if (await runXdotool()) return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdotool' });
969
978
  unavailable('Drag and drop', 'install xdotool');
@@ -567,24 +567,37 @@ export async function typeText(args = {}) {
567
567
  const value = String(args.text ?? '');
568
568
  const method = requireEnum(args.method || 'auto', 'method', ['auto','accessibility','clipboard','keys']);
569
569
  const resolvedArgs = args.label != null ? withResolvedUiLabel(args) : args;
570
- const hasSelector = Boolean(
570
+ const hasElementSelector = Boolean(
571
571
  optionalString(resolvedArgs.id) || optionalString(resolvedArgs.name) || optionalString(resolvedArgs.role) ||
572
- optionalString(resolvedArgs.automation_id || resolvedArgs.automationId) || optionalString(resolvedArgs.app) ||
573
- optionalString(resolvedArgs.window_title || resolvedArgs.windowTitle) || Number.isInteger(Number(resolvedArgs.pid))
572
+ optionalString(resolvedArgs.automation_id || resolvedArgs.automationId)
574
573
  );
575
574
  const clear = args.clear === true;
576
575
  const pressEnter = args.press_enter === true;
577
576
  const caret = requireEnum(args.caret_position || 'idle', 'caret_position', ['start','idle','end']);
578
577
 
579
- if ((method === 'auto' || method === 'accessibility') && hasSelector) {
580
- const result = await uiAction({ ...resolvedArgs, action:'set_value', value });
581
- const raw = result.content?.[0]?.text || '';
582
- let target = raw;
583
- try { target = JSON.parse(raw); } catch {}
584
- if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
585
- return jsonResult({ length:value.length, method:'accessibility', target, clear:true, press_enter:pressEnter });
578
+ if ((method === 'auto' || method === 'accessibility') && hasElementSelector) {
579
+ try {
580
+ const result = await uiAction({ ...resolvedArgs, action:'set_value', value });
581
+ const raw = result.content?.[0]?.text || '';
582
+ let target = raw;
583
+ try { target = JSON.parse(raw); } catch {}
584
+ if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
585
+ return jsonResult({ length:value.length, method:'accessibility', target, clear:true, press_enter:pressEnter });
586
+ } catch (error) {
587
+ if (method === 'accessibility') throw error;
588
+ if (!clear && caret === 'idle' && typeof adapter.typeTextFocused === 'function') {
589
+ await uiAction({ ...resolvedArgs, action:'focus' });
590
+ const semantic = await adapter.typeTextFocused(value, resolvedArgs);
591
+ if (semantic) {
592
+ if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
593
+ return jsonResult({ length:value.length, method:'accessibility', target:semantic, press_enter:pressEnter });
594
+ }
595
+ }
596
+ const detail = error instanceof Error ? error.message : String(error);
597
+ throw new Error(`Semantic text target rejected accessibility input. Refresh the UI target or use browser_action for browser page content. ${detail}`);
598
+ }
586
599
  }
587
- if ((method === 'auto' || method === 'accessibility') && typeof adapter.typeTextFocused === 'function' && !clear && caret === 'idle') {
600
+ if ((method === 'auto' || method === 'accessibility') && !hasElementSelector && typeof adapter.typeTextFocused === 'function' && !clear && caret === 'idle') {
588
601
  const semantic = await adapter.typeTextFocused(value, resolvedArgs);
589
602
  if (semantic) {
590
603
  if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
@@ -592,8 +605,11 @@ export async function typeText(args = {}) {
592
605
  }
593
606
  if (method === 'accessibility') throw new Error('No focused editable accessibility element is available');
594
607
  }
608
+ if (method === 'accessibility') {
609
+ throw new Error('Accessibility-only text input requires a semantic element target for replacement, or a focused editable control with clear=false and caret_position=idle');
610
+ }
595
611
 
596
- if (hasSelector) await uiAction({ ...resolvedArgs, action:'focus' }).catch(() => null);
612
+ if (hasElementSelector) await uiAction({ ...resolvedArgs, action:'focus' });
597
613
  if (clear) {
598
614
  await keyboard({ ...resolvedArgs, shortcut:process.platform === 'darwin' ? 'CMD+A' : 'CTRL+A' });
599
615
  await keyboard({ ...resolvedArgs, key:'BACKSPACE' });
@@ -127,12 +127,29 @@ async function readXlsx(filePath, args) {
127
127
  } finally { await removeTemp(dir); }
128
128
  }
129
129
 
130
+ async function readPdfDocument(filePath, data) {
131
+ try {
132
+ return readPdfText(data);
133
+ } catch (builtinError) {
134
+ if (commandExists('pdftotext')) {
135
+ const extracted = await runFile('pdftotext', ['-enc','UTF-8',filePath,'-'], {
136
+ label:'PDF text extraction',
137
+ timeout:60_000,
138
+ allowFailure:true,
139
+ });
140
+ const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
141
+ if (extracted.code === 0 && fallbackText) return fallbackText;
142
+ }
143
+ throw builtinError;
144
+ }
145
+ }
146
+
130
147
  export async function readDocument(args) {
131
148
  const filePath = await resolveSafePath(args.path,'path');
132
149
  const lower = filePath.toLowerCase();
133
150
  const data = await readFile(filePath);
134
151
  if (lower.endsWith('.docx')) return text(readDocxText(data));
135
- if (lower.endsWith('.pdf')) return text(readPdfText(data));
152
+ if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
136
153
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
137
154
  if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
138
155
  throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');