mixdog 0.9.9 → 0.9.11

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 (49) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +12 -8
  3. package/scripts/bench/cache-probe-tasks.json +8 -0
  4. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  5. package/scripts/bench/r4-mixed-tasks.json +1 -1
  6. package/scripts/bench/review-tasks.json +1 -1
  7. package/scripts/build-runtime-windows.ps1 +242 -242
  8. package/scripts/recall-usecase-cases.json +1 -1
  9. package/scripts/smoke-runtime-negative.ps1 +106 -106
  10. package/scripts/tool-smoke.mjs +109 -0
  11. package/src/defaults/mixdog-config.template.json +1 -0
  12. package/src/mixdog-session-runtime.mjs +8 -0
  13. package/src/rules/agent/30-explorer.md +18 -20
  14. package/src/rules/lead/02-channels.md +3 -3
  15. package/src/runtime/agent/orchestrator/config.mjs +30 -0
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
  18. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
  19. package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -0
  22. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
  23. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
  25. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
  28. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
  29. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  31. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
  35. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
  36. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
  37. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
  39. package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
  40. package/src/session-runtime/settings-api.mjs +13 -0
  41. package/src/session-runtime/tool-catalog.mjs +34 -7
  42. package/src/standalone/agent-tool/render.mjs +2 -0
  43. package/src/standalone/agent-tool.mjs +28 -6
  44. package/src/standalone/explore-tool.mjs +2 -2
  45. package/src/standalone/memory-runtime-proxy.mjs +85 -21
  46. package/src/tui/App.jsx +20 -1
  47. package/src/tui/app/extension-pickers.mjs +6 -3
  48. package/src/tui/dist/index.mjs +28 -4
  49. package/src/tui/engine.mjs +2 -0
@@ -3,6 +3,7 @@ import { isAbsolute, resolve } from 'path';
3
3
  import { trueCasePath } from './path-utils.mjs';
