mixdog 0.9.38 → 0.9.39

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 (114) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +179 -0
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/src/agents/heavy-worker/AGENT.md +10 -7
  28. package/src/agents/reviewer/AGENT.md +6 -4
  29. package/src/agents/worker/AGENT.md +7 -5
  30. package/src/rules/agent/00-common.md +4 -4
  31. package/src/rules/agent/00-core.md +11 -14
  32. package/src/rules/agent/20-skip-protocol.md +3 -3
  33. package/src/rules/agent/30-explorer.md +50 -60
  34. package/src/rules/agent/40-cycle1-agent.md +15 -24
  35. package/src/rules/agent/41-cycle2-agent.md +33 -57
  36. package/src/rules/agent/42-cycle3-agent.md +28 -42
  37. package/src/rules/lead/01-general.md +7 -10
  38. package/src/rules/lead/lead-brief.md +11 -14
  39. package/src/rules/lead/lead-tool.md +6 -5
  40. package/src/rules/shared/01-tool.md +44 -45
  41. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  42. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  43. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  46. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  48. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  55. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  71. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  72. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  73. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  74. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  75. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  76. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  77. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  78. package/src/runtime/shared/tool-primitives.mjs +4 -1
  79. package/src/runtime/shared/tool-status.mjs +27 -0
  80. package/src/runtime/shared/tool-surface.mjs +6 -3
  81. package/src/session-runtime/config-helpers.mjs +14 -0
  82. package/src/session-runtime/context-status.mjs +1 -0
  83. package/src/session-runtime/effort.mjs +6 -2
  84. package/src/session-runtime/model-recency.mjs +5 -2
  85. package/src/session-runtime/runtime-core.mjs +35 -2
  86. package/src/session-runtime/tool-catalog.mjs +34 -0
  87. package/src/standalone/agent-tool/notify.mjs +13 -0
  88. package/src/standalone/agent-tool.mjs +45 -69
  89. package/src/standalone/explore-tool.mjs +6 -7
  90. package/src/tui/App.jsx +31 -0
  91. package/src/tui/app/model-options.mjs +5 -3
  92. package/src/tui/app/model-picker.mjs +12 -24
  93. package/src/tui/app/transcript-window.mjs +1 -0
  94. package/src/tui/components/ToolExecution.jsx +11 -6
  95. package/src/tui/components/TranscriptItem.jsx +1 -1
  96. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  97. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  98. package/src/tui/dist/index.mjs +517 -142
  99. package/src/tui/engine/agent-job-feed.mjs +144 -17
  100. package/src/tui/engine/agent-response-tail.mjs +68 -0
  101. package/src/tui/engine/notification-plan.mjs +16 -0
  102. package/src/tui/engine/session-api.mjs +8 -2
  103. package/src/tui/engine/session-flow.mjs +19 -1
  104. package/src/tui/engine/tool-card-results.mjs +54 -32
  105. package/src/tui/engine/tool-result-status.mjs +75 -21
  106. package/src/tui/engine/turn.mjs +77 -42
  107. package/src/tui/engine.mjs +63 -2
  108. package/src/workflows/bench/WORKFLOW.md +25 -35
  109. package/src/workflows/default/WORKFLOW.md +38 -32
  110. package/src/workflows/solo/WORKFLOW.md +19 -22
  111. package/scripts/_jitter-fuzz.mjs +0 -44410
  112. package/scripts/_jitter-fuzz2.mjs +0 -44400
  113. package/scripts/_jitter-probe.mjs +0 -44397
  114. package/scripts/_jp2.mjs +0 -45614
@@ -25,62 +25,69 @@ function isHump(prevCh, ch) {
25
25
  && /[A-Z]/.test(ch);
26
26
  }
27
27
 
