@visorcraft/idlehands 1.1.6 → 1.1.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.
Files changed (122) hide show
  1. package/README.md +32 -0
  2. package/dist/agent/formatting.js +251 -0
  3. package/dist/agent/formatting.js.map +1 -0
  4. package/dist/agent/review-artifact.js +147 -0
  5. package/dist/agent/review-artifact.js.map +1 -0
  6. package/dist/agent/tool-calls.js +226 -0
  7. package/dist/agent/tool-calls.js.map +1 -0
  8. package/dist/agent.js +314 -695
  9. package/dist/agent.js.map +1 -1
  10. package/dist/anton/controller.js +1 -1
  11. package/dist/anton/controller.js.map +1 -1
  12. package/dist/anton/lock.js +0 -3
  13. package/dist/anton/lock.js.map +1 -1
  14. package/dist/anton/parser.js +0 -1
  15. package/dist/anton/parser.js.map +1 -1
  16. package/dist/anton/reporter.js +1 -1
  17. package/dist/anton/reporter.js.map +1 -1
  18. package/dist/bot/commands.js +3 -2
  19. package/dist/bot/commands.js.map +1 -1
  20. package/dist/bot/confirm-telegram.js +2 -1
  21. package/dist/bot/confirm-telegram.js.map +1 -1
  22. package/dist/bot/discord-routing.js +179 -0
  23. package/dist/bot/discord-routing.js.map +1 -0
  24. package/dist/bot/discord-streaming.js +171 -0
  25. package/dist/bot/discord-streaming.js.map +1 -0
  26. package/dist/bot/discord.js +25 -221
  27. package/dist/bot/discord.js.map +1 -1
  28. package/dist/bot/format.js +2 -25
  29. package/dist/bot/format.js.map +1 -1
  30. package/dist/bot/telegram.js +56 -12
  31. package/dist/bot/telegram.js.map +1 -1
  32. package/dist/cli/args.js +4 -1
  33. package/dist/cli/args.js.map +1 -1
  34. package/dist/cli/build-repl-context.js.map +1 -1
  35. package/dist/cli/command-registry.js +2 -1
  36. package/dist/cli/command-registry.js.map +1 -1
  37. package/dist/cli/command-utils.js +27 -0
  38. package/dist/cli/command-utils.js.map +1 -0
  39. package/dist/cli/commands/anton.js +3 -2
  40. package/dist/cli/commands/anton.js.map +1 -1
  41. package/dist/cli/commands/model.js +8 -7
  42. package/dist/cli/commands/model.js.map +1 -1
  43. package/dist/cli/commands/project.js +5 -4
  44. package/dist/cli/commands/project.js.map +1 -1
  45. package/dist/cli/commands/session.js +118 -8
  46. package/dist/cli/commands/session.js.map +1 -1
  47. package/dist/cli/commands/tools.js +4 -3
  48. package/dist/cli/commands/tools.js.map +1 -1
  49. package/dist/cli/input.js +2 -1
  50. package/dist/cli/input.js.map +1 -1
  51. package/dist/cli/repl-dispatch.js +85 -0
  52. package/dist/cli/repl-dispatch.js.map +1 -0
  53. package/dist/cli/runtime-cmds.js +7 -7
  54. package/dist/cli/runtime-cmds.js.map +1 -1
  55. package/dist/cli/service.js +0 -14
  56. package/dist/cli/service.js.map +1 -1
  57. package/dist/cli/setup.js +25 -5
  58. package/dist/cli/setup.js.map +1 -1
  59. package/dist/cli/watch.js +2 -1
  60. package/dist/cli/watch.js.map +1 -1
  61. package/dist/client.js +51 -4
  62. package/dist/client.js.map +1 -1
  63. package/dist/config.js +79 -0
  64. package/dist/config.js.map +1 -1
  65. package/dist/context.js +101 -10
  66. package/dist/context.js.map +1 -1
  67. package/dist/harnesses.js +1 -1
  68. package/dist/harnesses.js.map +1 -1
  69. package/dist/hooks/index.js +5 -0
  70. package/dist/hooks/index.js.map +1 -0
  71. package/dist/hooks/loader.js +58 -0
  72. package/dist/hooks/loader.js.map +1 -0
  73. package/dist/hooks/manager.js +180 -0
  74. package/dist/hooks/manager.js.map +1 -0
  75. package/dist/hooks/plugins/example-console.js +24 -0
  76. package/dist/hooks/plugins/example-console.js.map +1 -0
  77. package/dist/hooks/scaffold.js +53 -0
  78. package/dist/hooks/scaffold.js.map +1 -0
  79. package/dist/hooks/types.js +8 -0
  80. package/dist/hooks/types.js.map +1 -0
  81. package/dist/index.js +16 -64
  82. package/dist/index.js.map +1 -1
  83. package/dist/progress/agent-hooks.js +37 -0
  84. package/dist/progress/agent-hooks.js.map +1 -0
  85. package/dist/progress/ir.js +7 -0
  86. package/dist/progress/ir.js.map +1 -0
  87. package/dist/progress/progress-message-renderer.js +63 -0
  88. package/dist/progress/progress-message-renderer.js.map +1 -0
  89. package/dist/progress/serialize-discord.js +60 -0
  90. package/dist/progress/serialize-discord.js.map +1 -0
  91. package/dist/progress/serialize-telegram.js +55 -0
  92. package/dist/progress/serialize-telegram.js.map +1 -0
  93. package/dist/progress/serialize-tui.js +39 -0
  94. package/dist/progress/serialize-tui.js.map +1 -0
  95. package/dist/progress/tool-summary.js +58 -0
  96. package/dist/progress/tool-summary.js.map +1 -0
  97. package/dist/progress/tool-tail.js +48 -0
  98. package/dist/progress/tool-tail.js.map +1 -0
  99. package/dist/progress/turn-progress.js +215 -0
  100. package/dist/progress/turn-progress.js.map +1 -0
  101. package/dist/replay.js +2 -5
  102. package/dist/replay.js.map +1 -1
  103. package/dist/safety.js +0 -1
  104. package/dist/safety.js.map +1 -1
  105. package/dist/spinner.js +8 -0
  106. package/dist/spinner.js.map +1 -1
  107. package/dist/tools.js +422 -29
  108. package/dist/tools.js.map +1 -1
  109. package/dist/tui/branch-picker.js.map +1 -1
  110. package/dist/tui/command-handler.js.map +1 -1
  111. package/dist/tui/controller.js +417 -33
  112. package/dist/tui/controller.js.map +1 -1
  113. package/dist/tui/keymap.js +15 -0
  114. package/dist/tui/keymap.js.map +1 -1
  115. package/dist/tui/render.js +115 -3
  116. package/dist/tui/render.js.map +1 -1
  117. package/dist/tui/state.js +82 -1
  118. package/dist/tui/state.js.map +1 -1
  119. package/dist/upgrade.js.map +1 -1
  120. package/dist/utils.js +17 -0
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -292,14 +292,24 @@ export async function undo_path(ctx, args) {
292
292
  }