4
4
  import {
5
5
  canonicalizeGlobSlashes,
6
+ coerceReadFamilyPathArg,
6
7
  coerceShapeFlex,
7
8
  extractGlobBaseDirectory,
8
9
  hasGlobMagic,
@@ -17,6 +18,7 @@ import {
17
18
  _suggestIndexedPaths,
18
19
  basePathDiagnostic,
19
20
  buildNotFoundHint,
21
+ tryReadFamilyEnoentRedirect,
20
22
  isUncOrSmbPath,
21
23
  relativePathPrefix,
22
24
  relativeSearchResultPath,
@@ -186,7 +188,179 @@ function parseGrepCountLine(line) {
186
188
  return { path, count };
187
189
  }
188
190
 
189
- function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true }) {
191
+ const GREP_RESULT_LINE_SKIP = /^\[(?:Showing|total|pattern set|capped|warning|redirected|regex parse)/;
192
+ const GREP_CHUNK_AGGREGATE_FLOOR = 200;
193
+ const GREP_CHUNK_AGGREGATE_DEFAULT = 800;
194
+ const GREP_CHUNK_AGGREGATE_MAX = 4000;
195
+
196
+ function chunkPatternList(patterns, cap) {
197
+ const out = [];
198
+ for (let i = 0; i < patterns.length; i += cap) out.push(patterns.slice(i, i + cap));
199
+ return out;
200
+ }
201
+
202
+ function computeGrepChunkAggregateBudget(offset, headLimit, headLimitCoerced) {
203
+ if (headLimitCoerced === 0 && headLimit === Infinity) return GREP_CHUNK_AGGREGATE_MAX;
204
+ if (headLimit === Infinity) return GREP_CHUNK_AGGREGATE_DEFAULT;
205
+ const need = offset + headLimit;
206
+ return Math.min(GREP_CHUNK_AGGREGATE_MAX, Math.max(GREP_CHUNK_AGGREGATE_FLOOR, need * 2));
207
+ }
208
+
209
+ function compareGrepLinesByPathLine(a, b) {
210
+ const pa = splitGrepLinePrefix(a);
211
+ const pb = splitGrepLinePrefix(b);
212
+ if (!pa && !pb) return String(a).localeCompare(String(b));
213
+ if (!pa) return 1;
214
+ if (!pb) return -1;
215
+ const byPath = pa.path.localeCompare(pb.path);
216
+ if (byPath !== 0) return byPath;
217
+ return pa.lineNo - pb.lineNo;
218
+ }
219
+
220
+ function deriveGrepCountLinesFromMatchContent(lines) {
221
+ const byPath = new Map();
222
+ for (const line of lines) {
223
+ const split = splitGrepLinePrefix(line);
224
+ if (!split || split.delimiter !== ':') continue;
225
+ if (!byPath.has(split.path)) byPath.set(split.path, new Set());
226
+ byPath.get(split.path).add(split.lineNo);
227
+ }
228
+ return [...byPath.entries()]
229
+ .sort((a, b) => a[0].localeCompare(b[0]))
230
+ .map(([path, lineNos]) => `${path}:${lineNos.size}`);
231
+ }
232
+
233
+ function isGrepMatchLine(line) {
234
+ const split = splitGrepLinePrefix(line);
235
+ return !!(split && split.delimiter === ':');
236
+ }
237
+
238
+ function grepMatchAnchorKey(line) {
239
+ const split = splitGrepLinePrefix(line);
240
+ if (!split || split.delimiter !== ':') return '';
241
+ return `${split.path}\0${split.lineNo}`;
242
+ }
243
+
244
+ function parseGrepContextBlocksInSegment(segmentLines) {
245
+ const blocks = [];
246
+ let pending = [];
247
+ let i = 0;
248
+ while (i < segmentLines.length) {
249
+ const line = segmentLines[i];
250
+ if (isGrepMatchLine(line)) {
251
+ const blockLines = pending.concat([line]);
252
+ pending = [];
253
+ i += 1;
254
+ while (i < segmentLines.length) {
255
+ const next = segmentLines[i];
256
+ if (next === '--' || isGrepMatchLine(next)) break;
257
+ blockLines.push(next);
258
+ i += 1;
259
+ }
260
+ const anchor = grepMatchAnchorKey(line);
261
+ if (anchor) blocks.push({ anchor, lines: blockLines });
262
+ continue;
263
+ }
264
+ pending.push(line);
265
+ i += 1;
266
+ }
267
+ return blocks;
268
+ }
269
+
270
+ function compareGrepAnchorKeys(a, b) {
271
+ const [pa, la] = String(a || '').split('\0');
272
+ const [pb, lb] = String(b || '').split('\0');
273
+ const byPath = pa.localeCompare(pb);
274
+ if (byPath !== 0) return byPath;
275
+ return Number(la) - Number(lb);
276
+ }
277
+
278
+ function mergeGrepContextChunkLines(lines) {
279
+ const segments = [];
280
+ let current = [];
281
+ for (const line of lines) {
282
+ if (line === '--') {
283
+ segments.push(current);
284
+ current = [];
285
+ } else {
286
+ current.push(line);
287
+ }
288
+ }
289
+ segments.push(current);
290
+ const seen = new Set();
291
+ const blocks = [];
292
+ for (const segment of segments) {
293
+ if (!segment.length) continue;
294
+ for (const block of parseGrepContextBlocksInSegment(segment)) {
295
+ if (!block.anchor || seen.has(block.anchor)) continue;
296
+ seen.add(block.anchor);
297
+ blocks.push(block);
298
+ }
299
+ }
300
+ blocks.sort((a, b) => compareGrepAnchorKeys(a.anchor, b.anchor));
301
+ const out = [];
302
+ for (const block of blocks) {
303
+ if (out.length) out.push('--');
304
+ out.push(...block.lines);
305
+ }
306
+ return out;
307
+ }
308
+
309
+ function mergeGrepChunkLines(lines, { outputMode, beforeN, afterN, contextN }) {
310
+ const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
311
+ if (outputMode === 'count') {
312
+ return deriveGrepCountLinesFromMatchContent(lines);
313
+ }
314
+ if (outputMode === 'files_with_matches') {
315
+ const seen = new Set();
316
+ const out = [];
317
+ for (const line of lines) {
318
+ const path = String(line || '').trim();
319
+ if (!path || seen.has(path)) continue;
320
+ seen.add(path);
321
+ out.push(path);
322
+ }
323
+ out.sort((a, b) => a.localeCompare(b));
324
+ return out;
325
+ }
326
+ if (outputMode === 'content' && hasContext) {
327
+ return mergeGrepContextChunkLines(lines);
328
+ }
329
+ const matches = new Map();
330
+ for (const line of lines) {
331
+ const split = splitGrepLinePrefix(line);
332
+ if (split && split.delimiter === ':') {
333
+ const key = `${split.path}\0${split.lineNo}`;
334
+ if (!matches.has(key)) matches.set(key, line);
335
+ }
336
+ }
337
+ return [...matches.values()].sort(compareGrepLinesByPathLine);
338
+ }
339
+
340
+ function extractGrepChunkResultLines(body, room = Infinity) {
341
+ const text = String(body || '').trim();
342
+ if (!text || /^Error:/i.test(text)) return { error: text || 'Error: empty grep chunk result' };
343
+ if (/^\(no matches\)/i.test(text)) return { lines: [], truncated: false };
344
+ const rawLines = text.split('\n');
345
+ const childShowingTruncated = rawLines.some((line) => /^\[Showing /i.test(String(line || '').trim()));
346
+ const lines = rawLines.filter((line) => line && !GREP_RESULT_LINE_SKIP.test(line));
347
+ const truncated = childShowingTruncated
348
+ || (Number.isFinite(room) && room >= 0 && lines.length >= room);
349
+ return { lines, truncated };
350
+ }
351
+
352
+ function buildGrepChunkMergePrefix(patternChunkCount, truncated, aggregateBudget, outputMode = 'content') {
353
+ if (patternChunkCount <= 1 && !truncated) return '';
354
+ const parts = [];
355
+ if (patternChunkCount > 1) parts.push(`pattern set split into ${patternChunkCount} chunks`);
356
+ if (truncated) {
357
+ parts.push(`chunk results truncated at aggregate budget ${aggregateBudget} lines; results are partial`);
358
+ if (outputMode === 'count') parts.push('counts are lower bounds (>=)');
359
+ }
360
+ return `[${parts.join('; ')}]\n`;
361
+ }
362
+
363
+ function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true, disableContentGrouping = false }) {
190
364
  const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
191
365
  const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
192
366
  const remaining = Math.max(0, totalWindowed - lines.length);
@@ -210,7 +384,7 @@ function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offs
210
384
  countSummary = `\n[total ${totalMatches} match${totalMatches === 1 ? '' : 'es'} across ${fileCount} file${fileCount === 1 ? '' : 's'}]`;
211
385
  }
212
386
  const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
213
- const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted)
387
+ const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted && !disableContentGrouping)
214
388
  ? groupGrepContentByFile(normalized)
