@remcp/runtime 0.2.139 → 0.2.141

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
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.141 — 2026-10-02
4
+
5
+ - Linux `display_inventory` capability discovery now uses the same real backend resolver as execution instead of treating every Wayland session as supported.
6
+ - wlroots compositors gain a native `wlr-randr` parser for connector metadata, logical position/size, scale, transform, pixel mode and refresh rate; the fallback chain is Mutter → wlr-randr → xrandr.
7
+ - Unsupported Wayland sessions without one of those working backends stop advertising `display_inventory`, and dynamic capability polling can add/remove the tool through `tools/list_changed`.
8
+ - Live GNOME Wayland verification still returns the ASUS eDP-1 through Mutter with the 83-tool desktop surface unchanged.
9
+
10
+ ## 0.2.140 — 2026-10-02
11
+
12
+ - Windows `create_archive` / `extract_archive` are enabled when the native inbox `tar.exe` (libarchive/bsdtar) backend is available.
13
+ - Windows ZIP creation/extraction uses the same validated tar backend as tar-family archives, preserving traversal, link/special-file, expanded-size, staging, post-extraction, and atomic-commit safety checks.
14
+ - Archive member names are normalized to portable forward slashes before backend invocation.
15
+ - `read_binary` now sizes each returned base64 chunk against the live output cap so the JSON envelope remains parseable under reduced per-call limits; `nextOffsetBytes` reflects the actual returned prefix.
16
+ - Public Windows release CI exercises tar.gz, ZIP, and option-like filename round trips. Fully capable Windows now advertises all 83 runtime tools.
17
+
3
18
  ## 0.2.139 — 2026-10-02
4
19
 
5
20
  - `create_archive` and `extract_archive` are now advertised on macOS in addition to Linux.
package/README.md CHANGED
@@ -84,7 +84,7 @@ Desktop capabilities additionally require an active graphical session (`DISPLAY`
84
84
  headless hosts do not advertise window/UI/input/clipboard/display/screenshot/launch/open/reveal/notification
85
85
  operations merely because desktop helper binaries happen to be installed. The universal `computer_snapshot`
86
86
  remains available on those hosts but skips unavailable desktop probes instead of reporting synthetic backend
87
- errors. On Linux, `screenshot_region` is advertised only when the current session has a real matching capture
87
+ errors. Linux `display_inventory` follows the real monitor backend chain (GNOME Mutter DisplayConfig → `wlr-randr` → `xrandr`) and is hidden on Wayland when none of those backends can return a display. On Linux, `screenshot_region` is advertised only when the current session has a real matching capture
88
88
  backend (Wayland grim/GNOME/portal path or X11 ImageMagick), not merely because `ffmpeg` is installed.
89
89
 
90
90
  The generated hosted reference covers the current **10 hosted production definitions**, all model-visible
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.139",
3
+ "version": "0.2.141",
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
@@ -1,4 +1,4 @@
1
- import { fileToolHandlers } from './tools/files.mjs';
1
+ import { archiveBackendAvailable, fileToolHandlers } from './tools/files.mjs';
2
2
  import { searchToolHandlers } from './tools/search.mjs';
3
3
  import { terminalToolHandlers } from './tools/terminal.mjs';
4
4
  import { systemToolHandlers } from './tools/system.mjs';
@@ -830,12 +830,18 @@ export const toolDefinitions = [
830
830
  },
831
831
  ];
832
832
 