28
- /**
29
- * @param {string} query user query (partial name)
30
- * @param {string} str candidate path (relative)
31
- * @returns {number|null} score, or null if `query` is not a subsequence
32
- */
33
- export function fuzzyScore(query, str) {
28
+ function prepareFuzzyScore(query) {
34
29
  if (!query) return 0;
35
30
  const normalizedQuery = String(query).replace(/[\/\\_.\-\s]+/g, '');
36
31
  if (!normalizedQuery) return 0;
37
32
  const q = normalizedQuery.toLowerCase();
38
- const s = str.toLowerCase();
39
33
  const qlen = q.length;
40
- const slen = s.length;
41
- if (qlen === 0) return 0;
42
- if (qlen > slen) return null;
43
-
44
- const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
45
-
46
- let score = 0;
47
- let si = 0;
48
- let prevMatch = -2;
49
- let firstMatchIdx = -1;
50
-
51
- for (let qi = 0; qi < qlen; qi++) {
52
- const qc = q[qi];
53
- let found = -1;
54
- for (let k = si; k < slen; k++) {
55
- if (s[k] === qc) { found = k; break; }
56
- }
57
- if (found === -1) return null;
58
- if (firstMatchIdx === -1) firstMatchIdx = found;
34
+ return (str) => {
35
+ const s = str.toLowerCase();
36
+ const slen = s.length;
37
+ if (qlen === 0) return 0;
38
+ if (qlen > slen) return null;
59
39
 
60
- score += 1; // base point per matched char
40
+ const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
61
41
 
62
- if (found === prevMatch + 1) score += 5; // contiguous run
42
+ let score = 0;
43
+ let si = 0;
44
+ let prevMatch = -2;
45
+ let firstMatchIdx = -1;
63
46
 
64
- const prevCh = found > 0 ? str[found - 1] : undefined;
65
- if (prevCh === undefined || isBoundaryChar(prevCh) || isHump(prevCh, str[found])) {
66
- score += 8; // word-boundary start
67
- }
47
+ for (let qi = 0; qi < qlen; qi++) {
48
+ const qc = q[qi];
49
+ let found = -1;
50
+ for (let k = si; k < slen; k++) {
51
+ if (s[k] === qc) { found = k; break; }
52
+ }
53
+ if (found === -1) return null;
54
+ if (firstMatchIdx === -1) firstMatchIdx = found;
68
55
 
69
- if (str[found] === normalizedQuery[qi]) score += 1; // exact-case tie-break
56
+ score += 1; // base point per matched char
70
57
 
71
- prevMatch = found;
72
- si = found + 1;
73
- }
58
+ if (found === prevMatch + 1) score += 5; // contiguous run
74
59
 
75
- // Matches that begin inside the basename (after the last separator) are far
76
- // more relevant than ones buried in directory components.
77
- if (firstMatchIdx > lastSep) score += 10;
60
+ const prevCh = found > 0 ? str[found - 1] : undefined;
61
+ if (prevCh === undefined || isBoundaryChar(prevCh) || isHump(prevCh, str[found])) {
62
+ score += 8; // word-boundary start
63
+ }
78
64
 
79
- // Mild pulls: shorter candidates and earlier first matches rank higher.
80
- score -= Math.floor(slen / 16);
81
- score -= Math.floor(firstMatchIdx / 8);
65
+ if (str[found] === normalizedQuery[qi]) score += 1; // exact-case tie-break
82
66
 
83
- return score;
67
+ prevMatch = found;
68
+ si = found + 1;
69
+ }
70
+
71
+ // Matches that begin inside the basename (after the last separator) are far
72
+ // more relevant than ones buried in directory components.
73
+ if (firstMatchIdx > lastSep) score += 10;
74
+
75
+ // Mild pulls: shorter candidates and earlier first matches rank higher.
76
+ score -= Math.floor(slen / 16);
77
+ score -= Math.floor(firstMatchIdx / 8);
78
+
79
+ return score;
80
+ };
81
+ }
82
+
83
+ /**
84
+ * @param {string} query user query (partial name)
85
+ * @param {string} str candidate path (relative)
86
+ * @returns {number|null} score, or null if `query` is not a subsequence
87
+ */
88
+ export function fuzzyScore(query, str) {
89
+ const scorer = prepareFuzzyScore(query);
90
+ return typeof scorer === 'function' ? scorer(str) : scorer;
84
91
  }
85
92
 
86
93
  // Below this per-query-char score, a subsequence-only match (query chars in
@@ -97,6 +104,53 @@ function normalizeForContains(s) {
97
104
  return String(s || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
98
105
  }
99
106
 
107
+ function compareRanked(a, b) {
108
+ return (b.score - a.score)
109
+ || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0);
110
+ }
111
+
112
+ function compareRankedNodes(a, b) {
113
+ return compareRanked(a.entry, b.entry) || (a.ordinal - b.ordinal);
114
+ }
115
+
116
+ function siftDownRankedHeap(heap, index) {
117
+ for (;;) {
118
+ const left = index * 2 + 1;
119
+ const right = left + 1;
120
+ let worst = index;
121
+ if (left < heap.length && compareRankedNodes(heap[left], heap[worst]) > 0) worst = left;
122
+ if (right < heap.length && compareRankedNodes(heap[right], heap[worst]) > 0) worst = right;
123
+ if (worst === index) return;
124
+ const node = heap[index];
125
+ heap[index] = heap[worst];
126
+ heap[worst] = node;
127
+ index = worst;
128
+ }
129
+ }
130
+
131
+ // Keep the worst retained candidate at index zero. The ordinal completes the
132
+ // public comparator only for heap bookkeeping, preserving stable-sort behavior
133
+ // for duplicate score/path entries.
134
+ function retainTopRanked(heap, entry, ordinal, limit) {
135
+ const node = { entry, ordinal };
136
+ if (heap.length < limit) {
137
+ heap.push(node);
138
+ for (let index = heap.length - 1; index > 0;) {
139
+ const parent = Math.floor((index - 1) / 2);
140
+ if (compareRankedNodes(heap[index], heap[parent]) <= 0) break;
141
+ const parentNode = heap[parent];
142
+ heap[parent] = heap[index];
143
+ heap[index] = parentNode;
144
+ index = parent;
145
+ }
146
+ return;
147
+ }
148
+ if (compareRankedNodes(node, heap[0]) < 0) {
149
+ heap[0] = node;
150
+ siftDownRankedHeap(heap, 0);
151
+ }
152
+ }
153
+
100
154
  /**
101
155
  * Rank candidates by fuzzy score against `query`, dropping non-matches.
102
156
  * @param {string} query
@@ -106,30 +160,41 @@ function normalizeForContains(s) {
106
160
  */
107
161
  export function fuzzyRank(query, items, limit = 0) {
108
162
  const normQuery = normalizeForContains(query);
163
+ const score = prepareFuzzyScore(query);
109
164
  // Floor scales with query length: a scattered subsequence earns ~1 point
110
165
  // per char, while any contiguous run (+5/char) or word-boundary hit
111
166
  // (+8) pushes a genuine match well past 4/char.
112
167
  const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
113
168
  const scored = [];
114
- for (const item of items) {
169
+ // Preserve slice's legacy coercion behavior for non-integer public callers;
170
+ // normal tool limits are positive integers and take the bounded fast path.
171
+ const bounded = Number.isInteger(limit) && limit > 0;
172
+ for (let ordinal = 0; ordinal < items.length; ordinal++) {
173
+ const item = items[ordinal];
115
174
  const p = String(item.path || '');
116
- const pathScore = fuzzyScore(query, p);
117
- const base = p.split(/[\\/]/).pop() || '';
118
- const baseScore = fuzzyScore(query, base);
175
+ const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
176
+ const base = p.slice(lastSep + 1);
177
+ const pathScore = typeof score === 'function' ? score(p) : score;
178
+ const baseScore = typeof score === 'function' ? score(base) : score;
119
179
  const sc = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
120
180
  if (!Number.isFinite(sc)) continue;
121
181
  // Strong match: the query (separators stripped) is a contiguous
122
182
  // substring of the basename or the full path. These ALWAYS pass so an
123
183
  // exact substring/basename hit can never be starved out as noise.
124
- const strong = normQuery.length > 0
125
- && (normalizeForContains(base).includes(normQuery)
126
- || normalizeForContains(p).includes(normQuery));
184
+ const strong = normQuery.length > 0 && normalizeForContains(p).includes(normQuery);
127
185
  // Otherwise it is subsequence-only: keep it only if it clears the
128
186
  // per-char floor. Weak scattered matches (the pgAdmin-style junk that
129
187
  // merely contains the query chars in order) fall below it and drop out.
130
188
  if (!strong && sc < floor) continue;
131
- scored.push({ item, score: sc });
189
+ const entry = { item, score: sc };
190
+ if (bounded) retainTopRanked(scored, entry, ordinal, limit);
191
+ else scored.push(entry);
192
+ }
193
+ if (!bounded) {
194
+ scored.sort(compareRanked);
195
+ return limit > 0 ? scored.slice(0, limit) : scored;
132
196
  }
133
- scored.sort((a, b) => (b.score - a.score) || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
134
- return limit > 0 ? scored.slice(0, limit) : scored;
197
+ return scored
198
+ .sort(compareRankedNodes)
199
+ .map(({ entry }) => entry);
135
200
  }
@@ -20,6 +20,7 @@ import {
20
20
  cacheGet,
21
21
  cacheSet,
22
22
  getCachedReadOnlyStat,
23
+ statCacheSet,
23
24
  statPathsForMtime,
24
25
  lstatPathsForMtime,
25
26
  registerCacheInvalidationListener,
@@ -92,23 +93,41 @@ export async function executeListTool(args, workDir, options = {}) {
92
93
  // wall-clock cost changes. Concurrency is capped so a 10-path batch
93
94
  // cannot exhaust the child-spawn / FS-handle budget.
94
95
  const LIST_FANOUT_CONCURRENCY = 4;
95
- const bodies = new Array(targets.length);
96
+ // Collapse semantic-duplicate targets: two spellings that resolve to
97
+ // the same directory (e.g. `foo` and `./foo`) are stat'd/walked ONCE.
98
+ // Each caller label keeps its own `# list <p>` section reusing the
99
+ // shared body, so output stays byte-identical — only the duplicate
100
+ // concurrent walk is eliminated.
101
+ const resolveKey = (p) => {
102
+ try { return normalizeOutputPath(resolveAgainstCwd(normalizeInputPath(p), workDir)); }
103
+ catch { return p; }
104
+ };
105
+ const keyByIndex = new Array(targets.length);
106
+ const uniqueKeys = [];
107
+ const repByKey = new Map();
108
+ for (let i = 0; i < targets.length; i++) {
109
+ const k = resolveKey(targets[i]);
110
+ keyByIndex[i] = k;
111
+ if (!repByKey.has(k)) { repByKey.set(k, targets[i]); uniqueKeys.push(k); }
112
+ }
113
+ const bodyByKey = new Map();
96
114
  let cursor = 0;
97
115
  const runWorker = async () => {
98
116
  for (;;) {
99
117
  const i = cursor++;
100
- if (i >= targets.length) return;
118
+ if (i >= uniqueKeys.length) return;
119
+ const k = uniqueKeys[i];
101
120
  try {
102
- bodies[i] = await executeListTool({ ...args, path: targets[i] }, workDir, options);
121
+ bodyByKey.set(k, await executeListTool({ ...args, path: repByKey.get(k) }, workDir, options));
103
122
  } catch (err) {
104
- bodies[i] = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
123
+ bodyByKey.set(k, `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`);
105
124
  }
106
125
  }
107
126
  };
108
127
  await Promise.all(
109
- Array.from({ length: Math.min(LIST_FANOUT_CONCURRENCY, targets.length) }, runWorker),
128
+ Array.from({ length: Math.min(LIST_FANOUT_CONCURRENCY, uniqueKeys.length) }, runWorker),
110
129
  );
111
- const sections = targets.map((p, i) => `# list ${p}\n${bodies[i]}`);
130
+ const sections = targets.map((p, i) => `# list ${p}\n${bodyByKey.get(keyByIndex[i])}`);
112
131
  if (capped) sections.push(`... [capped at 10 of ${list.length} paths]`);
113
132
  return sections.join('\n\n');
114
133
  }
@@ -150,8 +169,12 @@ export async function executeListTool(args, workDir, options = {}) {
150
169
  });
151
170
  const cached = cacheGet(cacheKey);
152
171
  if (cached !== null) return cached;
153
- try { await assertPathReachable(fullPath); }
172
+ let _preStat;
173
+ try { _preStat = await assertPathReachable(fullPath); }
154
174
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
175
+ // Feed the reachability preflight's stat into the cache so the root is
176
+ // stat'd once instead of immediately re-stat'd by getCachedReadOnlyStat.
177
+ if (_preStat) statCacheSet(fullPath, _preStat);
155
178
  let st;
156
179
  try { st = getCachedReadOnlyStat(fullPath); }
157
180
  catch (err) {
@@ -283,8 +306,10 @@ export async function executeTreeTool(args, workDir, options = {}) {
283
306
  });
284
307
  const cached = cacheGet(cacheKey);
285
308
  if (cached !== null) return cached;
286
- try { await assertPathReachable(fullPath); }
309
+ let _preStat;
310
+ try { _preStat = await assertPathReachable(fullPath); }
287
311
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
312
+ if (_preStat) statCacheSet(fullPath, _preStat);
288
313
  let st;
289
314
  try { st = getCachedReadOnlyStat(fullPath); }
290
315
  catch (err) {
@@ -357,7 +382,7 @@ export async function executeTreeTool(args, workDir, options = {}) {
357
382
 
358
383
  // ── Broad-enumeration cache (shared `rg --files` sweep) ──────────────────
359
384
  // A `rg --files` sweep of a root depends ONLY on (root, hidden, depth,
360
- // includeNoise) — NOT on the per-query narrowing. Yet both the fuzzy-find
385
+ // includeNoise, ignoreMode) — NOT on the per-query narrowing. Yet both the fuzzy-find
361
386
  // broad pass and the find_files broad fast path re-run that full sweep for
362
387
  // every query item AND for every concurrent caller (measured 1-4s each when
363
388
  // 8 explorer sub-sessions hit the same root). Cache the PARSED file list per
@@ -387,8 +412,8 @@ function findEnumTtlMs() {
387
412
  return Math.floor(n); // 0 = disabled
388
413
  }
389
414
 
390
- function findEnumKey({ root, hidden, depth, includeNoise }) {
391
- return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}`;
415
+ function findEnumKey({ root, hidden, depth, includeNoise, ignoreMode }) {
416
+ return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}\u0000${ignoreMode}`;
392
417
  }
393
418
 
394
419
  // Parse `rg --files` stdout into the same normalized relative-path list both
@@ -405,11 +430,12 @@ function parseRgFileList(stdout) {
405
430
  // Run (or reuse) the broad `rg --files` sweep for a root. Returns
406
431
  // { files, truncated, partial }. The returned `files` array is SHARED — callers
407
432
  // must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
408
- // narrowing); the cache key is the 4 dims only, so any caller producing an
433
+ // narrowing); the cache key includes every enumeration-affecting dimension, so
434
+ // any caller producing an
409
435
  // equivalent sweep for the same dims reuses the result.
410
- async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg, bestEffort = false }) {
436
+ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode, rgArgs, cwd, runRgImpl = runRg, bestEffort = false }) {
411
437
  const ttl = findEnumTtlMs();
412
- const key = findEnumKey({ root, hidden, depth, includeNoise });
438
+ const key = findEnumKey({ root, hidden, depth, includeNoise, ignoreMode });
413
439
  if (ttl > 0) {
414
440
  const hit = FIND_ENUM_CACHE.get(key);
415
441
  if (hit && hit.gen === FIND_ENUM_GEN && hit.expiresAt > Date.now()) {
@@ -460,12 +486,12 @@ export async function prewarmFindEnumeration(root) {
460
486
  try {
461
487
  if (!root || typeof root !== 'string') return;
462
488
  const hidden = true, includeNoise = false, depth = null;
463
- const rgArgs = ['--files', '--no-ignore', '--hidden'];
489
+ const rgArgs = ['--files', '--no-require-git', '--hidden'];
464
490
  for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
465
491
  rgArgs.push('.');
466
492
  await getBroadEnumeration({
467
493
  root: normalizeOutputPath(root),
468
- hidden, depth, includeNoise,
494
+ hidden, depth, includeNoise, ignoreMode: 'git',
469
495
  rgArgs, cwd: root, bestEffort: true,
470
496
  });
471
497
  } catch { /* best-effort warm; never surface */ }
@@ -543,11 +569,10 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
543
569
  });
544
570
  const cached = cacheGet(cacheKey);
545
571
  if (cached !== null) return cached;
546
- // --no-ignore: match the find_files fast path contract do not consult
547
- // .gitignore, so a .gitignored-but-present file is still discoverable.
572
+ // Common discovery respects .gitignore even outside a Git repository.
573
+ // include_noise deliberately retains the old hardened --no-ignore behavior.
548
574
  // Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
549
- // Shared rg flags for both enumeration passes below.
550
- const baseRgArgs = ['--files', '--no-ignore'];
575
+ const baseRgArgs = ['--files', includeNoise ? '--no-ignore' : '--no-require-git'];
551
576
  if (hidden) baseRgArgs.push('--hidden');
552
577
  if (depth != null) baseRgArgs.push('--max-depth', String(depth));
553
578
  // Noise-exclusion globs are kept SEPARATE and always appended LAST (after
@@ -586,7 +611,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
586
611
  try {
587
612
  broadEnum = await getBroadEnumeration({
588
613
  root: normalizeOutputPath(fullPath),
589
- hidden, depth, includeNoise,
614
+ hidden, depth, includeNoise, ignoreMode: includeNoise ? 'all' : 'git',
590
615
  rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
591
616
  cwd: fullPath,
592
617
  runRgImpl,
@@ -594,25 +619,67 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
594
619
  } catch (err) {
595
620
  return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
596
621
  }
597
- const rgTruncated = broadEnum.truncated;
598
- const rgPartial = broadEnum.partial;
622
+ let rgTruncated = broadEnum.truncated;
623
+ let rgPartial = broadEnum.partial;
624
+ const passOneItems = broadEnum.files.map((path) => ({ path }));
625
+ // Any single path segment is an exact-name candidate, including names with
626
+ // spaces, extensionless names, and literal glob characters. The comparison
627
+ // below is direct, so glob syntax has no special meaning in this decision.
628
+ const exactFilenameQuery = !/[\\/]/.test(query);
629
+ const queryLower = query.toLowerCase();
630
+ const passOneHasExactBasename = !exactFilenameQuery || broadEnum.files.some((path) =>
631
+ path.slice(path.lastIndexOf('/') + 1).toLowerCase() === queryLower);
632
+ const passOneHasCandidate = fuzzyRank(query, passOneItems, 1).length > 0;
633
+ let fallbackRan = false;
634
+ let fallbackPaths = [];
635
+ // Only pay for the hardened tree walk when common discovery has no answer,
636
+ // or when an exact filename could be hidden by .gitignore. Reuse the
637
+ // hardened broad cache shared with find_files: its key distinguishes
638
+ // ignoreMode, so this cannot collide with the common pass.
639
+ if (!includeNoise && (!passOneHasCandidate || !passOneHasExactBasename)) {
640
+ try {
641
+ const fallbackArgs = ['--files', '--no-ignore', ...baseRgArgs.slice(2), ...ignoreGlobs, '.'];
642
+ const fallbackEnum = await getBroadEnumeration({
643
+ root: normalizeOutputPath(fullPath),
644
+ hidden, depth, includeNoise, ignoreMode: 'all',
645
+ rgArgs: fallbackArgs, cwd: fullPath, runRgImpl,
646
+ });
647
+ rgTruncated ||= fallbackEnum.truncated;
648
+ rgPartial ||= fallbackEnum.partial;
649
+ fallbackPaths = fallbackEnum.files;
650
+ fallbackRan = true;
651
+ } catch {
652
+ // This required fallback leaves the result incomplete. Surface the
653
+ // existing partial warning and prevent the final output cache from
654
+ // preserving a pass-one-only answer.
655
+ rgPartial = true;
656
+ }
657
+ }
599
658
  // Narrowed enumeration: only files whose NAME contains the query
600
659
  // (case-insensitive substring glob). This output is tiny and effectively
601
660
  // never truncated, so exact/substring hits are guaranteed to reach ranking
602
661
  // regardless of whether the broad pass was cut at the cap. Best-effort:
603
662
  // failures here never fail the tool — the broad pass still stands.
604
663
  let narrowPaths = [];
605
- try {
664
+ // Only spawn the per-query narrowed rg pass when the broad enumeration was
665
+ // cut (cap/exit-2): an untruncated broad pass already contains every file,
666
+ // so every narrowed substring hit is a broad-set duplicate that the
667
+ // dedup below drops — running it would add zero items. Skipping it there
668
+ // removes a redundant full-tree walk per query with identical output.
669
+ if (rgTruncated || rgPartial) try {
606
670
  // A positive --iglob whitelist makes ripgrep re-admit paths its own
607
671
  // `!**/<noise>/**` negations would otherwise exclude (the whitelist
608
672
  // wins regardless of glob order), so noise dirs are pruned in JS here
609
673
  // instead — matching the broad pass's effective exclusion set.
610
- const narrowStdout = await runRgImpl([...baseRgArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
674
+ const narrowBaseArgs = fallbackRan
675
+ ? ['--files', '--no-ignore', ...baseRgArgs.slice(2)]
676
+ : baseRgArgs;
677
+ const narrowStdout = await runRgImpl([...narrowBaseArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
611
678
  narrowPaths = parseRgFiles(narrowStdout).filter((p) =>
612
679
  includeNoise || !p.split('/').some((seg) => NOISE_DIR_NAMES.has(seg)));
613
680
  } catch { /* best-effort backstop; broad pass already collected */ }
614
- // Merge broad + narrowed, deduplicating by path (broad order preserved,
615
- // narrowed-only exact-name candidates appended).
681
+ // Merge pass one + fallback + narrowed, deduplicating by path. Pass-one
682
+ // order always wins so the fallback cannot perturb common-path ranking.
616
683
  const seen = new Set();
617
684
  const items = [];
618
685
  for (const p of broadEnum.files) {
@@ -620,6 +687,11 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
620
687
  seen.add(p);
621
688
  items.push({ path: p });
622
689
  }
690
+ for (const p of fallbackPaths) {
691
+ if (seen.has(p)) continue;
692
+ seen.add(p);
693
+ items.push({ path: p });
694
+ }
623
695
  for (const p of narrowPaths) {
624
696
  if (seen.has(p)) continue;
625
697
  seen.add(p);
@@ -638,6 +710,9 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
638
710
  if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise head_limit for more)`);
639
711
  if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
640
712
  if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
713
+ if (!fallbackRan && headLimit > 0 && fuzzyRank(query, passOneItems, headLimit).length >= headLimit) {
714
+ lines.push('[gitignored trees not searched; retry with include_noise:true]');
715
+ }
641
716
  const result = lines.join('\n');
642
717
  // Do not cache a truncated/partial enumeration — the broad ranking is
643
718
  // known-incomplete, so a later call with a larger head_limit must re-run.
@@ -753,8 +828,10 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
753
828
  return subject.toLowerCase().includes(nameLower);
754
829
  };
755
830
 
756
- try { await assertPathReachable(fullPath); }
831
+ let _preStat;
832
+ try { _preStat = await assertPathReachable(fullPath); }
757
833
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
834
+ if (_preStat) statCacheSet(fullPath, _preStat);
758
835
  let rootStat;
759
836
  try { rootStat = getCachedReadOnlyStat(fullPath); }
760
837
  catch (err) {
@@ -794,7 +871,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
794
871
  if (!namePattern) {
795
872
  const enumRes = await getBroadEnumeration({
796
873
  root: normalizeOutputPath(fullPath),
797
- hidden, depth, includeNoise,
874
+ hidden, depth, includeNoise, ignoreMode: 'all',
798
875
  rgArgs, cwd: fullPath,
799
876
  });
800
877
  rgStdoutTruncated = enumRes.truncated;
@@ -2,6 +2,7 @@ import { homedir } from 'os';
2
2
  import { isAbsolute, relative, resolve } from 'path';
3
3
  import { realpathSync, statSync } from 'node:fs';
4
4
  import { isWSL } from '../../../../shared/wsl.mjs';
5
+ import { statCacheSet } from './cache-layers.mjs';
5
6
 
6
7
  // Restore the on-disk casing of a path (win32 only). rg relativizes candidate
7
8
  // paths against its process cwd with a CASE-SENSITIVE prefix strip before
@@ -248,7 +249,13 @@ export function coerceReadFamilyPathArg(path, workDir = null) {
248
249
  if (/\s/.test(trimmed)) {
249
250
  let fullPathExists = false;
250
251
  try {
251
- statSync(resolveAgainstCwd(normalizeInputPath(trimmed), workDir));
252
+ // Seed the stat cache with this existence probe so the caller's
253
+ // immediate getCachedReadOnlyStat on the same resolved path
254
+ // reuses it instead of re-stat'ing. Pure de-dup: a cache miss
255
+ // just falls back to the caller's own stat.
256
+ const _full = resolveAgainstCwd(normalizeInputPath(trimmed), workDir);
257
+ const _st = statSync(_full);
258
+ statCacheSet(_full, _st);
252
259
  fullPathExists = true;
253
260
  } catch { /* split only when the literal path is missing */ }
254
261
  if (!fullPathExists) {
@@ -89,7 +89,7 @@ export function readEntryLineWindow(entry) {
89
89
  };
90
90
  }
91
91
 
92
- export function coalesceObjectReadEntries(rawEntries) {
92
+ export function coalesceObjectReadEntries(rawEntries, resolvePath = null) {
93
93
  const out = new Array(rawEntries.length);
94
94
  const groups = new Map();
95
95
  for (let i = 0; i < rawEntries.length; i++) {
@@ -99,7 +99,9 @@ export function coalesceObjectReadEntries(rawEntries) {
99
99
  continue;
100
100
  }
101
101
  const win = readEntryLineWindow(entry);
102
- const key = entry.path || '';
102
+ // Group by RESOLVED path so two path strings that point at the same
103
+ // file share one coalesced disk window instead of each opening it.
104
+ const key = (typeof resolvePath === 'function' ? resolvePath(entry.path || '') : (entry.path || ''));
103
105
  if (!groups.has(key)) groups.set(key, []);
104
106
  groups.get(key).push({ index: i, entry, offset: win.offset, end: win.end });
105
107
  }
@@ -101,7 +101,9 @@ function ensureReadRangeIndexDiskSwept() {
101
101
  function loadReadRangeIndexFromDisk(fullPath, st) {
102
102
  ensureReadRangeIndexDiskSwept();
103
103
  const file = readRangeIndexFilePath(fullPath);
104
- if (!file || !st || !existsSync(file)) return null;
104
+ // No existsSync preflight: a missing file surfaces as an ENOENT from
105
+ // readFileSync below, caught by the same try/catch — one FS pass, not two.
106
+ if (!file || !st) return null;
105
107
  try {
106
108
  const row = JSON.parse(readFileSync(file, 'utf-8'));
107
109
  if (!readRangeIndexMatches(row, fullPath, st)) return null;
@@ -124,7 +124,10 @@ export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
124
124
  }
125
125
  if (!next.contentHash && snapshotCoversFullFile(next)) {
126
126
  try {
127
- const content = decodeRawBufferForSnapshotCheck(readFileSync(fullPath));
127
+ // Reuse the raw-content cache (populated by the read that produced
128
+ // this snapshot) instead of a fresh readFileSync + decode purely to
129
+ // hash. Decodes via the same helper, so the hash is byte-identical.
130
+ const content = readTextForSnapshotCheck(fullPath, null, st);
128
131
  next.contentHash = hashText(content);
129
132
  } catch {}
130
133
  }
@@ -225,7 +225,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
225
225
  // range into one huge window. Far-apart reads stay separate,
226
226
  // which avoids scanning and then slicing thousands of lines
227
227
  // just to return two tiny windows.
228
- const entries = coalesceObjectReadEntries(rawEntries);
228
+ const entries = coalesceObjectReadEntries(rawEntries, (p) => resolveAgainstCwd(p, workDir));
229
229
  // Deduplicate so the same union-range is read only once per path.
230
230
  const _seen = new Map(); // cacheKey → dedupedEntries index
231
231
  const dedupedEntries = [];
@@ -282,11 +282,31 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
282
282
  // re-order results to match the caller's original entry order.
283
283
  const _origEntries2 = Array.isArray(args._readsOrigEntries) ? args._readsOrigEntries : null;
284
284
  const _entryMap2 = Array.isArray(args._readsEntryToDeduped) ? args._readsEntryToDeduped : null;
285
+ // Dedup string-batch entries by RESOLVED path + window so a file that
286
+ // appears twice (incl. two path strings that resolve to the same file)
287
+ // is stat/opened/read ONCE, not per duplicate. Duplicates copy the
288
+ // primary's body, keeping the per-index render byte-identical. Skipped
289
+ // when `overrides` (reads[] coalesce path) is set — those entries were
290
+ // already deduped upstream and carry the union-slice bookkeeping.
291
+ const _readIndexFor = new Array(entries.length);
292
+ if (!overrides) {
293
+ const _dedup = new Map();
294
+ for (let i = 0; i < entries.length; i++) {
295
+ const e = entries[i];
296
+ if (!e || !e.path) { _readIndexFor[i] = i; continue; }
297
+ const rp = resolveAgainstCwd(e.path, workDir);
298
+ const k = `${rp}|${e.mode ?? ''}|${e.offset ?? ''}|${e.limit ?? ''}|${e.n ?? ''}|${e.full ?? ''}`;
299
+ if (_dedup.has(k)) { _readIndexFor[i] = _dedup.get(k); }
300
+ else { _dedup.set(k, i); _readIndexFor[i] = i; }
301
+ }
302
+ } else {
303
+ for (let i = 0; i < entries.length; i++) _readIndexFor[i] = i;
304
+ }
285
305
  const tasks = entries.map((entry, index) => ({
286
306
  entry,
287
307
  index,
288
308
  offset: _isFullModeReadEntry(entry) ? _readEntryLineWindow(entry).offset : 0,
289
- })).sort((a, b) => {
309
+ })).filter((t) => _readIndexFor[t.index] === t.index).sort((a, b) => {
290
310
  const ap = a.entry?.path || '';
291
311
  const bp = b.entry?.path || '';
292
312
  if (ap !== bp) return ap < bp ? -1 : 1;
@@ -320,6 +340,17 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
320
340
  readChains.set(key, next.catch(() => {}));
321
341
  return next;
322
342
  }));
343
+ // Fan the primary read's result out to its duplicate indices so every
344
+ // caller slot is populated without a second disk window.
345
+ for (let i = 0; i < entries.length; i++) {
346
+ const src = _readIndexFor[i];
347
+ if (src === i) continue;
348
+ const e = entries[i];
349
+ const s = results[src];
350
+ results[i] = s
351
+ ? { path: e.path, mode: e.mode || 'full', n: e.n, body: s.body }
352
+ : { path: e.path, mode: e.mode || 'full', n: e.n, body: 'Error: dedup mapping failed' };
353
+ }
323
354
  const orderedResults = _origEntries2
324
355
  ? _origEntries2.map((orig, i) => {
325
356
  const r = results[_entryMap2 ? _entryMap2[i] : i] || { path: orig.path, mode: orig.mode || 'full', body: 'Error: dedup mapping failed' };