215
389
  : normalized.join('\n');
216
390
  const body = groupedBody + truncated + countSummary;
@@ -219,6 +393,7 @@ function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offs
219
393
 
220
394
  export async function executeGrepTool(args, workDir, executeChildBuiltinTool, readStateScope = null, options = {}) {
221
395
  args = normalizeGrepArgs(args);
396
+ args.path = coerceReadFamilyPathArg(args.path, workDir);
222
397
  // Fan-out guard: batch multiple string paths sequentially, mirroring
223
398
  // code_graph files[] batching. Recursive calls pass a single string
224
399
  // path, so recursion bottoms out after one level.
@@ -280,12 +455,6 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
280
455
  // \n; an explicit multiline:true from the caller still wins outright.
281
456
  const patternsWantMultiline = patterns.some((p) => /\\n/.test(p));
282
457
  const multilineMode = args.multiline === true || patternsWantMultiline;
283
- if (multilineMode && patterns.length > GREP_MULTILINE_PATTERN_CAP) {
284
- return `Error: multiline:true with more than ${GREP_MULTILINE_PATTERN_CAP} patterns is not allowed (got ${patterns.length}); split into separate grep calls`;
285
- }
286
- if (patterns.length > GREP_ARRAY_PATTERN_CAP) {
287
- return `Error: pattern array exceeds the ${GREP_ARRAY_PATTERN_CAP}-pattern cap (got ${patterns.length}); split into separate grep calls`;
288
- }
289
458
  // Rescue: lookaround/backreference patterns are rejected by rg's default
290
459
  // Rust regex engine ("look-around ... is not supported" / "backreferences
291
460
  // ... not supported"). rg builds compiled with the optional `pcre2`
@@ -427,6 +596,80 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
427
596
  }
