@remcp/runtime 0.2.72 → 0.2.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.72",
3
+ "version": "0.2.73",
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",
@@ -470,15 +470,40 @@ function findExpression(args) {
470
470
  }
471
471
  return parts.join(' > ');
472
472
  }
473
+ function semanticRole(el) {
474
+ const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
475
+ if (explicit) return explicit;
476
+ const tag = el.tagName.toLowerCase();
477
+ if (tag === 'button') return 'button';
478
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
479
+ if (tag === 'textarea') return 'textbox';
480
+ if (tag === 'select') return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
481
+ if (tag === 'option') return 'option';
482
+ if (tag === 'img') return 'img';
483
+ if (tag === 'progress') return 'progressbar';
484
+ if (tag === 'meter') return 'meter';
485
+ if (tag === 'summary') return 'button';
486
+ if (tag === 'input') {
487
+ const type = String(el.type || 'text').toLowerCase();
488
+ if (['button','submit','reset','image'].includes(type)) return 'button';
489
+ if (type === 'checkbox') return 'checkbox';
490
+ if (type === 'radio') return 'radio';
491
+ if (type === 'range') return 'slider';
492
+ if (type === 'number') return 'spinbutton';
493
+ if (type === 'search') return 'searchbox';
494
+ if (!['hidden','file','color'].includes(type)) return 'textbox';
495
+ }
496
+ return '';
497
+ }
473
498
  return source.filter(el => {
474
499
  const style = getComputedStyle(el);
475
500
  const visible = style.visibility !== 'hidden' && style.display !== 'none' && el.getClientRects().length > 0;
476
501
  const hay = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
477
- const r = el.getAttribute('role') || '';
478
- return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r.toLowerCase() === role.toLowerCase());
502
+ const r = semanticRole(el);
503
+ return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r === role.toLowerCase());
479
504
  }).slice(0, ${limit}).map(el => {
480
505
  const b = el.getBoundingClientRect();
481
- return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: (el.innerText || el.textContent || '').trim().slice(0, 500), value: 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
506
+ return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: (el.innerText || el.textContent || '').trim().slice(0, 500), value: 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
482
507
  });
