@remcp/runtime 0.2.57 → 0.2.59

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,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.59 — 2026-09-20
6
+
7
+ - Tool descriptions now make routing boundaries explicit across overlapping filesystem, search, process, system, desktop, diagnostics, and document operations so AI hosts prefer the narrowest semantic tool instead of broad shell or fallback tools.
8
+ - Wayland absolute pointer delivery uses the EIS helper path and avoids stale cached-position assumptions when falling back, improving deterministic desktop input on GNOME/Wayland.
9
+ - Lockstep patch release with the ReMCP client at `0.2.59`.
10
+
11
+ ## 0.2.58 — 2026-09-20
12
+
13
+ - `computer_snapshot` normalizes partial platform-capture failures into schema-stable collections/objects and bounded `{ source, message }` errors instead of leaking error strings into structured output.
14
+ - Output-budget pruning preserves the structured snapshot error contract, and browser snapshot fallbacks reject invalid/error-shaped payloads.
15
+ - Dynamic browser/CDP capability groups now toggle registrations in one batch and emit one `tools/list_changed` notification per transition, preventing concurrent stdio notification writes from exceeding Node's listener limit.
16
+ - Lockstep patch release with the ReMCP client at `0.2.58`.
17
+
5
18
  ## 0.2.57 — 2026-09-20
6
19
 
7
20
  - ZIP extraction recognizes Info-ZIP `unzip` on Debian/Ubuntu by probing it with `-v`, fixing the false “unzip is not installed” error reproduced during physical 76-tool package verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.57",