428
597
  if (fileTypes.length > 1) fileType = fileTypes;
429
598
  else if (fileTypes.length === 1) fileType = fileTypes[0];
599
+
600
+ const patternChunkCap = multilineMode ? GREP_MULTILINE_PATTERN_CAP : GREP_ARRAY_PATTERN_CAP;
601
+ if (patterns.length > patternChunkCap) {
602
+ const patternChunks = chunkPatternList(patterns, patternChunkCap);
603
+ const aggregateBudget = computeGrepChunkAggregateBudget(offset, headLimit, headLimitCoerced);
604
+ let truncatedAggregate = false;
605
+ const chunkBaseArgs = {
606
+ ...args,
607
+ offset: 0,
608
+ ...(outputMode === 'count' ? { output_mode: 'content' } : {}),
609
+ };
610
+ const chunkMergeOptions = { ...options, _grepChunkMerge: true };
611
+ const mergedRaw = [];
612
+ for (const chunk of patternChunks) {
613
+ if (mergedRaw.length >= aggregateBudget) {
614
+ truncatedAggregate = true;
615
+ break;
616
+ }
617
+ const room = aggregateBudget - mergedRaw.length;
618
+ const chunkBody = await executeGrepTool(
619
+ { ...chunkBaseArgs, pattern: chunk, head_limit: room },
620
+ workDir,
621
+ executeChildBuiltinTool,
622
+ readStateScope,
623
+ chunkMergeOptions,
624
+ );
625
+ const extracted = extractGrepChunkResultLines(chunkBody, room);
626
+ if (extracted.error) return extracted.error.startsWith('Error:') ? extracted.error : `Error: ${extracted.error}`;
627
+ const slice = extracted.lines.slice(0, room);
628
+ mergedRaw.push(...slice);
629
+ if (extracted.truncated || extracted.lines.length > room) truncatedAggregate = true;
630
+ }
631
+ const chunkPrefix = buildGrepChunkMergePrefix(
632
+ patternChunks.length,
633
+ truncatedAggregate,
634
+ aggregateBudget,
635
+ outputMode,
636
+ );
637
+ const merged = mergeGrepChunkLines(mergedRaw, {
638
+ outputMode,
639
+ beforeN,
640
+ afterN,
641
+ contextN,
642
+ });
643
+ const sliced = offset > 0 ? merged.slice(offset) : merged;
644
+ const limit = headLimit === Infinity ? sliced.length : headLimit;
645
+ const windowed = limit === Infinity ? sliced : sliced.slice(0, limit);
646
+ if (!windowed.length) {
647
+ const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
648
+ const globStr = normalizedGlobPatterns.length > 0 ? ` glob=${JSON.stringify(normalizedGlobPatterns)}` : '';
649
+ return `${chunkPrefix}(no matches) pattern=${patternStr} path=${searchPath}${globStr}`;
650
+ }
651
+ return formatGrepOutput({
652
+ windowed,
653
+ totalWindowed: merged.length,
654
+ totalKnown: !truncatedAggregate,
655
+ headLimit,
656
+ offset,
657
+ outputMode,
658
+ patterns,
659
+ beforeN,
660
+ afterN,
661
+ contextN,
662
+ searchPath,
663
+ grepResolvedPath,
664
+ workDir,
665
+ globPatterns: normalizedGlobPatterns,
666
+ fileType,
667
+ filenameOmitted: false,
668
+ prefix: chunkPrefix,
669
+ });
670
+ }
671
+
672
+ const forceGrepFilename = !!options._grepChunkMerge;
430
673
  const cacheKey = buildGrepCacheKey({
431
674
  patterns,
432
675
  searchPath: normalizeOutputPath(grepResolvedPath),
@@ -443,6 +686,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
443
686
  onlyMatching: args['-o'] === true,
444
687
  fileType,
445
688
  pcre2: pcre2Mode,
689
+ withFilename: forceGrepFilename,
446
690
  });
