@semalt-ai/code 1.19.0 → 1.20.1

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 (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. package/path +0 -1
@@ -287,6 +287,22 @@ function _matchDual(text, template) {
287
287
  return results;
288
288
  }
289
289
 
290
+ // 1-based starting line numbers of every literal occurrence of `needle` in
291
+ // `data`. Used by replace_in_file's uniqueness guard to tell the model WHERE the
292
+ // ambiguous matches are so it can add disambiguating context. Capped so a needle
293
+ // that matches hundreds of times doesn't produce a giant error string.
294
+ function _literalOccurrenceLines(data, needle, cap = 10) {
295
+ const lines = [];
296
+ let from = 0;
297
+ let idx;
298
+ while ((idx = data.indexOf(needle, from)) !== -1) {
299
+ lines.push(data.slice(0, idx).split('\n').length);
300
+ from = idx + needle.length;
301
+ if (lines.length >= cap) break;
302
+ }
303
+ return lines;
304
+ }
305
+
290
306
  function _unwrapInnerTag(inner) {
291
307
  if (inner == null) return inner;
292
308
  const trimmed = String(inner).trim();
@@ -367,6 +383,12 @@ async function _execWriteAppend(ctx, action, args, options) {
367
383
  }
368
384
 
369
385
  try {
386
+ // Capture prior content so the diff can render at EXECUTION time (the agent
387
+ // loop hands _diffBefore/_diffAfter to onToolEnd). Non-existent file → '' →
388
+ // the diff renders as a new file. Cheap relative to the write itself.
389
+ let before = '';
390
+ try { before = await fsp.readFile(filePath, 'utf8'); } catch {}
391
+ const after = action === 'write' ? (content || '') : (before + (content || ''));
370
392
  const dir = path.dirname(filePath);
371
393
  if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
372
394
  if (action === 'write') await fsp.writeFile(filePath, content || '');
@@ -374,7 +396,7 @@ async function _execWriteAppend(ctx, action, args, options) {
374
396
  const verb = action === 'write' ? 'Wrote' : 'Appended to';
375
397
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
376
398
  logToolCall(tag, { path: filePath, content }, true, 'ok');
377
- return { status: 'ok', path: filePath, bytes: (content || '').length };
399
+ return { status: 'ok', path: filePath, bytes: (content || '').length, _diffBefore: before, _diffAfter: after };
378
400
  } catch (error) {
379
401
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
380
402
  logToolCall(tag, { path: filePath, content }, true, 'error');
@@ -383,25 +405,29 @@ async function _execWriteAppend(ctx, action, args, options) {
383
405
  }
384
406
 
385
407
  async function _permWriteAppend(ctx, action, args) {
386
- const { _dryRun, renderDiff, DIFF_BUBBLE_INSET, writer } = ctx;
408
+ const { _dryRun, renderDiff, writer } = ctx;
387
409
  const _uiActive = ctx._uiActive;
388
410
  const filePath = args[0];
389
411
  const content = args[1];
390
412
  const tag = action === 'write' ? 'write_file' : 'append_file';
391
413
 
392
- let existing = '';
393
- try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
394
- const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
395
- const diffOutput = _uiActive
396
- ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
397
- : renderDiff(existing, finalContent, filePath);
398
- if (!_uiActive) writer.scrollback(diffOutput);
414
+ // The full diff is rendered at EXECUTION time (the agent loop's onToolEnd),
415
+ // decoupled from this modal so an auto-approved write shows its diff just
416
+ // like a manually-approved one, and the diff is shown exactly once. The modal
417
+ // therefore carries only a compact description, NOT the diff. The one path
418
+ // without an execution-time renderer (headless / oneshot, !_uiActive) still
419
+ // surfaces the diff here so a write is never silent there.
420
+ if (!_uiActive && !_dryRun) {
421
+ let existing = '';
422
+ try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
423
+ const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
424
+ writer.scrollback(renderDiff(existing, finalContent, filePath));
425
+ }
399
426
 
400
427
  if (_dryRun) return null;
401
428
 
402
429
  let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
403
430
  if (content) desc += ` (${content.length} chars)`;
404
- if (_uiActive) desc = `${desc}\n${diffOutput}`;
405
431
  return { actionType: 'file', description: desc, tag };
406
432
  }
407
433
 
@@ -620,10 +646,93 @@ function _finalizeGrep(raw, pattern) {
620
646
  return out;
621
647
  }
622
648
 
649
+ // Resolve the grep `path` argument into a concrete search plan. `path` is no
650
+ // longer a glob-only filter: it may denote an existing FILE (search just that
651
+ // file, like search_in_file), an existing DIRECTORY (use it as the walk root),
652
+ // or — when it is NOT an existing filesystem path — a GLOB filter applied to the
653
+ // cwd walk (the legacy behavior, preserved for callers like `path="*.js"`). A
654
+ // `path` that is a literal that does not exist falls into the glob branch and
655
+ // simply matches zero candidate files; the wrapper's safety net then turns that
656
+ // into a clear diagnostic instead of a silent {count:0}. Relative paths resolve
657
+ // against the process cwd (statSync semantics), matching read_file/search_in_file.
658
+ function _resolveGrepPath(pathArg) {
659
+ if (pathArg == null || pathArg === '') return { mode: 'none', baseDir: '.', pathGlob: null };
660
+ let st = null;
661
+ try { st = fs.statSync(pathArg); } catch { st = null; }
662
+ if (st && st.isFile()) return { mode: 'file', file: pathArg, display: pathArg };
663
+ if (st && st.isDirectory()) return { mode: 'dir', baseDir: pathArg, pathGlob: null };
664
+ return { mode: 'glob', baseDir: '.', pathGlob: pathArg };
665
+ }
666
+
667
+ // Grep a single explicit file (FILE-target mode). Engine-independent — an
668
+ // explicitly named file is read directly (like search_in_file), which also means
669
+ // it is searched even if .gitignore'd, since the model asked for THIS file. Binary
670
+ // files yield no matches. Returns the same { matches:[{file,line,text}] } shape as
671
+ // _grepNode so it flows through _finalizeGrep identically.
672
+ function _grepFile({ pattern, ignoreCase, file, display, signal }) {
673
+ let re;
674
+ try { re = new RegExp(pattern, ignoreCase ? 'i' : ''); }
675
+ catch (err) { return { error: `Invalid regex pattern: ${err.message}` }; }
676
+ if (signal && signal.aborted) return { aborted: true };
677
+ let buf;
678
+ try { buf = fs.readFileSync(file); } catch (err) { return { error: err.message }; }
679
+ if (_isBinaryBuf(buf)) return { matches: [] };
680
+ const data = buf.toString('utf8');
681
+ const lines = data.split('\n');
682
+ if (data.endsWith('\n')) lines.pop();
683
+ const posix = _toPosix(display);
684
+ const matches = [];
685
+ for (let i = 0; i < lines.length; i++) {
686
+ if (re.test(lines[i])) matches.push({ file: posix, line: i + 1, text: lines[i] });
687
+ }
688
+ return { matches };
689
+ }
690
+
691
+ // Does the walk root contain ANY file grep would consider searching (passing the
692
+ // gitignore + skip-dir + pathGlob filters)? Used ONLY by the safety net, and only
693
+ // when a result came back empty — it answers "was there anything to search at
694
+ // all?" so a glob that matched real files (true negative) is never demoted to an
695
+ // error. Deliberately does NOT read file contents (no binary sniff): over-counting
696
+ // errs toward returning {count:0}, the safe direction. Short-circuits on first hit.
697
+ function _grepHasCandidate({ baseDir, pathGlob, signal }) {
698
+ const rules = _loadGitignore(baseDir);
699
+ const pf = pathGlob ? _globToRegExp(pathGlob) : null;
700
+ const pfBasename = pathGlob && !pathGlob.includes('/');
701
+ let found = false;
702
+ _walkTree(baseDir, {
703
+ rules,
704
+ signal,
705
+ onFile: (rel, name) => {
706
+ if (found) return;
707
+ if (pf && !pf.test(pfBasename ? name : rel)) return;
708
+ found = true;
709
+ },
710
+ });
711
+ return found;
712
+ }
713
+
623
714
  // engine: 'auto' (rg if available, else Node), 'rg', or 'node'. Exported for
624
715
  // the parity tests, which drive both engines and assert deep equality.
625
- function _grepSearch({ pattern, pathGlob = null, ignoreCase = false, baseDir = '.', engine = 'auto', signal = null }) {
716
+ //
717
+ // `path` (when supplied) is the path-aware target: it is resolved via
718
+ // _resolveGrepPath into a FILE read / DIRECTORY walk root / GLOB filter, and feeds
719
+ // BOTH engines identically (FILE mode is a direct read, trivially engine-agnostic).
720
+ // The legacy `pathGlob`/`baseDir` params are still honored directly for callers
721
+ // that pre-resolve (the parity tests). The safety net fires ONLY on the `path`
722
+ // pathway, so existing pathGlob/baseDir callers are byte-for-byte unaffected.
723
+ function _grepSearch({ pattern, path: pathArg = null, pathGlob = null, ignoreCase = false, baseDir = '.', engine = 'auto', signal = null }) {
626
724
  if (typeof pattern !== 'string' || pattern === '') return { error: 'grep: pattern is required' };
725
+ const pathSupplied = pathArg != null && pathArg !== '';
726
+ if (pathSupplied) {
727
+ const plan = _resolveGrepPath(pathArg);
728
+ if (plan.mode === 'file') {
729
+ const raw = _grepFile({ pattern, ignoreCase, file: plan.file, display: plan.display, signal });
730
+ if (raw && (raw.error || raw.aborted)) return raw;
731
+ return _finalizeGrep(raw, pattern); // an existing file with 0 hits is a true negative, not an error
732
+ }
733
+ baseDir = plan.baseDir;
734
+ pathGlob = plan.pathGlob;
735
+ }
627
736
  const useRg = engine === 'rg' || (engine === 'auto' && !!_detectRipgrep());
628
737
  let raw;
629
738
  if (useRg) {
@@ -635,7 +744,17 @@ function _grepSearch({ pattern, pathGlob = null, ignoreCase = false, baseDir = '
635
744
  raw = _grepNode({ pattern, pathGlob, ignoreCase, baseDir, signal });
636
745
  }
637
746
  if (raw && (raw.error || raw.aborted)) return raw;
638
- return _finalizeGrep(raw, pattern);
747
+ const out = _finalizeGrep(raw, pattern);
748
+ // Safety net (FIX 2): a `path` was supplied but there was NOTHING to search
749
+ // (path doesn't exist, or the glob/dir matched zero candidate files). Surface a
750
+ // diagnostic instead of a silent {count:0} that reads as "pattern absent". Gated
751
+ // strictly on zero CANDIDATE FILES — a glob that matched real files where the
752
+ // pattern just isn't present still returns {count:0} (a true negative).
753
+ if (out && !out.error && out.count === 0 && pathSupplied
754
+ && !_grepHasCandidate({ baseDir, pathGlob, signal })) {
755
+ return { error: `grep: path "${pathArg}" did not resolve to any file within the search root ${baseDir}` };
756
+ }
757
+ return out;
639
758
  }
640
759
 
641
760
  function _globSearch({ pattern, baseDir = '.', signal = null }) {
@@ -1170,6 +1289,53 @@ const TOOL_REGISTRY = [
1170
1289
  }
1171
1290
  },
1172
1291
  },
1292
+ {
1293
+ tool: 'view_image',
1294
+ specNames: ['view_image'],
1295
+ tags: ['view_image'],
1296
+ // Both XML forms accepted: the attribute form `<view_image path="…"/>` and
1297
+ // the inline form `<view_image>PATH</view_image>` (like read_file/download).
1298
+ parseXml: (text) => {
1299
+ const out = [];
1300
+ for (const m of _matchDual(text, '<view_image\\s+path=Q([^Q]+)Q\\s*(?:><\\/view_image>|\\/>)')) {
1301
+ out.push(['view_image', m[1]]);
1302
+ }
1303
+ for (const m of text.matchAll(/<view_image>([\s\S]*?)<\/view_image>/g)) {
1304
+ out.push(['view_image', _unwrapInnerTag(m[1]).trim()]);
1305
+ }
1306
+ return out;
1307
+ },
1308
+ fromParams: (p) => (p.path ? ['view_image', String(p.path)] : null),
1309
+ // Read-only: a local file read, so no permission gate (parity with read_file /
1310
+ // grep, permission: () => null). Path safety + the size cap are enforced by
1311
+ // readImage (via isPathSafe) inside execute, exactly as the /image command does.
1312
+ permission: () => null,
1313
+ // Stage a LOCAL image into vision context. Reuses readImage — the SAME encoder
1314
+ // the /image slash command uses (read through isPathSafe, size-capped, magic-byte
1315
+ // media-type detect, base64). The returned `image` record is collected by the
1316
+ // agent loop and attached to the tool-result message's `images[]`, so api.js
1317
+ // buildProviderMessages turns it into a provider image block on the next turn.
1318
+ execute: async (ctx, args) => {
1319
+ const [arg0 = null] = args;
1320
+ const { _log, logToolCall, isPathSafe, getConfig, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1321
+ const filePath = arg0;
1322
+ try {
1323
+ const { readImage } = require('./images');
1324
+ const cfg = getConfig ? getConfig() : {};
1325
+ const img = readImage(filePath, { maxBytes: cfg.image_max_bytes, isPathSafe });
1326
+ const kb = Math.max(1, Math.round(img.bytes / 1024));
1327
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Viewing image ${filePath} (${img.media_type}, ${kb} KB)${RST}`);
1328
+ logToolCall('view_image', { path: filePath }, true, 'ok');
1329
+ // `image` carries the base64 bytes for staging; it never enters the
1330
+ // model-facing text (formatFileResult builds a short confirmation line).
1331
+ return { status: 'ok', path: filePath, media_type: img.media_type, bytes: img.bytes, image: img };
1332
+ } catch (error) {
1333
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
1334
+ logToolCall('view_image', { path: filePath }, true, 'error');
1335
+ return { error: error.message };
1336
+ }
1337
+ },
1338
+ },
1173
1339
  {
1174
1340
  tool: 'write',
1175
1341
  specNames: ['write_file', 'create_file'],
@@ -1294,9 +1460,16 @@ const TOOL_REGISTRY = [
1294
1460
  permission: () => null,
1295
1461
  execute: async (ctx, args, options) => {
1296
1462
  const signal = (options && options.signal) || null;
1297
- const [pattern = null, pathGlob = null, ignoreCase = false, outputMode = null, headLimit, offset] = args;
1298
- const { _log, logToolCall, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1299
- const res = _grepSearch({ pattern, pathGlob, ignoreCase, baseDir: '.', engine: 'auto', signal });
1463
+ const [pattern = null, pathArg = null, ignoreCase = false, outputMode = null, headLimit, offset] = args;
1464
+ const { _log, logToolCall, isProtectedSecretPath, _secretReadError, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1465
+ // Path-as-target now reads files directly, so apply the SAME secret-file read
1466
+ // guard read_file/search_in_file use (the OS sandbox remains the outer
1467
+ // confinement; this just refuses the credential/history files by name).
1468
+ if (pathArg != null && pathArg !== '' && isProtectedSecretPath(pathArg)) {
1469
+ logToolCall('grep', { pattern, path: pathArg }, false, 'denied');
1470
+ return _secretReadError(pathArg);
1471
+ }
1472
+ const res = _grepSearch({ pattern, path: pathArg, ignoreCase, engine: 'auto', signal });
1300
1473
  if (res.aborted) { logToolCall('grep', { pattern }, true, 'aborted'); return res; }
1301
1474
  if (res.error) {
1302
1475
  _log(` ${FG_RED}✗ ${res.error}${RST}`);
@@ -1310,7 +1483,7 @@ const TOOL_REGISTRY = [
1310
1483
  res.head_limit = _normHeadLimit(headLimit, require('./constants').DEFAULT_GREP_HEAD_LIMIT);
1311
1484
  res.offset = _normOffset(offset);
1312
1485
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}grep "${pattern}" — ${res.count} match(es)${RST}`);
1313
- logToolCall('grep', { pattern, path: pathGlob }, true, 'ok');
1486
+ logToolCall('grep', { pattern, path: pathArg }, true, 'ok');
1314
1487
  return res;
1315
1488
  },
1316
1489
  },
@@ -1548,15 +1721,30 @@ const TOOL_REGISTRY = [
1548
1721
  tool: 'edit_file',
1549
1722
  specNames: ['edit_file'],
1550
1723
  tags: ['edit_file'],
1551
- parseXml: (text) => _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q>([\\s\\S]*?)<\\/edit_file>').map((m) => ['edit_file', m[1], parseInt(m[2], 10), m[3]]),
1552
- fromParams: (p) => (p.path && p.line !== undefined ? ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : ''] : null),
1553
- permission: (ctx, args) => ({ actionType: 'file', description: `Edit line ${args[1]} in ${args[0]}`, tag: 'edit_file' }),
1724
+ // Optional `end_line` (W.5 trailing-arg discipline: absent the 4-element
1725
+ // single-line tuple, byte-for-byte the prior behavior). When present, lines
1726
+ // `line..end_line` are replaced wholesale by the content a regex-free way
1727
+ // to swap a block, pairing with read_file's start_line/end_line + line
1728
+ // numbers (read a numbered slice, then replace that exact range).
1729
+ parseXml: (text) => _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q(?:\\s+end_line=Q(\\d+)Q)?>([\\s\\S]*?)<\\/edit_file>').map((m) => {
1730
+ const call = ['edit_file', m[1], parseInt(m[2], 10), m[4]];
1731
+ if (m[3] != null) call.push(parseInt(m[3], 10));
1732
+ return call;
1733
+ }),
1734
+ fromParams: (p) => {
1735
+ if (!(p.path && p.line !== undefined)) return null;
1736
+ const call = ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : ''];
1737
+ if (p.end_line != null) call.push(parseInt(p.end_line, 10));
1738
+ return call;
1739
+ },
1740
+ permission: (ctx, args) => ({ actionType: 'file', description: args[4] != null ? `Edit lines ${args[1]}-${args[4]} in ${args[0]}` : `Edit line ${args[1]} in ${args[0]}`, tag: 'edit_file' }),
1554
1741
  execute: async (ctx, args) => {
1555
- const [arg0 = null, arg1 = null, arg2 = null] = args;
1742
+ const [arg0 = null, arg1 = null, arg2 = null, arg3] = args;
1556
1743
  const { _log, logToolCall, isProtectedConfigPath, _protectedConfigWriteError, permissionManager, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1557
1744
  const filePath = arg0;
1558
1745
  const lineNum = arg1;
1559
1746
  const newContent = arg2;
1747
+ const endLine = arg3; // undefined → single-line edit (unchanged)
1560
1748
  const blocked = permissionManager.readonlyBlock('edit_file');
1561
1749
  if (blocked) {
1562
1750
  logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
@@ -1573,11 +1761,28 @@ const TOOL_REGISTRY = [
1573
1761
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
1574
1762
  return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
1575
1763
  }
1576
- lines[lineNum - 1] = newContent;
1577
- await fsp.writeFile(filePath, lines.join('\n'));
1578
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
1579
- logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
1580
- return { status: 'ok', path: filePath, line: lineNum };
1764
+ if (endLine == null) {
1765
+ // Single-line replace — exactly the prior behavior (no regression).
1766
+ lines[lineNum - 1] = newContent;
1767
+ const after = lines.join('\n');
1768
+ await fsp.writeFile(filePath, after);
1769
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
1770
+ logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
1771
+ // _diffBefore/_diffAfter feed the execution-time diff renderer (onToolEnd).
1772
+ return { status: 'ok', path: filePath, line: lineNum, _diffBefore: data, _diffAfter: after };
1773
+ }
1774
+ // Line-range replace: swap lines lineNum..endLine for newContent (which
1775
+ // may itself be multi-line). No regex involved → no ReDoS surface.
1776
+ if (endLine < lineNum || endLine > lines.length) {
1777
+ logToolCall('edit_file', { path: filePath, line: lineNum, end_line: endLine }, true, 'error');
1778
+ return { error: `Line range ${lineNum}-${endLine} out of range (file has ${lines.length} lines)` };
1779
+ }
1780
+ lines.splice(lineNum - 1, endLine - lineNum + 1, ...newContent.split('\n'));
1781
+ const afterRange = lines.join('\n');
1782
+ await fsp.writeFile(filePath, afterRange);
1783
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited lines ${lineNum}-${endLine} in ${filePath}${RST}`);
1784
+ logToolCall('edit_file', { path: filePath, line: lineNum, end_line: endLine }, true, 'ok');
1785
+ return { status: 'ok', path: filePath, line: lineNum, end_line: endLine, _diffBefore: data, _diffAfter: afterRange };
1581
1786
  } catch (error) {
1582
1787
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
1583
1788
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
@@ -1594,7 +1799,7 @@ const TOOL_REGISTRY = [
1594
1799
  permission: () => null,
1595
1800
  execute: async (ctx, args) => {
1596
1801
  const [arg0 = null, arg1 = null] = args;
1597
- const { _log, logToolCall, isProtectedSecretPath, _secretReadError, _checkRegexSafety, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1802
+ const { _log, logToolCall, isProtectedSecretPath, _secretReadError, _checkRegexSafety, _isLiteralPattern, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1598
1803
  const filePath = arg0;
1599
1804
  const pattern = arg1;
1600
1805
  if (isProtectedSecretPath(filePath)) {
@@ -1608,9 +1813,13 @@ const TOOL_REGISTRY = [
1608
1813
  logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
1609
1814
  return guardErr;
1610
1815
  }
1611
- const regex = new RegExp(pattern);
1816
+ // A metacharacter-free pattern is matched literally (substring) — same
1817
+ // line results as a regex for plain text, but unbounded by length so a
1818
+ // long pasted block can be located. Genuine regexes still compile.
1819
+ const isLiteral = _isLiteralPattern(pattern);
1820
+ const test = isLiteral ? (content) => content.includes(pattern) : ((regex) => (content) => regex.test(content))(new RegExp(pattern));
1612
1821
  const matches = data.split('\n')
1613
- .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
1822
+ .map((content, idx) => test(content) ? { line: idx + 1, content } : null)
1614
1823
  .filter(Boolean);
1615
1824
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
1616
1825
  logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
@@ -1626,16 +1835,43 @@ const TOOL_REGISTRY = [
1626
1835
  tool: 'replace_in_file',
1627
1836
  specNames: ['replace_in_file'],
1628
1837
  tags: ['replace_in_file'],
1629
- parseXml: (text) => _matchDual(text, '<replace_in_file\\s+path=Q([^Q]+)Q\\s+search=Q([^Q]*)Q\\s+replace=Q([^Q]*)Q>([\\s\\S]*?)<\\/replace_in_file>').map((m) => ['replace_in_file', m[1], m[2], m[3], m[4].trim()]),
1630
- fromParams: (p) => (p.path && p.search !== undefined ? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || ''] : null),
1838
+ // LITERAL by default (Claude Code Edit model): the search text is matched
1839
+ // VERBATIM, byte-for-byte, no matter what regex-special chars it contains
1840
+ // so a copied code block with ( ) { } . [ ] just works. Regex is opt-in via
1841
+ // `regex="true"`. Matching must be UNIQUE: 0 matches → error (no-op masked as
1842
+ // success was the silent-corruption trap), >1 matches → error unless
1843
+ // `replace_all="true"`. Inline body = regex flags (only meaningful with
1844
+ // regex="true"). Attr-based parse (like read_file) so the optional flags
1845
+ // appear in any order.
1846
+ parseXml: (text) => {
1847
+ const out = [];
1848
+ const re = /<replace_in_file\b([^>]*?)>([\s\S]*?)<\/replace_in_file>/g;
1849
+ for (const m of text.matchAll(re)) {
1850
+ const attrStr = m[1] || '';
1851
+ const body = m[2] != null ? m[2] : '';
1852
+ const attr = (k) => {
1853
+ const mm = attrStr.match(new RegExp(`${k}="([^"]*)"`)) || attrStr.match(new RegExp(`${k}='([^']*)'`));
1854
+ return mm ? mm[1] : null;
1855
+ };
1856
+ const p = attr('path');
1857
+ const search = attr('search');
1858
+ if (p == null || search == null) continue;
1859
+ const replace = attr('replace') != null ? attr('replace') : '';
1860
+ out.push(['replace_in_file', p, search, replace, body.trim(), attr('regex') === 'true', attr('replace_all') === 'true']);
1861
+ }
1862
+ return out;
1863
+ },
1864
+ fromParams: (p) => (p.path && p.search !== undefined ? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '', p.regex === true || p.regex === 'true', p.replace_all === true || p.replace_all === 'true'] : null),
1631
1865
  permission: (ctx, args) => ({ actionType: 'file', description: `Replace in ${args[0]}`, tag: 'replace_in_file' }),
1632
1866
  execute: async (ctx, args) => {
1633
- const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
1867
+ const [arg0 = null, arg1 = null, arg2 = null, arg3 = null, arg4 = false, arg5 = false] = args;
1634
1868
  const { _log, logToolCall, isProtectedConfigPath, _protectedConfigWriteError, _checkRegexSafety, permissionManager, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
1635
1869
  const filePath = arg0;
1636
1870
  const searchStr = arg1;
1637
1871
  const replaceStr = arg2;
1638
1872
  const flags = arg3 || '';
1873
+ const useRegex = arg4 === true;
1874
+ const replaceAll = arg5 === true;
1639
1875
  const blocked = permissionManager.readonlyBlock('replace_in_file');
1640
1876
  if (blocked) {
1641
1877
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
@@ -1647,28 +1883,79 @@ const TOOL_REGISTRY = [
1647
1883
  }
1648
1884
  try {
1649
1885
  const data = await fsp.readFile(filePath, 'utf8');
1650
- const guardErr = _checkRegexSafety(searchStr, data);
1651
- if (guardErr) {
1886
+ if (searchStr == null || searchStr === '') {
1652
1887
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1653
- return guardErr;
1888
+ return { error: 'replace_in_file: search string is empty — nothing to match.' };
1889
+ }
1890
+ let newData;
1891
+ let count;
1892
+ let remaining; // post-replace occurrences of the search string in newData
1893
+ if (!useRegex) {
1894
+ // LITERAL path (default): O(dataLen) substring match — no regex
1895
+ // compiled, so a long block is matched verbatim with no ReDoS surface
1896
+ // and no length bound. The replacement is raw text (no $1/$& handling).
1897
+ const parts = data.split(searchStr);
1898
+ count = parts.length - 1;
1899
+ // ---- Uniqueness / occurrence guard (BEFORE writing) ----
1900
+ if (count === 0) {
1901
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1902
+ return { error: `replace_in_file: search string not found in ${filePath} — file unchanged. Verify exact text including whitespace/indentation.` };
1903
+ }
1904
+ if (count > 1 && !replaceAll) {
1905
+ const lines = _literalOccurrenceLines(data, searchStr);
1906
+ const at = lines.length ? ` (matches start at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}${count > lines.length ? ', …' : ''})` : '';
1907
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1908
+ return { error: `replace_in_file: found ${count} matches but replace_all is not set${at}. Add surrounding context to uniquely identify ONE occurrence, or set replace_all:true to replace all ${count}.` };
1909
+ }
1910
+ if (replaceAll) {
1911
+ newData = parts.join(replaceStr);
1912
+ } else {
1913
+ // count === 1: replace the single occurrence.
1914
+ const idx = data.indexOf(searchStr);
1915
+ newData = data.slice(0, idx) + replaceStr + data.slice(idx + searchStr.length);
1916
+ count = 1;
1917
+ }
1918
+ remaining = newData.split(searchStr).length - 1;
1919
+ } else {
1920
+ // REGEX path (opt-in via regex:true): ReDoS guard applies. `g` flag OR
1921
+ // replace_all replaces all; otherwise the match must be unique.
1922
+ const guardErr = _checkRegexSafety(searchStr, data, false);
1923
+ if (guardErr) {
1924
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1925
+ return guardErr;
1926
+ }
1927
+ const safeFlags = flags.replace(/[^gimsuy]/g, '');
1928
+ const globalFlags = safeFlags.replace('g', '') + 'g';
1929
+ const isGlobal = safeFlags.includes('g') || replaceAll;
1930
+ count = (data.match(new RegExp(searchStr, globalFlags)) || []).length;
1931
+ // ---- Uniqueness / occurrence guard (BEFORE writing) ----
1932
+ if (count === 0) {
1933
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1934
+ return { error: `replace_in_file: pattern not found in ${filePath} — file unchanged. Verify the regex (or drop regex:true to match literally).` };
1935
+ }
1936
+ if (count > 1 && !isGlobal) {
1937
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
1938
+ return { error: `replace_in_file: pattern matched ${count} times but neither the "g" flag nor replace_all is set. Refine the pattern to match ONE occurrence, or set replace_all:true to replace all ${count}.` };
1939
+ }
1940
+ const regex = new RegExp(searchStr, (isGlobal ? globalFlags : safeFlags.replace('g', '')) || undefined);
1941
+ newData = data.replace(regex, replaceStr);
1942
+ count = isGlobal ? count : 1;
1943
+ remaining = (newData.match(new RegExp(searchStr, globalFlags)) || []).length;
1654
1944
  }
1655
- const safeFlags = flags.replace(/[^gimsuy]/g, '');
1656
- const regex = new RegExp(searchStr, safeFlags || undefined);
1657
- // Semantics (intentional, unchanged): String.prototype.replace replaces
1658
- // ALL matches only when the regex is global; without "g" it replaces just
1659
- // the first match. The returned count must equal the replacements actually
1660
- // performed — so count all matches when global, else 1 if there is a match
1661
- // (else 0). (Task 1.4c: previously count was computed with an always-global
1662
- // regex and overstated non-global replacements.)
1663
- const isGlobal = safeFlags.includes('g');
1664
- const count = isGlobal
1665
- ? (data.match(new RegExp(searchStr, safeFlags)) || []).length
1666
- : (new RegExp(searchStr, safeFlags).test(data) ? 1 : 0);
1667
- const newData = data.replace(regex, replaceStr);
1668
1945
  await fsp.writeFile(filePath, newData);
1669
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
1946
+ const result = { status: 'ok', path: filePath, count, _diffBefore: data, _diffAfter: newData };
1947
+ // POST-REPLACE VERIFICATION: if the search string still appears (e.g. the
1948
+ // replacement contains it, or matches overlapped), surface a warning
1949
+ // instead of reporting clean success.
1950
+ if (remaining > 0) {
1951
+ result.warning = `replace_in_file: replaced ${count} occurrence(s), but the search string still appears ${remaining} time(s) in ${filePath} (the replacement may contain the search text, or matches overlapped).`;
1952
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath} (${remaining} still present)${RST}`);
1953
+ } else {
1954
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
1955
+ }
1670
1956
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
1671
- return { status: 'ok', path: filePath, count };
1957
+ // _diffBefore/_diffAfter feed the execution-time diff renderer (onToolEnd).
1958
+ return result;
1672
1959
  } catch (error) {
1673
1960
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
1674
1961
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
@@ -2269,14 +2556,26 @@ const TOOL_REGISTRY = [
2269
2556
  tags: ['ask_user'],
2270
2557
  parseXml: (text) => _matchDual(text, '<ask_user\\s+question=Q([^Q]+)Q\\s*(?:><\\/ask_user>|\\/>)').map((m) => ['ask_user', m[1]]),
2271
2558
  fromParams: (p) => (p.question ? ['ask_user', p.question] : null),
2272
- permission: (ctx, args) => ({ actionType: 'user', description: `Ask user: ${args[0]}`, tag: 'ask_user' }),
2559
+ // No permission descriptor: ask_user has no system side effects it only
2560
+ // prompts the interactive user, which IS the interaction. A separate "may I
2561
+ // ask you?" gate would be pure friction (a double prompt before the real
2562
+ // question/menu). A null descriptor also makes it available during plan mode
2563
+ // (the plan-mode gate withholds only effectful tools, i.e. non-null
2564
+ // descriptors), so the agent can ask clarifying questions while planning.
2565
+ permission: () => null,
2273
2566
  execute: async (ctx, args) => {
2274
2567
  const [arg0 = null] = args;
2275
- const { _log, logToolCall, _parseNumberedOptions, permissionManager, writer, FG_YELLOW, FG_GRAY, RST, DIM } = ctx;
2568
+ const { _log, logToolCall, _parseAskMenu, permissionManager, writer, FG_YELLOW, FG_GRAY, RST, DIM } = ctx;
2276
2569
  const question = arg0;
2277
- const options = _parseNumberedOptions(question);
2570
+ // Display-only split: the menu gets ONLY the numbered options; the modal
2571
+ // header gets ONLY the prose prompt (no duplication). The model-facing
2572
+ // result below still carries the FULL original `question` (agent.js builds
2573
+ // "User answered \"<question>\": <answer>" from it). Edge: a question with
2574
+ // no prose before the numbers yields an empty prompt — fall back to the raw
2575
+ // question so the modal never renders an empty header.
2576
+ const { prompt, options } = _parseAskMenu(question);
2278
2577
  if (options.length >= 2) {
2279
- const selected = await permissionManager.captureSelect({ options });
2578
+ const selected = await permissionManager.captureSelect({ prompt: prompt || question, options });
2280
2579
  logToolCall('ask_user', { question }, true, 'ok');
2281
2580
  return { question, answer: selected || options[0] };
2282
2581
  }
@@ -2543,6 +2842,11 @@ module.exports = {
2543
2842
  _grepSearch,
2544
2843
  _globSearch,
2545
2844
  _detectRipgrep,
2845
+ // Path-aware grep target resolution + single-file grep (file/dir/glob path
2846
+ // semantics + the unresolvable-path safety net). Exported for focused tests.
2847
+ _resolveGrepPath,
2848
+ _grepFile,
2849
+ _grepHasCandidate,
2546
2850
  // grep output modes + bound normalizers (Task W.5).
2547
2851
  GREP_OUTPUT_MODES,
2548
2852
  _normGrepMode,