3
+ "version": "0.2.59",
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",
package/src/catalog.mjs CHANGED
@@ -109,7 +109,7 @@ export const toolDefinitions = [
109
109
  {
110
110
  name: 'read_files',
111
111
  title: 'Read files by glob',
112
- description: 'Read files discovered by one glob under a directory, each section prefixed with its path and line count. Use this when the exact file list is not already known. If you already know the exact paths, use read_multiple_files; for one known path use read_file.',
112
+ description: 'Read the contents of files discovered by one glob under a directory, each section prefixed with its path and line count. Use this when the matched file contents are the result you want. Use start_search instead to find filenames or text matches without reading every matched file. If you already know the exact paths, use read_multiple_files; for one known path use read_file.',
113
113
  inputSchema: {
114
114
  type: 'object',
115
115
  properties: {
@@ -142,7 +142,7 @@ export const toolDefinitions = [
142
142
  {
143
143
  name: 'read_image',
144
144
  title: 'Read image',
145
- description: 'Return an image file (PNG, JPEG, GIF, WebP, BMP, AVIF, or SVG) as a viewable image, so screenshots and diagrams can be inspected. Fails above the inline size limit.',
145
+ description: 'Return an image file (PNG, JPEG, GIF, WebP, BMP, AVIF, or SVG) as native MCP image content so screenshots, photos, and diagrams can be inspected or shown. Use read_binary only for byte-for-byte transfer, not merely to display an image. Fails above the inline size limit.',
146
146
  inputSchema: {
147
147
  type: 'object',
148
148
  properties: {
@@ -157,7 +157,7 @@ export const toolDefinitions = [
157
157
  {
158
158
  name: 'read_binary',
159
159
  title: 'Read binary chunk',
160
- description: 'Read any file as base64, in chunks, for transferring binaries, images, archives, or documents off the computer. Returns size, offset, and nextOffsetBytes; call again with offset_bytes set to nextOffsetBytes until complete is true.',
160
+ description: 'Read any file as base64 chunks for byte-for-byte transfer of binaries, archives, documents, or oversized media. For a normal image that should be inspected or shown, use read_image instead. Returns size, offset, and nextOffsetBytes; call again with offset_bytes set to nextOffsetBytes until complete is true.',
161
161
  inputSchema: {
162
162
  type: 'object',
163
163
  properties: {
@@ -239,7 +239,7 @@ export const toolDefinitions = [
239
239
  {
240
240
  name: 'write_file',
241
241
  title: 'Write file',
242
- description: 'Create or replace exactly one text file. Parent directories are created automatically; mode "append" adds to the end. Use write_files for two or more independent files, and write_binary for binary bytes.',
242
+ description: 'Create or replace the complete contents of exactly one text file. Parent directories are created automatically; mode "append" adds to the end. For a small exact-block edit use edit_block, for a known line range use replace_lines, and when you already have a unified diff use apply_patch. Use write_files for two or more independent complete files, and write_binary for binary bytes.',
243
243
  inputSchema: {
244
244
  type: 'object',
245
245
  properties: {
@@ -256,7 +256,7 @@ export const toolDefinitions = [
256
256
  {
257
257
  name: 'write_files',
258
258
  title: 'Write multiple files',
259
- description: 'Create or replace two or more text files in one batch, each with its own path, content, and optional mode. Use this for scaffolding or coherent multi-file writes; for one path use write_file.',
259
+ description: 'Create or replace the complete contents of two or more text files in one batch, each with its own path, content, and optional mode. Use this for scaffolding or coherent multi-file writes; for one path use write_file. For partial edits use edit_block, replace_lines, replace_in_files, or apply_patch instead of rewriting whole files.',
260
260
  inputSchema: {
261
261
  type: 'object',
262
262
  properties: {
@@ -284,7 +284,7 @@ export const toolDefinitions = [
284
284
  {
285
285
  name: 'apply_patch',
286
286
  title: 'Apply patch',
287
- description: 'Apply a unified diff to one file or to several files at once, matching each hunk with a little fuzz so small offsets and whitespace differences still apply. This is the fastest way to land a multi-line change a model has already worked out. Pass dry_run to see the result as a diff first.',
287
+ description: 'Apply an already-prepared unified diff to one file or several files, matching each hunk with a little fuzz so small offsets and whitespace differences still apply. Prefer this when the change is naturally patch-shaped; use edit_block for one exact text block and write_file only when replacing a complete file. Pass dry_run to see the result first.',
288
288
  inputSchema: {
289
289
  type: 'object',
290
290
  properties: {
@@ -320,7 +320,7 @@ export const toolDefinitions = [
320
320
  {
321
321
  name: 'edit_block',
322
322
  title: 'Edit file',
323
- description: 'Replace an exact block of text in a file. Provide enough surrounding context to make old_string unique; the call fails unless the number of matches equals expected_replacements. When the exact text is not found, a whitespace-tolerant match is attempted and reported. Pass dry_run to preview the change as a diff without writing.',
323
+ description: 'Replace one known text block in one file. Provide enough surrounding context to make old_string unique; the call fails unless the number of matches equals expected_replacements. Use write_file for a whole-file replacement and replace_lines for a known line range. When exact text is not found, a whitespace-tolerant match is attempted and reported. Pass dry_run to preview without writing.',
324
324
  inputSchema: {
325
325
  type: 'object',
326
326
  properties: {
@@ -340,7 +340,7 @@ export const toolDefinitions = [
340
340
  {
341
341
  name: 'replace_lines',
342
342
  title: 'Replace lines',
343
- description: 'Replace an inclusive 1-based line range with new text. The rest of the file, including its line endings, is preserved. Pass dry_run to preview the change as a diff without writing.',
343
+ description: 'Replace an inclusive 1-based line range with new text. Use this when line numbers define the target; use edit_block when matching existing text and write_file for a complete rewrite. The rest of the file, including its line endings, is preserved. Pass dry_run to preview without writing.',
344
344
  inputSchema: {
345
345
  type: 'object',
346
346
  properties: {
@@ -359,7 +359,7 @@ export const toolDefinitions = [
359
359
  {
360
360
  name: 'replace_in_files',
361
361
  title: 'Replace in files',
362
- description: 'Replace text or a regular expression across the text files under a path and report what changed. Applies immediately; pass dry_run true to preview the affected files first.',
362
+ description: 'Search and replace the same text or regular expression across multiple text files under a path and report what changed. Use edit_block for one known block in one file and write_file for one complete known file. Applies immediately; pass dry_run true to preview the affected files first.',
363
363
  inputSchema: {
364
364
  type: 'object',
365
365
  properties: {
@@ -598,7 +598,7 @@ export const toolDefinitions = [
598
598
  {
599
599
  name: 'start_search',
600
600
  title: 'Start search',
601
- description: 'Start a filename or content search on this computer and return the first results. Content searches return "path:line: text" rows. Use get_more_search_results to page and stop_search to stop a long search.',
601
+ description: 'Start a filename or content search on this computer and return the first matches without reading every matched file in full. Use read_files instead when a glob is already known and the contents of all matching files are the desired result. Content searches return "path:line: text" rows. Use get_more_search_results to page and stop_search to stop a long search.',
602
602
  inputSchema: {
603
603
  type: 'object',
604
604
  properties: {
@@ -662,7 +662,7 @@ export const toolDefinitions = [
662
662
  {
663
663
  name: 'start_process',
664
664
  title: 'Start process',
665
- description: 'Run a shell command on this computer and return its initial output. The process keeps running so read_process_output or interact_with_process can be used later. Commands can change local or external state.',
665
+ description: 'Run a shell command, script, build, test suite, or terminal server and return its initial output. Prefer dedicated semantic tools for jobs they already cover: launch_app for GUI apps, service for service-manager operations, power_action for lock/sleep/restart/shutdown, network for routine network inspection/tests, event_log for OS logs, installed_apps for software inventory, environment for environment facts, and open_path/reveal_path for opening or revealing files. The process keeps running so read_process_output or interact_with_process can be used later.',
666
666
  inputSchema: {
667
667
  type: 'object',
668
668
  properties: {
@@ -736,7 +736,7 @@ export const toolDefinitions = [
736
736
  {
737
737
  name: 'force_terminate',
738
738
  title: 'Stop session',
739
- description: 'Stop a session started with start_process, escalating from SIGTERM to SIGKILL when it does not exit.',
739
+ description: 'Stop a ReMCP terminal session started with start_process, escalating from SIGTERM to SIGKILL when it does not exit. Use kill_process for an arbitrary operating-system PID that is not a ReMCP terminal session.',
740
740
  inputSchema: {
741
741
  type: 'object',
742
742
  properties: {
@@ -752,7 +752,7 @@ export const toolDefinitions = [
752
752
  {
753
753
  name: 'list_sessions',
754
754
  title: 'List sessions',
755
- description: 'List terminal sessions started during this ReMCP runtime session with their status and how long they have been running.',
755
+ description: 'List only terminal sessions started through ReMCP start_process, with status and runtime. Use list_processes for the computer-wide operating-system process list.',
756
756
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
757
757
  annotations: readOnly,
758
758
  outputSchema: LIST_SESSIONS_OUTPUT_SCHEMA,
@@ -761,7 +761,7 @@ export const toolDefinitions = [
761
761
  {
762
762
  name: 'get_system_info',
763
763
  title: 'Get system info',
764
- description: 'Report host details for the paired computer: operating system and kernel, CPU model and load, memory pressure, free disk space on the working volume, uptime, and the default shell.',
764
+ description: 'Report machine health and host facts for the paired computer: operating system and kernel, CPU model/load, memory pressure, free disk space, uptime, and default shell. Use environment for PATH/shell/runtime environment variables and get_runtime_info for ReMCP-specific version, policy, roots, and limits.',
765
765
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
766
766
  annotations: readOnly,
767
767
  handler: systemToolHandlers.get_system_info,
@@ -769,7 +769,7 @@ export const toolDefinitions = [
769
769
  {
770
770
  name: 'list_processes',
771
771
  title: 'List processes',
772
- description: 'List running operating-system processes on this computer, highest CPU first, with pid, parent pid, CPU and memory usage, and command. Values that look like secrets are masked.',
772
+ description: 'List the computer-wide operating-system processes, highest CPU first, with pid, parent pid, CPU/memory usage, and command. Use list_sessions when the question is only about commands started through ReMCP start_process. Values that look like secrets are masked.',
773
773
  inputSchema: {
774
774
  type: 'object',
775
775
  properties: {
@@ -783,7 +783,7 @@ export const toolDefinitions = [
783
783
  {
784
784
  name: 'kill_process',
785
785
  title: 'Kill process',
786
- description: 'Terminate an operating-system process by pid. Terminates the process and its children on Windows.',
786
+ description: 'Terminate an arbitrary operating-system process by pid. If the pid belongs to a ReMCP terminal session created by start_process, prefer force_terminate so ReMCP updates the session consistently and handles its process tree. Terminates the process and its children on Windows.',
787
787
  inputSchema: {
788
788
  type: 'object',
789
789
  properties: {
@@ -798,7 +798,7 @@ export const toolDefinitions = [
798
798
  {
799
799
  name: 'get_runtime_info',
800
800
  title: 'Get runtime info',
801
- description: 'Report this device runtime: version, allowed roots, command policy, output limits, settable preferences, and whether usage metrics are enabled. Read-only; use set_config_value only for telemetryEnabled, maxReadLines, maxBufferedLines, or maxOutputBytes. Access roots and command security stay local to the computer.',
801
+ description: 'Report this computer\'s local ReMCP runtime: version, allowed roots, command policy, output limits, settable preferences, and usage-metric setting. Use get_system_info for OS/CPU/memory/disk facts and hosted get_configuration for deployment/account configuration. Read-only; use set_config_value only for telemetryEnabled, maxReadLines, maxBufferedLines, or maxOutputBytes. Access roots and command security stay local to the computer.',
802
802
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
803
803
  annotations: readOnly,
804
804
  handler: statsToolHandlers.get_runtime_info,
@@ -806,7 +806,7 @@ export const toolDefinitions = [
806
806
  {
807
807
  name: 'get_runtime_stats',
808
808
  title: 'Get runtime stats',
809
- description: 'Report local counters for this runtime session: tool calls and failures, blocked commands, active terminal and search sessions, and usage-metric queue state.',
809
+ description: 'Report counters from the current local runtime session on this machine: tool calls/failures, blocked commands, active terminal/search sessions, and usage-metric queue state. Use hosted get_usage_statistics for account-level historical usage aggregated across devices and days.',
810
810
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
811
811
  annotations: readOnly,
812
812
  handler: statsToolHandlers.get_runtime_stats,
@@ -30,9 +30,9 @@ const o = (properties = {}, required = []) => ({ type: 'object', properties, ...
30
30
  const AI_TOOL_DESCRIPTIONS = Object.freeze({
31
31
  computer_snapshot: 'Use this for the first look at an unfamiliar desktop state when you need one compact bundle of windows, semantic UI, displays, clipboard metadata and optional pixels/browser/OCR. Prefer it over repeated screenshots; narrow later with ui_find, browser_find or screenshot_region.',
32
32
  computer_action: 'Use this only when one semantic click/edit/focus/selection action must work across an uncertain target backend and may need Accessibility → browser DOM/CDP → OCR → coordinate fallback, or when multi_select/multi_edit avoids many identical native UI calls. Do not use it when the backend or operation is already known: use ui_action for native controls, browser_action for page DOM, type_text for prose, keyboard for shortcuts, pointer for raw pointer gestures, drag_drop or scroll for those gestures, window_action for windows, and the dedicated clipboard/launch/open/reveal/notification tools for those jobs.',
33
- list_windows: 'Use this to discover top-level desktop windows or obtain a stable window id/PID/bounds before window_action, screenshot_region or targeted input. Prefer it over screenshot-based window guessing.',
34
- window_action: 'Use this to focus, minimize, maximize, restore, move, resize or close one known top-level window. Select it by id/PID/app/title from list_windows; do not use pointer coordinates for window management when this semantic tool can express the action.',
35
- launch_app: 'Use this to start a desktop application by executable/path or friendly application name without a shell. Prefer it over start_process for GUI application launch; use start_process for terminal commands, build tools and shell pipelines.',
33
+ list_windows: 'Use this to discover top-level desktop windows or obtain a stable window id/PID/bounds before window_action, screenshot_region or targeted input. Use ui_snapshot instead when you need controls inside a window; prefer list_windows over screenshot-based window guessing.',
34
+ window_action: 'Use this to focus, minimize, maximize, restore, move, resize or close one known top-level window. Select it by id/PID/app/title from list_windows. Use ui_action for controls inside the window; do not use pointer coordinates for window management when this semantic tool can express the action.',
35
+ launch_app: 'Use this to start a desktop application by executable/path or friendly application name without a shell. Prefer it over start_process for GUI application launch. If the user wants to open a specific file or directory in its associated application, use open_path instead; use start_process for terminal commands, build tools and shell pipelines.',
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.',
@@ -44,8 +44,8 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
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.',
46
46
  display_inventory: 'Use this before monitor-specific screenshots, cross-monitor geometry or coordinate work to obtain monitor bounds, scale and primary-display information.',
47
- screenshot_region: 'Use this when pixels are required for visual verification but only one window, monitor or rectangle matters. Prefer this over a full-desktop screenshot; use ui_snapshot/browser_snapshot first when semantic structure is sufficient.',
48
- open_path: 'Use this to open a permitted file or directory in its normal desktop application. Prefer it over shell-specific open/start/xdg-open commands because ReMCP applies filesystem confinement.',
47
+ screenshot_region: 'Use this when static pixels are required for visual verification but only one window, monitor or rectangle matters. Prefer it over take_screenshot for a full desktop and over record_screen when motion/timing is not required; use ui_snapshot/browser_snapshot first when semantic structure is sufficient.',
48
+ open_path: 'Use this to open a permitted file or directory in its normal desktop application. Use launch_app when the task is to start an application itself without a particular file. Prefer open_path over shell-specific open/start/xdg-open commands because ReMCP applies filesystem confinement.',
49
49
  reveal_path: 'Use this to reveal a permitted local file or directory in Finder/Explorer/file manager without opening the document itself.',
50
50
  notification: 'Use this only when the user wants a visible native desktop notification on the paired computer. Do not use it as a substitute for replying in chat.',
51
51
  browser_tabs: 'Use this first for CDP browser automation when the target page/tab is not already unambiguous. It lists debuggable Chromium pages from a loopback-only endpoint; use the returned target_id/title/url to scope later browser tools.',
@@ -59,11 +59,11 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
59
59
  event_log: 'Use this to read bounded recent OS logs (Windows Event Log, macOS unified log or systemd journal) when diagnosing a service/app/system problem. Prefer it over an unbounded shell log dump.',
60
60
  network: 'Use this for structured interfaces/DNS/routes/listeners inspection or a bounded TCP connectivity test. Prefer it over shell ip/netstat/nslookup commands for routine diagnostics.',
61
61
  installed_apps: 'Use this to answer what software/packages are installed and their versions. Prefer a filter when looking for one product instead of returning the full inventory.',
62
- environment: 'Use this for platform/runtime/path/shell facts and only opt into sanitized environment variables when the task truly needs them. Secret-looking values remain masked.',
62
+ environment: 'Use this for PATH, shell, Node/runtime environment paths, architecture and optionally sanitized environment variables. Use get_system_info for host health such as CPU/memory/disk/uptime, and get_runtime_info for ReMCP policy, roots and limits. Secret-looking values remain masked.',
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
- 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.',
64
+ power_action: 'Use this only when the user explicitly asks to lock, sleep, restart or shut down the computer. Prefer it over invoking those system actions through start_process; 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. PDF reading uses the built-in parser first and may fall back to local pdftotext when embedded fonts prevent direct decoding.',
66
+ read_document: 'Use this for structured document content, especially PDF, DOCX and XLSX, and for CSV/JSON/XML when document-style parsing is useful. Use read_file for ordinary source code, logs, configuration, Markdown, or other plain line-oriented text. Prefer read_document 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.',
@@ -3,7 +3,7 @@ import process from 'node:process';
3
3
  import { readFile, stat } from 'node:fs/promises';
4
4
  import { image, multi, text } from '../util.mjs';
5
5
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
6
- import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
6
+ import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalPointerMotionAbsolute, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
7
7
  import {
8
8
  clamp,
9
9
  commandExists,
@@ -863,9 +863,18 @@ async function portalMoveTo(x, y, options = {}) {
863
863
  const targetX = Math.trunc(Number(x));
864
864
  const targetY = Math.trunc(Number(y));
865
865
  if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) throw new Error('x and y must be finite coordinates');
866
- if (options.recalibrate || !portalPointerState) await calibratePortalPointer(options);
867
866
  const motionOptions = { ...options };
868
867
  delete motionOptions.recalibrate;
868
+ try {
869
+ await portalPointerMotionAbsolute(targetX, targetY, motionOptions);
870
+ portalPointerState = { x:targetX, y:targetY };
871
+ return;
872
+ } catch (error) {
873
+ if (!/requires the EIS Remote Desktop backend/i.test(String(error?.message || error))) throw error;
874
+ }
875
+ // Legacy portal fallback: relative motion has no absolute position feedback,
876
+ // so recalibrate on every move instead of trusting cached coordinates.
877
+ await calibratePortalPointer(motionOptions);
869
878
  await portalPointerMotion(targetX - portalPointerState.x, targetY - portalPointerState.y, motionOptions);
870
879
  portalPointerState = { x:targetX, y:targetY };
871
880
  }
@@ -137,35 +137,78 @@ function resultValue(result) {
137
137
  try { return JSON.parse(raw); } catch { return raw; }
138
138
  }
139
139
 
140
+ function objectRecord(value) {
141
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
142
+ }
143
+
144
+ function toolResultMessage(result) {
145
+ const text = result?.content?.find(part => part.type === 'text')?.text;
146
+ return String(text || 'tool returned an error result').slice(0, 2000);
147
+ }
148
+
149
+ function snapshotError(source, settled) {
150
+ if (settled?.status === 'rejected') {
151
+ return { source, message:String(settled.reason?.message || settled.reason || 'capture rejected').slice(0, 2000) };
152
+ }
153
+ if (settled?.status === 'fulfilled' && settled.value?.isError === true) {
154
+ return { source, message:toolResultMessage(settled.value) };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function normalizeSnapshotErrors(errors) {
160
+ if (!Array.isArray(errors)) return [];
161
+ return errors.slice(0, 50).map((error, index) => {
162
+ if (objectRecord(error)) {
163
+ return {
164
+ ...error,
165
+ source:String(error.source || 'snapshot').slice(0, 120),
166
+ message:String(error.message || error.error || 'snapshot capture error').slice(0, 2000),
167
+ };
168
+ }
169
+ return { source:'snapshot', message:String(error ?? `snapshot error ${index + 1}`).slice(0, 2000) };
170
+ });
171
+ }
172
+
140
173
  function jsonBytes(value) {
141
174
  return Buffer.byteLength(JSON.stringify(value), 'utf8');
142
175
  }
143
176
 
144
177
  export function boundedComputerSnapshot(payload) {
178
+ const normalized = {
179
+ ...payload,
180
+ active_window:objectRecord(payload?.active_window),
181
+ windows:Array.isArray(payload?.windows) ? payload.windows : [],
182
+ displays:Array.isArray(payload?.displays) ? payload.displays : [],
183
+ cursor:objectRecord(payload?.cursor) || { x:null, y:null },
184
+ ui:objectRecord(payload?.ui) || { platform:process.platform, count:0, nodes:[] },
185
+ clipboard:objectRecord(payload?.clipboard) || { available:false, length:0 },
186
+ errors:normalizeSnapshotErrors(payload?.errors),
187
+ };
145
188
  const budget = Math.min(768 * 1024, Math.max(16 * 1024, Math.floor(Number(liveConfig('maxOutputBytes') || 0) * 0.35)));
146
- const originalBytes = jsonBytes(payload);
189
+ const originalBytes = jsonBytes(normalized);
147
190
  if (originalBytes <= budget) {
148
- return { ...payload, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
191
+ return { ...normalized, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
149
192
  }
150
193
 
151
194
  const bounded = {
152
- ...payload,
153
- windows:Array.isArray(payload.windows) ? [...payload.windows] : payload.windows,
154
- errors:Array.isArray(payload.errors) ? payload.errors.map(value => String(value).slice(0, 1000)).slice(0, 50) : payload.errors,
155
- ui:payload.ui && typeof payload.ui === 'object' ? {
156
- ...payload.ui,
157
- ...(Array.isArray(payload.ui.nodes) ? { nodes:[...payload.ui.nodes] } : {}),
158
- } : payload.ui,
159
- browser:payload.browser && typeof payload.browser === 'object' ? {
160
- ...payload.browser,
161
- ...(Array.isArray(payload.browser.nodes) ? { nodes:[...payload.browser.nodes] } : {}),
162
- } : payload.browser,
163
- ocr:payload.ocr && typeof payload.ocr === 'object' ? {
164
- ...payload.ocr,
165
- text:String(payload.ocr.text || '').slice(0, 50_000),
166
- ...(Array.isArray(payload.ocr.words) ? { words:[...payload.ocr.words] } : {}),
167
- ...(Array.isArray(payload.ocr.lines) ? { lines:[...payload.ocr.lines] } : {}),
168
- } : payload.ocr,
195
+ ...normalized,
196
+ windows:[...normalized.windows],
197
+ errors:normalizeSnapshotErrors(normalized.errors),
198
+ ui:{
199
+ ...normalized.ui,
200
+ ...(Array.isArray(normalized.ui.nodes) ? { nodes:[...normalized.ui.nodes] } : {}),
201
+ },
202
+ browser:objectRecord(normalized.browser) ? {
203
+ ...normalized.browser,
204
+ ...(Array.isArray(normalized.browser.nodes) ? { nodes:[...normalized.browser.nodes] } : {}),
205
+ } : undefined,
206
+ ocr:objectRecord(normalized.ocr) ? {
207
+ ...normalized.ocr,
208
+ text:String(normalized.ocr.text || '').slice(0, 50_000),
209
+ ...(Array.isArray(normalized.ocr.words) ? { words:[...normalized.ocr.words] } : {}),
210
+ ...(Array.isArray(normalized.ocr.lines) ? { lines:[...normalized.ocr.lines] } : {}),
211
+ } : undefined,
169
212
  snapshot_original_bytes:originalBytes,
170
213
  snapshot_budget_bytes:budget,
171
214
  snapshot_truncated:true,
@@ -939,15 +982,33 @@ export async function computerSnapshot(args = {}) {
939
982
  cursorPosition(),
940
983
  captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
941
984
  ]);
942
- const parse = result => {
943
- if (result.status !== 'fulfilled' || !result.value) return null;
944
- const raw = result.value.content?.[0]?.text;
945
- if (typeof raw !== 'string') return null;
946
- try { return JSON.parse(raw); } catch { return raw; }
985
+ const parse = (result, fallback = null) => {
986
+ if (result.status !== 'fulfilled' || !result.value || result.value.isError === true) return fallback;
987
+ return resultValue(result.value) ?? fallback;
947
988
  };
948
- const clipText = clipboardResult.status === 'fulfilled' ? clipboardResult.value.content?.[0]?.text || '' : '';
949
- const windows = parse(windowsResult);
950
- const ui = parse(uiResult);
989
+ const shapeErrors = [];
990
+ const parsedWindows = parse(windowsResult, []);
991
+ const windows = Array.isArray(parsedWindows) ? parsedWindows : [];
992
+ if (parsedWindows !== windows && parsedWindows != null) {
993
+ shapeErrors.push({ source:'windows', message:'window inventory returned an invalid result shape' });
994
+ }
995
+ const parsedDisplays = parse(displaysResult, []);
996
+ const displays = Array.isArray(parsedDisplays) ? parsedDisplays : [];
997
+ if (parsedDisplays !== displays && parsedDisplays != null) {
998
+ shapeErrors.push({ source:'displays', message:'display inventory returned an invalid result shape' });
999
+ }
1000
+ const parsedCursor = parse(cursorResult, { x:null, y:null });
1001
+ const cursor = objectRecord(parsedCursor) || { x:null, y:null };
1002
+ if (parsedCursor !== cursor && parsedCursor != null) {
1003
+ shapeErrors.push({ source:'cursor', message:'cursor position returned an invalid result shape' });
1004
+ }
1005
+ const parsedUi = includeUi ? parse(uiResult, null) : null;
1006
+ const ui = objectRecord(parsedUi) || { platform:process.platform, count:0, nodes:[], ...(includeUi ? { unavailable:true } : { skipped:true }) };
1007
+ if (includeUi && parsedUi !== ui && parsedUi != null) {
1008
+ shapeErrors.push({ source:'ui', message:'accessibility snapshot returned an invalid result shape' });
1009
+ }
1010
+ const clipboardOk = clipboardResult.status === 'fulfilled' && clipboardResult.value?.isError !== true;
1011
+ const clipText = clipboardOk ? clipboardResult.value.content?.[0]?.text || '' : '';
951
1012
  const uiNodes = Array.isArray(ui?.nodes) ? ui.nodes : Array.isArray(ui) ? ui : [];
952
1013
  const focusedNodes = uiNodes.filter(node => node.focused === true);
953
1014
  const focusScore = node => {
@@ -1002,18 +1063,24 @@ export async function computerSnapshot(args = {}) {
1002
1063
  if (browserRequested && await browserCapabilityAvailable(undefined, 400)) {
1003
1064
  const hint = browserTitleHint(activeWindow);
1004
1065
  try {
1005
- browser = resultValue(await browserSnapshot({
1066
+ const browserResult = await browserSnapshot({
1006
1067
  ...(hint ? { title:hint } : {}),
1007
1068
  max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
1008
1069
  timeout_ms:3000,
1009
- }));
1070
+ });
1071
+ if (browserResult?.isError === true) throw new Error(toolResultMessage(browserResult));
1072
+ browser = objectRecord(resultValue(browserResult));
1073
+ if (!browser) throw new Error('browser snapshot returned an invalid result shape');
1010
1074
  } catch (error) {
1011
1075
  if (hint) {
1012
1076
  try {
1013
- browser = resultValue(await browserSnapshot({
1077
+ const fallbackResult = await browserSnapshot({
1014
1078
  max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
1015
1079
  timeout_ms:3000,
1016
- }));
1080
+ });
1081
+ if (fallbackResult?.isError === true) throw new Error(toolResultMessage(fallbackResult));
1082
+ browser = objectRecord(resultValue(fallbackResult));
1083
+ if (!browser) throw new Error('browser snapshot returned an invalid result shape');
1017
1084
  } catch (fallbackError) {
1018
1085
  browserError = String(fallbackError?.message || fallbackError);
1019
1086
  }
@@ -1058,8 +1125,8 @@ export async function computerSnapshot(args = {}) {
1058
1125
  active_app: activeApp,
1059
1126
  active_window: activeWindow,
1060
1127
  windows,
1061
- displays: parse(displaysResult),
1062
- cursor: parse(cursorResult),
1128
+ displays,
1129
+ cursor,
1063
1130
  ui,
1064
1131
  ...(browser ? { browser } : {}),
1065
1132
  ...(ocr ? { ocr } : {}),
@@ -1098,12 +1165,18 @@ export async function computerSnapshot(args = {}) {
1098
1165
  : (args.screenshot_monitor != null || args.screenshot_monitor_index != null) ? 'monitor'
1099
1166
  : 'desktop',
1100
1167
  },
1101
- clipboard: { available:clipboardResult.status === 'fulfilled', length:clipText.length },
1168
+ clipboard: { available:clipboardOk, length:clipText.length },
1102
1169
  errors: [
1103
- ...[windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult]
1104
- .filter(result => result.status === 'rejected')
1105
- .map(result => String(result.reason?.message || result.reason)),
1106
- ...(browserError ? [`browser snapshot: ${browserError}`] : []),
1170
+ ...[
1171
+ ['windows', windowsResult],
1172
+ ['displays', displaysResult],
1173
+ ...(includeUi ? [['ui', uiResult]] : []),
1174
+ ['clipboard', clipboardResult],
1175
+ ['cursor', cursorResult],
1176
+ ...(captureScreenshot ? [['screenshot', shotResult]] : []),
1177
+ ].map(([source, result]) => snapshotError(source, result)).filter(Boolean),
1178
+ ...shapeErrors,
1179
+ ...(browserError ? [{ source:'browser', message:browserError.slice(0, 2000) }] : []),
1107
1180
  ],
1108
1181
  };
1109
1182
  const boundedPayload = boundedComputerSnapshot(payload);
@@ -214,10 +214,13 @@ class EiSender:
214
214
  self.lib.ei_device_unref.restype = c_void_p
215
215
  self.lib.ei_device_has_capability.argtypes = [c_void_p, ctypes.c_int]
216
216
  self.lib.ei_device_has_capability.restype = ctypes.c_bool
217
+ self.lib.ei_device_get_region_at.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
218
+ self.lib.ei_device_get_region_at.restype = c_void_p
217
219
  self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
218
220
  self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
219
221
  self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
220
222
  self.lib.ei_device_pointer_motion.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
223
+ self.lib.ei_device_pointer_motion_absolute.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
221
224
  self.lib.ei_device_button_button.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
222
225
  self.lib.ei_device_scroll_discrete.argtypes = [c_void_p, ctypes.c_int32, ctypes.c_int32]
223
226
  self.lib.ei_device_frame.argtypes = [c_void_p, ctypes.c_uint64]
@@ -243,6 +246,8 @@ class EiSender:
243
246
  self.devices = {}
244
247
  self.sequence = 1
245
248
  self.connected = False
249
+ self.pointer_device_key = None
250
+ self.pointer_position = None
246
251
 
247
252
  def _device_key(self, ptr):
248
253
  return int(ptr or 0)
@@ -259,6 +264,9 @@ class EiSender:
259
264
  def _drop_device(self, ptr):
260
265
  key = self._device_key(ptr)
261
266
  state = self.devices.pop(key, None)
267
+ if key == self.pointer_device_key:
268
+ self.pointer_device_key = None
269
+ self.pointer_position = None
262
270
  if state:
263
271
  if state["emulating"]:
264
272
  try:
@@ -349,6 +357,46 @@ class EiSender:
349
357
  return state["ptr"]
350
358
  raise RuntimeError(f"no resumed EIS device with capability {cap}")
351
359
 
360
+ def device_for_absolute(self, x, y):
361
+ candidates = []
362
+ for state in self.devices.values():
363
+ if not state["resumed"] or not state["emulating"]:
364
+ continue
365
+ device = state["ptr"]
366
+ if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
367
+ continue
368
+ candidates.append(device)
369
+ if self.lib.ei_device_get_region_at(device, float(x), float(y)):
370
+ return device
371
+ if candidates:
372
+ raise RuntimeError(f"no absolute-pointer EIS region contains desktop coordinate ({x}, {y})")
373
+ raise RuntimeError("no resumed EIS device with absolute-pointer capability")
374
+
375
+ def device_for_pointer_context(self, cap):
376
+ state = self.devices.get(self.pointer_device_key)
377
+ if (
378
+ state
379
+ and state["resumed"]
380
+ and state["emulating"]
381
+ and self.lib.ei_device_has_capability(state["ptr"], cap)
382
+ ):
383
+ return state["ptr"]
384
+ if self.pointer_position is not None:
385
+ x, y = self.pointer_position
386
+ for candidate in self.devices.values():
387
+ if not candidate["resumed"] or not candidate["emulating"]:
388
+ continue
389
+ device = candidate["ptr"]
390
+ if not self.lib.ei_device_has_capability(device, cap):
391
+ continue
392
+ if self.lib.ei_device_get_region_at(device, float(x), float(y)):
393
+ return device
394
+ return self.device_for(cap)
395
+
396
+ def remember_pointer(self, device, position=None):
397
+ self.pointer_device_key = self._device_key(device)
398
+ self.pointer_position = position
399
+
352
400
  def frame(self, device):
353
401
  self.lib.ei_device_frame(device, self.lib.ei_now(self.ei))
354
402
 
@@ -361,13 +409,21 @@ class EiSender:
361
409
  elif op == "motion":
362
410
  device = self.device_for(EI_CAP_POINTER)
363
411
  self.lib.ei_device_pointer_motion(device, float(payload.get("dx", 0)), float(payload.get("dy", 0)))
412
+ self.remember_pointer(device)
413
+ self.frame(device)
414
+ elif op == "motion_absolute":
415
+ x = float(payload["x"])
416
+ y = float(payload["y"])
417
+ device = self.device_for_absolute(x, y)
418
+ self.lib.ei_device_pointer_motion_absolute(device, x, y)
419
+ self.remember_pointer(device, (x, y))
364
420
  self.frame(device)
365
421
  elif op == "button":
366
- device = self.device_for(EI_CAP_BUTTON)
422
+ device = self.device_for_pointer_context(EI_CAP_BUTTON)
367
423
  self.lib.ei_device_button_button(device, int(payload["button"]), bool(payload.get("pressed")))
368
424
  self.frame(device)
369
425
  elif op == "scroll":
370
- device = self.device_for(EI_CAP_SCROLL)
426
+ device = self.device_for_pointer_context(EI_CAP_SCROLL)
371
427
  self.lib.ei_device_scroll_discrete(device, int(payload.get("dx", 0)), int(payload.get("dy", 0)))
372
428
  self.frame(device)
373
429
  elif op == "ping":
package/src/mcp-v2.mjs CHANGED
@@ -64,15 +64,23 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
64
64
  if (closed) return;
65
65
  try {
66
66
  const next = new Set((await advertisedExtendedTools()).map(tool => tool.name));
67
+ let changed = false;
67
68
  for (const definition of extendedToolDefinitions) {
68
69
  const before = lastSupported.has(definition.name);
69
70
  const after = next.has(definition.name);
70
71
  if (before === after) continue;
71
72
  const registered = registrations.get(definition.name);
72
- if (after) registered?.enable();
73
- else registered?.disable();
73
+ if (!registered) continue;
74
+ // The SDK's enable()/disable() helper emits tools/list_changed immediately. Browser CDP
75
+ // appears as a seven-tool capability group, so calling those helpers in a loop creates
76
+ // seven concurrent stdio writes and can exceed Node's listener limit. Mutating the
77
+ // registration handle is otherwise identical to update({enabled}), then one notification
78
+ // below publishes the complete new toolset atomically.
79
+ registered.enabled = after;
80
+ changed = true;
74
81
  }
75
82
  lastSupported = next;
83
+ if (changed) server.sendToolListChanged();
76
84
  } catch (error) {
77
85
  onError(error instanceof Error ? error : new Error(String(error)));
78
86
  }
@@ -590,6 +590,15 @@ export async function portalPointerMotion(dx, dy, options = {}) {
590
590
  await state.remote.NotifyPointerMotion(state.session, {}, Number(dx), Number(dy));
591
591
  }
592
592
 
593
+ export async function portalPointerMotionAbsolute(x, y, options = {}) {
594
+ const state = options.state || await ensureWaylandRemoteDesktop(options);
595
+ if (state.backend === 'xdg-eis') {
596
+ await state.send({ op:'motion_absolute', x:Number(x), y:Number(y) });
597
+ return;
598
+ }
599
+ throw new ToolError('Absolute pointer motion requires the EIS Remote Desktop backend');
600
+ }
601
+
593
602
  export async function portalPointerButton(button, pressed, options = {}) {
594
603
  const state = options.state || await ensureWaylandRemoteDesktop(options);
595
604
  const code = String(button || 'left').toLowerCase() === 'right' ? 0x111 : String(button || 'left').toLowerCase() === 'middle' ? 0x112 : 0x110;