@remcp/runtime 0.2.48 → 0.2.50

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,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.50 — 2026-09-20
6
+
7
+ - Lockstep release with the ReMCP client after the hosted MCP/plugin contract was hardened for current OpenAI/MCP Apps requirements.
8
+ - Computer-use tools retain the 83-tool runtime surface with AI-oriented descriptions, conditional JSON Schema validation, structured outputs, dynamic capability advertising, and semantic Accessibility/DOM-first routing.
9
+ - `browser_navigate` adds `action=new_tab` so CDP automation can create the first page target without a shell/browser-address-bar workaround.
10
+ - Live Linux verification covers Tesseract OCR targeting, XDG RemoteDesktop keyboard/pointer input, Unicode `type_text`, and the complete 39-tool computer-use smoke.
11
+
12
+ ## 0.2.49 — 2026-09-20
13
+
14
+ - Terminal process tools now return schema-validated structured session state, output ranges, match results and termination facts alongside their human-readable text.
15
+ - The runtime catalog and hosted tool contract align on bounded structured outputs and accurate read-only/destructive/idempotent/open-world annotations for the expanded computer-use surface.
16
+ - Preview references remain optional presentation metadata rather than replacing the source result used for model reasoning.
17
+ - Lockstep patch release with the ReMCP client at `0.2.49`.
18
+
5
19
  ## 0.2.48 — 2026-09-20
6
20
 
7
21
  - Migrate the local stdio runtime to the official `@modelcontextprotocol/server` v2 serving entry while preserving legacy 2025-era client compatibility.
@@ -13,6 +27,10 @@
13
27
  - Added high-level `computer_snapshot` and `computer_action` primitives so agents can prefer semantic Accessibility/DOM targets and fall back to coordinates/screenshots only when necessary.
14
28
  - Chrome DevTools Protocol endpoints are restricted to loopback addresses. New filesystem/document operations continue through the existing resolved-path allowlist, and launch/service/power operations reuse the runtime command policy.
15
29
  - Fixed metadata output truncation once the JSON tool contract exceeded the operating-system pipe buffer by waiting for stdout writes to flush before exit.
30
+ - Terminal session tools now expose schema-validated structured `pid`, status, exit information, output ranges/matches, and session lists alongside their human-readable text, so hosted agents can chain process calls without parsing prose.
31
+ - Tool descriptions now state the intended routing boundary for exact-path vs glob reads, singular vs batch filesystem operations, full-screen vs targeted/semantic screenshots, and local runtime settings vs hosted device labels.
32
+ - Desktop action schemas and common guards now reject targetless `ui_action`/`window_action`, coordinate-less pointer moves, zero-motion scrolls, incomplete cross-backend `computer_action` targets, and connectivity tests without host/port before platform adapters can act. Empty `ui_find`/`browser_find` calls are redirected to their snapshot tools, and `wait_for_ui` requires an explicit state/condition instead of silently acting as a no-op.
33
+ - `take_screenshot` is correctly advertised as non-read-only/non-idempotent because kept or oversized captures can persist timestamped files; selector-scoped `browser_snapshot` screenshots restore the original page scroll position before returning so the inspection path remains read-only.
16
34
  - No browser/document rendering stack was added; the runtime remains dependency-light and uses native OS facilities plus existing qpdf/poppler/zip helpers when available.
17
35
 
18
36
  ## 0.2.47 — 2026-09-19
