@remcp/runtime 0.2.217 → 0.2.220

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.217",
3
+ "version": "0.2.220",
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",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@jellybrick/dbus-next": "^0.11.3",
28
- "@modelcontextprotocol/server": "^2.3.0"
28
+ "@modelcontextprotocol/server": "^2.3.0",
29
+ "pdf-lib": "^1.17.1"
29
30
  },
30
31
  "optionalDependencies": {
31
32
  "macos-temperature-sensor": "2.1.1"
@@ -405,6 +405,11 @@ export async function screenshotRegion(args = {}) {
405
405
  }
406
406
  if (!row) throw new Error('No matching window found for screenshot_region');
407
407
  x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
408
+ } else if (process.platform === 'win32' && optionalString(args.monitor)?.toLowerCase() === 'primary'
409
+ && args.monitor_index == null && clamp(args.padding, 0, 0, 200) === 0) {
410
+ // Windows GDI capture resolves primary geometry in the same PowerShell
411
+ // invocation. Avoid a second, expensive process on loaded Windows VMs.
412
+ return adapter.screenshotRegion({ monitor:'primary' });
408
413
  } else if (args.monitor != null || args.monitor_index != null) {
409
414
  const displays = resultValue(await displayInventory());
410
415
  if (!Array.isArray(displays)) throw new Error('This platform does not expose normalized monitor geometry for screenshot_region');
@@ -302,7 +302,7 @@ export function buildWindowsClipboardReadPowerShell() {
302
302
  export function decodeWindowsClipboardRead(stdout = '') {
303
303
  const encoded = String(stdout).trim();
304
304
  if (!encoded) return '';
305
- return Buffer.from(encoded, 'base64').toString('utf8').replace(/\r?\n$/, '');
305
+ return Buffer.from(encoded, 'base64').toString('utf8');
306
306
  }
307
307
 
308
308
  export async function clipboard(args = {}) {
@@ -409,16 +409,30 @@ public static class RMDisplay{
409
409
  return jsonResult(Array.isArray(parsed)?parsed:[parsed]);
410
410
  }
411
411
 
412
+ export function windowsRegionCaptureScript({primary=false,x=0,y=0,width=0,height=0} = {}, target='') {
413
+ const safe=escapePowerShellSingle(target);
414
+ const bounds=primary
415
+ ? 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;$r=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;'
416
+ : `Add-Type -AssemblyName System.Drawing;$r=[pscustomobject]@{X=${x};Y=${y};Width=${width};Height=${height}};`;
417
+ return `$ErrorActionPreference='Stop';${bounds}if($r.Width -le 0 -or $r.Height -le 0 -or $r.Width -gt 8192 -or $r.Height -gt 8192 -or ($r.Width*$r.Height) -gt 16777216){throw 'Invalid or oversized display bounds'};$b=New-Object System.Drawing.Bitmap $r.Width,$r.Height;$g=$null;try{$g=[System.Drawing.Graphics]::FromImage($b);$g.CopyFromScreen($r.X,$r.Y,0,0,$b.Size);$b.Save('${safe}',[System.Drawing.Imaging.ImageFormat]::Png)}finally{if($g){$g.Dispose()};$b.Dispose()}`;
418
+ }
419
+
412
420
  export async function screenshotRegion(args = {}) {
421
+ const primary=args.monitor==='primary' && args.x==null && args.y==null
422
+ && args.width==null && args.height==null;
413
423
  const x=Math.trunc(Number(args.x)),y=Math.trunc(Number(args.y)),width=Math.trunc(Number(args.width)),height=Math.trunc(Number(args.height));
414
- if(![x,y,width,height].every(Number.isFinite)||width<=0||height<=0)throw new Error('x, y, width and height are required; width/height must be positive');
415
- if(width>8192||height>8192||width*height>16_777_216)throw new Error('screenshot region is too large; width/height must be <= 8192 and area <= 16 megapixels');
424
+ if(!primary && (![x,y,width,height].every(Number.isFinite)||width<=0||height<=0))
425
+ throw new Error('x, y, width and height are required; width/height must be positive');
426
+ if(!primary && (width>8192||height>8192||width*height>16_777_216))
427
+ throw new Error('screenshot region is too large; width/height must be <= 8192 and area <= 16 megapixels');
416
428
  const dir=await tempDir('remcp-region-'),target=path.join(dir,'region.png');
417
429
  try{
418
- const safe=escapePowerShellSingle(target);
419
- await runPowerShell(`Add-Type -AssemblyName System.Drawing;$b=New-Object System.Drawing.Bitmap(${width},${height});$g=[System.Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(${x},${y},0,0,$b.Size);$b.Save('${safe}',[System.Drawing.Imaging.ImageFormat]::Png);$g.Dispose();$b.Dispose()`,{label:'screenshot region'});
430
+ const script=windowsRegionCaptureScript({primary,x,y,width,height},target);
431
+ await runPowerShell(script,{label:'screenshot region',timeout:30_000});
420
432
  const {data}=await readPrivateTempFile(target,4*1024*1024);
421
- return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
433
+ const description=primary ? `Captured primary display ${data.readUInt32BE(16)}x${data.readUInt32BE(20)}.`
434
+ : `Captured ${width}x${height} at ${x},${y}.`;
435
+ return multi([{type:'text',text:description},image(data.toString('base64'),'image/png')]);
422
436
  }finally{await removeTemp(dir);}
423
437
  }
424
438
 
@@ -275,6 +275,11 @@ function pdfAnnotations(buffer) {
275
275
  return rows;
276
276
  }
277
277
 
278
+ async function loadPdfLibrary() {
279
+ const { PDFDocument } = await import('pdf-lib');
280
+ return PDFDocument;
281
+ }
282
+
278
283
  export async function pdfAction(args) {
279
284
  const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
280
285
  if (action === 'annotations') {
@@ -289,7 +294,9 @@ export async function pdfAction(args) {
289
294
  finally { await removeTemp(snapshot.dir); }
290
295
  }
291
296
  const data = await readDocumentFile(source);
292
- return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
297
+ let pageCount = null;
298
+ try { const PDFDocument = await loadPdfLibrary(); pageCount = (await PDFDocument.load(data, { updateMetadata:false })).getPageCount(); } catch {}
299
+ return jsonResult({ path:source, bytes:data.length, ...(pageCount === null ? {} : { page_count:pageCount }), annotation_count:pdfAnnotations(data).length });
293
300
  }
294
301
  if (action === 'merge') {
295
302
  const requested = Array.isArray(args.paths) ? args.paths : [];
@@ -310,7 +317,15 @@ export async function pdfAction(args) {
310
317
  }
311
318
  if(commandExists('pdfunite')) await runFile('pdfunite',[...snapshots,staged],{label:'PDF merge',timeout:60_000});
312
319
  else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...snapshots,'--',staged],{label:'PDF merge',timeout:60_000});
313
- else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
320
+ else {
321
+ const PDFDocument=await loadPdfLibrary();
322
+ const merged=await PDFDocument.create();
323
+ for(const snapshot of snapshots){
324
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot),{updateMetadata:false});
325
+ for(const page of await merged.copyPages(sourceDoc,sourceDoc.getPageIndices())) merged.addPage(page);
326
+ }
327
+ await writeFile(staged,await merged.save());
328
+ }
314
329
  await commitDocumentOutput(staged,output);
315
330
  return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
316
331
  } finally {
@@ -331,7 +346,20 @@ export async function pdfAction(args) {
331
346
  try {
332
347
  const pattern=path.join(work,patternName);
333
348
  if(commandExists('pdfseparate')) await runFile('pdfseparate',[snapshot.path,pattern],{label:'PDF split',timeout:60_000});
334
- else unavailable('PDF split','install poppler-utils (pdfseparate)');
349
+ else {
350
+ const PDFDocument=await loadPdfLibrary();
351
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot.path),{updateMetadata:false});
352
+ const total=sourceDoc.getPageCount();
353
+ for(let page=1;page<=total;page+=1){
354
+ const single=await PDFDocument.create();
355
+ const [copied]=await single.copyPages(sourceDoc,[page-1]);
356
+ single.addPage(copied);
357
+ const name=patternName.includes('%d')
358
+ ? patternName.replace(/%d/g,String(page))
359
+ : patternName.toLowerCase().endsWith('.pdf') ? `${patternName.slice(0,-4)}-${page}.pdf` : `${patternName}-${page}.pdf`;
360
+ await writeFile(path.join(work,name),await single.save());
361
+ }
362
+ }
335
363
  for(const name of await readdir(work)){
336
364
  if(!name.toLowerCase().endsWith('.pdf')) continue;
337
365
  const staged=path.join(stageDir,name);
@@ -378,7 +406,14 @@ export async function pdfAction(args) {
378
406
  }
379
407
  } finally { await removeTemp(work); }
380
408
  } else {
381
- unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
409
+ const PDFDocument=await loadPdfLibrary();
410
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot.path),{updateMetadata:false});
411
+ const total=sourceDoc.getPageCount();
412
+ const indices=selectedPages.map(page=>page-1);
413
+ if(indices.some(index=>index>=total)) throw new Error(`Page selection exceeds ${total} pages`);
414
+ const extracted=await PDFDocument.create();
415
+ for(const page of await extracted.copyPages(sourceDoc,indices)) extracted.addPage(page);
416
+ await writeFile(staged,await extracted.save());
382
417
  }
