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
@@ -1,9 +1,13 @@
1
1
  import { spawn, execFile } from 'child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { resolve } from 'node:path';
4
+ import { accessSync, constants, statSync } from 'node:fs';
4
5
  import { promisify } from 'node:util';
5
6
  import os from 'node:os';
6
- import { acquire as acquireChildSpawnSlot } from '../../../../shared/child-spawn-gate.mjs';
7
+ import {
8
+ acquire as acquireChildSpawnSlot,
9
+ hasSpareCapacity as hasRgSpareCapacity,
10
+ } from '../../../../shared/child-spawn-gate.mjs';
7
11
 
8
12
  const execFileAsync = promisify(execFile);
9
13
 
@@ -28,15 +32,48 @@ const execFileAsync = promisify(execFile);
28
32
  // ── rg --threads cap ─────────────────────────────────────────────────────
29
33
  // Each rg process otherwise fans out across EVERY core; with several agents
30
34
  // running grep at once the box over-saturates and the whole batch trips the
31
- // 20s deadline together. Cap rg's worker threads to a fraction of the host so
32
- // concurrent greps share the machine. Internal dynamic default; env override
33
- // only — deliberately NOT exposed on any tool schema / parameter surface.
34
- function _rgThreadCap() {
35
+ // 20s deadline together. Use the larger half-CPU cap only when the spawn gate
36
+ // has spare capacity; otherwise retain the conservative quarter-CPU cap.
37
+ // Internal dynamic default; env override only — deliberately NOT exposed on
38
+ // any tool schema / parameter surface.
39
+ let _rgDefaultThreadCap = null;
40
+ export function _rgThreadCap(spareCapacity = false) {
35
41
  const override = Number(process.env.MIXDOG_RG_THREADS);
36
42
  if (Number.isFinite(override) && override >= 1) return Math.floor(override);
37
- let cpus = 0;
38
- try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
39
- return Math.max(2, Math.ceil((cpus || 4) / 4));
43
+ if (_rgDefaultThreadCap === null) {
44
+ let cpus = 0;
45
+ try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
46
+ _rgDefaultThreadCap = cpus || 4;
47
+ }
48
+ const cpus = _rgDefaultThreadCap;
49
+ return spareCapacity
50
+ ? Math.max(4, Math.ceil(cpus / 2))
51
+ : Math.max(2, Math.ceil(cpus / 4));
52
+ }
53
+
54
+ // Walk rg options once for all thread-argument helpers. Values of these
55
+ // flags are opaque patterns/paths and must not be mistaken for options.
56
+ const _rgValueFlags = new Set([
57
+ '-e', '--glob', '--iglob', '--type', '-A', '-B', '-C',
58
+ '--max-depth', '--max-columns',
59
+ ]);
60
+ function _walkRgArgs(argsList, visit) {
61
+ let options = true;
62
+ for (let i = 0; i < argsList.length; i++) {
63
+ const a = argsList[i];
64
+ if (options && a === '--') {
65
+ visit(a, i, null, true);
66
+ options = false;
67
+ continue;
68
+ }
69
+ if (options && typeof a === 'string'
70
+ && (_rgValueFlags.has(a) || a === '-j' || a === '--threads')) {
71
+ visit(a, i, i + 1, true);
72
+ i++;
73
+ continue;
74
+ }
75
+ visit(a, i, null, options);
76
+ }
40
77
  }
41
78
 
42
79
  // Single source of truth for "did the caller already pin rg's thread count?".
@@ -44,23 +81,23 @@ function _rgThreadCap() {
44
81
  // is the NEXT arg, short-attached (`-j8`), and long-attached (`--threads=8`).
45
82
  // Used by both the _withRgThreads injection guard and the EAGAIN retry guard
46
83
  // so the two never disagree.
47
- function _hasRgThreadArg(argsList) {
48
- for (const a of argsList) {
49
- if (typeof a !== 'string') continue;
50
- if (a === '-j' || a === '--threads') return true;
51
- if (/^-j\d+$/.test(a)) return true;
52
- if (a.startsWith('--threads=')) return true;
53
- }
54
- return false;
84
+ export function _hasRgThreadArg(argsList) {
85
+ let found = false;
86
+ _walkRgArgs(argsList, (a, _index, _valueIndex, options) => {
87
+ if (!options || typeof a !== 'string') return;
88
+ if (a === '-j' || a === '--threads'
89
+ || /^-j\d+$/.test(a) || a.startsWith('--threads=')) found = true;
90
+ });
91
+ return found;
55
92
  }
56
93
 
57
94
  // Inject `--threads N` unless the caller already pinned thread count
58
95
  // (`-j`/`--threads`/`-jN`/`--threads=N`, e.g. the EAGAIN `-j 1` retry). Does
59
96
  // not mutate; may return the ORIGINAL array unchanged when already
60
97
  // thread-pinned (callers must not assume a fresh copy).
61
- function _withRgThreads(argsList) {
98
+ export function _withRgThreads(argsList, spareCapacity = false) {
62
99
  if (_hasRgThreadArg(argsList)) return argsList;
63
- return ['--threads', String(_rgThreadCap()), ...argsList];
100
+ return ['--threads', String(_rgThreadCap(spareCapacity)), ...argsList];
64
101
  }
65
102
 
66
103
  // Build the EAGAIN single-thread retry args. If the caller already pinned a
@@ -68,14 +105,15 @@ function _withRgThreads(argsList) {
68
105
  // retry is unambiguously single-threaded (never `-j 8 ... -j 1`). Drops both the
69
106
  // separated form (`-j N` / `--threads N` → flag + following count) and the
70
107
  // attached forms (`-jN` / `--threads=N`).
71
- function _rgEagainRetryArgs(argsList) {
108
+ export function _rgEagainRetryArgs(argsList) {
72
109
  const out = [];
73
- for (let i = 0; i < argsList.length; i++) {
74
- const a = argsList[i];
75
- if (a === '-j' || a === '--threads') { i++; continue; } // skip flag + its count
76
- if (typeof a === 'string' && (/^-j\d+$/.test(a) || a.startsWith('--threads='))) continue;
110
+ _walkRgArgs(argsList, (a, index, valueIndex, options) => {
111
+ if (options && typeof a === 'string'
112
+ && (a === '-j' || a === '--threads'
113
+ || /^-j\d+$/.test(a) || a.startsWith('--threads='))) return;
77
114
  out.push(a);
78
- }
115
+ if (valueIndex !== null && valueIndex < argsList.length) out.push(argsList[valueIndex]);
116
+ });
79
117
  return ['-j', '1', ...out];
80
118
  }
81
119
 
@@ -83,43 +121,75 @@ function _rgEagainRetryArgs(argsList) {
83
121
  // retry would change nothing. Matches the separated `-j 1` / `--threads 1` form
84
122
  // and the attached `-j1` / `--threads=1` form. Any other thread pin (e.g. -j8)
85
123
  // is NOT "single-thread pinned": the retry should still run and downshift it.
86
- function _isRgSingleThreadPinned(argsList) {
87
- for (let i = 0; i < argsList.length; i++) {
88
- const a = argsList[i];
89
- if (typeof a !== 'string') continue;
90
- if ((a === '-j' || a === '--threads') && String(argsList[i + 1]).trim() === '1') return true;
91
- if (a === '-j1' || a === '--threads=1') return true;
92
- }
93
- return false;
124
+ export function _isRgSingleThreadPinned(argsList) {
125
+ let found = false;
126
+ _walkRgArgs(argsList, (a, _index, valueIndex, options) => {
127
+ if (!options || typeof a !== 'string') return;
128
+ if ((a === '-j' || a === '--threads')
129
+ && valueIndex !== null && String(argsList[valueIndex]).trim() === '1') found = true;
130
+ if (a === '-j1' || a === '--threads=1') found = true;
131
+ });
132
+ return found;
94
133
  }
95
134
 
96
135
  let _rgExecutableResolved = null;
136
+ let _rgExecutableResolutionKey = null;
137
+ let _rgResolvePromise = null;
138
+ let _rgResolvePromiseKey = null;
139
+ let _rgResolutionGeneration = 0;
97
140
 
98
- async function _resolveRgExecutable() {
99
- const isWin = process.platform === 'win32';
141
+ function _rgResolutionKey() {
142
+ return [
143
+ process.platform,
144
+ process.cwd(),
145
+ String(process.env.PATH || ''),
146
+ String(process.env.PATHEXT || ''),
147
+ ].join('\0');
148
+ }
149
+
150
+ function _usableRgCandidate(candidate, isWin = process.platform === 'win32') {
100
151
  try {
101
- const cmd = isWin ? 'where' : 'which';
102
- const { stdout: out } = await execFileAsync(cmd, ['rg'], { encoding: 'utf8', windowsHide: true });
103
- const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
104
- if (first && existsSync(first)) return first;
105
- } catch { /* fall through to PATH scan */ }
152
+ const info = statSync(candidate);
153
+ if (!info.isFile()) return false;
154
+ if (isWin) {
155
+ const ext = candidate.slice(candidate.lastIndexOf('.')).toLowerCase();
156
+ return ext === '.exe' || ext === '.com';
157
+ }
158
+ accessSync(candidate, constants.X_OK);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ export async function _resolveRgExecutable() {
166
+ const isWin = process.platform === 'win32';
106
167
  const pathSep = isWin ? ';' : ':';
107
- const dirs = String(process.env.PATH || '').split(pathSep).filter(Boolean);
108
- const pathext = isWin
109
- ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase())
110
- : [''];
111
- const names = isWin ? ['rg.exe', 'rg'] : ['rg'];
112
- for (const dir of dirs) {
168
+ const dirs = String(process.env.PATH || '').split(pathSep);
169
+ // Avoid `where`/`which` on the normal path: on Windows it costs tens of
170
+ // milliseconds before doing the same filesystem search. Windows command
171
+ // lookup checks the current directory before PATH and applies PATHEXT in
172
+ // order; POSIX lookup is just the PATH order.
173
+ const searchDirs = isWin ? [process.cwd(), ...dirs] : dirs;
174
+ const names = isWin
175
+ ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
176
+ .map((ext) => ext.trim().toLowerCase())
177
+ .filter((ext) => ext === '.exe' || ext === '.com')
178
+ .map((ext) => `rg${ext}`)
179
+ : ['rg'];
180
+ for (const dir of searchDirs) {
113
181
  for (const name of names) {
114
- const candidate = join(dir, name);
115
- if (!existsSync(candidate)) continue;
116
- if (isWin) {
117
- const ext = candidate.slice(candidate.lastIndexOf('.')).toLowerCase();
118
- if (ext && !pathext.includes(ext)) continue;
119
- }
120
- return candidate;
182
+ const candidate = resolve(dir, name);
183
+ if (existsSync(candidate) && _usableRgCandidate(candidate, isWin)) return candidate;
121
184
  }
122
185
  }
186
+ try {
187
+ const cmd = isWin ? 'where' : 'which';
188
+ const { stdout: out } = await execFileAsync(cmd, ['rg'], { encoding: 'utf8', windowsHide: true });
189
+ const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
190
+ const candidate = first ? resolve(first) : '';
191
+ if (candidate && _usableRgCandidate(candidate, isWin)) return candidate;
192
+ } catch { /* preserve bare-rg fallback */ }
123
193
  return 'rg';
124
194
  }
125
195
 
@@ -134,12 +204,23 @@ function rgExecutable() {
134
204
  // blocking the event loop ~90ms on the first grep/glob/find of a session
135
205
  // (freezing the TUI). Resolve asynchronously and cache the result; subsequent
136
206
  // calls return immediately.
137
- let _rgResolvePromise = null;
138
- async function ensureRgResolved() {
139
- if (_rgExecutableResolved !== null) return _rgExecutableResolved;
140
- if (!_rgResolvePromise) {
207
+ export async function ensureRgResolved() {
208
+ const key = _rgResolutionKey();
209
+ const cachedUsable = _rgExecutableResolved !== null
210
+ && _rgExecutableResolved !== 'rg'
211
+ && _usableRgCandidate(_rgExecutableResolved);
212
+ if (_rgExecutableResolutionKey === key && cachedUsable) return _rgExecutableResolved;
213
+ if (_rgExecutableResolved !== null && (_rgExecutableResolutionKey !== key || !cachedUsable)) {
214
+ _rgResolutionGeneration++;
215
+ _rgExecutableResolved = null;
216
+ _rgExecutableResolutionKey = null;
217
+ _rgResolvePromise = null;
218
+ }
219
+ if (!_rgResolvePromise || _rgResolvePromiseKey !== key) {
220
+ _rgResolvePromiseKey = key;
141
221
  _rgResolvePromise = _resolveRgExecutable().then((resolved) => {
142
222
  _rgExecutableResolved = resolved;
223
+ _rgExecutableResolutionKey = key;
143
224
  return resolved;
144
225
  });
145
226
  }
@@ -155,7 +236,6 @@ const RG_FALLBACK_FAIL_TTL_MS = 30000;
155
236
  let _rgFallbackUsable = null;
156
237
  let _rgFallbackFailAt = 0;
157
238
  async function rgFallbackUsable() {
158
- if (_rgFallbackUsable === true) return true;
159
239
  if (_rgFallbackUsable === false && (Date.now() - _rgFallbackFailAt) < RG_FALLBACK_FAIL_TTL_MS) {
160
240
  return false;
161
241
  }
@@ -195,12 +275,16 @@ const RG_PCRE2_FAIL_TTL_MS = 30000;
195
275
  let _rgPcre2Supported = null;
196
276
  let _rgPcre2FailAt = 0;
197
277
  export async function rgSupportsPcre2() {
198
- if (_rgPcre2Supported === true) return true;
199
- if (_rgPcre2Supported === false && (Date.now() - _rgPcre2FailAt) < RG_PCRE2_FAIL_TTL_MS) {
200
- return false;
278
+ await ensureRgResolved();
279
+ const cacheKey = `${_rgResolutionGeneration}:${rgExecutable()}`;
280
+ if (rgSupportsPcre2._cacheKey !== cacheKey) {
281
+ _rgPcre2Supported = null;
282
+ _rgPcre2FailAt = 0;
283
+ rgSupportsPcre2._cacheKey = cacheKey;
201
284
  }
285
+ if (_rgPcre2Supported === true) return true;
286
+ if (_rgPcre2Supported === false && (Date.now() - _rgPcre2FailAt) < RG_PCRE2_FAIL_TTL_MS) return false;
202
287
  try {
203
- await ensureRgResolved();
204
288
  await execFileAsync(rgExecutable(), ['--pcre2-version'], { windowsHide: true, timeout: 3000 });
205
289
  _rgPcre2Supported = true;
206
290
  } catch {
@@ -311,7 +395,9 @@ function spawnRg(argsList, execOptions) {
311
395
  // node guardian is started: rg owns its full SIGTERM→grace→force-kill +
312
396
  // force-settle teardown, so the 1:1 guardian process was pure overhead.
313
397
  return acquireChildSpawnSlot(gateSignal).then((releaseSlot) => new Promise((resolve, reject) => {
314
- const proc = spawn(rgExecutable(), _withRgThreads(argsList), {
398
+ // Probe after acquisition: this spawn counts toward inflight. The
399
+ // answer is best-effort and only selects the per-process rg cap.
400
+ const proc = spawn(rgExecutable(), _withRgThreads(argsList, hasRgSpareCapacity()), {
315
401
  cwd: execOptions?.cwd,
316
402
  env: execOptions?.env || process.env,
317
403
  windowsHide: true,
@@ -456,7 +542,8 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
456
542
  // excluded from the rg timeout (armed after spawn). releaseSlot is fired
457
543
  // once via .finally on every settle path.
458
544
  return acquireChildSpawnSlot(gateSignal).then((releaseSlot) => new Promise((resolve, reject) => {
459
- const proc = spawn(rgExecutable(), _withRgThreads(argsList), {
545
+ // As above, probe only after this spawn has acquired its gate slot.
546
+ const proc = spawn(rgExecutable(), _withRgThreads(argsList, hasRgSpareCapacity()), {
460
547
  cwd: execOptions?.cwd,
461
548
  env: execOptions?.env || process.env,
462
549
  windowsHide: true,
@@ -67,6 +67,24 @@ const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
67
67
 
68
68
  export const ENOENT_FIND_NUDGE = 'Locate with find on the basename before retrying.';
69
69
 
70
+ // Per-invocation memo for the ENOENT recovery fs scans. A single grep/glob
71
+ // ENOENT surfaces the SAME missing path through tryReadFamilyEnoentRedirect
72
+ // (resolveUniqueEnoentRedirect) AND buildNotFoundHint (which re-runs
73
+ // resolveUniqueEnoentRedirect plus its own findFileByBasename). Threading an
74
+ // optional cache object keyed on that single path collapses the repeated
75
+ // suffix-strip / basename BFS into one scan. Absent cache → scan fresh
76
+ // (unchanged behavior for callers that don't pass one).
77
+ function cachedSuffixStrip(workDir, missingPath, cache) {
78
+ if (!cache) return findBySuffixStrip(workDir, missingPath);
79
+ if (!('suffixHit' in cache)) cache.suffixHit = findBySuffixStrip(workDir, missingPath);
80
+ return cache.suffixHit;
81
+ }
82
+ function cachedFileByBasename(workDir, missingPath, cache) {
83
+ if (!cache) return findFileByBasename(workDir, missingPath);
84
+ if (!('fileHits' in cache)) cache.fileHits = findFileByBasename(workDir, missingPath);
85
+ return cache.fileHits;
86
+ }
87
+
70
88
  function isDirectoryPathGuess(missingPath) {
71
89
  const base = basename(String(missingPath || '').replace(/\\/g, '/'));
72
90
  if (!base || /[*?[\]{}]/.test(base)) return false;
@@ -112,11 +130,11 @@ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENO
112
130
  return appendEnoentFindNudge(String(hint || '') + spaceJoinedPathHint(requestedPath));
113
131
  }
114
132
 
115
- export function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT') {
133
+ export function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT', cache = null) {
116
134
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
117
- const suffixHit = findBySuffixStrip(workDir, missingPath);
135
+ const suffixHit = cachedSuffixStrip(workDir, missingPath, cache);
118
136
  if (suffixHit) return suffixHit;
119
- const elsewhere = findFileByBasename(workDir, missingPath);
137
+ const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
120
138
  if (elsewhere.length === 1) return elsewhere[0];
121
139
  if (isDirectoryPathGuess(missingPath)) {
122
140
  const dirHits = findDirectoryByBasename(workDir, missingPath, { limit: 3 });
@@ -136,19 +154,20 @@ export async function tryReadFamilyEnoentRedirect({
136
154
  errCode,
137
155
  options,
138
156
  rerun,
157
+ cache = null,
139
158
  }) {
140
159
  if (options?._enoentRedirectFrom) return null;
141
- const target = resolveUniqueEnoentRedirect(workDir, resolvedPath, errCode);
160
+ const target = resolveUniqueEnoentRedirect(workDir, resolvedPath, errCode, cache);
142
161
  if (!target) return null;
143
162
  const body = await rerun(target, { ...options, _enoentRedirectFrom: resolvedPath });
144
163
  const shown = requestedPath ?? resolvedPath;
145
164
  return redirectedFromPrefix(shown) + body;
146
165
  }
147
166
 
148
- export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT') {
167
+ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT', cache = null) {
149
168
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return '';
150
- if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode)) return '';
151
- const elsewhere = findFileByBasename(workDir, missingPath);
169
+ if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode, cache)) return '';
170
+ const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
152
171
  if (elsewhere.length) {
153
172
  return ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. ${actionVerb} that path directly.`;
154
173
  }
@@ -211,15 +230,20 @@ export function uncRefusalMessage(toolName, original, resolved) {
211
230
  return `Error: ${toolName} refuses UNC/SMB path ${JSON.stringify(shown)}; remote share access is blocked to prevent NTLM credential leaks`;
212
231
  }
213
232
 
214
- export function basePathDiagnostic(basePaths, workDir) {
233
+ export function basePathDiagnostic(basePaths, workDir, statCache = null) {
215
234
  return basePaths.map((basePath) => {
216
235
  const resolved = resolveSearchScope(basePath, workDir);
217
- try {
218
- const st = statSync(resolved);
236
+ // Reuse the caller's call-scoped stat cache (glob preflight + per-group
237
+ // rg runs already stat'd this same resolved root) instead of re-stating.
238
+ let st = null;
239
+ let err = null;
240
+ const cached = statCache && statCache.get(resolved);
241
+ if (cached) { st = cached.st; err = cached.err; }
242
+ else { try { st = statSync(resolved); } catch (e) { err = e; } }
243
+ if (!err) {
219
244
  return `${normalizeOutputPath(basePath)}: ${st.isDirectory() ? 'path exists (dir)' : 'path exists (file)'}`;
220
- } catch (err) {
221
- return `${normalizeOutputPath(basePath)}: path does not exist (${err?.code || 'ENOENT'})`
222
- + finalizeReadFamilyEnoentTail(buildNotFoundHint(workDir, resolved, 'Search', err?.code), basePath, err?.code);
223
245
  }
246
+ return `${normalizeOutputPath(basePath)}: path does not exist (${err?.code || 'ENOENT'})`
247
+ + finalizeReadFamilyEnoentTail(buildNotFoundHint(workDir, resolved, 'Search', err?.code), basePath, err?.code);
224
248
  }).join('; ');
225
249
  }