newmark-agent 0.6.7 → 0.6.8

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.
@@ -64,6 +64,7 @@ const localOcr_1 = require("../core/localOcr");
64
64
  const visualTextFallback_1 = require("../core/visualTextFallback");
65
65
  const computerUseSession_1 = require("../core/computerUseSession");
66
66
  const searchMcpPool_1 = require("../core/searchMcpPool");
67
+ const fileWriteObservation_1 = require("../core/fileWriteObservation");
67
68
  function normalizeComputerUseAction(action) {
68
69
  return String(action || '').trim().toLowerCase();
69
70
  }
@@ -279,6 +280,7 @@ class ToolExecutor {
279
280
  localOcr;
280
281
  argumentValidators = new toolArgumentValidator_1.ToolArgumentValidatorRegistry();
281
282
  searchMcpPool;
283
+ fileWriteObservationGate = new fileWriteObservation_1.FileWriteObservationGate();
282
284
  hostProfile = {
283
285
  kind: 'desktop',
284
286
  platform: process.platform,
@@ -351,15 +353,15 @@ class ToolExecutor {
351
353
  const tools = [
352
354
  t('bash', `${shellDescription} Optional timeout_ms lets the Agent choose this command timeout in milliseconds; 0 requests no limit, but a nonzero terminal.interrupt_timeout_ms setting is the upper cap.`, { command: { type: 'string' }, timeout_ms: { type: 'number', description: 'Per-command timeout in milliseconds. 0 means no requested limit unless capped by settings.' } }, ['command']),
353
355
  t('pwd', 'Print working directory (current folder path)', {}, []),
354
- t('read', 'Read file contents. Use ABSOLUTE paths. The working directory is given in system prompt.', { path: { type: 'string' } }, ['path']),
355
- t('write', 'Write/create a file. Use ABSOLUTE paths.', { path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content']),
356
- t('edit', 'Edit file with find-and-replace. Use ABSOLUTE paths.', { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, ['path', 'old_str', 'new_str']),
357
- t('delete_file', 'Delete ONE file under Agent supervision. Use ABSOLUTE paths. This tool refuses directory deletion and wildcard paths; delete files one by one. Never use bash rm/del/Remove-Item for batch (recursive/wildcard/loop/pipe/multi-target) deletion — the runtime hard-blocks such commands.', { path: { type: 'string' } }, ['path']),
356
+ t('read', 'Read file contents. Use ABSOLUTE paths. A complete read or explicit line range records a short-lived, conversation/workspace/run-scoped observation for write/edit/delete_file. Read an exact target path before changing it. Large files can be read with start_line/end_line; overwrite/delete requires full line coverage, while edit requires the exact old_str lines.', { path: { type: 'string' }, start_line: { type: 'integer', minimum: 1, description: 'Optional inclusive first line for a bounded read. Provide together with end_line.' }, end_line: { type: 'integer', minimum: 1, description: 'Optional inclusive last line for a bounded read. Provide together with start_line.' } }, ['path']),
357
+ t('write', 'Write/create a file only after the exact target path has been read in this conversation, workspace, actor, and Build run. Existing files require a complete read; new files require an exact-path absence observation. Changed targets are refused until read again.', { path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content']),
358
+ t('edit', 'Edit a file with find-and-replace only after reading the exact target lines in this conversation, workspace, actor, and Build run. The old_str must have appeared in a successful read range; stale or ambiguous matches are refused.', { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, ['path', 'old_str', 'new_str']),
359
+ t('delete_file', 'Delete ONE file only after reading its complete contents at the exact target path in this conversation, workspace, actor, and Build run. This tool refuses directory deletion and wildcard paths; delete files one by one. Never use bash rm/del/Remove-Item for batch (recursive/wildcard/loop/pipe/multi-target) deletion — the runtime hard-blocks such commands.', { path: { type: 'string' } }, ['path']),
358
360
  t('glob', 'Find files by glob pattern (e.g. **/*.ts, src/**/*.html)', { pattern: { type: 'string' } }, ['pattern']),
359
361
  t('grep', 'Search file content with regex', { pattern: { type: 'string' }, path: { type: 'string' } }, ['pattern', 'path']),
360
362
  t('web_search', 'Search the web', { query: { type: 'string' } }, ['query']),
361
363
  t('web_fetch', 'Fetch and extract URL content', { url: { type: 'string' } }, ['url']),
362
- t('web_catch', 'Download a public web resource, save a webpage, or capture one page component into an exact workspace destination. Advanced Build/Goal tool; unavailable in Plan and Chat. This writes only the requested local output and never changes the remote page.', {
364
+ t('web_catch', 'Download a public web resource, save a webpage, or capture one page component into an exact workspace destination. Read the exact destination first; existing files require a complete read and new files require an exact-path absence observation. Advanced Build/Goal tool; unavailable in Plan and Chat. This writes only the requested local output and never changes the remote page.', {
363
365
  action: { type: 'string', enum: ['download', 'save_page', 'capture_component'], description: 'download saves response bytes; save_page stores the HTML response; capture_component selects one CSS element and saves its src/href asset, or its outer HTML when no asset attribute exists.' },
364
366
  url: { type: 'string', description: 'Public http:// or https:// URL.' },
365
367
  destination: { type: 'string', description: 'Exact output file path. Relative paths resolve inside the active workspace.' },
@@ -405,23 +407,25 @@ class ToolExecutor {
405
407
  capture_max_height: { type: 'number', minimum: 240, maximum: 2048, description: 'Maximum ephemeral screenshot height; defaults to 960. Aspect ratio is preserved and the source is never enlarged.' },
406
408
  max_chars: { type: 'number', minimum: 1000, maximum: 100000, description: 'Maximum returned semantic UI text characters.' },
407
409
  }, []),
408
- t('computer_use', 'Native Windows desktop control with persistent observation/action helpers. Use observe/app_observe before acting. For passive observe, sparse_wait_ms can wait up to 5 seconds on a coarse local pixel-change signal without a local perception model; after a signal or timeout it always captures a fresh full screenshot and UI Automation tree for the selected vision model. This low-resolution signal can miss small or brief changes and does not prove that no change occurred. sequence may perform up to three stable low-risk move/click/scroll/wait/app_activate steps and stops when focus, window, menu, dialog, scene, or risk changes. Vision screenshots remain one-time inputs and are deleted immediately.', {
409
- action: { type: 'string', enum: ['observe', 'app_list', 'app_observe', 'sequence', 'takeover_start', 'takeover_stop', 'move', 'click', 'scroll', 'type', 'key', 'wait', 'app_activate', 'app_click', 'app_scroll', 'app_type', 'app_key'] },
410
+ t('computer_use', 'Native Windows desktop control with persistent observation/action helpers. Use observe/app_observe before acting. Set include_ui_tree=false for a fast screenshot-only observation; the default includes a bounded UI Automation tree for semantic targets. wait_for polls one visible app window for text, a target_id from the latest observation, or window availability. A match refreshes the semantic target cache; inspect the returned element before acting. For passive observe, sparse_wait_ms can wait up to 5 seconds on a coarse local pixel-change signal without a local perception model; after a signal or timeout it captures a fresh screenshot and the requested UI Automation data. This low-resolution signal can miss small or brief changes and does not prove that no change occurred. sequence may perform up to three stable low-risk move/click/scroll/wait/app_activate steps and stops when focus, window, menu, dialog, scene, or risk changes. Vision screenshots remain one-time inputs and are deleted immediately.', {
411
+ action: { type: 'string', enum: ['observe', 'app_list', 'app_observe', 'wait_for', 'sequence', 'takeover_start', 'takeover_stop', 'move', 'click', 'scroll', 'type', 'key', 'wait', 'app_activate', 'app_click', 'app_scroll', 'app_type', 'app_key'] },
410
412
  capture_max_width: { type: 'number', minimum: 320, maximum: 2048, description: 'For observe/app_observe only. Maximum ephemeral screenshot width; defaults to 1280. Aspect ratio is preserved and the source is never enlarged.' },
411
413
  capture_max_height: { type: 'number', minimum: 240, maximum: 2048, description: 'For observe/app_observe only. Maximum ephemeral screenshot height; defaults to 960. Aspect ratio is preserved and the source is never enlarged.' },
412
- sparse_wait_ms: { type: 'integer', minimum: 0, maximum: 5000, description: 'Optional for action=observe only; defaults to 0 (immediate capture). Wait this many milliseconds for a coarse 32×18 grayscale change signal with no local neural model, then capture a fresh full screenshot and UI Automation tree whether signaled or timed out. Small/transient changes can be missed.' },
414
+ include_ui_tree: { type: 'boolean', description: 'For observe/app_observe only. Defaults to true. Set false to skip UI Automation traversal and return a screenshot-only observation; prior semantic target IDs are invalidated.' },
415
+ sparse_wait_ms: { type: 'integer', minimum: 0, maximum: 5000, description: 'Optional for action=observe only; defaults to 0 (immediate capture). Wait this many milliseconds for a coarse 32×18 grayscale change signal with no local neural model, then capture a fresh screenshot and the UI Automation tree when include_ui_tree=true, whether signaled or timed out. Small/transient changes can be missed.' },
413
416
  x: { type: 'number' },
414
417
  y: { type: 'number' },
415
- target_id: { type: 'string', description: 'Stable id from the latest observe perception.objects entry. Preferred over raw coordinates when available.' },
418
+ target_id: { type: 'string', description: 'Stable id from the latest observe perception.objects entry. Preferred over raw coordinates when available; for wait_for it must come from the latest semantic observation.' },
416
419
  app_target: { type: 'string', description: 'Application title, process name, or process id for app-scoped observation and actions.' },
417
420
  window_handle: { type: 'string', description: 'Native window handle from app_list/app_observe for exact app-scoped actions.' },
418
421
  max_chars: { type: 'number', minimum: 1000, maximum: 100000, description: 'Maximum returned text characters for observation and application listings.' },
419
422
  scroll_x: { type: 'number', description: 'Horizontal scroll delta for action=scroll.' },
420
423
  scroll_y: { type: 'number', description: 'Vertical scroll delta for action=scroll. Positive scrolls down.' },
421
424
  button: { type: 'string', enum: ['left', 'right'] },
422
- text: { type: 'string' },
425
+ text: { type: 'string', description: 'Text to type, or for wait_for a case-insensitive substring of a visible UI Automation Name or AutomationId.' },
423
426
  key: { type: 'string' },
424
427
  duration_ms: { type: 'number' },
428
+ timeout_ms: { type: 'integer', minimum: 250, maximum: 30000, description: 'For wait_for only. Polling budget in milliseconds; defaults to 5000 and is capped at 30000. A UI Automation call already in flight can add bounded completion latency.' },
425
429
  dry_run: { type: 'boolean' },
426
430
  include_raw_ui: { type: 'boolean', description: 'Debug only. Include the raw UI Automation element list; normal observation returns compact semantic objects.' },
427
431
  steps: {
@@ -651,14 +655,18 @@ class ToolExecutor {
651
655
  if (tool.function?.name !== 'computer_use')
652
656
  return tool;
653
657
  const copy = JSON.parse(JSON.stringify(tool));
654
- copy.function.description = 'Plan read-only desktop: observe, app_list, app_observe only.';
658
+ copy.function.description = 'Plan read-only desktop: observe, app_list, app_observe, wait_for only.';
655
659
  copy.function.parameters.properties = {
656
660
  action: { type: 'string', enum: [...toolPolicy_1.PLAN_COMPUTER_USE_ACTIONS] },
657
661
  app_target: copy.function.parameters.properties.app_target,
658
662
  window_handle: copy.function.parameters.properties.window_handle,
663
+ target_id: copy.function.parameters.properties.target_id,
664
+ text: copy.function.parameters.properties.text,
659
665
  max_chars: copy.function.parameters.properties.max_chars,
660
666
  capture_max_width: copy.function.parameters.properties.capture_max_width,
661
667
  capture_max_height: copy.function.parameters.properties.capture_max_height,
668
+ include_ui_tree: copy.function.parameters.properties.include_ui_tree,
669
+ timeout_ms: copy.function.parameters.properties.timeout_ms,
662
670
  include_raw_ui: copy.function.parameters.properties.include_raw_ui,
663
671
  };
664
672
  return copy;
@@ -788,15 +796,99 @@ class ToolExecutor {
788
796
  switch (tool) {
789
797
  case 'bash': return await this.bash(g('command'), wsPath, args.timeout_ms, context.signal);
790
798
  case 'pwd': return `Current directory: ${wsPath}`;
791
- case 'read': return this.fread(resolve(g('path')));
792
- case 'write': return this.fwrite(resolve(g('path')), g('content'));
793
- case 'edit': return this.fedit(resolve(g('path')), g('old_str'), g('new_str'));
794
- case 'delete_file': return this.fdelete(resolve(g('path')));
799
+ case 'read':
800
+ return await this.fileWriteObservationGate.read(resolve(g('path')), this.fileWriteObservationScope(context, wsPath), {
801
+ startLine: args.start_line === undefined ? undefined : Number(args.start_line),
802
+ endLine: args.end_line === undefined ? undefined : Number(args.end_line),
803
+ });
804
+ case 'write': {
805
+ const target = resolve(g('path'));
806
+ const mutation = await this.fileWriteObservationGate.mutate(target, this.fileWriteObservationScope(context, wsPath), { kind: 'full' }, gate => {
807
+ const denied = this.checkWorkspaceAccess('write', gate.canonicalPath, context.workspacePath || wsPath);
808
+ if (denied)
809
+ return { value: denied, committed: false };
810
+ if (gate.exists) {
811
+ gate.assertBeforeCommit();
812
+ fs.writeFileSync(target, g('content'), 'utf-8');
813
+ }
814
+ else {
815
+ fs.mkdirSync(path.dirname(target), { recursive: true });
816
+ gate.assertBeforeCommit();
817
+ fs.writeFileSync(target, g('content'), { encoding: 'utf-8', flag: 'wx' });
818
+ }
819
+ return { value: '[write] OK: ' + target, committed: true };
820
+ });
821
+ return mutation.ok ? mutation.value : mutation.error;
822
+ }
823
+ case 'edit': {
824
+ const target = resolve(g('path'));
825
+ const oldText = g('old_str');
826
+ const replacement = g('new_str');
827
+ const mutation = await this.fileWriteObservationGate.mutate(target, this.fileWriteObservationScope(context, wsPath), { kind: 'edit', oldText }, gate => {
828
+ const denied = this.checkWorkspaceAccess('edit', gate.canonicalPath, context.workspacePath || wsPath);
829
+ if (denied)
830
+ return { value: denied, committed: false };
831
+ if (!gate.exists || gate.text === undefined)
832
+ return { value: '[edit] Target file does not exist: ' + target, committed: false };
833
+ const first = gate.text.indexOf(oldText);
834
+ if (first < 0)
835
+ return { value: '[edit] String not found in ' + target + '.', committed: false };
836
+ if (gate.text.indexOf(oldText, first + oldText.length) >= 0) {
837
+ return { value: '[edit] Refused: old_str matches more than one position in ' + target + '; read a more specific context and retry.', committed: false };
838
+ }
839
+ gate.assertBeforeCommit();
840
+ const updated = gate.text.slice(0, first) + replacement + gate.text.slice(first + oldText.length);
841
+ fs.writeFileSync(target, updated, 'utf-8');
842
+ return { value: '[edit] OK: ' + target, committed: true };
843
+ });
844
+ return mutation.ok ? mutation.value : mutation.error;
845
+ }
846
+ case 'delete_file': {
847
+ const target = resolve(g('path'));
848
+ if (/[*?]/.test(target))
849
+ return '[delete_file] Refused: wildcard paths are not allowed. Delete one file per call.';
850
+ try {
851
+ if (fs.lstatSync(target).isDirectory()) {
852
+ return '[delete_file] Refused: deleting a directory is not allowed. Delete files one by one under Agent supervision.';
853
+ }
854
+ }
855
+ catch (error) {
856
+ if (error?.code !== 'ENOENT') {
857
+ return '[delete_file] ' + (error instanceof Error ? error.message : String(error));
858
+ }
859
+ }
860
+ const mutation = await this.fileWriteObservationGate.mutate(target, this.fileWriteObservationScope(context, wsPath), { kind: 'full' }, gate => {
861
+ const denied = this.checkWorkspaceAccess('delete_file', gate.canonicalPath, context.workspacePath || wsPath);
862
+ if (denied)
863
+ return { value: denied, committed: false };
864
+ if (!gate.exists)
865
+ return { value: '[delete_file] Target does not exist: ' + target, committed: false };
866
+ gate.assertBeforeCommit();
867
+ fs.unlinkSync(target);
868
+ return { value: '[delete_file] OK: ' + target, committed: true };
869
+ });
870
+ return mutation.ok ? mutation.value : mutation.error;
871
+ }
795
872
  case 'glob': return this.glob(g('pattern'), wsPath);
796
873
  case 'grep': return this.grep(g('pattern'), resolve(g('path')));
797
874
  case 'web_search': return await this.wsearch(g('query'), context.signal);
798
875
  case 'web_fetch': return await this.wfetch(g('url'), context.signal);
799
- case 'web_catch': return await this.wcatch(args, resolve(g('destination')), context.signal);
876
+ case 'web_catch': {
877
+ const destination = resolve(g('destination'));
878
+ const mutation = await this.fileWriteObservationGate.mutate(destination, this.fileWriteObservationScope(context, wsPath), { kind: 'full' }, async (gate) => {
879
+ const denied = this.checkWorkspaceAccess('web_catch', gate.canonicalPath, context.workspacePath || wsPath);
880
+ if (denied)
881
+ return { value: denied, committed: false };
882
+ const value = await this.wcatch(args, destination, context.signal, () => gate.assertBeforeCommit());
883
+ let committed = false;
884
+ try {
885
+ committed = JSON.parse(value).ok === true;
886
+ }
887
+ catch { }
888
+ return { value, committed };
889
+ });
890
+ return mutation.ok ? mutation.value : mutation.error;
891
+ }
800
892
  case 'browser_open': return await this.browserRun({ action: 'open', url: g('url') }, context.signal, context, wsPath);
801
893
  case 'browser_snapshot': return await this.browserRun({ action: 'snapshot', maxChars: Number(args.max_chars || 12000) }, context.signal, context, wsPath);
802
894
  case 'browser_click': return await this.browserRun({ action: 'click', selector: g('selector') }, context.signal, context, wsPath);
@@ -1000,6 +1092,14 @@ class ToolExecutor {
1000
1092
  }
1001
1093
  case 'computer_use': {
1002
1094
  const action = normalizeComputerUseAction(g('action'));
1095
+ if (process.env.NEWMARK_TEST_CU_OBSERVE_ONLY === '1'
1096
+ && !['observe', 'app_observe', 'app_list', 'wait_for'].includes(action)) {
1097
+ return JSON.stringify({
1098
+ ok: false,
1099
+ action,
1100
+ error: '[computer_use test guard] The live-model smoke only permits read-only desktop observation.',
1101
+ });
1102
+ }
1003
1103
  const owner = `${computerUseOwner(context, wsPath)}:${String(context.actorId || 'root')}`;
1004
1104
  const lockGuard = action === 'takeover_stop'
1005
1105
  ? assertComputerUseLockOwner(action, owner, context, wsPath)
@@ -1065,6 +1165,8 @@ class ToolExecutor {
1065
1165
  captureMaxWidth: Number(args.capture_max_width),
1066
1166
  captureMaxHeight: Number(args.capture_max_height),
1067
1167
  sparseWaitMs: Number(args.sparse_wait_ms),
1168
+ includeUiTree: args.include_ui_tree !== false,
1169
+ timeoutMs: Number(args.timeout_ms),
1068
1170
  signal: context.signal,
1069
1171
  gradientColors: Array.isArray(args.gradient_colors) ? args.gradient_colors : undefined,
1070
1172
  gradientSpeed: args.gradient_speed !== undefined ? Number(args.gradient_speed) : undefined,
@@ -1302,6 +1404,18 @@ class ToolExecutor {
1302
1404
  const action = policy === 'ask_user' ? 'User approval required' : 'Denied';
1303
1405
  return `[permission] ${action}: ${tool} cannot access outside workspace (${target}).`;
1304
1406
  }
1407
+ fileWriteObservationScope(context, wsPath) {
1408
+ const workspacePath = path.resolve(context.workspacePath || wsPath || this.root);
1409
+ return JSON.stringify({
1410
+ runtimeKey: String(context.runtimeKey || process.env.NEWMARK_RUNTIME_KEY || ''),
1411
+ runId: String(context.runId || ''),
1412
+ workspaceId: String(context.workspaceId || workspacePath),
1413
+ workspacePath,
1414
+ conversationId: String(context.conversationId || 'default'),
1415
+ actorId: String(context.actorId || terminalTakeover_1.ROOT_TERMINAL_ACTOR_ID),
1416
+ backend: String(context.backend || (process.env.NEWMARK_WSL_DISTRO ? 'wsl' : process.platform)),
1417
+ });
1418
+ }
1305
1419
  checkBashWorkspaceAccess(command, wsPath) {
1306
1420
  const perm = this.config.getStr('workspace', 'access_permission');
1307
1421
  if (perm === 'full_access')
@@ -1389,54 +1503,6 @@ class ToolExecutor {
1389
1503
  return `[bash] ${e instanceof Error ? e.message : String(e)}`;
1390
1504
  }
1391
1505
  }
1392
- fread(p) {
1393
- try {
1394
- const c = fs.readFileSync(p, 'utf-8');
1395
- return c.length > 30000 ? c.slice(0, 30000) + '...\n[truncated]' : c;
1396
- }
1397
- catch (e) {
1398
- return `[read] ${e}`;
1399
- }
1400
- }
1401
- fwrite(p, content) {
1402
- try {
1403
- fs.mkdirSync(path.dirname(p), { recursive: true });
1404
- fs.writeFileSync(p, content, 'utf-8');
1405
- return `[write] OK: ${p}`;
1406
- }
1407
- catch (e) {
1408
- return `[write] ${e}`;
1409
- }
1410
- }
1411
- fedit(p, oldStr, newStr) {
1412
- try {
1413
- const c = fs.readFileSync(p, 'utf-8');
1414
- if (!c.includes(oldStr))
1415
- return `[edit] String not found in ${p}.`;
1416
- const updated = c.replace(oldStr, newStr);
1417
- fs.writeFileSync(p, updated, 'utf-8');
1418
- return `[edit] OK: ${p}`;
1419
- }
1420
- catch (e) {
1421
- return `[edit] ${e}`;
1422
- }
1423
- }
1424
- fdelete(p) {
1425
- try {
1426
- if (/[*?]/.test(p))
1427
- return '[delete_file] Refused: wildcard paths are not allowed. Delete one file per call.';
1428
- const resolved = path.resolve(p);
1429
- const stat = fs.lstatSync(resolved);
1430
- if (stat.isDirectory()) {
1431
- return '[delete_file] Refused: deleting a directory is not allowed. Delete files one by one under Agent supervision.';
1432
- }
1433
- fs.unlinkSync(resolved);
1434
- return `[delete_file] OK: ${resolved}`;
1435
- }
1436
- catch (e) {
1437
- return `[delete_file] ${e instanceof Error ? e.message : String(e)}`;
1438
- }
1439
- }
1440
1506
  glob(pattern, ws) {
1441
1507
  try {
1442
1508
  const results = globSync(pattern, {
@@ -1888,7 +1954,7 @@ class ToolExecutor {
1888
1954
  guard.dispose();
1889
1955
  }
1890
1956
  }
1891
- async wcatch(args, destination, signal) {
1957
+ async wcatch(args, destination, signal, beforeCommit) {
1892
1958
  const action = String(args.action || 'download');
1893
1959
  const maxBytes = Math.max(1, Math.min(100 * 1024 * 1024, Number(args.max_bytes || 50 * 1024 * 1024)));
1894
1960
  const sourceUrl = this.checkedPublicWebUrl(String(args.url || ''));
@@ -1932,9 +1998,20 @@ class ToolExecutor {
1932
1998
  const backup = destinationExists ? `${destination}.newmark-${crypto.randomUUID()}.bak` : '';
1933
1999
  try {
1934
2000
  fs.writeFileSync(temp, bytes, { flag: 'wx' });
1935
- if (destinationExists)
2001
+ beforeCommit?.();
2002
+ const currentDestinationExists = fs.existsSync(destination);
2003
+ if (currentDestinationExists !== destinationExists)
2004
+ throw new Error('Destination changed during download; read it again and retry.');
2005
+ if (currentDestinationExists && !fs.statSync(destination).isFile())
2006
+ throw new Error('Destination changed to a non-file during download.');
2007
+ if (destinationExists) {
1936
2008
  fs.renameSync(destination, backup);
1937
- fs.renameSync(temp, destination);
2009
+ fs.renameSync(temp, destination);
2010
+ }
2011
+ else {
2012
+ fs.linkSync(temp, destination);
2013
+ fs.unlinkSync(temp);
2014
+ }
1938
2015
  if (backup)
1939
2016
  fs.unlinkSync(backup);
1940
2017
  }