@remcp/runtime 0.2.107 → 0.2.109

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.
@@ -4,7 +4,7 @@ const JSON_OUTPUT_SCHEMA = {
4
4
  additionalProperties: true,
5
5
  description: 'Structured tool result. Array/scalar payloads are exposed under data for compatibility with MCP clients that require object-root output schemas.',
6
6
  };
7
- import { browserControlAvailable, browserHandlers } from './browser.mjs';
7
+ import { browserControlAvailable, browserHandlers, browserRemoteEnabled } from './browser.mjs';
8
8
  import { isWaylandSession } from '../screenshot-portal.mjs';
9
9
  import { waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
10
10
  import { commandExists, runFile } from './common.mjs';
@@ -557,7 +557,7 @@ export const extendedToolDefinitions = [
557
557
  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']),
558
558
  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('CSS selector for the target element; prefer the reusable selector returned by browser_find.'), text:s('For action=type, Unicode text to insert. For element actions without selector, visible text may identify the target.'), value:s('Value for action=set_value; also accepted as a compatibility fallback for action=type.'), text_value:s('Compatibility alias for the type/set_value text value.'), 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']),
559
559
  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']),
560
- define('browser_evaluate', 'Evaluate browser JavaScript', 'Evaluate JavaScript in a selected debuggable browser page and return its serializable value. This is a powerful escape hatch for page-specific automation.', o({ ...browserTarget, expression:s('JavaScript expression.'), await_promise:b('Await a returned Promise; default true.') }, ['expression']), openMutating, browserHandlers.browser_evaluate, ['browser_cdp']),
560
+ define('browser_evaluate', 'Evaluate browser JavaScript', 'Evaluate JavaScript in a selected debuggable browser page and return its serializable value. This is a powerful escape hatch for page-specific automation.', o({ ...browserTarget, expression:s('JavaScript expression.'), await_promise:b('Await a returned Promise; default true.') }, ['expression']), openMutating, browserHandlers.browser_evaluate, ['browser_evaluate']),
561
561
 
562
562
  define('service', 'Service control', 'List, inspect, start, stop, or restart operating-system services using Service Control Manager, launchctl, or systemd. Mutating actions pass through ReMCP command policy.', o({ action:e(['list','status','start','stop','restart']), name:s('Service/unit/launchd label.'), scope:e(['user','system']) }, ['action']), mutating, diagnosticHandlers.service, ['services']),
563
563
  define('event_log', 'Event logs', 'Read recent Windows Event Log, macOS unified log, or systemd journal entries with bounded time and result limits.', o({ log:s('Windows log name.'), since:s('Time window such as 30s, 10m, 2h, or 1d.'), filter:s(), query:s(), limit:n() }), readOnlyLive, diagnosticHandlers.event_log, ['logs']),
@@ -568,10 +568,10 @@ export const extendedToolDefinitions = [
568
568
  define('power_action', 'Power action', 'Lock, sleep, restart, or shut down the paired computer using native operating-system power facilities. The action passes through ReMCP command policy.', o({ action:e(['lock','sleep','restart','shutdown']), delay_seconds:n('Optional delay, maximum 3600 seconds.') }, ['action']), mutating, diagnosticHandlers.power_action, ['power']),
569
569
  define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path.') }), mutating, diagnosticHandlers.record_screen, ['record']),
570
570
 
571
- define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document),
572
- define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['ooxml']),
573
- define('edit_document', 'Edit DOCX document', 'Create a new DOCX document or edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode.', o({ path:s('Existing .docx path, or the new document destination when create=true.'), create:b('Create a new DOCX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .docx path when editing an existing document.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['ooxml']),
574
- define('pdf_action', 'PDF action', 'Inspect PDF annotations or metadata, merge PDFs, split a PDF into pages, or extract selected page ranges. Structural writes use qpdf/poppler/pdftk when installed.', o({ action:e(['merge','split','extract_pages','annotations','info']), path:s(), paths:{type:'array',items:{type:'string'}}, output:s(), output_dir:s(), pattern:s(), pages:s('Page list/ranges such as 1-3,5.') }, ['action']), mutating, documentHandlers.pdf_action),
571
+ define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document, ['documents']),
572
+ define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents']),
573
+ define('edit_document', 'Edit DOCX document', 'Create a new DOCX document or edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode.', o({ path:s('Existing .docx path, or the new document destination when create=true.'), create:b('Create a new DOCX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .docx path when editing an existing document.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['documents']),
574
+ define('pdf_action', 'PDF action', 'Inspect PDF annotations or metadata, merge PDFs, split a PDF into pages, or extract selected page ranges. Structural writes use qpdf/poppler/pdftk when installed.', o({ action:e(['merge','split','extract_pages','annotations','info']), path:s(), paths:{type:'array',items:{type:'string'}}, output:s(), output_dir:s(), pattern:s(), pages:s('Page list/ranges such as 1-3,5.') }, ['action']), mutating, documentHandlers.pdf_action, ['documents']),
575
575
  ];
576
576
 
577
577
  export const extendedToolHandlers = new Map(extendedToolDefinitions.map(definition => [definition.name, definition]));
@@ -606,7 +606,7 @@ export async function capabilitySnapshot() {
606
606
  const wayland = linux && isWaylandSession();
607
607
  const portalInput = wayland && waylandPortalCandidate();
608
608
  const x11Input = linux && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
609
- const browserCdp = await browserControlAvailable(undefined, 500);
609
+ const browserCdp = browserRemoteEnabled() && await browserControlAvailable(undefined, 500);
610
610
  return {
611
611
  windows: win || mac || commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi),
612
612
  ui: win || mac || pyAtSpi,
@@ -618,13 +618,16 @@ export async function capabilitySnapshot() {
618
618
  screen_region: win || mac || commandExists('grim') || commandExists('import') || (isWaylandSession() && commandExists('ffmpeg')),
619
619
  notifications: win || mac || commandExists('notify-send'),
620
620
  browser_cdp: browserCdp,
621
+ browser_evaluate: browserCdp && (process.env.NODE_ENV !== 'production' || ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_EVALUATE || '').toLowerCase())),
622
+ documents: linux,
621
623
  services: win || mac || commandExists('systemctl'),
624
+
622
625
  logs: win || mac || commandExists('journalctl'),
623
626
  apps: win || mac || commandExists('dpkg-query') || commandExists('rpm'),
624
627
  audio: win || mac || commandExists('wpctl') || commandExists('pactl') || commandExists('amixer'),
625
628
  power: win || mac || commandExists('systemctl') || commandExists('loginctl'),
626
629
  record: recordScreenAvailable({ platform:process.platform, wayland }),
627
- ooxml: win || (commandExists('zip') && commandExists('unzip')),
630
+ ooxml: linux && (commandExists('zip') && commandExists('unzip')),
628
631
  };
629
632
  }
630
633
 
@@ -334,16 +334,20 @@ export async function uiSnapshot(args = {}) {
334
334
  let raw = await adapter.uiSnapshot(effective);
335
335
  let value = resultValue(raw);
336
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 };
337
+ const isEmptySnapshot = payload => Array.isArray(payload)
338
+ ? payload.length === 0
339
+ : Array.isArray(payload?.nodes) && payload.nodes.length === 0;
340
+ if (process.platform === 'linux' && isEmptySnapshot(value)) {
341
+ // GNOME/AT-SPI can transiently report an empty tree. Retry once at the requested
342
+ // depth before reducing traversal depth, so valid deep trees are not discarded.
343
+ await sleep(40);
345
344
  raw = await adapter.uiSnapshot(effective);
346
345
  value = resultValue(raw);
346
+ if (isEmptySnapshot(value) && Number.isFinite(requestedDepth) && requestedDepth > 12) {
347
+ effective = { ...effective, max_depth:12 };
348
+ raw = await adapter.uiSnapshot(effective);
349
+ value = resultValue(raw);
350
+ }
347
351
  }
348
352
  return jsonResult(enrichUiPayload(value, effective));
349
353
  }
@@ -429,7 +433,7 @@ export async function scroll(args = {}) {
429
433
  }
430
434
  const semanticTarget = args.label != null || args.id || args.name || args.role || args.automation_id || args.automationId;
431
435
  if (semanticTarget) {
432
- const nodes = await uiMatches({ ...args, limit:1 });
436
+ const nodes = await uiMatches({ ...args, limit:1, refresh:true });
433
437
  const node = nodes[0];
434
438
  if (!node) throw new Error('Scroll target UI element was not found');
435
439
  const x = Number(node.center_x ?? (Number(node.x) + Number(node.width) / 2));
@@ -746,7 +750,7 @@ export async function typeText(args = {}) {
746
750
  }
747
751
 
748
752
  async function uiElementCenter(id, field) {
749
- const matches = await uiMatches({ id, limit: 1, max_nodes: 5000, max_depth: 32 });
753
+ const matches = await uiMatches({ id, limit: 1, max_nodes: 5000, max_depth: 32, refresh:true });
750
754
  const node = matches[0];
751
755
  if (!node) throw new Error(`${field} UI element was not found: ${id}`);
752
756
  const x = Number(node.x), y = Number(node.y), width = Number(node.width), height = Number(node.height);
@@ -3,10 +3,12 @@ import net from 'node:net';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { existsSync } from 'node:fs';
7
- import { stat } from 'node:fs/promises';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { constants, existsSync } from 'node:fs';
8
+ import { lstat, mkdtemp, open, rename, rm, stat, unlink } from 'node:fs/promises';
8
9
  import { assertAllowedCommand } from '../policy.mjs';
9
10
  import { isWaylandSession } from '../screenshot-portal.mjs';
11
+ import { openDirectoryPath } from '../tools/files.mjs';
10
12
  import { resolveSafePath, text } from '../util.mjs';
11
13
  import {
12
14
  clamp,
@@ -297,38 +299,97 @@ export function recordScreenAvailable({
297
299
  return false;
298
300
  }
299
301
 
302
+ async function openRecordingParent(destination) {
303
+ return openDirectoryPath(path.dirname(destination));
304
+ }
305
+
306
+ async function copyRecordingFile(source, destination) {
307
+ let input;
308
+ let output;
309
+ let parent;
310
+ let temporary;
311
+ try {
312
+ input = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
313
+ const sourceInfo = await input.stat();
314
+ if (!sourceInfo.isFile()) throw new Error('Screen recording staging path is not a regular file');
315
+ parent = await openRecordingParent(destination);
316
+ const anchoredDestination = path.join(parent.anchor, path.basename(destination));
317
+ const existing = await lstat(anchoredDestination).catch(() => null);
318
+ if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
319
+ if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
320
+ temporary = path.join(parent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
321
+ output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
322
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
323
+ let offset = 0;
324
+ for (;;) {
325
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
326
+ if (!bytesRead) break;
327
+ let written = 0;
328
+ while (written < bytesRead) {
329
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
330
+ if (!result.bytesWritten) throw new Error('Could not finish writing the screen recording');
331
+ written += result.bytesWritten;
332
+ }
333
+ offset += bytesRead;
334
+ }
335
+ await output.sync();
336
+ await output.close();
337
+ output = null;
338
+ await rename(temporary, anchoredDestination);
339
+ temporary = null;
340
+ } finally {
341
+ if (output) await output.close().catch(() => {});
342
+ if (temporary) await unlink(temporary).catch(() => {});
343
+ if (input) await input.close().catch(() => {});
344
+ if (parent) await parent.close().catch(() => {});
345
+ }
346
+ }
347
+
300
348
  export async function recordScreen(args) {
301
349
  const seconds = clamp(args.duration_seconds, 5, 1, 120);
302
350
  const fps = clamp(args.fps, 15, 1, 60);
303
- const destination = args.destination ? await resolveSafePath(args.destination, 'destination') : path.join(os.tmpdir(), `remcp-screen-${Date.now()}.mp4`);
351
+ const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
352
+ if (requestedDestination) {
353
+ const existing = await lstat(requestedDestination).catch(() => null);
354
+ if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
355
+ if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
356
+ }
357
+ const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
358
+ const staged = path.join(stagingDirectory, `capture-${randomUUID()}.mp4`);
304
359
  const wayland = process.platform === 'linux' && isWaylandSession();
305
- if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
306
- const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
307
- if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
308
- } else {
309
- const ffmpeg = resolveRecordScreenFfmpeg();
310
- if (wayland) {
311
- unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
312
- }
313
- if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
314
- unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
315
- }
316
- if (!ffmpeg) {
317
- if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
318
- if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
319
- unavailable('Screen recording', 'ffmpeg is required on X11');
360
+ let keepStaging = false;
361
+ try {
362
+ if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
363
+ const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
364
+ if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
365
+ } else {
366
+ const ffmpeg = resolveRecordScreenFfmpeg();
367
+ if (wayland) unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
368
+ if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
369
+ if (!ffmpeg) {
370
+ if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
371
+ if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
372
+ unavailable('Screen recording', 'ffmpeg is required on X11');
373
+ }
374
+ let argv;
375
+ if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',staged];
376
+ else if (process.platform === 'darwin') {
377
+ const input = await avfoundationScreenInput(ffmpeg);
378
+ argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
379
+ } else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
380
+ await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
320
381
  }
321
- let argv;
322
- if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
323
- else if (process.platform === 'darwin') {
324
- const input = await avfoundationScreenInput(ffmpeg);
325
- argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
382
+ if (requestedDestination) {
383
+ await copyRecordingFile(staged, requestedDestination);
384
+ await rm(stagingDirectory, { recursive:true, force:true });
326
385
  }
327
- else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
328
- await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
386
+ const output = requestedDestination || staged;
387
+ const info = await stat(output);
388
+ keepStaging = !requestedDestination;
389
+ return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format:path.extname(output).slice(1) || 'mp4' });
390
+ } finally {
391
+ if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
329
392
  }
330
- const info = await stat(destination);
331
- return jsonResult({ path:destination, bytes:info.size, duration_seconds:seconds, format:path.extname(destination).slice(1) || 'mp4' });
332
393
  }
333
394
 
334
395
  export const diagnosticHandlers = {