483
508
  })()`;
484
509
  }
@@ -330,9 +330,21 @@ export async function uiSnapshot(args = {}) {
330
330
  const activeOnly = args.active_only != null
331
331
  ? args.active_only === true
332
332
  : (!explicitlyScoped && args.scope !== 'desktop');
333
- const effective = { ...args, active_only:activeOnly };
334
- const raw = await adapter.uiSnapshot(effective);
335
- const value = resultValue(raw);
333
+ let effective = { ...args, active_only:activeOnly };
334
+ let raw = await adapter.uiSnapshot(effective);
335
+ let value = resultValue(raw);
336
+ const requestedDepth = Number(effective.max_depth);
337
+ const emptySnapshot = Array.isArray(value)
338
+ ? value.length === 0
339
+ : Array.isArray(value?.nodes) && value.nodes.length === 0;
340
+ if (process.platform === 'linux' && Number.isFinite(requestedDepth) && requestedDepth > 15 && emptySnapshot) {
341
+ // Some GNOME/AT-SPI providers collapse otherwise valid trees to an empty result
342
+ // when traversed too deeply. Preserve the caller's deep request first, but recover
343
+ // from that provider failure with the deepest stable fallback observed on Wayland.
344
+ effective = { ...effective, max_depth:15 };
345
+ raw = await adapter.uiSnapshot(effective);
346
+ value = resultValue(raw);
347
+ }
336
348
  return jsonResult(enrichUiPayload(value, effective));
337
349
  }
338
350
  export async function uiAction(args = {}) {
@@ -452,7 +464,7 @@ async function uiMatches(args = {}) {
452
464
  const scopedPid = resolved.pid != null ? resolved.pid : stableLinuxPid ? Number(stableLinuxPid) : null;
453
465
  const result = await uiSnapshot({
454
466
  max_nodes: resolved.max_nodes || 2000,
455
- max_depth: resolved.max_depth || 16,
467
+ max_depth: resolved.max_depth || (process.platform === 'linux' ? 12 : 16),
456
468
  ...(scopedPid != null ? { pid: scopedPid } : {}),
457
469
  ...(resolved.app ? { app: resolved.app } : {}),
458
470
  ...((resolved.window_title || resolved.windowTitle) ? { window_title: resolved.window_title || resolved.windowTitle } : {}),
@@ -508,7 +520,7 @@ async function uiConditionMatches(args, condition) {
508
520
  if (!cached) {
509
521
  const snapshot = resultValue(await uiSnapshot({
510
522
  max_nodes:args.max_nodes || 2000,
511
- max_depth:args.max_depth || 16,
523
+ max_depth:args.max_depth || (process.platform === 'linux' ? 12 : 16),
512
524
  ...(args.pid != null ? { pid:args.pid } : {}),
513
525
  ...(args.app ? { app:args.app } : {}),
514
526
  ...((args.window_title || args.windowTitle) ? { window_title:args.window_title || args.windowTitle } : {}),
@@ -336,6 +336,29 @@ export async function editDocument(args) {
336
336
  }
337
337
  }
338
338
 
339
+ export function expandPdfPages(spec, maxPages = 10_000) {
340
+ const normalized = String(spec || '').replace(/\s+/g, '');
341
+ if (!normalized || !/^[0-9,\-]+$/.test(normalized)) throw new Error('pages must look like 1-3,5');
342
+ const pages = [];
343
+ for (const token of normalized.split(',')) {
344
+ if (!token) throw new Error('pages must look like 1-3,5');
345
+ const range = token.match(/^(\d+)-(\d+)$/);
346
+ if (range) {
347
+ const start = Number(range[1]);
348
+ const end = Number(range[2]);
349
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) throw new Error(`Invalid PDF page range: ${token}`);
350
+ if (pages.length + (end - start + 1) > maxPages) throw new Error(`PDF page selection exceeds ${maxPages} pages`);
351
+ for (let page = start; page <= end; page += 1) pages.push(page);
352
+ continue;
353
+ }
354
+ const page = Number(token);
355
+ if (!Number.isInteger(page) || page < 1) throw new Error(`Invalid PDF page: ${token}`);
356
+ pages.push(page);
357
+ if (pages.length > maxPages) throw new Error(`PDF page selection exceeds ${maxPages} pages`);
358
+ }
359
+ return pages;
360
+ }
361
+
339
362
  function pdfAnnotations(buffer) {
340
363
  const raw = buffer.toString('latin1');
341
364
  const rows = [];
@@ -382,12 +405,38 @@ export async function pdfAction(args) {
382
405
  else unavailable('PDF split','install poppler-utils (pdfseparate)');
383
406
  return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
384
407
  }
385
- const pages=optionalString(args.pages); if(!pages || !/^[0-9,\-\s]+$/.test(pages)) throw new Error('pages must look like 1-3,5');
408
+ const pages=optionalString(args.pages);
409
+ const selectedPages=expandPdfPages(pages);
410
+ const normalizedPages=selectedPages.join(',');
386
411
  const output=await resolveSafePath(args.output,'output');
387
- if(commandExists('qpdf')) await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
388
- else if(commandExists('pdftk')) await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
389
- else unavailable('PDF page extraction','install qpdf or pdftk');
390
- return jsonResult({action,source,pages,output,bytes:(await stat(output)).size});
412
+ if(commandExists('qpdf')) {
413
+ await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
414
+ } else if(commandExists('pdftk')) {
415
+ await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
416
+ } else if(commandExists('pdfseparate')) {
417
+ const dir=await tempDir('remcp-pdf-pages-');
418
+ try {
419
+ const extracted=[];
420
+ for(let index=0;index<selectedPages.length;index+=1){
421
+ const page=selectedPages[index];
422
+ const pattern=path.join(dir,`selection-${index+1}-%d.pdf`);
423
+ await runFile('pdfseparate',['-f',String(page),'-l',String(page),source,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
424
+ extracted.push(path.join(dir,`selection-${index+1}-${page}.pdf`));
425
+ }
426
+ if(extracted.length===1) {
427
+ await copyFile(extracted[0],output);
428
+ } else if(commandExists('pdfunite')) {
429
+ await runFile('pdfunite',[...extracted,output],{label:'PDF assemble extracted pages',timeout:60_000});
430
+ } else {
431
+ unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
432
+ }
433
+ } finally {
434
+ await removeTemp(dir);
435
+ }
436
+ } else {
437
+ unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
438
+ }
439
+ return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
391
440
  }
392
441
 
393
442
  export const documentHandlers = {
package/src/sessions.mjs CHANGED
@@ -68,6 +68,10 @@ export function createProcessSession({ pid, child, command, shell }) {
68
68
  droppedLines: 0,
69
69
  bufferedChars: 0,
70
70
  cursor: 0,
71
+ // wait_for_process_output has its own consumption watermark. start_process may
72
+ // display initial output and advance the ordinary read cursor, but the first
73
+ // wait must still be able to match that retained output once.
74
+ waitCursor: 0,
71
75
  exitCode: null,
72
76
  signal: null,
73
77
  exited: false,
@@ -150,6 +150,7 @@ export async function readProcessOutputTool(args, extra = {}) {
150
150
  }
151
151
  }
152
152
  slice = readNewOutput(session);
153
+ session.waitCursor = session.cursor;
153
154
  const first = Math.max(1, session.cursor - slice.length + 1);
154
155
  range = slice.length ? `${first}-${session.cursor} of ${totalLines(session)}` : `no new output (${totalLines(session)} lines total)`;
155
156
  }
@@ -181,9 +182,11 @@ export async function waitForProcessOutputTool(args, extra = {}) {
181
182
  const pattern = requireString(args.pattern, 'pattern');
182
183
  const matcher = compileWaiter(pattern);
183
184
  const timeoutMs = clampInteger(args.timeout_ms, 10000, 0, 120000);
184
- const cursorStart = Math.max(0, session.cursor - session.droppedLines);
185
- // Output that was already buffered before the call still counts: a pattern printed
186
- // earlier should answer immediately instead of spinning for the whole timeout.
185
+ const waitCursor = Number.isInteger(session.waitCursor) ? session.waitCursor : session.cursor;
186
+ const cursorStart = Math.max(0, waitCursor - session.droppedLines);
187
+ // Output already retained before the call still counts once, even when start_process
188
+ // displayed it and advanced the ordinary read cursor. A separate wait watermark keeps
189
+ // retries from replaying the same historical match forever.
187
190
  let slice = session.lines.slice(cursorStart);
188
191
  const bufferedMatch = waiterMatches(slice, matcher);
189
192
  const deadline = Date.now() + timeoutMs;
@@ -196,6 +199,7 @@ export async function waitForProcessOutputTool(args, extra = {}) {
196
199
  }
197
200
  const matched = waiterMatches(slice, matcher);
198
201
  session.cursor = session.droppedLines + session.lines.length;
202
+ session.waitCursor = session.cursor;
199
203
  session.lastPartialRead = session.partial || null;
200
204
  const status = describeSession(session);
201
205
  const headline = matched
@@ -220,6 +224,7 @@ export async function interactWithProcessTool(args, extra = {}) {
220
224
  const input = typeof args.input === 'string' ? args.input : fail('input must be a string');
221
225
  const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
222
226
  session.cursor = session.droppedLines + session.lines.length;
227
+ session.waitCursor = session.cursor;
223
228
  session.lastPartialRead = null;
224
229
  throwIfAborted(extra.signal);
225
230
  const stdin = session.child?.stdin;
@@ -239,6 +244,7 @@ export async function interactWithProcessTool(args, extra = {}) {
239
244
  await waitForProcessActivity(session, Math.min(200, Math.max(20, deadline - Date.now())));
240
245
  }
241
246
  const slice = readNewOutput(session);
247
+ session.waitCursor = session.cursor;
242
248
  const status = describeSession(session);
243
249
  const output = slice.join('\n');
244
250
  return terminalStructured({