447
691
  // Single-file grep registers a whole-file read snapshot (parity with
448
692
  // apply_patch), satisfying the read-before-edit guard while keeping drift
@@ -467,12 +711,27 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
467
711
  let grepStat;
468
712
  try { grepStat = statSync(grepResolvedPath); }
469
713
  catch (err) {
714
+ const redirected = await tryReadFamilyEnoentRedirect({
715
+ workDir,
716
+ resolvedPath: grepResolvedPath,
717
+ requestedPath: searchPath,
718
+ errCode: err?.code,
719
+ options,
720
+ rerun: (target, opts) => executeGrepTool(
721
+ { ...args, path: target },
722
+ workDir,
723
+ executeChildBuiltinTool,
724
+ readStateScope,
725
+ opts,
726
+ ),
727
+ });
728
+ if (redirected) return redirected;
470
729
  const msg = `Error: path does not exist: ${normalizeOutputPath(grepResolvedPath)} (${err?.code || 'ENOENT'})`;
471
730
  const hint = buildNotFoundHint(workDir, grepResolvedPath, 'Search', err?.code);
472
731
  if (hint) return msg + hint;
473
732
  return msg + await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
474
733
  }
475
- const filenameOmitted = grepStat.isFile();
734
+ const filenameOmitted = forceGrepFilename ? false : grepStat.isFile();
476
735
 
477
736
  // rg builds --glob overrides rooted at its process cwd and relativizes each
478
737
  // candidate against it with a CASE-SENSITIVE prefix strip; workDir is
@@ -507,6 +766,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
507
766
  fileType,
508
767
  onlyMatching: args['-o'] === true,
509
768
  pcre2: pcre2Mode,
769
+ withFilename: forceGrepFilename,
510
770
  });
511
771
  let windowed;
512
772
  let totalWindowed = 0;
@@ -565,6 +825,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
565
825
  globPatterns: normalizedGlobPatterns,
566
826
  fileType,
567
827
  filenameOmitted,
828
+ disableContentGrouping: !!options._grepChunkMerge,
568
829
  });
569
830
  if (!body) {
570
831
  const pathInfo = grepStat.isDirectory() ? 'path exists (dir)' : 'path exists (file)';
@@ -650,6 +911,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
650
911
  fileType,
651
912
  onlyMatching: args['-o'] === true,
652
913
  fixedStrings: true,
914
+ withFilename: forceGrepFilename,
653
915
  });
654
916
  const effectiveHeadLimit = headLimit === Infinity
655
917
  ? (outputMode === 'content' ? GREP_CONTENT_HARD_CAP : Infinity)
@@ -723,11 +985,34 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
723
985
 