833
- export function coreToolSupported(definition, { platform = process.platform } = {}) {
834
- return !['create_archive', 'extract_archive'].includes(definition.name) || ['linux', 'darwin'].includes(platform);
833
+ export function coreToolSupported(definition, {
834
+ platform = process.platform,
835
+ archiveAvailable = platform === 'win32' ? archiveBackendAvailable({ platform }) : true,
836
+ } = {}) {
837
+ if (!['create_archive', 'extract_archive'].includes(definition.name)) return true;
838
+ if (platform === 'linux' || platform === 'darwin') return true;
839
+ return platform === 'win32' && archiveAvailable === true;
835
840
  }
836
841
 
837
842
  export function supportedCoreTools() {
838
- return toolDefinitions.filter(coreToolSupported);
843
+ const archiveAvailable = process.platform === 'win32' ? archiveBackendAvailable({ platform:process.platform }) : true;
844
+ return toolDefinitions.filter(definition => coreToolSupported(definition, { platform:process.platform, archiveAvailable }));
839
845
  }
840
846
 
841
847
  export function advertisedTools() {
@@ -9,7 +9,7 @@ import { isWaylandSession } from '../screenshot-portal.mjs';
9
9
  import { waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
10
10
  import { commandExists, desktopSessionAvailable, runFile } from './common.mjs';
11
11
  import { desktopHandlers } from './desktop.mjs';
12
- import { regionScreenshotBackends } from './desktop-linux.mjs';
12
+ import { displayInventoryAvailable, regionScreenshotBackends } from './desktop-linux.mjs';
13
13
  import { diagnosticHandlers, gnomeScreencastSupported, recordScreenAvailable } from './diagnostics.mjs';
14
14
  import { documentHandlers } from './documents.mjs';
15
15
 
@@ -760,6 +760,7 @@ export async function capabilitySnapshot() {
760
760
  const portalCandidate = wayland && waylandPortalCandidate();
761
761
  const portalInput = portalCandidate;
762
762
  const x11Input = linux && desktopSession && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
763
+ const displayInventoryReady = linux && desktopSession ? await displayInventoryAvailable() : false;
763
764
  const browserCdp = browserRemoteEnabled() && await browserControlAvailable(undefined, 500);
764
765
  const documentCapabilities = documentCapabilitySnapshot();
765
766
  return {
@@ -770,7 +771,7 @@ export async function capabilitySnapshot() {
770
771
  pointer: win || mac || (desktopSession && (portalInput || x11Input)),
771
772
  drag: win || mac || (desktopSession && (portalInput || x11Input)),
772
773
  clipboard: win || mac || (desktopSession && (commandExists('wl-paste') || commandExists('xclip') || commandExists('xsel'))),
773
- displays: win || mac || (desktopSession && ((wayland && linux) || commandExists('wlr-randr') || commandExists('xrandr'))),
774
+ displays: win || mac || displayInventoryReady,
774
775
  screen_region: win || mac || (desktopSession && regionScreenshotBackends({ wayland, portalCandidate }).length > 0),
775
776
  notifications: win || mac || (desktopSession && commandExists('notify-send')),
776
777
  browser_cdp: browserCdp,
@@ -1445,24 +1445,165 @@ async function mutterDisplayInventory() {
1445
1445
  }
1446
1446
  }
1447
1447
 
1448
- export async function displayInventory() {
1449
- const mutter = await mutterDisplayInventory();
1450
- if (mutter) return jsonResult(mutter);
1451
- if (!commandExists('xrandr')) unavailable('Display inventory', 'GNOME Mutter DisplayConfig or xrandr is required');
1452
- const { stdout } = await runFile('xrandr', ['--query'], { label: 'display inventory' });
1453
- let scale = Number(process.env.GDK_SCALE || process.env.QT_SCALE_FACTOR || 0);
1448
+ export function parseWlrRandrOutput(stdout) {
1449
+ const rows = [];
1450
+ let current = null;
1451
+
1452
+ const finish = () => {
1453
+ if (!current || current.enabled === false) return;
1454
+ const mode = current.currentMode || current.firstMode;
1455
+ if (!mode) return;
1456
+ const scale = Number.isFinite(current.scale) && current.scale > 0 ? current.scale : 1;
1457
+ const rotated = /(?:^|\D)(?:90|270)(?:\D|$)/.test(current.transform || '');
1458
+ const pixelWidth = mode.width;
1459
+ const pixelHeight = mode.height;
1460
+ rows.push({
1461
+ name:current.name,
1462
+ display_name:current.description || current.model || current.name,
1463
+ primary:false,
1464
+ x:Number.isFinite(current.x) ? current.x : null,
1465
+ y:Number.isFinite(current.y) ? current.y : null,
1466
+ width:Math.round((rotated ? pixelHeight : pixelWidth) / scale),
1467
+ height:Math.round((rotated ? pixelWidth : pixelHeight) / scale),
1468
+ pixel_width:pixelWidth,
1469
+ pixel_height:pixelHeight,
1470
+ scale,
1471
+ refresh_hz:mode.refresh_hz,
1472
+ transform:current.transform || 'normal',
1473
+ builtin:/^(?:eDP|LVDS|DSI)-/i.test(current.name),
1474
+ vendor:current.make || null,
1475
+ product:current.model || null,
1476
+ serial:current.serial || null,
1477
+ backend:'wlr-randr',
1478
+ });
1479
+ };
1480
+
1481
+ for (const rawLine of String(stdout || '').split(/\r?\n/)) {
1482
+ if (rawLine && !/^\s/.test(rawLine)) {
1483
+ finish();
1484
+ const header = rawLine.match(/^(\S+)(?:\s+"([^"]*)")?\s*$/);
1485
+ current = header ? {
1486
+ name:header[1],
1487
+ description:header[2] || '',
1488
+ enabled:true,
1489
+ scale:1,
1490
+ transform:'normal',
1491
+ currentMode:null,
1492
+ firstMode:null,
1493
+ } : null;
1494
+ continue;
1495
+ }
1496
+ if (!current) continue;
1497
+ const line = rawLine.trim();
1498
+ let match;
1499
+ if ((match = line.match(/^Enabled:\s*(yes|no)$/i))) current.enabled = match[1].toLowerCase() === 'yes';
1500
+ else if ((match = line.match(/^Position:\s*(-?\d+)\s*,\s*(-?\d+)$/i))) {
1501
+ current.x = Number(match[1]);
1502
+ current.y = Number(match[2]);
1503
+ } else if ((match = line.match(/^Scale:\s*(\d+(?:\.\d+)?)$/i))) current.scale = Number(match[1]);
1504
+ else if ((match = line.match(/^Transform:\s*(.+)$/i))) current.transform = match[1].trim();
1505
+ else if ((match = line.match(/^Make:\s*(.*)$/i))) current.make = match[1].trim();
1506
+ else if ((match = line.match(/^Model:\s*(.*)$/i))) current.model = match[1].trim();
1507
+ else if ((match = line.match(/^Serial:\s*(.*)$/i))) current.serial = match[1].trim();
1508
+ else if ((match = line.match(/^(\d+)x(\d+)\s+px,\s*([\d.]+)\s+Hz(?:\s+\(([^)]*)\))?/i))) {
1509
+ const mode = { width:Number(match[1]), height:Number(match[2]), refresh_hz:Number(match[3]) };
1510
+ current.firstMode ||= mode;
1511
+ if (/\bcurrent\b/i.test(match[4] || '')) current.currentMode = mode;
1512
+ }
1513
+ }
1514
+ finish();
1515
+ return rows;
1516
+ }
1517
+
1518
+ export function parseXrandrDisplayOutput(stdout, scale = 1) {
1519
+ const rows = [];
1520
+ for (const line of String(stdout || '').split(/\r?\n/)) {
1521
+ const match = line.match(/^(\S+) connected( primary)?(?: (\d+)x(\d+)\+(-?\d+)\+(-?\d+))?/);
1522
+ if (match) rows.push({
1523
+ name:match[1],
1524
+ display_name:match[1],
1525
+ primary:Boolean(match[2]),
1526
+ width:match[3]?Number(match[3]):null,
1527
+ height:match[4]?Number(match[4]):null,
1528
+ pixel_width:match[3]?Number(match[3]):null,
1529
+ pixel_height:match[4]?Number(match[4]):null,
1530
+ x:match[5]?Number(match[5]):null,
1531
+ y:match[6]?Number(match[6]):null,
1532
+ scale,
1533
+ backend:'xrandr',
1534
+ });
1535
+ }
1536
+ return rows;
1537
+ }
1538
+
1539
+ async function xrandrDisplayScale({ commandExistsFn = commandExists, runFileFn = runFile, env = process.env } = {}) {
1540
+ let scale = Number(env.GDK_SCALE || env.QT_SCALE_FACTOR || 0);
1454
1541
  if (!Number.isFinite(scale) || scale <= 0) scale = 0;
1455
- if (!scale && commandExists('gsettings')) {
1456
- const probe = await runFile('gsettings', ['get','org.gnome.desktop.interface','scaling-factor'], { label:'display scale', allowFailure:true, timeout:1500 });
1542
+ if (!scale && commandExistsFn('gsettings')) {
1543
+ const probe = await runFileFn('gsettings', ['get','org.gnome.desktop.interface','scaling-factor'], { label:'display scale', allowFailure:true, timeout:1500 });
1457
1544
  const parsed = Number(String(probe.stdout || '').match(/(\d+(?:\.\d+)?)/)?.[1]);
1458
1545
  if (Number.isFinite(parsed) && parsed > 0) scale = parsed;
1459
1546
  }
1460
- if (!scale) scale = 1;
1461
- const rows = [];
1462
- for (const line of stdout.split('\n')) {
1463
- const match = line.match(/^(\S+) connected( primary)?(?: (\d+)x(\d+)\+(-?\d+)\+(-?\d+))?/);
1464
- if (match) rows.push({ name:match[1], display_name:match[1], primary:Boolean(match[2]), width:match[3]?Number(match[3]):null, height:match[4]?Number(match[4]):null, pixel_width:match[3]?Number(match[3]):null, pixel_height:match[4]?Number(match[4]):null, x:match[5]?Number(match[5]):null, y:match[6]?Number(match[6]):null, scale, backend:'xrandr' });
1547
+ return scale || 1;
1548
+ }
1549
+
1550
+ export async function resolveLinuxDisplayInventory({
1551
+ wayland = isWaylandSession(),
1552
+ mutterProbe = mutterDisplayInventory,
1553
+ commandExistsFn = commandExists,
1554
+ runFileFn = runFile,
1555
+ env = process.env,
1556
+ } = {}) {
1557
+ if (wayland) {
1558
+ const mutter = await mutterProbe().catch(() => null);
1559
+ if (Array.isArray(mutter) && mutter.length) return mutter;
1560
+
1561
+ if (commandExistsFn('wlr-randr')) {
1562
+ const result = await runFileFn('wlr-randr', [], { label:'display inventory', allowFailure:true, timeout:2500, maxBuffer:4*1024*1024 });
1563
+ if (result.code === 0) {
1564
+ const rows = parseWlrRandrOutput(result.stdout);
1565
+ if (rows.length) return rows;
1566
+ }
1567
+ }
1465
1568
  }
1569
+
1570
+ if (commandExistsFn('xrandr')) {
1571
+ const result = await runFileFn('xrandr', ['--query'], { label:'display inventory', allowFailure:true, timeout:2500, maxBuffer:4*1024*1024 });
1572
+ if (result.code === 0) {
1573
+ const scale = await xrandrDisplayScale({ commandExistsFn, runFileFn, env });
1574
+ const rows = parseXrandrDisplayOutput(result.stdout, scale);
1575
+ if (rows.length) return rows;
1576
+ }
1577
+ }
1578
+ return null;
1579
+ }
1580
+
1581
+ let displayCapabilityCache = { value:false, expiresAt:0, pending:null };
1582
+
1583
+ export async function displayInventoryAvailable(options) {
1584
+ if (options && Object.keys(options).length) {
1585
+ return Boolean((await resolveLinuxDisplayInventory(options))?.length);
1586
+ }
1587
+ const now = Date.now();
1588
+ if (displayCapabilityCache.expiresAt > now) return displayCapabilityCache.value;
1589
+ if (displayCapabilityCache.pending) return displayCapabilityCache.pending;
1590
+ const pending = resolveLinuxDisplayInventory()
1591
+ .then(rows => {
1592
+ const value = Boolean(rows?.length);
1593
+ displayCapabilityCache = { value, expiresAt:Date.now() + 5000, pending:null };
1594
+ return value;
1595
+ })
1596
+ .catch(() => {
1597
+ displayCapabilityCache = { value:false, expiresAt:Date.now() + 5000, pending:null };
1598
+ return false;
1599
+ });
1600
+ displayCapabilityCache.pending = pending;
1601
+ return pending;
1602
+ }
1603
+
1604
+ export async function displayInventory() {
1605
+ const rows = await resolveLinuxDisplayInventory();
1606
+ if (!rows?.length) unavailable('Display inventory', 'GNOME Mutter DisplayConfig, wlr-randr or xrandr is required');
1466
1607
  return jsonResult(rows);
1467
1608
  }
1468
1609
 
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
12
12
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
13
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
14
  import { countEvent, recordEvent } from '../telemetry.mjs';
15
- import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
15
+ import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
16
 
17
17
  const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
18
18
  // An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
@@ -785,16 +785,33 @@ export async function readBinaryTool(args) {
785
785
  try {
786
786
  const buffer = Buffer.alloc(end - start);
787
787
  if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
788
- const payload = JSON.stringify({
789
- path: displayPath(absolute),
790
- size: info.size,
791
- offsetBytes: start,
792
- lengthBytes: buffer.length,
793
- nextOffsetBytes: end < info.size ? end : null,
794
- complete: end >= info.size,
795
- encoding: 'base64',
796
- data: buffer.toString('base64'),
797
- });
788
+ const renderPayload = lengthBytes => {
789
+ const actualEnd = start + lengthBytes;
790
+ return JSON.stringify({
791
+ path: displayPath(absolute),
792
+ size: info.size,
793
+ offsetBytes: start,
794
+ lengthBytes,
795
+ nextOffsetBytes: actualEnd < info.size ? actualEnd : null,
796
+ complete: actualEnd >= info.size,
797
+ encoding: 'base64',
798
+ data: buffer.subarray(0, lengthBytes).toString('base64'),
799
+ });
800
+ };
801
+ let returnedLength = buffer.length;
802
+ let payload = renderPayload(returnedLength);
803
+ if (!fitsOutput(payload)) {
804
+ let low = 0;
805
+ let high = returnedLength;
806
+ while (low < high) {
807
+ const candidate = Math.ceil((low + high) / 2);
808
+ if (fitsOutput(renderPayload(candidate))) low = candidate;
809
+ else high = candidate - 1;
810
+ }
811
+ returnedLength = low;
812
+ payload = renderPayload(returnedLength);
813
+ if (!fitsOutput(payload)) fail('Runtime output limit is too small to return read_binary metadata');
814
+ }
798
815
  return text(payload);
799
816
  } finally {
800
817
  await opened.close();
@@ -1775,14 +1792,31 @@ async function moveArchiveFile(source, destination) {
1775
1792
  }
1776
1793
  }
1777
1794
 
1778
- function archiveTool() {
1779
- const probe = (name, versionArgs = ['--version']) => {
1780
- const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
1781
- return !result.error && result.status === 0 ? name : null;
1782
- };
1795
+ function archiveCommandProbe(name, versionArgs = ['--version']) {
1796
+ const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
1797
+ return !result.error && result.status === 0 ? name : null;
1798
+ }
1799
+
1800
+ function archiveTool({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1801
+ const tarName = platform === 'win32' ? 'tar.exe' : 'tar';
1802
+ // Windows ships libarchive/bsdtar as tar.exe. It can create/read ZIP as well as tar-family
1803
+ // archives, so keeping one backend avoids introducing a second extraction/safety path.
1804
+ if (platform === 'win32') return { tar: probe(tarName), zip:null, unzip:null };
1783
1805
  // Info-ZIP unzip (the default on Debian/Ubuntu) treats --version as an invalid combination and
1784
1806
  // exits 10 even though the binary is healthy. Its portable version probe is -v.
1785
- return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1807
+ return { tar: probe(tarName), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1808
+ }
1809
+
1810
+ export function archiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1811
+ if (platform === 'win32') return Boolean(archiveTool({ platform, probe }).tar);
1812
+ return platform === 'linux' || platform === 'darwin';
1813
+ }
1814
+
1815
+ export function zipArchiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1816
+ const tools = archiveTool({ platform, probe });
1817
+ if (platform === 'win32') return Boolean(tools.tar);
1818
+ if (platform === 'linux' || platform === 'darwin') return Boolean(tools.zip && tools.unzip);
1819
+ return false;
1786
1820
  }
1787
1821
 
1788
1822
  export async function createArchiveTool(args, extra = {}) {
@@ -1801,7 +1835,10 @@ export async function createArchiveTool(args, extra = {}) {
1801
1835
  const baseDir = path.dirname(resolved[0]);
1802
1836
  const names = resolved.map(entry => path.relative(baseDir, entry));
1803
1837
  if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
1804
- const archiveNames = names.map(name => name.startsWith('-') ? `./${name}` : name);
1838
+ const archiveNames = names.map(name => {
1839
+ const portable = name.split(path.sep).join('/');
1840
+ return portable.startsWith('-') ? `./${portable}` : portable;
1841
+ });
1805
1842
  const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
1806
1843
  const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
1807
1844
  if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
@@ -1822,9 +1859,15 @@ export async function createArchiveTool(args, extra = {}) {
1822
1859
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1823
1860
  }
1824
1861
  if (format === 'zip') {
1825
- if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1826
- const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1827
- if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1862
+ if (process.platform === 'win32') {
1863
+ if (!tools.tar) fail('Windows archive backend is unavailable: tar.exe is required');
1864
+ const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1865
+ if (result.status !== 0) fail(`tar.exe ZIP creation failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1866
+ } else {
1867
+ if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1868
+ const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1869
+ if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1870
+ }
1828
1871
  } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
1829
1872
  if (!tools.tar) fail('tar is not installed on this device');
1830
1873
  const flags = format === 'tar' ? '-cf' : '-czf';
@@ -1880,7 +1923,7 @@ export async function extractArchiveTool(args) {
1880
1923
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1881
1924
  const existingDestination = await lstat(destination).catch(() => null);
1882
1925
  if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
1883
- if (format === 'zip') {
1926
+ if (format === 'zip' && process.platform !== 'win32') {
1884
1927
  if (!tools.unzip) fail('unzip is not installed on this device');
1885
1928
  const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1886
1929
  if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
@@ -1895,7 +1938,7 @@ export async function extractArchiveTool(args) {
1895
1938
  const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1896
1939
  if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1897
1940
  } else {
1898
- if (!tools.tar) fail('tar is not installed on this device');
1941
+ if (!tools.tar) fail(process.platform === 'win32' ? 'Windows archive backend is unavailable: tar.exe is required' : 'tar is not installed on this device');
1899
1942
  const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
1900
1943
  const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1901
1944
  if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
package/src/util.mjs CHANGED
@@ -136,10 +136,16 @@ function frameBytes(text) {
136
136
  return Buffer.byteLength(JSON.stringify(String(text)), 'utf8');
137
137
  }
138
138
 
139
+ export function fitsOutput(text, maxBytes) {
140
+ const limit = maxBytes || liveConfig('maxOutputBytes');
141
+ const value = String(text);
142
+ return Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit;
143
+ }
144
+
139
145
  export function truncate(text, maxBytes) {
140
146
  const limit = maxBytes || liveConfig('maxOutputBytes');
141
147
  const value = String(text);
142
- if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
148
+ if (fitsOutput(value, limit)) return value;
143
149
  // Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
144
150
  let size = Math.min(Buffer.byteLength(value, 'utf8'), limit);
145
151
  let rendered = '';