383
418
  await commitDocumentOutput(staged,output);
384
419
  return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
@@ -15,7 +15,7 @@ export function privilegeBoundary({
15
15
  if (output === undefined) {
16
16
  try {
17
17
  const result = spawnSync('whoami.exe', ['/groups'], {
18
- encoding: 'utf8', windowsHide: true, timeout: 3000, maxBuffer: 64 * 1024,
18
+ encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 64 * 1024,
19
19
  });
20
20
  output = result.status === 0 && !result.error ? result.stdout : '';
21
21
  } catch { output = ''; }
@@ -17,7 +17,7 @@ import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp,
17
17
 
18
18
  import { IMAGE_TYPES, MAX_IMAGE_BYTES, MAX_INLINE_FILE_BYTES, READ_NOFOLLOW_NONBLOCK, detectEol, openDirectoryPath, openEntryAtPath, openRegularFile, readRegularBuffer } from './files-secure-paths.mjs';
19
19
 
20
- export async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
20
+ export async function collectTree(root, { maxFiles = 500, skip = [], signal = null, acceptFile = null } = {}) {
21
21
  const found = [];
22
22
  const denied = [];
23
23
  const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
@@ -52,7 +52,7 @@ export async function collectTree(root, { maxFiles = 500, skip = [], signal = nu
52
52
  try {
53
53
  opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
54
54
  const info = await opened.handle.stat();
55
- if (info.isFile()) found.push(childLogical);
55
+ if (info.isFile() && (!acceptFile || await acceptFile(childLogical, info))) found.push(childLogical);
56
56
  } catch {
57
57
  denied.push(childLogical);
58
58
  } finally {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { spawnSync } from 'node:child_process';
3
+ import { execFile, spawnSync } from 'node:child_process';
4
+ import { promisify } from 'node:util';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
6
  import { constants, createReadStream } from 'node:fs';
6
7
  import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
@@ -21,6 +22,9 @@ import { diffFilesTool, editBlockTool, moveToTrashTool, readBinaryTool, replaceI
21
22
  import { applyPatchTool, copyFileTool, copyPathsTool, createDirectoryTool, deletePathTool, deletePathsTool, moveFileTool, movePathsTool, pathExists, readFilesTool, setPermissionsTool, writeFilesTool } from './files-bulk-operations.mjs';
22
23
  import { createArchiveTool, extractArchiveTool } from './files-archive-actions.mjs';
23
24
 
25
+ const execFileAsync = promisify(execFile);
26
+ let windowsScreenshotInFlight = false;
27
+
24
28
  export const SCREENSHOT_COMMANDS = [
25
29
  { command: 'grim', args: file => [file] },
26
30
  { command: 'gnome-screenshot', args: file => ['-f', file] },
@@ -38,6 +42,9 @@ export function screenshotAdvice(attempts, { platform = process.platform, env =
38
42
  return `macOS refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Grant Screen Recording to the binary that runs the tools — System Settings → Privacy & Security → Screen Recording → + → ${execPath} — then restart the agent with \`remcp start\`. macOS requires it for screencapture even when the file itself is writable.`;
39
43
  }
40
44
  if (platform === 'win32') {
45
+ if (attempts.some(attempt => attempt.includes('A Windows screen capture is already in progress'))) {
46
+ return 'A Windows screen capture is already in progress. Retry after it finishes.';
47
+ }
41
48
  return `Windows refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Screen capture needs an interactive desktop session: a machine where nobody is signed in, or a locked session, cannot be captured. Sign in on that computer and try again.`;
42
49
  }
43
50
  const wayland = /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
@@ -58,13 +65,36 @@ export function screenshotAdvice(attempts, { platform = process.platform, env =
58
65
 
59
66
  export function windowsScreenshotScript(file) {
60
67
  return [
68
+ "$ErrorActionPreference = 'Stop'",
61
69
  'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
62
70
  '$b = [System.Windows.Forms.SystemInformation]::VirtualScreen',
63
- '$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
64
- '$g = [System.Drawing.Graphics]::FromImage($bmp)',
65
- '$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
71
+ "if ($b.Width -le 0 -or $b.Height -le 0) { throw 'No valid desktop geometry for screenshot' }",
72
+ '$bmp = $null',
73
+ '$g = $null',
74
+ 'try {',
75
+ ' $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
76
+ ' $g = [System.Drawing.Graphics]::FromImage($bmp)',
77
+ ' $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
66
78
  `$bmp.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
67
- ].join('; ');
79
+ '} finally {',
80
+ ' if ($null -ne $g) { $g.Dispose() }',
81
+ ' if ($null -ne $bmp) { $bmp.Dispose() }',
82
+ '}',
83
+ ].join('\n');
84
+ }
85
+
86
+ // Never block the runtime event loop while Windows initializes GDI/PowerShell.
87
+ // A slow or inaccessible interactive desktop must not starve ping or relay RPC.
88
+ export async function runWindowsScreenshot(file, runProcess = execFileAsync) {
89
+ if (windowsScreenshotInFlight) throw new Error('A Windows screen capture is already in progress; retry after it finishes');
90
+ windowsScreenshotInFlight = true;
91
+ try {
92
+ return await runProcess('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], {
93
+ encoding:'utf8', timeout:30_000, windowsHide:true, maxBuffer:64*1024,
94
+ });
95
+ } finally {
96
+ windowsScreenshotInFlight = false;
97
+ }
68
98
  }
69
99
 
70
100
  export async function takeScreenshotTool(args) {
@@ -86,8 +116,11 @@ export async function takeScreenshotTool(args) {
86
116
  let preserveCapture = false;
87
117
  try {
88
118
  if (process.platform === 'win32') {
89
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
90
- attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
119
+ try {
120
+ await runWindowsScreenshot(file);
121
+ } catch (error) {
122
+ attempts.push(`powershell: ${String(error?.stderr || error?.message || error).trim().slice(0, 512)}`);
123
+ }
91
124
  } else {
92
125
  if (process.platform === 'linux' && isWaylandSession()) {
93
126
  try {
@@ -234,12 +234,43 @@ export async function replaceInFilesTool(args, extra = {}) {
234
234
  fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
235
235
  }
236
236
  }
237
- const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
238
- const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
239
- const files = await confineAll(walk.files);
240
237
  const glob = filePattern ? globToRegExp(filePattern) : null;
241
238
  const changed = [];
242
239
  let scanned = 0;
240
+ const match = original => {
241
+ if (isRegex) matcher.lastIndex = 0;
242
+ const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
243
+ if (!count) return null;
244
+ if (isRegex) matcher.lastIndex = 0;
245
+ const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
246
+ return updated === original ? null : { count, updated };
247
+ };
248
+ // Select actual candidates before writing, so maxFiles limits changed files
249
+ // rather than unrelated directory entries, and cancellation during discovery
250
+ // still cannot leave a partially edited tree.
251
+ const acceptFile = async (file, info) => {
252
+ throwIfCancelled(extra.signal);
253
+ if (info.size > MAX_INLINE_FILE_BYTES || (glob && !glob.test(path.basename(file)))) return false;
254
+ let opened;
255
+ try {
256
+ const safe = await resolveSafePath(file);
257
+ opened = await openRegularFile(safe, dryRun ? READ_NOFOLLOW : READWRITE_NOFOLLOW);
258
+ if (opened.info.size > MAX_INLINE_FILE_BYTES) return false;
259
+ const buffer = await opened.handle.readFile();
260
+ const decoded = decodeText(buffer);
261
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) return false;
262
+ scanned += 1;
263
+ return Boolean(match(decoded.text));
264
+ } catch {
265
+ throwIfCancelled(extra.signal);
266
+ return false;
267
+ } finally {
268
+ if (opened) await opened.close().catch(() => {});
269
+ }
270
+ };
271
+ const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
272
+ const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal, acceptFile });
273
+ const files = await confineAll(walk.files);
243
274
  for (const file of files) {
244
275
  throwIfCancelled(extra.signal);
245
276
  if (changed.length >= maxFiles) break;
@@ -253,13 +284,11 @@ export async function replaceInFilesTool(args, extra = {}) {
253
284
  const buffer = await handle.readFile();
254
285
  const decoded = decodeText(buffer);
255
286
  if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
256
- scanned += 1;
287
+ if (info.isFile()) scanned += 1;
257
288
  const original = decoded.text;
258
- const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
259
- if (!count) continue;
260
- if (isRegex) matcher.lastIndex = 0;
261
- const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
262
- if (updated === original) continue;
289
+ const result = match(original);
290
+ if (!result) continue;
291
+ const { count, updated } = result;
263
292
  assertWritableSize(updated);
264
293
  if (!dryRun) await overwriteOpenFile(handle, updated);
265
294
  const stats = diffStats(original, updated);
@@ -1,6 +1,6 @@
1
1
  // Best-effort host measurements. Keep interface names, mount paths and sensor labels bounded;
2
2
  // never expose raw process arguments or arbitrary kernel sensor metadata.
3
- import { readFile, readdir } from 'node:fs/promises';
3
+ import { readFile, readdir, statfs } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import os from 'node:os';
@@ -185,8 +185,36 @@ export function parseDiskVolumes(input) {
185
185
  return volumes;
186
186
  }
187
187
 
188
+ async function windowsDiskVolumes() {
189
+ const volumes = [];
190
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
191
+ const mount = `${letter}:\\`;
192
+ let timer;
193
+ try {
194
+ const info = await Promise.race([
195
+ statfs(mount),
196
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('volume probe timeout')), 250); }),
197
+ ]);
198
+ const totalBytes = Number(info.blocks) * Number(info.bsize);
199
+ const freeBytes = Number(info.bfree) * Number(info.bsize);
200
+ const availableBytes = Number(info.bavail) * Number(info.bsize);
201
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) continue;
202
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
203
+ volumes.push({
204
+ mount,
205
+ totalBytes,
206
+ usedBytes,
207
+ availableBytes,
208
+ usedRatio: Number((usedBytes / totalBytes).toFixed(3)),
209
+ });
210
+ } catch {}
211
+ finally { clearTimeout(timer); }
212
+ }
213
+ return volumes;
214
+ }
215
+
188
216
  export async function diskVolumes() {
189
- if (process.platform === 'win32') return [];
217
+ if (process.platform === 'win32') return windowsDiskVolumes();
190
218
  try {
191
219
  const { stdout } = await run('df', ['-kP'], { timeout: 2000, maxBuffer: 256 * 1024 });
192
220
  return parseDiskVolumes(stdout);
@@ -1,6 +1,8 @@
1
1
  import os from 'node:os';
2
+ import path from 'node:path';
2
3
  import process from 'node:process';
3
4
  import { execFile } from 'node:child_process';
5
+ import { statfs } from 'node:fs/promises';
4
6
  import { promisify } from 'node:util';
5
7
  import { runtimeConfig } from '../config.mjs';
6
8
  import { clampInteger, fail, requireInteger, text } from '../util.mjs';
@@ -18,7 +20,23 @@ export function redactSecrets(command) {
18
20
  }
19
21
 
20
22
  async function diskUsage() {
21
- if (process.platform === 'win32') return null;
23
+ if (process.platform === 'win32') {
24
+ const mount = path.parse(process.cwd()).root || 'C:\\';
25
+ let timer;
26
+ try {
27
+ const info = await Promise.race([
28
+ statfs(mount),
29
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('disk probe timeout')), 500); }),
30
+ ]);
31
+ const totalBytes = Number(info.blocks) * Number(info.bsize);
32
+ const freeBytes = Number(info.bfree) * Number(info.bsize);
33
+ const availableBytes = Number(info.bavail) * Number(info.bsize);
34
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
35
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
36
+ return { mount, totalBytes, usedBytes, availableBytes, usedRatio: Number((usedBytes / totalBytes).toFixed(3)) };
37
+ } catch { return null; }
38
+ finally { clearTimeout(timer); }
39
+ }
22
40
  try {
23
41
  const { stdout } = await run('df', ['-kP', process.cwd()], { maxBuffer: 1024 * 1024 });
24
42
  const line = stdout.trim().split('\n')[1];