724
986
  export async function executeGlobTool(args, workDir, options = {}) {
725
987
  args = normalizeGlobArgs(args);
726
- args.path = Array.isArray(args.path)
727
- ? args.path.map((p) => stripEmbeddedPathQuotes(normalizeInputPath(p))).filter((p) => typeof p === 'string' && p)
728
- : stripEmbeddedPathQuotes(normalizeInputPath(args.path));
988
+ args.path = coerceReadFamilyPathArg(args.path, workDir);
989
+ if (Array.isArray(args.path)) {
990
+ const GLOB_PATH_CAP = 10;
991
+ const seen = new Set();
992
+ const list = args.path
993
+ .map((p) => (typeof p === 'string' ? stripEmbeddedPathQuotes(normalizeInputPath(p)).trim() : ''))
994
+ .filter((p) => p && !seen.has(p) && seen.add(p));
995
+ if (list.length > 1) {
996
+ const capped = list.slice(0, GLOB_PATH_CAP);
997
+ const parts = [];
998
+ for (const p of capped) {
999
+ let body;
1000
+ try {
1001
+ body = await executeGlobTool({ ...args, path: p }, workDir, options);
1002
+ } catch (err) {
1003
+ body = `Error: ${err && err.message ? err.message : err}`;
1004
+ }
1005
+ parts.push(`# glob ${p}\n${body}`);
1006
+ }
1007
+ if (list.length > GLOB_PATH_CAP) parts.push(`[capped at ${GLOB_PATH_CAP} of ${list.length} paths]`);
1008
+ return parts.join('\n\n');
1009
+ }
1010
+ args.path = list[0] ?? '.';
1011
+ } else {
1012
+ args.path = stripEmbeddedPathQuotes(normalizeInputPath(args.path));
1013
+ }
729
1014
  if (Array.isArray(args.path) && args.path.length === 0) {
730
- return 'Error: path array must contain at least one base directory';
1015
+ args.path = '.';
731
1016
  }
732
1017
  args.pattern = coerceShapeFlex(args.pattern);
733
1018
  const rawPattern = args.pattern;
@@ -750,12 +1035,29 @@ export async function executeGlobTool(args, workDir, options = {}) {
750
1035
  }
751
1036
  }
752
1037
  if (patterns.length === 0) {
753
- return globMissingPatternMessage();
1038
+ patterns = ['*'];
754
1039
  }
755
1040
 
756
1041
  const basePaths = (Array.isArray(args.path) && args.path.length > 0)
757
1042
  ? args.path
758
1043
  : [args.path || '.'];
1044
+ if (!options._enoentRedirectFrom) {
1045
+ for (const only of basePaths) {
1046
+ const resolvedOnly = resolveSearchScope(only, workDir);
1047
+ try { statSync(resolvedOnly); }
1048
+ catch (err) {
1049
+ const redirected = await tryReadFamilyEnoentRedirect({
1050
+ workDir,
1051
+ resolvedPath: resolvedOnly,
1052
+ requestedPath: only,
1053
+ errCode: err?.code,
1054
+ options,
1055
+ rerun: (target, opts) => executeGlobTool({ ...args, path: target }, workDir, opts),
1056
+ });
1057
+ if (redirected) return redirected;
1058
+ }
1059
+ }
1060
+ }
759
1061
  // A base path carrying glob magic (path:'src/**/cache/*') names a SET of
760
1062
  // directories, not a literal one — resolving it literally ENOENTs. Split
761
1063
  // it the way grep's path handling does: walk from the static baseDir and
@@ -854,6 +1156,22 @@ export async function executeGlobTool(args, workDir, options = {}) {
854
1156
  rgArgs.push('.');
855
1157
  try { statSync(rgCwd); }
856
1158
  catch (err) {
1159
+ const redirected = await tryReadFamilyEnoentRedirect({
1160
+ workDir,
1161
+ resolvedPath: rgCwd,
1162
+ requestedPath: root,
1163
+ errCode: err?.code,
1164
+ options,
1165
+ rerun: (target, opts) => executeGlobTool({ ...args, path: target }, workDir, opts),
1166
+ });
1167
+ if (redirected) {
1168
+ return {
1169
+ error: null,
1170
+ paths: [],
1171
+ stdoutTruncated: false,
1172
+ redirected,
1173
+ };
1174
+ }
857
1175
  const hint = buildNotFoundHint(workDir, rgCwd, 'Search', err?.code);
858
1176
  return {
859
1177
  error: `path does not exist: ${normalizeOutputPath(rgCwd)} (${err?.code || 'ENOENT'})${hint}`,
@@ -884,6 +1202,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
884
1202
  }));