package/README.md CHANGED
@@ -49,9 +49,11 @@ support and emits `notifications/tools/list_changed` when that capability set ch
49
49
  | Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` |
50
50
  | Documents | `read_document`, `edit_spreadsheet`, `edit_document`, `pdf_action` |
51
51
 
52
- The hosted ReMCP endpoint adds eight account/relay tools — `list_devices`, `ping_device`,
52
+ The hosted ReMCP endpoint adds eleven account/presentation tools — `list_devices`, `ping_device`,
53
53
  `who_am_i`, `create_pairing_command`, `get_recent_tool_calls`, `get_usage_statistics`,
54
- `get_configuration`, and `rename_device` — for a 91-tool release contract.
54
+ `get_configuration`, `render_file_preview`, `render_image_preview`, `render_terminal_preview`, and
55
+ `rename_device` — for a 94-tool MCP Apps catalog. Plain MCP clients receive 91 tools because the
56
+ three presentation-only render tools are omitted unless the UI extension is negotiated.
55
57
 
56
58
  `computer_snapshot` and `computer_action` keep OCR inside the compact high-level surface: when a local `tesseract` binary is available, snapshot can return bounded OCR text/boxes and click targeting can fall back through Accessibility → browser DOM/CDP → OCR → coordinates. OCR is optional and never adds another MCP tool or bundled OCR dependency.
57
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
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
@@ -16,6 +16,72 @@ export const TEXT_OUTPUT_SCHEMA = {
16
16
  additionalProperties: false,
17
17
  };
18
18
 
19
+ const NULLABLE_NUMBER_SCHEMA = { anyOf:[{ type:'number' }, { type:'null' }] };
20
+ const NULLABLE_STRING_SCHEMA = { anyOf:[{ type:'string' }, { type:'null' }] };
21
+ const TERMINAL_SESSION_PROPERTIES = {
22
+ pid:{ type:'number', description:'Process/session id used by follow-up terminal tools.' },
23
+ status:{ type:'string', description:'running or exited, with an exit code/signal when known.' },
24
+ runtimeMs:{ type:'number' },
25
+ lines:{ type:'number', description:'Total output lines produced by the session.' },
26
+ exited:{ type:'boolean' },
27
+ exitCode:NULLABLE_NUMBER_SCHEMA,
28
+ signal:NULLABLE_STRING_SCHEMA,
29
+ };
30
+ function terminalOutputSchema(extraProperties = {}, extraRequired = []) {
31
+ return {
32
+ type:'object',
33
+ properties:{ ...TERMINAL_SESSION_PROPERTIES, ...extraProperties },
34
+ required:['pid','status','runtimeMs','lines','exited','exitCode','signal', ...extraRequired],
35
+ additionalProperties:false,
36
+ };
37
+ }
38
+ const START_PROCESS_OUTPUT_SCHEMA = terminalOutputSchema({
39
+ command:{ type:'string' },
40
+ output:{ type:'string' },
41
+ partial:{ type:'string' },
42
+ warning:{ type:'string' },
43
+ }, ['command','output']);
44
+ const READ_PROCESS_OUTPUT_SCHEMA = terminalOutputSchema({
45
+ range:{ type:'string' },
46
+ output:{ type:'string' },
47
+ explicitOffset:{ type:'boolean' },
48
+ }, ['range','output','explicitOffset']);
49
+ const WAIT_PROCESS_OUTPUT_SCHEMA = terminalOutputSchema({
50
+ pattern:{ type:'string' },
51
+ matched:{ type:'boolean' },
52
+ bufferedMatch:{ type:'boolean' },
53
+ timeoutMs:{ type:'number' },
54
+ output:{ type:'string' },
55
+ }, ['pattern','matched','bufferedMatch','timeoutMs','output']);
56
+ const INTERACT_PROCESS_OUTPUT_SCHEMA = terminalOutputSchema({
57
+ output:{ type:'string' },
58
+ }, ['output']);
59
+ const TERMINATE_PROCESS_OUTPUT_SCHEMA = terminalOutputSchema({
60
+ terminated:{ type:'boolean' },
61
+ alreadyExited:{ type:'boolean' },
62
+ escalated:{ type:'boolean' },
63
+ }, ['terminated','alreadyExited','escalated']);
64
+ const LIST_SESSIONS_OUTPUT_SCHEMA = {
65
+ type:'object',
66
+ properties:{
67
+ sessions:{
68
+ type:'array',
69
+ items:{
70
+ type:'object',
71
+ properties:{
72
+ ...TERMINAL_SESSION_PROPERTIES,
73
+ blocked:{ type:'string' },
74
+ command:{ type:'string' },
75
+ },
76
+ required:['pid','status','runtimeMs','lines','exited','exitCode','signal','blocked','command'],
77
+ additionalProperties:false,
78
+ },
79
+ },
80
+ },
81
+ required:['sessions'],
82
+ additionalProperties:false,
83
+ };
84
+
19
85
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
20
86
  const readOnlyNonIdempotent = { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false };
21
87
  const additive = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -43,7 +109,7 @@ export const toolDefinitions = [
43
109
  {
44
110
  name: 'read_files',
45
111
  title: 'Read files by glob',
46
- description: 'Read every file matching a glob under a directory in one call, each section prefixed with its path and line count. Use this to load a whole project area into context quickly instead of one read per file.',
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.',
47
113
  inputSchema: {
48
114
  type: 'object',
49
115
  properties: {
@@ -61,7 +127,7 @@ export const toolDefinitions = [
61
127
  {
62
128
  name: 'read_multiple_files',
63
129
  title: 'Read multiple files',
64
- description: 'Read several text files in one call. Each file is returned separately and a failure to read one file does not stop the others.',
130
+ description: 'Read several explicitly named text files in one call. Use this when the exact paths are already known; each file is returned separately and one failure does not stop the others. Do not use this for glob discovery: use read_files instead. For one path use read_file.',
65
131
  inputSchema: {
66
132
  type: 'object',
67
133
  properties: {
@@ -173,7 +239,7 @@ export const toolDefinitions = [
173
239
  {
174
240
  name: 'write_file',
175
241
  title: 'Write file',
176
- description: 'Create a file or change its full content. Parent directories are created automatically. Replaces the file by default; use mode "append" to add to the end. For binary data pass encoding-free base64 through write_binary instead.',
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.',
177
243
  inputSchema: {
178
244
  type: 'object',
179
245
  properties: {
@@ -190,7 +256,7 @@ export const toolDefinitions = [
190
256
  {
191
257
  name: 'write_files',
192
258
  title: 'Write multiple files',
193
- description: 'Create or replace many files in one call, each with its own path, content, and optional mode. Use this to scaffold a project or apply a multi-file change without one round trip per file.',
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.',
194
260
  inputSchema: {
195
261
  type: 'object',
196
262
  properties: {
@@ -361,7 +427,7 @@ export const toolDefinitions = [
361
427
  {
362
428
  name: 'move_file',
363
429
  title: 'Move or rename',
364
- description: 'Move or rename a file or directory. Replaces an existing destination file by default; pass overwrite false to refuse instead.',
430
+ description: 'Move or rename exactly one file or directory from one source to one destination. Replaces an existing destination file by default; pass overwrite false to refuse instead. Use move_paths for two or more moves.',
365
431
  inputSchema: {
366
432
  type: 'object',
367
433
  properties: {
@@ -378,7 +444,7 @@ export const toolDefinitions = [
378
444
  {
379
445
  name: 'copy_file',
380
446
  title: 'Copy file',
381
- description: 'Copy one file to a new path, replacing the destination by default. Pass overwrite false to refuse an existing destination. Directories are not copied recursively.',
447
+ description: 'Copy exactly one regular file to a new path. Directories are not copied recursively. Use copy_paths for directories or two or more copy operations; pass overwrite false to refuse an existing destination.',
382
448
  inputSchema: {
383
449
  type: 'object',
384
450
  properties: {
@@ -395,7 +461,7 @@ export const toolDefinitions = [
395
461
  {
396
462
  name: 'copy_paths',
397
463
  title: 'Copy paths',
398
- description: 'Copy many files or whole directories in one call, each with its own source and destination. Directories are copied recursively.',
464
+ description: 'Copy two or more paths, or copy a directory recursively, in one call. Each item has its own source and destination. For exactly one regular file use copy_file.',
399
465
  inputSchema: {
400
466
  type: 'object',
401
467
  properties: {
@@ -423,7 +489,7 @@ export const toolDefinitions = [
423
489
  {
424
490
  name: 'move_paths',
425
491
  title: 'Move paths',
426
- description: 'Move or rename many files or whole directories in one call, each with its own source and destination. Falls back to copy-and-delete across filesystems.',
492
+ description: 'Move or rename two or more files/directories in one batch, each with its own source and destination. Falls back to copy-and-delete across filesystems. For one source/destination pair use move_file.',
427
493
  inputSchema: {
428
494
  type: 'object',
429
495
  properties: {
@@ -451,7 +517,7 @@ export const toolDefinitions = [
451
517
  {
452
518
  name: 'delete_path',
453
519
  title: 'Delete path',
454
- description: 'Delete a file or a directory on the computer. Directories are removed with their contents unless recursive is false. The filesystem root is refused.',
520
+ description: 'Delete exactly one file or directory. Directories are removed with their contents unless recursive is false; the filesystem root is refused. Use delete_paths for two or more targets, or move_to_trash when deletion should be reversible.',
455
521
  inputSchema: {
456
522
  type: 'object',
457
523
  properties: {
@@ -467,7 +533,7 @@ export const toolDefinitions = [
467
533
  {
468
534
  name: 'delete_paths',
469
535
  title: 'Delete paths',
470
- description: 'Delete many files and directories in one call, reporting each result. Use move_to_trash instead when the deletion should be reversible.',
536
+ description: 'Delete two or more files/directories in one batch and report each result. For one target use delete_path. Use move_to_trash instead when deletion should be reversible.',
471
537
  inputSchema: {
472
538
  type: 'object',
473
539
  properties: {
@@ -516,7 +582,7 @@ export const toolDefinitions = [
516
582
  {
517
583
  name: 'take_screenshot',
518
584
  title: 'Take screenshot',
519
- description: 'Capture the screen of the paired computer and return it as an image, for GUI work, visual checks, and demonstrating what is on screen. Uses the standard desktop screenshot portal on Wayland, with grim, gnome-screenshot, spectacle, scrot, ImageMagick import, screencapture, or PowerShell fallbacks depending on the platform.',
585
+ description: 'Capture the entire desktop only when full-screen pixels are actually needed. Prefer ui_snapshot or browser_snapshot for semantic inspection, and screenshot_region for one window, monitor, or rectangle. This tool uses the platform screenshot portal/utility and returns an image.',
520
586
  inputSchema: {
521
587
  type: 'object',
522
588
  properties: {
@@ -525,7 +591,8 @@ export const toolDefinitions = [
525
591
  },
526
592
  additionalProperties: false,
527
593
  },
528
- annotations: readOnly,
594
+ // keep=true and oversized fallbacks persist a timestamped PNG, so a repeated call can add files.
595
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
529
596
  handler: fileToolHandlers.take_screenshot,
530
597
  },
531
598
  {
@@ -606,6 +673,7 @@ export const toolDefinitions = [
606
673
  additionalProperties: false,
607
674
  },
608
675
  annotations: command,
676
+ outputSchema: START_PROCESS_OUTPUT_SCHEMA,
609
677
  handler: terminalToolHandlers.start_process,
610
678
  },
611
679
  {
@@ -626,6 +694,7 @@ export const toolDefinitions = [
626
694
  // Reading without an explicit offset advances the session cursor, so retries can return
627
695
  // different output even after the process exits. It never modifies user files.
628
696
  annotations: readOnlyNonIdempotent,
697
+ outputSchema: READ_PROCESS_OUTPUT_SCHEMA,
629
698
  handler: terminalToolHandlers.read_process_output,
630
699
  },
631
700
  {
@@ -643,6 +712,7 @@ export const toolDefinitions = [
643
712
  additionalProperties: false,
644
713
  },
645
714
  annotations: command,
715
+ outputSchema: INTERACT_PROCESS_OUTPUT_SCHEMA,
646
716
  handler: terminalToolHandlers.interact_with_process,
647
717
  },
648
718
  {
@@ -660,6 +730,7 @@ export const toolDefinitions = [
660
730
  additionalProperties: false,
661
731
  },
662
732
  annotations: readOnlyNonIdempotent,
733
+ outputSchema: WAIT_PROCESS_OUTPUT_SCHEMA,
663
734
  handler: terminalToolHandlers.wait_for_process_output,
664
735
  },
665
736
  {
@@ -675,6 +746,7 @@ export const toolDefinitions = [
675
746
  additionalProperties: false,
676
747
  },
677
748
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
749
+ outputSchema: TERMINATE_PROCESS_OUTPUT_SCHEMA,
678
750
  handler: terminalToolHandlers.force_terminate,
679
751
  },
680
752
  {
@@ -683,6 +755,7 @@ export const toolDefinitions = [
683
755
  description: 'List terminal sessions started during this ReMCP runtime session with their status and how long they have been running.',
684
756
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
685
757
  annotations: readOnly,
758
+ outputSchema: LIST_SESSIONS_OUTPUT_SCHEMA,
686
759
  handler: terminalToolHandlers.list_sessions,
687
760
  },
688
761
  {
@@ -741,7 +814,7 @@ export const toolDefinitions = [
741
814
  {
742
815
  name: 'set_config_value',
743
816
  title: 'Change runtime setting',
744
- description: 'Change one of this runtime’s own preferences on this computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. The change applies immediately and is saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the write limit cannot be set through MCP — they stay with the person at this computer.',
817
+ description: 'Change one of this local runtime’s own preferences on the selected computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. This does not rename the paired device; use hosted rename_device for that label. Access roots, blocked commands, shell/guardrails and the write limit remain local-only.',
745
818
  inputSchema: {
746
819
  type: 'object',
747
820
  properties: {
@@ -310,10 +310,12 @@ export async function browserSnapshot(args) {
310
310
 
311
311
  await session.send('Page.enable');
312
312
  const selector = optionalString(args.selector);
313
+ let restoreScroll = null;
313
314
  if (selector) {
314
315
  const bounds = await evaluate(session, `new Promise((resolve,reject) => {
315
316
  const el=document.querySelector(${JSON.stringify(selector)});
316
317
  if(!el){reject(new Error('Screenshot element not found'));return;}
318
+ const originalScrollX=scrollX, originalScrollY=scrollY;
317
319
  const initial=el.getBoundingClientRect();
318
320
  const desiredTop=Math.max(0, scrollY + initial.top - Math.max(0,(innerHeight-initial.height)/2));
319
321
  window.scrollTo({top:desiredTop,left:scrollX,behavior:'instant'});
@@ -328,6 +330,8 @@ export async function browserSnapshot(args) {
328
330
  viewport_height:innerHeight,
329
331
  scroll_x:scrollX,
330
332
  scroll_y:scrollY,
333
+ original_scroll_x:originalScrollX,
334
+ original_scroll_y:originalScrollY,
331
335
  fully_visible:r.x>=0 && r.y>=0 && r.right<=innerWidth && r.bottom<=innerHeight
332
336
  });
333
337
  }));