293
293
  export async function read_file(ctx, args) {
294
294
  const p = resolvePath(ctx, args?.path);
295
- const offset = args?.offset ? Number(args.offset) : undefined;
295
+ const offset = args?.offset != null ? Number(args.offset) : undefined;
296
296
  const rawLimit = args?.limit != null ? Number(args.limit) : undefined;
297
297
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
298
298
  ? Math.max(1, Math.floor(rawLimit))
299
- : undefined;
299
+ : 200;
300
300
  const search = typeof args?.search === 'string' ? args.search : undefined;
301
- const context = args?.context ? Number(args.context) : 10;
302
- const maxBytes = 100 * 1024;
301
+ const rawContext = args?.context != null ? Number(args.context) : undefined;
302
+ const context = Number.isFinite(rawContext) && rawContext >= 0
303
+ ? Math.max(0, Math.min(200, Math.floor(rawContext)))
304
+ : 10;
305
+ const formatRaw = typeof args?.format === 'string' ? args.format.trim().toLowerCase() : 'numbered';
306
+ const format = (formatRaw === 'plain' || formatRaw === 'numbered' || formatRaw === 'sparse')
307
+ ? formatRaw
308
+ : 'numbered';
309
+ const rawMaxBytes = args?.max_bytes != null ? Number(args.max_bytes) : undefined;
310
+ const maxBytes = Number.isFinite(rawMaxBytes) && rawMaxBytes > 0
311
+ ? Math.min(256 * 1024, Math.max(256, Math.floor(rawMaxBytes)))
312
+ : 20 * 1024;
303
313
  if (!p)
304
314
  throw new Error('read_file: missing path');
305
315
  // Detect directories early with a helpful message instead of cryptic EISDIR
@@ -315,13 +325,6 @@ export async function read_file(ctx, args) {
315
325
  const buf = await fs.readFile(p).catch((e) => {
316
326
  throw new Error(`read_file: cannot read ${p}: ${e?.message ?? String(e)}`);
317
327
  });
318
- if (buf.length > maxBytes) {
319
- // Truncate gracefully instead of throwing
320
- const truncText = buf.subarray(0, maxBytes).toString('utf8');
321
- const truncLines = truncText.split(/\r?\n/);
322
- const numbered = truncLines.map((l, i) => `${String(i + 1).padStart(4)}| ${l}`).join('\n');
323
- return `# ${p} [TRUNCATED: ${buf.length} bytes, showing first ${maxBytes}]\n${numbered}`;
324
- }
325
328
  // Binary detection: NUL byte in first 512 bytes (§8)
326
329
  for (let i = 0; i < Math.min(buf.length, 512); i++) {
327
330
  if (buf[i] === 0) {
@@ -332,41 +335,59 @@ export async function read_file(ctx, args) {
332
335
  const text = buf.toString('utf8');
333
336
  const lines = text.split(/\r?\n/);
334
337
  let start = 1;
335
- let end = limit ? Math.min(lines.length, limit) : lines.length;
338
+ let end = Math.min(lines.length, limit);
339
+ let matchLines = [];
336
340
  if (search) {
337
- const matchLines = [];
341
+ matchLines = [];
338
342
  for (let i = 0; i < lines.length; i++) {
339
343
  if (lines[i].includes(search))
340
344
  matchLines.push(i + 1);
341
345
  }
342
346
  if (!matchLines.length) {
343
- return `# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`;
347
+ return truncateBytes(`# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`, maxBytes).text;
344
348
  }
345
349
  const firstIdx = matchLines[0];
350
+ // Window around the first match, but never return more than `limit` lines.
346
351
  start = Math.max(1, firstIdx - context);
347
352
  end = Math.min(lines.length, firstIdx + context);
348
- const out = [];
349
- out.push(`# ${p}`);
350
- out.push(`# matches at lines: ${matchLines.join(', ')}${matchLines.length > 20 ? ' [truncated]' : ''}`);
351
- for (let ln = start; ln <= end; ln++) {
352
- out.push(`${String(ln).padStart(6, ' ')}| ${lines[ln - 1] ?? ''}`);
353
+ if (end - start + 1 > limit) {
354
+ const half = Math.floor(limit / 2);
355
+ start = Math.max(1, firstIdx - half);
356
+ end = Math.min(lines.length, start + limit - 1);
353
357
  }
354
- if (end < lines.length)
355
- out.push(`# ... (${lines.length - end} more lines)`);
356
- return out.join('\n');
357
358
  }
358
359
  else if (offset && offset >= 1) {
359
- start = offset;
360
- end = limit ? Math.min(lines.length, offset + limit - 1) : lines.length;
360
+ start = Math.max(1, Math.floor(offset));
361
+ end = Math.min(lines.length, start + limit - 1);
361
362
  }
363
+ const matchSet = new Set(matchLines);
362
364
  const out = [];
363
- out.push(`# ${p}`);
365
+ out.push(`# ${p} (lines ${start}-${end} of ${lines.length})`);
366
+ if (search) {
367
+ const shown = matchLines.slice(0, 20);
368
+ out.push(`# matches at lines: ${shown.join(', ')}${matchLines.length > shown.length ? ' …' : ''}`);
369
+ }
370
+ const renderNumbered = (ln, body) => `${ln}| ${body}`;
364
371
  for (let ln = start; ln <= end; ln++) {
365
- out.push(`${String(ln).padStart(6, ' ')}| ${lines[ln - 1] ?? ''}`);
372
+ const body = lines[ln - 1] ?? '';
373
+ if (format === 'plain') {
374
+ out.push(body);
375
+ continue;
376
+ }
377
+ if (format === 'numbered') {
378
+ out.push(renderNumbered(ln, body));
379
+ continue;
380
+ }
381
+ // sparse: number anchor lines + matches; otherwise raw text.
382
+ const isAnchor = ln === start || ln === end || (ln - start) % 10 === 0;
383
+ if (isAnchor || matchSet.has(ln))
384
+ out.push(renderNumbered(ln, body));
385
+ else
386
+ out.push(body);
366
387
  }
367
388
  if (end < lines.length)
368
389
  out.push(`# ... (${lines.length - end} more lines)`);
369
- return out.join('\n');
390
+ return truncateBytes(out.join('\n'), maxBytes).text;
370
391
  }
371
392
  export async function read_files(ctx, args) {
372
393
  const reqs = Array.isArray(args?.requests) ? args.requests : [];
@@ -602,6 +623,292 @@ export async function edit_file(ctx, args) {
602
623
  const cwdWarning = checkCwdWarning('edit_file', p, ctx);
603
624
  return `edited ${p} (replace_all=${replaceAll})${replayNote}${cwdWarning}`;
604
625
  }
626
+ function normalizePatchPath(p) {
627
+ let s = String(p ?? '').trim();
628
+ if (!s || s === '/dev/null')
629
+ return '';
630
+ // Strip quotes some generators add
631
+ s = s.replace(/^"|"$/g, '');
632
+ // Drop common diff prefixes
633
+ s = s.replace(/^[ab]\//, '').replace(/^\.\/+/, '');
634
+ // Normalize to posix separators for diffs
635
+ s = s.replace(/\\/g, '/');
636
+ const norm = path.posix.normalize(s);
637
+ if (norm.startsWith('../') || norm === '..' || norm.startsWith('/')) {
638
+ throw new Error(`apply_patch: unsafe path in patch: ${JSON.stringify(s)}`);
639
+ }
640
+ return norm;
641
+ }
642
+ function extractTouchedFilesFromPatch(patchText) {
643
+ const paths = [];
644
+ const created = new Set();
645
+ const deleted = new Set();
646
+ let pendingOld = null;
647
+ let pendingNew = null;
648
+ const seen = new Set();
649
+ const lines = String(patchText ?? '').split(/\r?\n/);
650
+ for (const line of lines) {
651
+ // Primary: git-style header
652
+ if (line.startsWith('diff --git ')) {
653
+ const m = /^diff --git\s+a\/(.+?)\s+b\/(.+?)\s*$/.exec(line);
654
+ if (m) {
655
+ const aPath = normalizePatchPath(m[1]);
656
+ const bPath = normalizePatchPath(m[2]);
657
+ const use = bPath || aPath;
658
+ if (use && !seen.has(use)) {
659
+ seen.add(use);
660
+ paths.push(use);
661
+ }
662
+ }
663
+ pendingOld = null;
664
+ pendingNew = null;
665
+ continue;
666
+ }
667
+ // Fallback: unified diff headers
668
+ if (line.startsWith('--- ')) {
669
+ pendingOld = line.slice(4).trim();
670
+ continue;
671
+ }
672
+ if (line.startsWith('+++ ')) {
673
+ pendingNew = line.slice(4).trim();
674
+ const oldP = pendingOld ? pendingOld.replace(/^a\//, '').trim() : '';
675
+ const newP = pendingNew ? pendingNew.replace(/^b\//, '').trim() : '';
676
+ const oldIsDevNull = oldP === '/dev/null';
677
+ const newIsDevNull = newP === '/dev/null';
678
+ if (!newIsDevNull) {
679
+ const rel = normalizePatchPath(newP);
680
+ if (rel && !seen.has(rel)) {
681
+ seen.add(rel);
682
+ paths.push(rel);
683
+ }
684
+ if (oldIsDevNull)
685
+ created.add(rel);
686
+ }
687
+ if (!oldIsDevNull && newIsDevNull) {
688
+ const rel = normalizePatchPath(oldP);
689
+ if (rel && !seen.has(rel)) {
690
+ seen.add(rel);
691
+ paths.push(rel);
692
+ }
693
+ deleted.add(rel);
694
+ }
695
+ pendingOld = null;
696
+ pendingNew = null;
697
+ continue;
698
+ }
699
+ }
700
+ return { paths, created, deleted };
701
+ }
702
+ async function runCommandWithStdin(cmd, cmdArgs, stdinText, cwd, maxOutBytes) {
703
+ return await new Promise((resolve, reject) => {
704
+ const child = spawn(cmd, cmdArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
705
+ const outChunks = [];
706
+ const errChunks = [];
707
+ let outSeen = 0;
708
+ let errSeen = 0;
709
+ let outCaptured = 0;
710
+ let errCaptured = 0;
711
+ const pushCapped = (chunks, buf, kind) => {
712
+ const n = buf.length;
713
+ if (kind === 'out')
714
+ outSeen += n;
715
+ else
716
+ errSeen += n;
717
+ const captured = kind === 'out' ? outCaptured : errCaptured;
718
+ const remaining = maxOutBytes - captured;
719
+ if (remaining <= 0)
720
+ return;
721
+ const take = n <= remaining ? buf : buf.subarray(0, remaining);
722
+ chunks.push(Buffer.from(take));
723
+ if (kind === 'out')
724
+ outCaptured += take.length;
725
+ else
726
+ errCaptured += take.length;
727
+ };
728
+ child.stdout.on('data', (d) => pushCapped(outChunks, Buffer.from(d), 'out'));
729
+ child.stderr.on('data', (d) => pushCapped(errChunks, Buffer.from(d), 'err'));
730
+ child.on('error', (e) => reject(new Error(`${cmd}: ${e?.message ?? String(e)}`)));
731
+ child.on('close', (code) => {
732
+ const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
733
+ const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
734
+ const outT = truncateBytes(outRaw, maxOutBytes, outSeen);
735
+ const errT = truncateBytes(errRaw, maxOutBytes, errSeen);
736
+ resolve({ rc: code ?? 0, out: outT.text, err: errT.text });
737
+ });
738
+ child.stdin.write(String(stdinText ?? ''), 'utf8');
739
+ child.stdin.end();
740
+ });
741
+ }
742
+ export async function edit_range(ctx, args) {
743
+ const p = resolvePath(ctx, args?.path);
744
+ const startLine = Number(args?.start_line);
745
+ const endLine = Number(args?.end_line);
746
+ const rawReplacement = args?.replacement;
747
+ const replacement = typeof rawReplacement === 'string' ? rawReplacement
748
+ : (rawReplacement != null && typeof rawReplacement === 'object' ? JSON.stringify(rawReplacement, null, 2) : undefined);
749
+ if (!p)
750
+ throw new Error('edit_range: missing path');
751
+ if (!Number.isFinite(startLine) || startLine < 1)
752
+ throw new Error('edit_range: missing/invalid start_line');
753
+ if (!Number.isFinite(endLine) || endLine < startLine)
754
+ throw new Error('edit_range: missing/invalid end_line');
755
+ if (replacement == null)
756
+ throw new Error('edit_range: missing replacement (got ' + typeof rawReplacement + ')');
757
+ // Path safety check (Phase 9)
758
+ const pathVerdict = checkPathSafety(p);
759
+ if (pathVerdict.tier === 'forbidden') {
760
+ throw new Error(`edit_range: ${pathVerdict.reason}`);
761
+ }
762
+ if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
763
+ if (ctx.confirm) {
764
+ const ok = await ctx.confirm(pathVerdict.prompt || `Edit range in ${p}?`, { tool: 'edit_range', args: { path: p, start_line: startLine, end_line: endLine } });
765
+ if (!ok)
766
+ throw new Error(`edit_range: cancelled by user (${pathVerdict.reason})`);
767
+ }
768
+ else {
769
+ throw new Error(`edit_range: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
770
+ }
771
+ }
772
+ if (ctx.dryRun)
773
+ return `dry-run: would edit_range ${p} lines ${startLine}-${endLine} (${Buffer.byteLength(replacement, 'utf8')} bytes)`;
774
+ // Phase 9d: snapshot /etc/ files before editing
775
+ if (ctx.mode === 'sys' && ctx.vault) {
776
+ await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
777
+ }
778
+ const beforeText = await fs.readFile(p, 'utf8').catch((e) => {
779
+ throw new Error(`edit_range: cannot read ${p}: ${e?.message ?? String(e)}`);
780
+ });
781
+ const eol = beforeText.includes('\r\n') ? '\r\n' : '\n';
782
+ const lines = beforeText.split(/\r?\n/);
783
+ if (startLine > lines.length) {
784
+ throw new Error(`edit_range: start_line ${startLine} out of range (file has ${lines.length} lines)`);
785
+ }
786
+ if (endLine > lines.length) {
787
+ throw new Error(`edit_range: end_line ${endLine} out of range (file has ${lines.length} lines)`);
788
+ }
789
+ const startIdx = startLine - 1;
790
+ const deleteCount = endLine - startLine + 1;
791
+ // For deletion, allow empty replacement to remove the range without leaving a blank line.
792
+ const replacementLines = replacement === '' ? [] : replacement.split(/\r?\n/);
793
+ lines.splice(startIdx, deleteCount, ...replacementLines);
794
+ const out = lines.join(eol);
795
+ await backupFile(p, ctx);
796
+ await atomicWrite(p, out);
797
+ ctx.onMutation?.(p);
798
+ const replayNote = await checkpointReplay(ctx, {
799
+ op: 'edit_range',
800
+ filePath: p,
801
+ before: Buffer.from(beforeText, 'utf8'),
802
+ after: Buffer.from(out, 'utf8')
803
+ });
804
+ const cwdWarning = checkCwdWarning('edit_range', p, ctx);
805
+ return `edited ${p} lines ${startLine}-${endLine}${replayNote}${cwdWarning}`;
806
+ }
807
+ export async function apply_patch(ctx, args) {
808
+ const rawPatch = args?.patch;
809
+ const patchText = typeof rawPatch === 'string' ? rawPatch
810
+ : (rawPatch != null && typeof rawPatch === 'object' ? JSON.stringify(rawPatch, null, 2) : undefined);
811
+ const rawFiles = Array.isArray(args?.files) ? args.files : [];
812
+ const files = rawFiles
813
+ .map((f) => (typeof f === 'string' ? f.trim() : ''))
814
+ .filter(Boolean);
815
+ const stripRaw = Number(args?.strip);
816
+ const strip = Number.isFinite(stripRaw) ? Math.max(0, Math.min(5, Math.floor(stripRaw))) : 0;
817
+ if (!patchText)
818
+ throw new Error('apply_patch: missing patch');
819
+ if (!files.length)
820
+ throw new Error('apply_patch: missing files[]');
821
+ const touched = extractTouchedFilesFromPatch(patchText);
822
+ if (!touched.paths.length) {
823
+ throw new Error('apply_patch: patch contains no recognizable file headers');
824
+ }
825
+ const declared = new Set(files.map(normalizePatchPath));
826
+ const unknown = touched.paths.filter((p) => !declared.has(p));
827
+ if (unknown.length) {
828
+ throw new Error(`apply_patch: patch touches undeclared file(s): ${unknown.join(', ')}`);
829
+ }
830
+ const absPaths = touched.paths.map((rel) => resolvePath(ctx, rel));
831
+ // Path safety check (Phase 9)
832
+ const verdicts = absPaths.map((p) => ({ p, v: checkPathSafety(p) }));
833
+ const forbidden = verdicts.filter(({ v }) => v.tier === 'forbidden');
834
+ if (forbidden.length) {
835
+ throw new Error(`apply_patch: ${forbidden[0].v.reason} (${forbidden[0].p})`);
836
+ }
837
+ const cautious = verdicts.filter(({ v }) => v.tier === 'cautious');
838
+ if (cautious.length && !ctx.noConfirm) {
839
+ if (ctx.confirm) {
840
+ const preview = patchText.length > 4000 ? patchText.slice(0, 4000) + '\n[truncated]' : patchText;
841
+ const ok = await ctx.confirm(`Apply patch touching ${touched.paths.length} file(s)?\n- ${touched.paths.join('\n- ')}\n\nProceed? (y/N) `, { tool: 'apply_patch', args: { files: touched.paths, strip }, diff: preview });
842
+ if (!ok)
843
+ throw new Error('apply_patch: cancelled by user');
844
+ }
845
+ else {
846
+ throw new Error('apply_patch: blocked (cautious paths) without --no-confirm/--yolo');
847
+ }
848
+ }
849
+ const maxToolBytes = ctx.maxExecBytes ?? DEFAULT_MAX_EXEC_BYTES;
850
+ const stripArg = `-p${strip}`;
851
+ // Dry-run: validate the patch applies cleanly, but do not mutate files.
852
+ if (ctx.dryRun) {
853
+ const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
854
+ if (haveGit) {
855
+ const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
856
+ if (chk.rc !== 0)
857
+ throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
858
+ }
859
+ else {
860
+ const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
861
+ if (chk.rc !== 0)
862
+ throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
863
+ }
864
+ return `dry-run: patch would apply cleanly (${touched.paths.length} files): ${touched.paths.join(', ')}`;
865
+ }
866
+ // Snapshot + backup before applying
867
+ const beforeMap = new Map();
868
+ for (const abs of absPaths) {
869
+ // Phase 9d: snapshot /etc/ files before editing
870
+ if (ctx.mode === 'sys' && ctx.vault) {
871
+ await snapshotBeforeEdit(ctx.vault, abs).catch(() => { });
872
+ }
873
+ const before = await fs.readFile(abs).catch(() => Buffer.from(''));
874
+ beforeMap.set(abs, before);
875
+ await backupFile(abs, ctx);
876
+ }
877
+ // Apply with git apply if available; fallback to patch.
878
+ const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
879
+ if (haveGit) {
880
+ const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
881
+ if (chk.rc !== 0)
882
+ throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
883
+ const app = await runCommandWithStdin('git', ['apply', stripArg, '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
884
+ if (app.rc !== 0)
885
+ throw new Error(`apply_patch: git apply failed:\n${app.err || app.out}`);
886
+ }
887
+ else {
888
+ const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
889
+ if (chk.rc !== 0)
890
+ throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
891
+ const app = await runCommandWithStdin('patch', [stripArg, '--batch'], patchText, ctx.cwd, maxToolBytes);
892
+ if (app.rc !== 0)
893
+ throw new Error(`apply_patch: patch failed:\n${app.err || app.out}`);
894
+ }
895
+ // Replay checkpoints + mutation hooks
896
+ let replayNotes = '';
897
+ let cwdWarnings = '';
898
+ for (const abs of absPaths) {
899
+ const after = await fs.readFile(abs).catch(() => Buffer.from(''));
900
+ ctx.onMutation?.(abs);
901
+ const replayNote = await checkpointReplay(ctx, {
902
+ op: 'apply_patch',
903
+ filePath: abs,
904
+ before: beforeMap.get(abs) ?? Buffer.from(''),
905
+ after
906
+ });
907
+ replayNotes += replayNote;
908
+ cwdWarnings += checkCwdWarning('apply_patch', abs, ctx);
909
+ }
910
+ return `applied patch (${touched.paths.length} files): ${touched.paths.join(', ')}${replayNotes}${cwdWarnings}`;
911
+ }
605
912
  export async function list_dir(ctx, args) {
606
913
  const p = resolvePath(ctx, args?.path ?? '.');
607
914
  const recursive = Boolean(args?.recursive);
@@ -739,6 +1046,84 @@ function hasBackgroundExecIntent(command) {
739
1046
  // like >&2, <&, and &>.
740
1047
  return /(^|[;\s])&(?![&><\d])(?=($|[;\s]))/.test(stripped);
741
1048
  }
1049
+ function safeFireAndForget(fn) {
1050
+ if (!fn)
1051
+ return;
1052
+ try {
1053
+ const r = fn();
1054
+ if (r && typeof r.catch === 'function')
1055
+ r.catch(() => { });
1056
+ }
1057
+ catch {
1058
+ // best effort only
1059
+ }
1060
+ }
1061
+ function makeExecStreamer(ctx) {
1062
+ const cb = ctx.onToolStream;
1063
+ if (!cb)
1064
+ return null;
1065
+ const id = ctx.toolCallId ?? '';
1066
+ const name = ctx.toolName ?? 'exec';
1067
+ const intervalMs = Math.max(50, Math.floor(ctx.toolStreamIntervalMs ?? 750));
1068
+ const maxChunkChars = Math.max(80, Math.floor(ctx.toolStreamMaxChunkChars ?? 900));
1069
+ const maxBufferChars = Math.max(maxChunkChars, Math.floor(ctx.toolStreamMaxBufferChars ?? 12_000));
1070
+ let outBuf = '';
1071
+ let errBuf = '';
1072
+ let lastEmit = 0;
1073
+ let timer = null;
1074
+ const emit = (stream, chunk) => {
1075
+ const trimmed = chunk.length > maxChunkChars ? chunk.slice(-maxChunkChars) : chunk;
1076
+ const ev = { id, name, stream, chunk: trimmed };
1077
+ safeFireAndForget(() => cb(ev));
1078
+ };
1079
+ const schedule = () => {
1080
+ if (timer)
1081
+ return;
1082
+ const delay = Math.max(0, intervalMs - (Date.now() - lastEmit));
1083
+ timer = setTimeout(() => {
1084
+ timer = null;
1085
+ flush(false);
1086
+ }, delay);
1087
+ };
1088
+ const flush = (force = false) => {
1089
+ const now = Date.now();
1090
+ if (!force && now - lastEmit < intervalMs) {
1091
+ schedule();
1092
+ return;
1093
+ }
1094
+ lastEmit = now;
1095
+ if (outBuf) {
1096
+ emit('stdout', outBuf);
1097
+ outBuf = '';
1098
+ }
1099
+ if (errBuf) {
1100
+ emit('stderr', errBuf);
1101
+ errBuf = '';
1102
+ }
1103
+ };
1104
+ const push = (stream, textRaw) => {
1105
+ const text = stripAnsi(textRaw).replace(/\r/g, '\n');
1106
+ if (!text)
1107
+ return;
1108
+ if (stream === 'stdout')
1109
+ outBuf += text;
1110
+ else
1111
+ errBuf += text;
1112
+ if (outBuf.length > maxBufferChars)
1113
+ outBuf = outBuf.slice(-maxBufferChars);
1114
+ if (errBuf.length > maxBufferChars)
1115
+ errBuf = errBuf.slice(-maxBufferChars);
1116
+ schedule();
1117
+ };
1118
+ const done = () => {
1119
+ if (timer) {
1120
+ clearTimeout(timer);
1121
+ timer = null;
1122
+ }
1123
+ flush(true);
1124
+ };
1125
+ return { push, done };
1126
+ }
742
1127
  export async function exec(ctx, args) {
743
1128
  const command = typeof args?.command === 'string' ? args.command : undefined;
744
1129
  const cwd = args?.cwd ? resolvePath(ctx, args.cwd) : ctx.cwd;
@@ -900,8 +1285,15 @@ export async function exec(ctx, args) {
900
1285
  else
901
1286
  errCaptured += take.length;
902
1287
  };
903
- child.stdout.on('data', (d) => pushCapped(outChunks, d, 'out'));
904
- child.stderr.on('data', (d) => pushCapped(errChunks, d, 'err'));
1288
+ const streamer = makeExecStreamer(ctx);
1289
+ child.stdout.on('data', (d) => {
1290
+ pushCapped(outChunks, d, 'out');
1291
+ streamer?.push('stdout', d.toString('utf8'));
1292
+ });
1293
+ child.stderr.on('data', (d) => {
1294
+ pushCapped(errChunks, d, 'err');
1295
+ streamer?.push('stderr', d.toString('utf8'));
1296
+ });
905
1297
  const rc = await new Promise((resolve, reject) => {
906
1298
  child.on('error', (err) => {
907
1299
  clearTimeout(killTimer);
@@ -912,6 +1304,7 @@ export async function exec(ctx, args) {
912
1304
  });
913
1305
  clearTimeout(killTimer);
914
1306
  ctx.signal?.removeEventListener('abort', onAbort);
1307
+ streamer?.done();
915
1308
  const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
916
1309
  const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
917
1310
  const outLines = collapseStackTraces(dedupeRepeats(outRaw.split(/\r?\n/))).join('\n').trimEnd();