885
1203
 
886
1204
  outer: for (const run of groupRuns) {
1205
+ if (run.redirected) return run.redirected;
887
1206
  if (run.error) {
888
1207
  rgErrors.push(run.error);
889
1208
  continue;
@@ -2,7 +2,9 @@
2
2
  // verbatim from patch.mjs; native protocol, cache/snapshot side effects, and
3
3
  // output formatting are unchanged.
4
4
 
5
- import { readFileSync } from 'node:fs';
5
+ import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
6
+ import { unlink } from 'node:fs/promises';
7
+ import { dirname as pathDirname } from 'node:path';
6
8
  import { performance } from 'node:perf_hooks';
7
9
  import {
8
10
  normalizeOutputPath,
@@ -10,8 +12,18 @@ import {
10
12
  recordReadSnapshotForPath,
11
13
  clearReadSnapshotForPath,
12
14
  } from '../builtin.mjs';
15
+ import { atomicWrite } from '../builtin/atomic-write.mjs';
16
+ import { isSpecialFileStat } from '../builtin/device-paths.mjs';
13
17
  import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
14
- import { classifyEntry, stripDiffPrefix, resolveEntryPath } from './paths.mjs';
18
+ import {
19
+ classifyEntry,
20
+ stripDiffPrefix,
21
+ resolveEntryPath,
22
+ parsedEntryResolvedPath,
23
+ isResolvedPathOutsideBase,
24
+ countHunkChanges,
25
+ } from './paths.mjs';
26
+ import { applyV4AHunksToLines } from './v4a-convert.mjs';
15
27
  import {
16
28
  getNativePatchServer,
17
29
  scheduleNativePatchIdleClose,
@@ -169,3 +181,118 @@ export async function dispatchNativePatch({ entries, basePath, nativePatchStr, f
169
181
  }
170
182
  return lines.join('\n');
171
183
  }
184
+
185
+ function entryPathKey(fullPath) {
186
+ return process.platform === 'win32' ? String(fullPath || '').toLowerCase() : String(fullPath || '');
187
+ }
188
+
189
+ function joinTextLinesForPatch(lines) {
190
+ const body = (lines || []).join('\n');
191
+ return lines?.hasFinalNewline !== false ? `${body}\n` : body;
192
+ }
193
+
194
+ function lstatRegularPatchFile(fullPath, displayPath) {
195
+ const st = lstatSync(fullPath);
196
+ if (isSpecialFileStat(st)) {
197
+ throw new Error(
198
+ `apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`,
199
+ );
200
+ }
201
+ return st;
202
+ }
203
+
204
+ function findParsedForRow(row, parsed, basePath) {
205
+ const key = entryPathKey(row.fullPath);
206
+ for (const entry of parsed || []) {
207
+ if (entryPathKey(parsedEntryResolvedPath(entry, basePath)) === key) return entry;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateScope }) {
213
+ const kind = classifyEntry(entry);
214
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
215
+ const fullPath = resolveEntryPath(basePath, headerName);
216
+ const displayPath = normalizeOutputPath(stripDiffPrefix(headerName));
217
+ if (!isResolvedPathOutsideBase(fullPath, basePath)) {
218
+ throw new Error(`apply_patch: internal JS dispatch invoked for in-base path ${displayPath}`);
219
+ }
220
+
221
+ if (kind === 'create') {
222
+ const addedLines = [];
223
+ for (const hunk of entry.hunks || []) {
224
+ for (const line of hunk.lines || []) {
225
+ if (line.startsWith('+')) addedLines.push(line.slice(1));
226
+ }
227
+ }
228
+ const content = joinTextLinesForPatch(addedLines);
229
+ if (!dryRun) {
230
+ mkdirSync(pathDirname(fullPath), { recursive: true });
231
+ await atomicWrite(fullPath, content, { sessionId: readStateScope });
232
+ invalidateBuiltinResultCache([fullPath]);
233
+ markCodeGraphDirtyPaths([fullPath]);
234
+ recordReadSnapshotForPath(fullPath, readStateScope, { source: 'apply_patch_js', isPartialView: false });
235
+ }
236
+ const { added, removed } = countHunkChanges(entry.hunks);
237
+ return { kind, fullPath, displayPath, added, removed };
238
+ }
239
+
240
+ if (kind === 'delete') {
241
+ lstatRegularPatchFile(fullPath, displayPath);
242
+ if (!dryRun) {
243
+ await unlink(fullPath);
244
+ invalidateBuiltinResultCache([fullPath]);
245
+ markCodeGraphDirtyPaths([fullPath]);
246
+ clearReadSnapshotForPath(fullPath, readStateScope);
247
+ }
248
+ const { added, removed } = countHunkChanges(entry.hunks);
249
+ return { kind, fullPath, displayPath, added, removed };
250
+ }
251
+
252
+ lstatRegularPatchFile(fullPath, displayPath);
253
+ const raw = readFileSync(fullPath);
254
+ const sourceLines = splitTextLinesForPatch(raw.toString('utf8'));
255
+ const v4aHunks = (entry.hunks || []).map((h) => ({ lines: h.lines || [] }));
256
+ const updatedLines = applyV4AHunksToLines(sourceLines, v4aHunks, { fuzzy });
257
+ const content = joinTextLinesForPatch(updatedLines);
258
+ if (!dryRun) {
259
+ await atomicWrite(fullPath, content, { sessionId: readStateScope });
260
+ invalidateBuiltinResultCache([fullPath]);
261
+ markCodeGraphDirtyPaths([fullPath]);
262
+ recordReadSnapshotForPath(fullPath, readStateScope, { source: 'apply_patch_js', isPartialView: false });
263
+ }
264
+ const { added, removed } = countHunkChanges(entry.hunks);
265
+ return { kind, fullPath, displayPath, added, removed };
266
+ }
267
+
268
+ export async function dispatchJsPatchEntries({ rows, parsed, basePath, dryRun, fuzzy, readStateScope }) {
269
+ const applied = [];
270
+ for (const row of rows || []) {
271
+ const entry = findParsedForRow(row, parsed, basePath);
272
+ if (!entry) throw new Error(`apply_patch: missing parsed entry for ${row.displayPath}`);
273
+ try {
274
+ applied.push(await applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateScope }));
275
+ } catch (err) {
276
+ return `Error: ${err?.message || String(err)}`;
277
+ }
278
+ }
279
+ const verbLabel = dryRun ? 'Checked' : 'Applied';
280
+ const countLabel = (count, singular, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`;
281
+ const kindLabel = (kind) => {
282
+ const text = String(kind || '').trim();
283
+ return text ? `${text.charAt(0).toUpperCase()}${text.slice(1).toLowerCase()}` : 'Update';
284
+ };
285
+ const lines = [`${verbLabel} ${countLabel(applied.length, 'File')} (JS)${dryRun ? ' Dry Run' : ''}`];
286
+ for (const entry of applied) {
287
+ const added = entry.added || 0;
288
+ const removed = entry.removed || 0;
289
+ const parts = [];
290
+ if (added > 0) parts.push(`+${countLabel(added, 'Line')}`);
291
+ if (removed > 0) parts.push(`-${countLabel(removed, 'Line')}`);
292
+ const detail = parts.join(' · ');
293
+ lines.push(detail
294
+ ? ` OK ${kindLabel(entry.kind)} ${entry.displayPath} — ${detail}`
295
+ : ` OK ${kindLabel(entry.kind)} ${entry.displayPath}`);
296
+ }
297
+ return lines.join('\n');
298
+ }