@@ -335,7 +339,9 @@ export async function browserSnapshot(args) {
335
339
  if (!bounds || !Number.isFinite(Number(bounds.width)) || !Number.isFinite(Number(bounds.height)) || bounds.width <= 0 || bounds.height <= 0) {
336
340
  throw new Error('Screenshot element has invalid bounds');
337
341
  }
338
- payload.screenshot_target = { selector, ...bounds };
342
+ restoreScroll = { x:Number(bounds.original_scroll_x || 0), y:Number(bounds.original_scroll_y || 0) };
343
+ const { original_scroll_x: _originalX, original_scroll_y: _originalY, ...reportedBounds } = bounds;
344
+ payload.screenshot_target = { selector, ...reportedBounds };
339
345
  }
340
346
  payload.screenshot = {
341
347
  scope: 'viewport',
@@ -343,11 +349,18 @@ export async function browserSnapshot(args) {
343
349
  height: await evaluate(session, 'innerHeight').catch(() => null),
344
350
  };
345
351
  const result = jsonResult(payload);
346
- const captured = await session.send('Page.captureScreenshot', {
347
- format: 'png',
348
- fromSurface: true,
349
- captureBeyondViewport: false,
350
- });
352
+ let captured;
353
+ try {
354
+ captured = await session.send('Page.captureScreenshot', {
355
+ format: 'png',
356
+ fromSurface: true,
357
+ captureBeyondViewport: false,
358
+ });
359
+ } finally {
360
+ if (restoreScroll) {
361
+ await evaluate(session, `window.scrollTo({left:${restoreScroll.x},top:${restoreScroll.y},behavior:'instant'})`).catch(() => {});
362
+ }
363
+ }
351
364
  const data = String(captured?.data || '');
352
365
  if (!data) throw new Error('CDP returned no screenshot data');
353
366
  if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
@@ -393,6 +406,7 @@ function findExpression(args) {
393
406
  }
394
407
 
395
408
  export async function browserFind(args) {
409
+ if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
396
410
  return withTarget(args, async (session, target) => {
397
411
  const matches = await evaluate(session, findExpression(args));
398
412
  return jsonResult({ target_id: target.id, count: Array.isArray(matches) ? matches.length : 0, matches: Array.isArray(matches) ? matches : [] });
@@ -34,14 +34,14 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
34
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
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.',
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
- ui_find: 'Use this after ui_snapshot when you need a small set of native UI elements by role/name/label/AutomationId. Prefer returned id or compact label for subsequent ui_action; refresh only when the UI has changed.',
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
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.',
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
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.',
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
- wait_for_ui: 'Use this after a desktop action to wait for a native UI element/window/focus/text condition instead of sleeping or polling screenshots. Prefer semantic presence/absence/change conditions and set a bounded timeout.',
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
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.',
@@ -50,8 +50,8 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
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.',
52
52
  browser_navigate: 'Use this to open a URL in a new debuggable tab or navigate a known Chromium page by URL, back, forward or reload. Prefer it over typing into the address bar; new_tab also bootstraps browser automation when no page target exists yet.',
53
- browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed.',
54
- browser_find: 'Use this to locate visible web elements by CSS selector, text or ARIA role and obtain reusable selectors/bounds. Prefer this before browser_action rather than guessing selectors or coordinates.',
53
+ browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed. A selector may be temporarily centered for capture, but the original page scroll position is restored before the tool returns.',
54
+ browser_find: 'Use this to locate visible web elements by CSS selector, text or ARIA role and obtain reusable selectors/bounds. It requires an element criterion; use browser_snapshot, not an empty browser_find, to enumerate page structure. Prefer this before browser_action rather than guessing selectors or coordinates.',
55
55
  browser_action: 'Use this for deterministic DOM/CDP interaction with a known web element: click/focus/type/value/select/upload/key/scroll or viewport emulation. Prefer it over desktop pointer/ui_action for page content; use browser_evaluate only when the supported actions cannot express the task.',
56
56
  browser_wait: 'Use this after browser navigation/action to wait for selector/text/URL/load/navigation/network-idle instead of fixed sleeps or screenshot polling. Use browser_evaluate separately for JavaScript predicates.',
57
57
  browser_evaluate: 'Use this as the powerful browser escape hatch only when browser_snapshot/find/action/wait cannot express the required page operation or inspection. JavaScript executes in page context and page content is untrusted.',
@@ -244,7 +244,93 @@ function requiredAny(...groups) {
244
244
 
245
245
  function applySchemaRules(name, schema) {
246
246
  const next = structuredClone(schema);
247
- if (name === 'launch_app') {
247
+ if (name === 'computer_action') {
248
+ const semanticTarget = requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id'], ['selector'], ['browser_text']);
249
+ next.allOf = [
250
+ {
251
+ if:{ properties:{ action:{ const:'click' } }, required:['action'] },
252
+ then:{ anyOf:[...semanticTarget, { required:['ocr_text'] }, { required:['x','y'] }] },
253
+ },
254
+ {
255
+ if:{ properties:{ action:{ enum:['invoke','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection'] } }, required:['action'] },
256
+ then:{ anyOf:semanticTarget },
257
+ },
258
+ {
259
+ if:{ properties:{ action:{ const:'focus' } }, required:['action'] },
260
+ then:{ anyOf:[...semanticTarget, ...requiredAny(['pid'], ['app'], ['window_title'], ['title'])] },
261
+ },
262
+ {
263
+ if:{ properties:{ action:{ enum:['set_value','set_range_value'] } }, required:['action'] },
264
+ then:{ required:['value'] },
265
+ },
266
+ {
267
+ if:{ properties:{ action:{ const:'multi_select' } }, required:['action'] },
268
+ then:{ required:['targets'] },
269
+ },
270
+ {
271
+ if:{ properties:{ action:{ const:'multi_edit' } }, required:['action'] },
272
+ then:{ required:['edits'] },
273
+ },
274
+ ];
275
+ } else if (name === 'ui_find') {
276
+ next.anyOf = requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']);
277
+ } else if (name === 'ui_action') {
278
+ next.allOf = [
279
+ { anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
280
+ {
281
+ if:{ properties:{ action:{ enum:['set_value','set_range_value'] } }, required:['action'] },
282
+ then:{ required:['value'] },
283
+ },
284
+ ];
285
+ } else if (name === 'wait_for_ui') {
286
+ next.anyOf = requiredAny(['state'], ['condition']);
287
+ next.allOf = [
288
+ {
289
+ if:{ properties:{ condition:{ const:'text_exists' } }, required:['condition'] },
290
+ then:{ required:['text'] },
291
+ },
292
+ {
293
+ if:{ properties:{ condition:{ const:'active_window' } }, required:['condition'] },
294
+ then:{ anyOf:requiredAny(['text'], ['name'], ['window_title']) },
295
+ },
296
+ {
297
+ if:{ properties:{ condition:{ enum:['element_exists','element_enabled'] } }, required:['condition'] },
298
+ then:{ anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
299
+ },
300
+ {
301
+ if:{ properties:{ state:{ enum:['present','absent'] } }, required:['state'] },
302
+ then:{ anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
303
+ },
304
+ ];
305
+ } else if (name === 'window_action') {
306
+ next.allOf = [
307
+ { anyOf:requiredAny(['id'], ['pid'], ['app'], ['title']) },
308
+ {
309
+ if:{ properties:{ action:{ const:'move' } }, required:['action'] },
310
+ then:{ required:['x','y'] },
311
+ },
312
+ {
313
+ if:{ properties:{ action:{ const:'resize' } }, required:['action'] },
314
+ then:{ required:['width','height'] },
315
+ },
316
+ {
317
+ if:{ properties:{ action:{ const:'move_resize' } }, required:['action'] },
318
+ then:{ required:['x','y','width','height'] },
319
+ },
320
+ ];
321
+ } else if (name === 'pointer') {
322
+ next.allOf = [{
323
+ if:{ properties:{ action:{ const:'move' } }, required:['action'] },
324
+ then:{ required:['x','y'] },
325
+ }];
326
+ } else if (name === 'scroll') {
327
+ next.anyOf = requiredAny(['delta_x'], ['delta_y'], ['delta'], ['direction']);
328
+ } else if (name === 'network') {
329
+ next.allOf = [{
330
+ if:{ properties:{ action:{ const:'test' } }, required:['action'] },
331
+ then:{ required:['host','port'] },
332
+ }];
333
+ } else if (name === 'launch_app') {
248
334
  next.anyOf = requiredAny(['app'], ['path']);
249
335
  } else if (name === 'keyboard') {
250
336
  next.anyOf = requiredAny(['shortcut'], ['key'], ['keys']);
@@ -259,6 +345,8 @@ function applySchemaRules(name, schema) {
259
345
  { required:['x','y','width','height'] },
260
346
  ...requiredAny(['window_id'], ['pid'], ['app'], ['title'], ['monitor'], ['monitor_index']),
261
347
  ];
348
+ } else if (name === 'browser_find') {
349
+ next.anyOf = requiredAny(['selector'], ['text'], ['role']);
262
350
  } else if (name === 'browser_navigate') {
263
351
  next.allOf = [{
264
352
  if:{ properties:{ action:{ enum:['url','new_tab'] } }, required:['action'] },
@@ -464,7 +552,7 @@ export const extendedToolDefinitions = [
464
552
 
465
553
  define('browser_tabs', 'Browser tabs', 'List debuggable Chrome, Edge, or Chromium page targets from a loopback-only Chrome DevTools Protocol endpoint.', o({ ...browserTarget }), readOnlyLive, browserHandlers.browser_tabs, ['browser_cdp']),
466
554
  define('browser_navigate', 'Browser navigate', 'Open a new debuggable tab or navigate a selected browser page by URL, history back/forward, or reload through Chrome DevTools Protocol.', o({ ...browserTarget, action:e(['url','new_tab','back','forward','reload'], 'Default url when url is provided, otherwise reload. new_tab creates the first page target when needed.'), url:s('Destination URL for action=url or action=new_tab.'), wait:b('Wait for the page load event; default true.'), ignore_cache:b('Reload without cache when action=reload.') }), openMutatingNonDestructive, browserHandlers.browser_navigate, ['browser_cdp']),
467
- define('browser_snapshot', 'Browser snapshot', 'Return the selected browser page accessibility tree through Chrome DevTools Protocol and, when requested, attach a real rendered viewport PNG for visual verification. A selector recenters the target before capture and returns its exact viewport bounds.', o({ ...browserTarget, max_nodes:n('Maximum AX nodes; default 1500.'), include_screenshot:b('Attach a real CDP-rendered viewport PNG screenshot; default false.'), selector:s('When include_screenshot=true, center this CSS-selected element before capture and report its viewport bounds.') }), readOnlyLive, browserHandlers.browser_snapshot, ['browser_cdp']),
555
+ define('browser_snapshot', 'Browser snapshot', 'Return the selected browser page accessibility tree through Chrome DevTools Protocol and, when requested, attach a real rendered viewport PNG for visual verification. A selector is temporarily recentered for capture, its exact viewport bounds are returned, and the prior scroll position is restored before return.', o({ ...browserTarget, max_nodes:n('Maximum AX nodes; default 1500.'), include_screenshot:b('Attach a real CDP-rendered viewport PNG screenshot; default false.'), selector:s('When include_screenshot=true, center this CSS-selected element before capture and report its viewport bounds.') }), readOnlyLive, browserHandlers.browser_snapshot, ['browser_cdp']),
468
556
  define('browser_find', 'Find browser element', 'Find visible DOM elements by CSS selector, text, or ARIA role and return reusable selectors plus text, value, and bounding boxes.', o({ ...browserTarget, selector:s('CSS selector.'), text:s('Visible text substring.'), role:s('ARIA role.'), limit:n() }), readOnlyLive, browserHandlers.browser_find, ['browser_cdp']),
469
557
  define('browser_action', 'Browser element action', 'Click, focus, type/set a value, select, scroll to, upload files, press a key, or set an exact responsive-test viewport in a debuggable browser page using DOM/CDP semantics.', o({ ...browserTarget, action:e(['click','focus','type','set_value','select','scroll_into_view','upload','press','set_viewport']), selector:s(), text:s(), value:s(), text_value:s(), option:s(), path:s(), paths:{type:'array',items:{type:'string'}}, key:s(), width:n('CSS viewport width for set_viewport.'), height:n('CSS viewport height for set_viewport.'), device_scale_factor:n('Device scale factor for set_viewport; default 1.'), mobile:b('Enable mobile emulation for set_viewport.') }, ['action']), openMutating, browserHandlers.browser_action, ['browser_cdp']),
470
558
  define('browser_wait', 'Wait for browser', 'Wait for DOM state, page text, URL, completed loading, a navigation away from the current URL, or a short network-idle period in a debuggable browser page. Use browser_evaluate separately for JavaScript predicates or page-specific inspection.', o({ ...browserTarget, condition:e(['selector','text','url_contains','load','navigation','network_idle']), selector:s(), text:s(), value:s(), poll_ms:n(), idle_ms:n('Required zero-in-flight network quiet window for network_idle; default 500 ms.') }), readOnlyLive, browserHandlers.browser_wait, ['browser_cdp']),
@@ -273,7 +273,15 @@ export async function listWindows(args = {}) {
273
273
  return { ...window, monitor: monitor?.name ?? null, monitor_index: monitor?.index ?? null };
274
274
  }));
275
275
  }
276
- export async function windowAction(args = {}) { return adapter.windowAction(args); }
276
+ export async function windowAction(args = {}) {
277
+ const hasTarget = Boolean(args.id || Number.isInteger(Number(args.pid)) || args.app || args.title);
278
+ if (!hasTarget) throw new Error('window_action requires id, pid, app, or title');
279
+ const action = optionalString(args.action);
280
+ if (action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('window_action move requires x and y');
281
+ if (action === 'resize' && ![args.width,args.height].every(value => Number.isFinite(Number(value)))) throw new Error('window_action resize requires width and height');
282
+ 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');
283
+ return adapter.windowAction(args);
284
+ }
277
285
  export async function uiSnapshot(args = {}) {
278
286
  const explicitlyScoped = args.pid != null || args.app || args.window_title || args.windowTitle;
279
287
  const activeOnly = args.active_only != null
@@ -285,10 +293,18 @@ export async function uiSnapshot(args = {}) {
285
293
  return jsonResult(enrichUiPayload(value, effective));
286
294
  }
287
295
  export async function uiAction(args = {}) {
288
- return adapter.uiAction(withResolvedUiLabel(args));
296
+ const resolved = withResolvedUiLabel(args);
297
+ const hasTarget = Boolean(resolved.id || resolved.label != null || resolved.name || resolved.role || resolved.automation_id || resolved.automationId);
298
+ if (!hasTarget) throw new Error('ui_action requires id, label, name, role, or automation_id');
299
+ const action = optionalString(resolved.action);
300
+ if (['set_value','set_range_value'].includes(action) && resolved.value == null) throw new Error(`ui_action ${action} requires value`);
301
+ return adapter.uiAction(resolved);
289
302
  }
290
303
  export async function keyboard(args = {}) { return adapter.keyboard(args); }
291
- export async function pointer(args = {}) { return adapter.pointer(args); }
304
+ export async function pointer(args = {}) {
305
+ if (args.action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('pointer move requires x and y');
306
+ return adapter.pointer(args);
307
+ }
292
308
  export async function clipboard(args = {}) { return adapter.clipboard(args); }
293
309
  export async function displayInventory(args = {}) { return adapter.displayInventory(args); }
294
310
  export async function cursorPosition(args = {}) { return typeof adapter.cursorPosition === 'function' ? adapter.cursorPosition(args) : jsonResult({ x:null, y:null }); }
@@ -334,6 +350,8 @@ export async function screenshotRegion(args = {}) {
334
350
  export async function notification(args = {}) { return adapter.notification(args); }
335
351
  export async function scroll(args = {}) {
336
352
  const direction = optionalString(args.direction);
353
+ const hasDelta = [args.delta_x,args.delta_y,args.delta].some(value => value != null && Number.isFinite(Number(value)));
354
+ if (!direction && !hasDelta) throw new Error('scroll requires direction or a delta');
337
355
  const times = clamp(args.wheel_times, 1, 1, 50);
338
356
  let deltaX = Number(args.delta_x || 0);
339
357
  let deltaY = Number(args.delta_y ?? args.delta ?? 0);
@@ -403,6 +421,8 @@ async function uiMatches(args = {}) {
403
421
  }
404
422
 
405
423
  export async function uiFind(args = {}) {
424
+ const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
425
+ if (!hasSelector) throw new Error('ui_find requires id, label, name, role, or automation_id; use ui_snapshot to enumerate UI');
406
426
  const nodes = await uiMatches(args);
407
427
  return jsonResult({ count: nodes.length, nodes });
408
428
  }
@@ -473,11 +493,17 @@ export async function waitForUi(args = {}) {
473
493
  const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
474
494
  const pollMs = clamp(args.poll_ms, 250, 50, 5000);
475
495
  const rawCondition = optionalString(args.condition);
496
+ const rawState = optionalString(args.state);
497
+ if (!rawCondition && !rawState) throw new Error('wait_for_ui requires state or condition');
476
498
  const aliases = { text:'text_exists', window:'active_window', element:'element_exists', enabled:'element_enabled', focused:'focused_element' };
477
499
  const condition = rawCondition
478
500
  ? requireEnum(aliases[rawCondition] || rawCondition, 'condition', ['text_exists','active_window','element_exists','element_enabled','focused_element'])
479
501
  : null;
480
- const state = condition ? null : requireEnum(args.state || 'present', 'state', ['present', 'absent', 'changed']);
502
+ const state = condition ? null : requireEnum(rawState, 'state', ['present', 'absent', 'changed']);
503
+ const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
504
+ if (['element_exists','element_enabled'].includes(condition) && !hasSelector) throw new Error(`wait_for_ui condition=${condition} requires a semantic target`);
505
+ if (condition === 'active_window' && !(args.text || args.name || args.window_title || args.windowTitle)) throw new Error('wait_for_ui condition=active_window requires text, name, or window_title');
506
+ if (['present','absent'].includes(state) && !hasSelector) throw new Error(`wait_for_ui state=${state} requires a semantic target`);
481
507
  const deadline = Date.now() + timeoutMs;
482
508
  const startedAt = Date.now();
483
509
  let attempts = 0;
@@ -17,7 +17,7 @@ import {
17
17
  waitForProcessExit,
18
18
  waitForProcessActivity,
19
19
  } from '../sessions.mjs';
20
- import { clampInteger, fail, requireInteger, requireString, text } from '../util.mjs';
20
+ import { clampInteger, fail, requireInteger, requireString, structured, text } from '../util.mjs';
21
21
 
22
22
  function shellCommand() {
23
23
  if (runtimeConfig.defaultShell) return runtimeConfig.defaultShell;
@@ -32,7 +32,15 @@ function shellArgs(command) {
32
32
 
33
33
  function describeSession(session) {
34
34
  const status = session.exited ? `exited${session.signal ? ` (${session.signal})` : session.exitCode === null ? '' : ` (code ${session.exitCode})`}` : 'running';
35
- return { pid: session.pid, status, runtimeMs: (session.finishedAt || Date.now()) - session.startedAt, lines: totalLines(session) };
35
+ return {
36
+ pid: session.pid,
37
+ status,
38
+ runtimeMs: (session.finishedAt || Date.now()) - session.startedAt,
39
+ lines: totalLines(session),
40
+ exited: Boolean(session.exited),
41
+ exitCode: session.exitCode ?? null,
42
+ signal: session.signal ?? null,
43
+ };
36
44
  }
37
45
 
38
46
  function abortError() {
@@ -90,8 +98,16 @@ export async function startProcessTool(args, extra = {}) {
90
98
  const partial = session.partial;
91
99
  session.cursor = session.droppedLines + session.lines.length;
92
100
  session.lastPartialRead = session.partial || null;
93
- const warning = verdict.note ? `${verdict.note}\n` : '';
94
- return text(`${warning}${[headline, output, partial].filter(Boolean).join('\n')}`);
101
+ const warning = verdict.note ? String(verdict.note) : '';
102
+ const rendered = [warning, headline, output, partial].filter(Boolean).join('\n');
103
+ const status = describeSession(session);
104
+ return structured({
105
+ ...status,
106
+ command,
107
+ output,
108
+ ...(partial ? { partial } : {}),
109
+ ...(warning ? { warning } : {}),
110
+ }, rendered);
95
111
  }
96
112
 
97
113
  export async function readProcessOutputTool(args, extra = {}) {
@@ -124,7 +140,13 @@ export async function readProcessOutputTool(args, extra = {}) {
124
140
  }
125
141
  const status = describeSession(session);
126
142
  const header = `pid ${pid} ${status.status} · lines ${range}`;
127
- return text(`${header}\n${slice.join('\n')}`);
143
+ const output = slice.join('\n');
144
+ return structured({
145
+ ...status,
146
+ range,
147
+ output,
148
+ explicitOffset:hasOffset,
149
+ }, `${header}\n${output}`);
128
150
  }
129
151
 
130
152
  function compileWaiter(pattern) {
@@ -164,7 +186,15 @@ export async function waitForProcessOutputTool(args, extra = {}) {
164
186
  const headline = matched
165
187
  ? `pid ${pid} ${status.status} · pattern matched${bufferedMatch ? ' (already buffered)' : ''}`
166
188
  : `pid ${pid} ${status.status} · pattern not matched within ${timeoutMs}ms`;
167
- return text([headline, slice.join('\n')].filter(Boolean).join('\n'));
189
+ const output = slice.join('\n');
190
+ return structured({
191
+ ...status,
192
+ pattern,
193
+ matched,
194
+ bufferedMatch,
195
+ timeoutMs,
196
+ output,
197
+ }, [headline, output].filter(Boolean).join('\n'));
168
198
  }
169
199
 
170
200
  export async function interactWithProcessTool(args, extra = {}) {
@@ -195,34 +225,49 @@ export async function interactWithProcessTool(args, extra = {}) {
195
225
  }
196
226
  const slice = readNewOutput(session);
197
227
  const status = describeSession(session);
198
- return text([`pid ${pid} ${status.status}`, slice.join('\n')].filter(Boolean).join('\n'));
228
+ const output = slice.join('\n');
229
+ return structured({
230
+ ...status,
231
+ output,
232
+ }, [`pid ${pid} ${status.status}`, output].filter(Boolean).join('\n'));
199
233
  }
200
234
 
201
235
  export async function forceTerminateTool(args) {
202
236
  const pid = requireInteger(args.pid, 'pid');
203
237
  const session = getProcessSession(pid);
204
238
  if (!session) fail(`No ReMCP session with pid ${pid}`);
205
- if (session.exited) return text(`Process ${pid} already exited.`);
239
+ if (session.exited) {
240
+ const status = describeSession(session);
241
+ return structured({ ...status, terminated:false, alreadyExited:true, escalated:false }, `Process ${pid} already exited.`);
242
+ }
206
243
  killSessionTree(session, 'SIGTERM');
207
244
  const deadline = Date.now() + 2000;
208
245
  while (!session.exited && Date.now() < deadline) await waitForProcessActivity(session, 100);
246
+ let escalated = false;
209
247
  if (!session.exited) {
248
+ escalated = true;
210
249
  killSessionTree(session, 'SIGKILL');
211
250
  await waitForProcessActivity(session, 1000);
212
251
  }
213
252
  const status = describeSession(session);
214
- return text(`Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`);
253
+ return structured(
254
+ { ...status, terminated:Boolean(session.exited), alreadyExited:false, escalated },
255
+ `Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`,
256
+ );
215
257
  }
216
258
 
217
259
  export async function listSessionsTool() {
218
260
  const sessions = listProcessSessions();
219
- if (!sessions.length) return text('No active terminal sessions.');
220
- const rows = sessions.map(session => {
261
+ const facts = sessions.map(session => {
221
262
  const status = describeSession(session);
222
263
  const blocked = session.exited ? '' : session.partial ? 'blocked-possibly' : 'idle-or-running';
223
- return `pid ${status.pid} · ${status.status} · ${Math.round(status.runtimeMs / 1000)}s · ${blocked} · ${session.command.slice(0, 120)}`;
264
+ return { ...status, blocked, command:session.command.slice(0, 120) };
224
265
  });
225
- return text(rows.join('\n'));
266
+ if (!facts.length) return structured({ sessions:[] }, 'No active terminal sessions.');
267
+ const rows = facts.map(session =>
268
+ `pid ${session.pid} · ${session.status} · ${Math.round(session.runtimeMs / 1000)}s · ${session.blocked} · ${session.command}`
269
+ );
270
+ return structured({ sessions:facts }, rows.join('\n'));
226
271
  }
227
272
 
228
273
  export const terminalToolHandlers = {