@visorcraft/idlehands 1.1.7 → 1.1.9

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 (120) hide show
  1. package/README.md +46 -0
  2. package/dist/agent/formatting.js +273 -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 +411 -0
  7. package/dist/agent/tool-calls.js.map +1 -0
  8. package/dist/agent.js +285 -684
  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 +6 -6
  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 +186 -0
  23. package/dist/bot/discord-routing.js.map +1 -0
  24. package/dist/bot/discord-streaming.js +107 -0
  25. package/dist/bot/discord-streaming.js.map +1 -0
  26. package/dist/bot/discord.js +49 -237
  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/session-manager.js +22 -11
  31. package/dist/bot/session-manager.js.map +1 -1
  32. package/dist/bot/telegram.js +83 -94
  33. package/dist/bot/telegram.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 +9 -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 +148 -20
  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 +3 -3
  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 +24 -7
  62. package/dist/client.js.map +1 -1
  63. package/dist/context.js +101 -10
  64. package/dist/context.js.map +1 -1
  65. package/dist/harnesses.js +1 -1
  66. package/dist/harnesses.js.map +1 -1
  67. package/dist/hooks/manager.js +5 -0
  68. package/dist/hooks/manager.js.map +1 -1
  69. package/dist/index.js +13 -64
  70. package/dist/index.js.map +1 -1
  71. package/dist/progress/agent-hooks.js +37 -0
  72. package/dist/progress/agent-hooks.js.map +1 -0
  73. package/dist/progress/ir.js +10 -0
  74. package/dist/progress/ir.js.map +1 -0
  75. package/dist/progress/message-edit-scheduler.js +97 -0
  76. package/dist/progress/message-edit-scheduler.js.map +1 -0
  77. package/dist/progress/progress-message-renderer.js +120 -0
  78. package/dist/progress/progress-message-renderer.js.map +1 -0
  79. package/dist/progress/progress-presenter.js +137 -0
  80. package/dist/progress/progress-presenter.js.map +1 -0
  81. package/dist/progress/serialize-discord.js +72 -0
  82. package/dist/progress/serialize-discord.js.map +1 -0
  83. package/dist/progress/serialize-telegram.js +67 -0
  84. package/dist/progress/serialize-telegram.js.map +1 -0
  85. package/dist/progress/serialize-tui.js +52 -0
  86. package/dist/progress/serialize-tui.js.map +1 -0
  87. package/dist/progress/tool-summary.js +58 -0
  88. package/dist/progress/tool-summary.js.map +1 -0
  89. package/dist/progress/tool-tail.js +48 -0
  90. package/dist/progress/tool-tail.js.map +1 -0
  91. package/dist/progress/turn-progress.js +215 -0
  92. package/dist/progress/turn-progress.js.map +1 -0
  93. package/dist/replay.js +2 -5
  94. package/dist/replay.js.map +1 -1
  95. package/dist/runtime/executor.js +58 -10
  96. package/dist/runtime/executor.js.map +1 -1
  97. package/dist/runtime/planner.js +19 -6
  98. package/dist/runtime/planner.js.map +1 -1
  99. package/dist/runtime/store.js +2 -1
  100. package/dist/runtime/store.js.map +1 -1
  101. package/dist/safety.js +0 -1
  102. package/dist/safety.js.map +1 -1
  103. package/dist/spinner.js +8 -0
  104. package/dist/spinner.js.map +1 -1
  105. package/dist/tools/tool-error.js +97 -0
  106. package/dist/tools/tool-error.js.map +1 -0
  107. package/dist/tools.js +471 -41
  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 +91 -28
  112. package/dist/tui/controller.js.map +1 -1
  113. package/dist/tui/render.js +15 -2
  114. package/dist/tui/render.js.map +1 -1
  115. package/dist/tui/state.js +13 -0
  116. package/dist/tui/state.js.map +1 -1
  117. package/dist/upgrade.js.map +1 -1
  118. package/dist/utils.js +17 -0
  119. package/dist/utils.js.map +1 -1
  120. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
+ import { ToolError } from './tools/tool-error.js';
5
6
  import { checkExecSafety, checkPathSafety, isProtectedDeleteTarget } from './safety.js';
6
7
  import { sys_context as sysContextTool } from './sys/context.js';
7
8
  import { stateDir, shellEscape, BASH_PATH } from './utils.js';
@@ -292,14 +293,24 @@ export async function undo_path(ctx, args) {
292
293
  }
293
294
  export async function read_file(ctx, args) {
294
295
  const p = resolvePath(ctx, args?.path);
295
- const offset = args?.offset ? Number(args.offset) : undefined;
296
+ const offset = args?.offset != null ? Number(args.offset) : undefined;
296
297
  const rawLimit = args?.limit != null ? Number(args.limit) : undefined;
297
298
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
298
299
  ? Math.max(1, Math.floor(rawLimit))
299
- : undefined;
300
+ : 200;
300
301
  const search = typeof args?.search === 'string' ? args.search : undefined;
301
- const context = args?.context ? Number(args.context) : 10;
302
- const maxBytes = 100 * 1024;
302
+ const rawContext = args?.context != null ? Number(args.context) : undefined;
303
+ const context = Number.isFinite(rawContext) && rawContext >= 0
304
+ ? Math.max(0, Math.min(200, Math.floor(rawContext)))
305
+ : 10;
306
+ const formatRaw = typeof args?.format === 'string' ? args.format.trim().toLowerCase() : 'numbered';
307
+ const format = (formatRaw === 'plain' || formatRaw === 'numbered' || formatRaw === 'sparse')
308
+ ? formatRaw
309
+ : 'numbered';
310
+ const rawMaxBytes = args?.max_bytes != null ? Number(args.max_bytes) : undefined;
311
+ const maxBytes = Number.isFinite(rawMaxBytes) && rawMaxBytes > 0
312
+ ? Math.min(256 * 1024, Math.max(256, Math.floor(rawMaxBytes)))
313
+ : 20 * 1024;
303
314
  if (!p)
304
315
  throw new Error('read_file: missing path');
305
316
  // Detect directories early with a helpful message instead of cryptic EISDIR
@@ -315,13 +326,6 @@ export async function read_file(ctx, args) {
315
326
  const buf = await fs.readFile(p).catch((e) => {
316
327
  throw new Error(`read_file: cannot read ${p}: ${e?.message ?? String(e)}`);
317
328
  });
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
329
  // Binary detection: NUL byte in first 512 bytes (§8)
326
330
  for (let i = 0; i < Math.min(buf.length, 512); i++) {
327
331
  if (buf[i] === 0) {
@@ -332,51 +336,82 @@ export async function read_file(ctx, args) {
332
336
  const text = buf.toString('utf8');
333
337
  const lines = text.split(/\r?\n/);
334
338
  let start = 1;
335
- let end = limit ? Math.min(lines.length, limit) : lines.length;
339
+ let end = Math.min(lines.length, limit);
340
+ let matchLines = [];
336
341
  if (search) {
337
- const matchLines = [];
342
+ matchLines = [];
338
343
  for (let i = 0; i < lines.length; i++) {
339
344
  if (lines[i].includes(search))
340
345
  matchLines.push(i + 1);
341
346
  }
342
347
  if (!matchLines.length) {
343
- return `# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`;
348
+ return truncateBytes(`# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`, maxBytes).text;
344
349
  }
345
350
  const firstIdx = matchLines[0];
351
+ // Window around the first match, but never return more than `limit` lines.
346
352
  start = Math.max(1, firstIdx - context);
347
353
  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] ?? ''}`);
354
+ if (end - start + 1 > limit) {
355
+ const half = Math.floor(limit / 2);
356
+ start = Math.max(1, firstIdx - half);
357
+ end = Math.min(lines.length, start + limit - 1);
353
358
  }
354
- if (end < lines.length)
355
- out.push(`# ... (${lines.length - end} more lines)`);
356
- return out.join('\n');
357
359
  }
358
360
  else if (offset && offset >= 1) {
359
- start = offset;
360
- end = limit ? Math.min(lines.length, offset + limit - 1) : lines.length;
361
+ start = Math.max(1, Math.floor(offset));
362
+ end = Math.min(lines.length, start + limit - 1);
361
363
  }
364
+ const matchSet = new Set(matchLines);
362
365
  const out = [];
363
- out.push(`# ${p}`);
366
+ out.push(`# ${p} (lines ${start}-${end} of ${lines.length})`);
367
+ if (search) {
368
+ const shown = matchLines.slice(0, 20);
369
+ out.push(`# matches at lines: ${shown.join(', ')}${matchLines.length > shown.length ? ' …' : ''}`);
370
+ }
371
+ const renderNumbered = (ln, body) => `${ln}| ${body}`;
364
372
  for (let ln = start; ln <= end; ln++) {
365
- out.push(`${String(ln).padStart(6, ' ')}| ${lines[ln - 1] ?? ''}`);
373
+ const body = lines[ln - 1] ?? '';
374
+ if (format === 'plain') {
375
+ out.push(body);
376
+ continue;
377
+ }
378
+ if (format === 'numbered') {
379
+ out.push(renderNumbered(ln, body));
380
+ continue;
381
+ }
382
+ // sparse: number anchor lines + matches; otherwise raw text.
383
+ const isAnchor = ln === start || ln === end || (ln - start) % 10 === 0;
384
+ if (isAnchor || matchSet.has(ln))
385
+ out.push(renderNumbered(ln, body));
386
+ else
387
+ out.push(body);
366
388
  }
367
389
  if (end < lines.length)
368
390
  out.push(`# ... (${lines.length - end} more lines)`);
369
- return out.join('\n');
391
+ return truncateBytes(out.join('\n'), maxBytes).text;
370
392
  }
371
393
  export async function read_files(ctx, args) {
372
394
  const reqs = Array.isArray(args?.requests) ? args.requests : [];
373
395
  if (!reqs.length)
374
- throw new Error('read_files: missing requests[]');
396
+ throw new ToolError('invalid_args', 'read_files: missing requests[]', false, 'Provide requests as an array of {path, limit,...} objects.');
375
397
  const parts = [];
376
- for (const r of reqs) {
377
- parts.push(await read_file(ctx, r));
398
+ let failures = 0;
399
+ for (let i = 0; i < reqs.length; i++) {
400
+ const r = reqs[i];
401
+ const p = typeof r?.path === 'string' ? r.path : `request[${i}]`;
402
+ try {
403
+ parts.push(await read_file(ctx, r));
404
+ }
405
+ catch (e) {
406
+ failures++;
407
+ const te = ToolError.fromError(e, 'internal');
408
+ parts.push(`[file:${p}] ERROR: code=${te.code} msg=${te.message}`);
409
+ }
378
410
  parts.push('');
379
411
  }
412
+ if (failures > 0) {
413
+ parts.push(`# read_files completed with partial failures: ${failures}/${reqs.length}`);
414
+ }
380
415
  return parts.join('\n');
381
416
  }
382
417
  export async function write_file(ctx, args) {
@@ -602,6 +637,292 @@ export async function edit_file(ctx, args) {
602
637
  const cwdWarning = checkCwdWarning('edit_file', p, ctx);
603
638
  return `edited ${p} (replace_all=${replaceAll})${replayNote}${cwdWarning}`;
604
639
  }
640
+ function normalizePatchPath(p) {
641
+ let s = String(p ?? '').trim();
642
+ if (!s || s === '/dev/null')
643
+ return '';
644
+ // Strip quotes some generators add
645
+ s = s.replace(/^"|"$/g, '');
646
+ // Drop common diff prefixes
647
+ s = s.replace(/^[ab]\//, '').replace(/^\.\/+/, '');
648
+ // Normalize to posix separators for diffs
649
+ s = s.replace(/\\/g, '/');
650
+ const norm = path.posix.normalize(s);
651
+ if (norm.startsWith('../') || norm === '..' || norm.startsWith('/')) {
652
+ throw new Error(`apply_patch: unsafe path in patch: ${JSON.stringify(s)}`);
653
+ }
654
+ return norm;
655
+ }
656
+ function extractTouchedFilesFromPatch(patchText) {
657
+ const paths = [];
658
+ const created = new Set();
659
+ const deleted = new Set();
660
+ let pendingOld = null;
661
+ let pendingNew = null;
662
+ const seen = new Set();
663
+ const lines = String(patchText ?? '').split(/\r?\n/);
664
+ for (const line of lines) {
665
+ // Primary: git-style header
666
+ if (line.startsWith('diff --git ')) {
667
+ const m = /^diff --git\s+a\/(.+?)\s+b\/(.+?)\s*$/.exec(line);
668
+ if (m) {
669
+ const aPath = normalizePatchPath(m[1]);
670
+ const bPath = normalizePatchPath(m[2]);
671
+ const use = bPath || aPath;
672
+ if (use && !seen.has(use)) {
673
+ seen.add(use);
674
+ paths.push(use);
675
+ }
676
+ }
677
+ pendingOld = null;
678
+ pendingNew = null;
679
+ continue;
680
+ }
681
+ // Fallback: unified diff headers
682
+ if (line.startsWith('--- ')) {
683
+ pendingOld = line.slice(4).trim();
684
+ continue;
685
+ }
686
+ if (line.startsWith('+++ ')) {
687
+ pendingNew = line.slice(4).trim();
688
+ const oldP = pendingOld ? pendingOld.replace(/^a\//, '').trim() : '';
689
+ const newP = pendingNew ? pendingNew.replace(/^b\//, '').trim() : '';
690
+ const oldIsDevNull = oldP === '/dev/null';
691
+ const newIsDevNull = newP === '/dev/null';
692
+ if (!newIsDevNull) {
693
+ const rel = normalizePatchPath(newP);
694
+ if (rel && !seen.has(rel)) {
695
+ seen.add(rel);
696
+ paths.push(rel);
697
+ }
698
+ if (oldIsDevNull)
699
+ created.add(rel);
700
+ }
701
+ if (!oldIsDevNull && newIsDevNull) {
702
+ const rel = normalizePatchPath(oldP);
703
+ if (rel && !seen.has(rel)) {
704
+ seen.add(rel);
705
+ paths.push(rel);
706
+ }
707
+ deleted.add(rel);
708
+ }
709
+ pendingOld = null;
710
+ pendingNew = null;
711
+ continue;
712
+ }
713
+ }
714
+ return { paths, created, deleted };
715
+ }
716
+ async function runCommandWithStdin(cmd, cmdArgs, stdinText, cwd, maxOutBytes) {
717
+ return await new Promise((resolve, reject) => {
718
+ const child = spawn(cmd, cmdArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
719
+ const outChunks = [];
720
+ const errChunks = [];
721
+ let outSeen = 0;
722
+ let errSeen = 0;
723
+ let outCaptured = 0;
724
+ let errCaptured = 0;
725
+ const pushCapped = (chunks, buf, kind) => {
726
+ const n = buf.length;
727
+ if (kind === 'out')
728
+ outSeen += n;
729
+ else
730
+ errSeen += n;
731
+ const captured = kind === 'out' ? outCaptured : errCaptured;
732
+ const remaining = maxOutBytes - captured;
733
+ if (remaining <= 0)
734
+ return;
735
+ const take = n <= remaining ? buf : buf.subarray(0, remaining);
736
+ chunks.push(Buffer.from(take));
737
+ if (kind === 'out')
738
+ outCaptured += take.length;
739
+ else
740
+ errCaptured += take.length;
741
+ };
742
+ child.stdout.on('data', (d) => pushCapped(outChunks, Buffer.from(d), 'out'));
743
+ child.stderr.on('data', (d) => pushCapped(errChunks, Buffer.from(d), 'err'));
744
+ child.on('error', (e) => reject(new Error(`${cmd}: ${e?.message ?? String(e)}`)));
745
+ child.on('close', (code) => {
746
+ const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
747
+ const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
748
+ const outT = truncateBytes(outRaw, maxOutBytes, outSeen);
749
+ const errT = truncateBytes(errRaw, maxOutBytes, errSeen);
750
+ resolve({ rc: code ?? 0, out: outT.text, err: errT.text });
751
+ });
752
+ child.stdin.write(String(stdinText ?? ''), 'utf8');
753
+ child.stdin.end();
754
+ });
755
+ }
756
+ export async function edit_range(ctx, args) {
757
+ const p = resolvePath(ctx, args?.path);
758
+ const startLine = Number(args?.start_line);
759
+ const endLine = Number(args?.end_line);
760
+ const rawReplacement = args?.replacement;
761
+ const replacement = typeof rawReplacement === 'string' ? rawReplacement
762
+ : (rawReplacement != null && typeof rawReplacement === 'object' ? JSON.stringify(rawReplacement, null, 2) : undefined);
763
+ if (!p)
764
+ throw new Error('edit_range: missing path');
765
+ if (!Number.isFinite(startLine) || startLine < 1)
766
+ throw new Error('edit_range: missing/invalid start_line');
767
+ if (!Number.isFinite(endLine) || endLine < startLine)
768
+ throw new Error('edit_range: missing/invalid end_line');
769
+ if (replacement == null)
770
+ throw new Error('edit_range: missing replacement (got ' + typeof rawReplacement + ')');
771
+ // Path safety check (Phase 9)
772
+ const pathVerdict = checkPathSafety(p);
773
+ if (pathVerdict.tier === 'forbidden') {
774
+ throw new Error(`edit_range: ${pathVerdict.reason}`);
775
+ }
776
+ if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
777
+ if (ctx.confirm) {
778
+ const ok = await ctx.confirm(pathVerdict.prompt || `Edit range in ${p}?`, { tool: 'edit_range', args: { path: p, start_line: startLine, end_line: endLine } });
779
+ if (!ok)
780
+ throw new Error(`edit_range: cancelled by user (${pathVerdict.reason})`);
781
+ }
782
+ else {
783
+ throw new Error(`edit_range: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
784
+ }
785
+ }
786
+ if (ctx.dryRun)
787
+ return `dry-run: would edit_range ${p} lines ${startLine}-${endLine} (${Buffer.byteLength(replacement, 'utf8')} bytes)`;
788
+ // Phase 9d: snapshot /etc/ files before editing
789
+ if (ctx.mode === 'sys' && ctx.vault) {
790
+ await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
791
+ }
792
+ const beforeText = await fs.readFile(p, 'utf8').catch((e) => {
793
+ throw new Error(`edit_range: cannot read ${p}: ${e?.message ?? String(e)}`);
794
+ });
795
+ const eol = beforeText.includes('\r\n') ? '\r\n' : '\n';
796
+ const lines = beforeText.split(/\r?\n/);
797
+ if (startLine > lines.length) {
798
+ throw new Error(`edit_range: start_line ${startLine} out of range (file has ${lines.length} lines)`);
799
+ }
800
+ if (endLine > lines.length) {
801
+ throw new Error(`edit_range: end_line ${endLine} out of range (file has ${lines.length} lines)`);
802
+ }
803
+ const startIdx = startLine - 1;
804
+ const deleteCount = endLine - startLine + 1;
805
+ // For deletion, allow empty replacement to remove the range without leaving a blank line.
806
+ const replacementLines = replacement === '' ? [] : replacement.split(/\r?\n/);
807
+ lines.splice(startIdx, deleteCount, ...replacementLines);
808
+ const out = lines.join(eol);
809
+ await backupFile(p, ctx);
810
+ await atomicWrite(p, out);
811
+ ctx.onMutation?.(p);
812
+ const replayNote = await checkpointReplay(ctx, {
813
+ op: 'edit_range',
814
+ filePath: p,
815
+ before: Buffer.from(beforeText, 'utf8'),
816
+ after: Buffer.from(out, 'utf8')
817
+ });
818
+ const cwdWarning = checkCwdWarning('edit_range', p, ctx);
819
+ return `edited ${p} lines ${startLine}-${endLine}${replayNote}${cwdWarning}`;
820
+ }
821
+ export async function apply_patch(ctx, args) {
822
+ const rawPatch = args?.patch;
823
+ const patchText = typeof rawPatch === 'string' ? rawPatch
824
+ : (rawPatch != null && typeof rawPatch === 'object' ? JSON.stringify(rawPatch, null, 2) : undefined);
825
+ const rawFiles = Array.isArray(args?.files) ? args.files : [];
826
+ const files = rawFiles
827
+ .map((f) => (typeof f === 'string' ? f.trim() : ''))
828
+ .filter(Boolean);
829
+ const stripRaw = Number(args?.strip);
830
+ const strip = Number.isFinite(stripRaw) ? Math.max(0, Math.min(5, Math.floor(stripRaw))) : 0;
831
+ if (!patchText)
832
+ throw new Error('apply_patch: missing patch');
833
+ if (!files.length)
834
+ throw new Error('apply_patch: missing files[]');
835
+ const touched = extractTouchedFilesFromPatch(patchText);
836
+ if (!touched.paths.length) {
837
+ throw new Error('apply_patch: patch contains no recognizable file headers');
838
+ }
839
+ const declared = new Set(files.map(normalizePatchPath));
840
+ const unknown = touched.paths.filter((p) => !declared.has(p));
841
+ if (unknown.length) {
842
+ throw new Error(`apply_patch: patch touches undeclared file(s): ${unknown.join(', ')}`);
843
+ }
844
+ const absPaths = touched.paths.map((rel) => resolvePath(ctx, rel));
845
+ // Path safety check (Phase 9)
846
+ const verdicts = absPaths.map((p) => ({ p, v: checkPathSafety(p) }));
847
+ const forbidden = verdicts.filter(({ v }) => v.tier === 'forbidden');
848
+ if (forbidden.length) {
849
+ throw new Error(`apply_patch: ${forbidden[0].v.reason} (${forbidden[0].p})`);
850
+ }
851
+ const cautious = verdicts.filter(({ v }) => v.tier === 'cautious');
852
+ if (cautious.length && !ctx.noConfirm) {
853
+ if (ctx.confirm) {
854
+ const preview = patchText.length > 4000 ? patchText.slice(0, 4000) + '\n[truncated]' : patchText;
855
+ 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 });
856
+ if (!ok)
857
+ throw new Error('apply_patch: cancelled by user');
858
+ }
859
+ else {
860
+ throw new Error('apply_patch: blocked (cautious paths) without --no-confirm/--yolo');
861
+ }
862
+ }
863
+ const maxToolBytes = ctx.maxExecBytes ?? DEFAULT_MAX_EXEC_BYTES;
864
+ const stripArg = `-p${strip}`;
865
+ // Dry-run: validate the patch applies cleanly, but do not mutate files.
866
+ if (ctx.dryRun) {
867
+ const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
868
+ if (haveGit) {
869
+ const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
870
+ if (chk.rc !== 0)
871
+ throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
872
+ }
873
+ else {
874
+ const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
875
+ if (chk.rc !== 0)
876
+ throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
877
+ }
878
+ return `dry-run: patch would apply cleanly (${touched.paths.length} files): ${touched.paths.join(', ')}`;
879
+ }
880
+ // Snapshot + backup before applying
881
+ const beforeMap = new Map();
882
+ for (const abs of absPaths) {
883
+ // Phase 9d: snapshot /etc/ files before editing
884
+ if (ctx.mode === 'sys' && ctx.vault) {
885
+ await snapshotBeforeEdit(ctx.vault, abs).catch(() => { });
886
+ }
887
+ const before = await fs.readFile(abs).catch(() => Buffer.from(''));
888
+ beforeMap.set(abs, before);
889
+ await backupFile(abs, ctx);
890
+ }
891
+ // Apply with git apply if available; fallback to patch.
892
+ const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
893
+ if (haveGit) {
894
+ const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
895
+ if (chk.rc !== 0)
896
+ throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
897
+ const app = await runCommandWithStdin('git', ['apply', stripArg, '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
898
+ if (app.rc !== 0)
899
+ throw new Error(`apply_patch: git apply failed:\n${app.err || app.out}`);
900
+ }
901
+ else {
902
+ const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
903
+ if (chk.rc !== 0)
904
+ throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
905
+ const app = await runCommandWithStdin('patch', [stripArg, '--batch'], patchText, ctx.cwd, maxToolBytes);
906
+ if (app.rc !== 0)
907
+ throw new Error(`apply_patch: patch failed:\n${app.err || app.out}`);
908
+ }
909
+ // Replay checkpoints + mutation hooks
910
+ let replayNotes = '';
911
+ let cwdWarnings = '';
912
+ for (const abs of absPaths) {
913
+ const after = await fs.readFile(abs).catch(() => Buffer.from(''));
914
+ ctx.onMutation?.(abs);
915
+ const replayNote = await checkpointReplay(ctx, {
916
+ op: 'apply_patch',
917
+ filePath: abs,
918
+ before: beforeMap.get(abs) ?? Buffer.from(''),
919
+ after
920
+ });
921
+ replayNotes += replayNote;
922
+ cwdWarnings += checkCwdWarning('apply_patch', abs, ctx);
923
+ }
924
+ return `applied patch (${touched.paths.length} files): ${touched.paths.join(', ')}${replayNotes}${cwdWarnings}`;
925
+ }
605
926
  export async function list_dir(ctx, args) {
606
927
  const p = resolvePath(ctx, args?.path ?? '.');
607
928
  const recursive = Boolean(args?.recursive);
@@ -673,7 +994,13 @@ export async function search_files(ctx, args) {
673
994
  }
674
995
  }
675
996
  // Slow fallback
676
- const re = new RegExp(pattern);
997
+ let re;
998
+ try {
999
+ re = new RegExp(pattern);
1000
+ }
1001
+ catch (e) {
1002
+ throw new ToolError('invalid_args', `search_files: invalid regex pattern: ${e?.message ?? String(e)}`, false, 'Escape regex metacharacters (\\, [, ], (, ), +, *, ?). If you intended literal text, use an escaped/literal pattern.');
1003
+ }
677
1004
  const out = [];
678
1005
  async function walk(dir, depth) {
679
1006
  if (out.length >= maxResults)
@@ -739,6 +1066,84 @@ function hasBackgroundExecIntent(command) {
739
1066
  // like >&2, <&, and &>.
740
1067
  return /(^|[;\s])&(?![&><\d])(?=($|[;\s]))/.test(stripped);
741
1068
  }
1069
+ function safeFireAndForget(fn) {
1070
+ if (!fn)
1071
+ return;
1072
+ try {
1073
+ const r = fn();
1074
+ if (r && typeof r.catch === 'function')
1075
+ r.catch(() => { });
1076
+ }
1077
+ catch {
1078
+ // best effort only
1079
+ }
1080
+ }
1081
+ function makeExecStreamer(ctx) {
1082
+ const cb = ctx.onToolStream;
1083
+ if (!cb)
1084
+ return null;
1085
+ const id = ctx.toolCallId ?? '';
1086
+ const name = ctx.toolName ?? 'exec';
1087
+ const intervalMs = Math.max(50, Math.floor(ctx.toolStreamIntervalMs ?? 750));
1088
+ const maxChunkChars = Math.max(80, Math.floor(ctx.toolStreamMaxChunkChars ?? 900));
1089
+ const maxBufferChars = Math.max(maxChunkChars, Math.floor(ctx.toolStreamMaxBufferChars ?? 12_000));
1090
+ let outBuf = '';
1091
+ let errBuf = '';
1092
+ let lastEmit = 0;
1093
+ let timer = null;
1094
+ const emit = (stream, chunk) => {
1095
+ const trimmed = chunk.length > maxChunkChars ? chunk.slice(-maxChunkChars) : chunk;
1096
+ const ev = { id, name, stream, chunk: trimmed };
1097
+ safeFireAndForget(() => cb(ev));
1098
+ };
1099
+ const schedule = () => {
1100
+ if (timer)
1101
+ return;
1102
+ const delay = Math.max(0, intervalMs - (Date.now() - lastEmit));
1103
+ timer = setTimeout(() => {
1104
+ timer = null;
1105
+ flush(false);
1106
+ }, delay);
1107
+ };
1108
+ const flush = (force = false) => {
1109
+ const now = Date.now();
1110
+ if (!force && now - lastEmit < intervalMs) {
1111
+ schedule();
1112
+ return;
1113
+ }
1114
+ lastEmit = now;
1115
+ if (outBuf) {
1116
+ emit('stdout', outBuf);
1117
+ outBuf = '';
1118
+ }
1119
+ if (errBuf) {
1120
+ emit('stderr', errBuf);
1121
+ errBuf = '';
1122
+ }
1123
+ };
1124
+ const push = (stream, textRaw) => {
1125
+ const text = stripAnsi(textRaw).replace(/\r/g, '\n');
1126
+ if (!text)
1127
+ return;
1128
+ if (stream === 'stdout')
1129
+ outBuf += text;
1130
+ else
1131
+ errBuf += text;
1132
+ if (outBuf.length > maxBufferChars)
1133
+ outBuf = outBuf.slice(-maxBufferChars);
1134
+ if (errBuf.length > maxBufferChars)
1135
+ errBuf = errBuf.slice(-maxBufferChars);
1136
+ schedule();
1137
+ };
1138
+ const done = () => {
1139
+ if (timer) {
1140
+ clearTimeout(timer);
1141
+ timer = null;
1142
+ }
1143
+ flush(true);
1144
+ };
1145
+ return { push, done };
1146
+ }
742
1147
  export async function exec(ctx, args) {
743
1148
  const command = typeof args?.command === 'string' ? args.command : undefined;
744
1149
  const cwd = args?.cwd ? resolvePath(ctx, args.cwd) : ctx.cwd;
@@ -751,7 +1156,7 @@ export async function exec(ctx, args) {
751
1156
  let execCwdWarning = '';
752
1157
  if (args?.cwd) {
753
1158
  const absExecCwd = path.resolve(cwd);
754
- if (!absExecCwd.startsWith(absCwd + path.sep) && absExecCwd !== absCwd) {
1159
+ if (!isWithinDir(absExecCwd, absCwd)) {
755
1160
  throw new Error(`exec: BLOCKED — cwd "${absExecCwd}" is outside the working directory "${absCwd}". Use relative paths and work within the project directory.`);
756
1161
  }
757
1162
  }
@@ -761,7 +1166,7 @@ export async function exec(ctx, args) {
761
1166
  let cdMatch;
762
1167
  while ((cdMatch = cdPattern.exec(command)) !== null) {
763
1168
  const cdTarget = path.resolve(cdMatch[2]);
764
- if (!cdTarget.startsWith(absCwd + path.sep) && cdTarget !== absCwd) {
1169
+ if (!isWithinDir(cdTarget, absCwd)) {
765
1170
  throw new Error(`exec: BLOCKED — command navigates to "${cdTarget}" which is outside the working directory "${absCwd}". Use relative paths and work within the project directory.`);
766
1171
  }
767
1172
  }
@@ -771,7 +1176,7 @@ export async function exec(ctx, args) {
771
1176
  let apMatch;
772
1177
  while ((apMatch = absPathPattern.exec(command)) !== null) {
773
1178
  const absTarget = path.resolve(apMatch[2]);
774
- if (!absTarget.startsWith(absCwd + path.sep) && absTarget !== absCwd) {
1179
+ if (!isWithinDir(absTarget, absCwd)) {
775
1180
  throw new Error(`exec: BLOCKED — command targets "${absTarget}" which is outside the working directory "${absCwd}". Use relative paths to work within the project directory.`);
776
1181
  }
777
1182
  }
@@ -844,7 +1249,8 @@ export async function exec(ctx, args) {
844
1249
  maxBytes,
845
1250
  captureLimit,
846
1251
  signal: ctx.signal,
847
- }) + execCwdWarning;
1252
+ execCwdWarning,
1253
+ });
848
1254
  }
849
1255
  // Use spawn with shell:true — lets Node.js resolve the shell internally,
850
1256
  // avoiding ENOENT issues with explicit bash paths in certain environments.
@@ -900,8 +1306,15 @@ export async function exec(ctx, args) {
900
1306
  else
901
1307
  errCaptured += take.length;
902
1308
  };
903
- child.stdout.on('data', (d) => pushCapped(outChunks, d, 'out'));
904
- child.stderr.on('data', (d) => pushCapped(errChunks, d, 'err'));
1309
+ const streamer = makeExecStreamer(ctx);
1310
+ child.stdout.on('data', (d) => {
1311
+ pushCapped(outChunks, d, 'out');
1312
+ streamer?.push('stdout', d.toString('utf8'));
1313
+ });
1314
+ child.stderr.on('data', (d) => {
1315
+ pushCapped(errChunks, d, 'err');
1316
+ streamer?.push('stderr', d.toString('utf8'));
1317
+ });
905
1318
  const rc = await new Promise((resolve, reject) => {
906
1319
  child.on('error', (err) => {
907
1320
  clearTimeout(killTimer);
@@ -912,6 +1325,7 @@ export async function exec(ctx, args) {
912
1325
  });
913
1326
  clearTimeout(killTimer);
914
1327
  ctx.signal?.removeEventListener('abort', onAbort);
1328
+ streamer?.done();
915
1329
  const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
916
1330
  const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
917
1331
  const outLines = collapseStackTraces(dedupeRepeats(outRaw.split(/\r?\n/))).join('\n').trimEnd();
@@ -933,15 +1347,21 @@ export async function exec(ctx, args) {
933
1347
  if (killed) {
934
1348
  errText = (errText ? errText + '\n' : '') + `[killed after ${timeout}s timeout]`;
935
1349
  }
936
- const result = { rc, out: outText, err: errText, truncated: outT.truncated || errT.truncated || capOut || capErr };
1350
+ const result = {
1351
+ rc,
1352
+ out: outText,
1353
+ err: errText,
1354
+ truncated: outT.truncated || errT.truncated || capOut || capErr,
1355
+ ...(execCwdWarning && { warnings: [execCwdWarning.trim()] })
1356
+ };
937
1357
  // Phase 9d: auto-note system changes in sys mode
938
1358
  if (ctx.mode === 'sys' && ctx.vault && rc === 0) {
939
1359
  autoNoteSysChange(ctx.vault, command, outText).catch(() => { });
940
1360
  }
941
- return JSON.stringify(result) + execCwdWarning;
1361
+ return JSON.stringify(result);
942
1362
  }
943
1363
  async function execWithPty(args) {
944
- const { pty, command, cwd, timeout, maxBytes, captureLimit, signal } = args;
1364
+ const { pty, command, cwd, timeout, maxBytes, captureLimit, signal, execCwdWarning } = args;
945
1365
  const proc = pty.spawn(BASH_PATH, ['-lc', command], {
946
1366
  name: 'xterm-color',
947
1367
  cwd,
@@ -1009,6 +1429,7 @@ async function execWithPty(args) {
1009
1429
  out: outText,
1010
1430
  err: errText,
1011
1431
  truncated: outT.truncated || cap || killed,
1432
+ ...(execCwdWarning && { warnings: [execCwdWarning.trim()] })
1012
1433
  };
1013
1434
  return JSON.stringify(result);
1014
1435
  }
@@ -1056,13 +1477,22 @@ function resolvePath(ctx, p) {
1056
1477
  throw new Error('missing path');
1057
1478
  return path.resolve(ctx.cwd, p);
1058
1479
  }
1480
+ /**
1481
+ * Check if a target path is within a directory.
1482
+ * Handles the classic root directory edge case: when dir is `/`, every absolute path is valid.
1483
+ */
1484
+ function isWithinDir(target, dir) {
1485
+ if (dir === '/')
1486
+ return target.startsWith('/');
1487
+ return target === dir || target.startsWith(dir + path.sep);
1488
+ }
1059
1489
  /**
1060
1490
  * Check if a resolved path is outside the working directory.
1061
1491
  * Returns a model-visible warning string if so, empty string otherwise.
1062
1492
  */
1063
1493
  function checkCwdWarning(tool, resolvedPath, ctx) {
1064
1494
  const absCwd = path.resolve(ctx.cwd);
1065
- if (resolvedPath.startsWith(absCwd + path.sep) || resolvedPath === absCwd)
1495
+ if (isWithinDir(resolvedPath, absCwd))
1066
1496
  return '';
1067
1497
  const warning = `\n[WARNING] Path "${resolvedPath}" is OUTSIDE the working directory "${absCwd}". You MUST use relative paths and work within the project directory. Do NOT create or edit files outside the cwd.`;
1068
1498
  console.warn(`[warning] ${tool}: path "${resolvedPath}" is outside the working directory "${absCwd}".`);