mixdog 0.9.149 → 0.9.151

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 (113) hide show
  1. package/README.md +28 -12
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +40 -4
  4. package/src/headless-exec.test.mjs +116 -1
  5. package/src/rules/lead/01-general.md +3 -2
  6. package/src/rules/shared/00-general.md +1 -0
  7. package/src/rules/shared/10-tool-workflow.md +11 -12
  8. package/src/rules/shared/30-exploration.md +4 -3
  9. package/src/rules/shared/60-verification.md +4 -3
  10. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
  16. package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
  17. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  18. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  19. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
  23. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
  24. package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
  26. package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
  28. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  34. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
  35. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
  36. package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
  37. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  38. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  39. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
  42. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
  44. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
  45. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  46. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  47. package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
  48. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
  50. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
  52. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
  55. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  57. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
  59. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  61. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
  62. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  63. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  64. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
  65. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
  66. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  67. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
  68. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  69. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  70. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  71. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  72. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  73. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
  75. package/src/runtime/media/renditions.mjs +7 -1
  76. package/src/runtime/memory/index.mjs +2 -4
  77. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  78. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  79. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  80. package/src/runtime/memory/lib/query-handlers.mjs +13 -19
  81. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  82. package/src/runtime/memory/tool-defs.mjs +2 -2
  83. package/src/runtime/shared/memory-snapshot.mjs +57 -4
  84. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  85. package/src/runtime/shared/pristine-execution.mjs +6 -3
  86. package/src/runtime/shared/tool-surface.mjs +6 -0
  87. package/src/runtime/shared/tool-surface.test.mjs +8 -0
  88. package/src/session-runtime/lifecycle-api.mjs +1 -0
  89. package/src/session-runtime/session-turn-api.mjs +5 -0
  90. package/src/standalone/agent-dispatch-broker.mjs +25 -80
  91. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  92. package/src/standalone/channel-worker.mjs +31 -1
  93. package/src/standalone/daemon.mjs +9 -9
  94. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  95. package/src/standalone/session-client.mjs +23 -3
  96. package/src/standalone/session-runtime-host-health.test.mjs +36 -0
  97. package/src/standalone/session-runtime-host.mjs +26 -76
  98. package/src/standalone/session-runtime-worker.mjs +97 -0
  99. package/src/tui/App.jsx +3 -2
  100. package/src/tui/app/use-global-key-input.mjs +9 -0
  101. package/src/tui/app/use-mouse-input.mjs +77 -14
  102. package/src/tui/components/Spinner.jsx +1 -1
  103. package/src/tui/dist/index.mjs +83 -23
  104. package/src/tui/lib/voice-setup.mjs +3 -2
  105. package/src/tui/session/completion-card-restore.test.mjs +18 -0
  106. package/src/tui/session/session-api-ext.mjs +2 -1
  107. package/src/tui/session/session-api.mjs +8 -1
  108. package/src/tui/session/task-wait-submit.test.mjs +80 -0
  109. package/src/tui/session/turn.mjs +58 -6
  110. package/src/tui/spinner-verbs.mjs +1 -0
  111. package/src/workflows/default/WORKFLOW.md +12 -19
  112. package/src/workflows/headless/WORKFLOW.md +11 -0
  113. package/src/workflows/solo/WORKFLOW.md +6 -11
@@ -0,0 +1,218 @@
1
+ // Single-FILE grep rescue: scan the one explicitly targeted file in JS when
2
+ // the native search server cannot answer it.
3
+ //
4
+ // A file operand has no tree to walk — the server scans it directly — so a
5
+ // deadline reached on a file scope is never about this file. It is queue
6
+ // starvation: the resident server is shared, and one scope rooted in a virtual
7
+ // filesystem (/proc, /sys) walks a tree that cannot finish inside any budget
8
+ // and holds the queue while it burns. Measured in the wild: three /sys glob
9
+ // reads spent 17.5s each, the grep behind them failed after 17.7s on a
10
+ // six-line /proc/self/status, and a plain read of that same file answered in
11
+ // 7ms three seconds later.
12
+ //
13
+ // Reading one file is bounded work, so the call answers instead of spending a
14
+ // whole turn on someone else's walk. Anything this scanner cannot reproduce
15
+ // exactly — multiline patterns, type filters, a PCRE-only regex, a binary or
16
+ // oversized file — declines (returns null) and the caller keeps the original
17
+ // native error.
18
+ import { readFile } from 'node:fs/promises';
19
+
20
+ import { formatGrepContextOutput, formatGrepOutput } from './grep-output.mjs';
21
+
22
+ /** Reading is the whole point of the rescue, so the file must stay small
23
+ * enough that reading it is cheaper than the failed search. */
24
+ export const MAX_RESCUE_BYTES = 8 * 1024 * 1024;
25
+ /** Bounds memory on a pathological match rate; a truncated window is reported
26
+ * as incomplete, exactly like a capped native result. */
27
+ const MAX_RESCUE_LINES = 5_000;
28
+
29
+ function compilePatterns(patterns, caseInsensitive) {
30
+ const flags = caseInsensitive ? 'i' : '';
31
+ const compiled = [];
32
+ for (const pattern of patterns) {
33
+ try {
34
+ compiled.push(new RegExp(String(pattern), flags));
35
+ } catch {
36
+ // A PCRE-only construct (lookbehind variants, \p classes, inline
37
+ // flags) has no faithful JS twin: decline rather than answer with
38
+ // different semantics than the caller asked for.
39
+ return null;
40
+ }
41
+ }
42
+ return compiled.length > 0 ? compiled : null;
43
+ }
44
+
45
+ export async function runGrepSingleFileRescue({
46
+ filePath,
47
+ searchPath,
48
+ patterns,
49
+ caseInsensitive,
50
+ multilineMode,
51
+ onlyMatching,
52
+ fileType,
53
+ outputMode,
54
+ showLineNumbers,
55
+ withFilename,
56
+ filenameOmitted,
57
+ beforeN,
58
+ afterN,
59
+ contextN,
60
+ headLimit,
61
+ offset,
62
+ workDir,
63
+ grepResolvedPath,
64
+ patternCapNote = '',
65
+ globPatterns = [],
66
+ }) {
67
+ if (multilineMode || fileType) return null;
68
+ const list = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
69
+ const compiled = compilePatterns(list, caseInsensitive);
70
+ if (!compiled) return null;
71
+
72
+ let raw;
73
+ try {
74
+ raw = await readFile(filePath);
75
+ } catch {
76
+ return null;
77
+ }
78
+ if (raw.length > MAX_RESCUE_BYTES || raw.includes(0)) return null;
79
+ const text = raw.toString('utf8');
80
+ const fileLines = text.split(/\r?\n/);
81
+ if (fileLines.length > 0 && fileLines[fileLines.length - 1] === '') fileLines.pop();
82
+
83
+ const matched = [];
84
+ for (let index = 0; index < fileLines.length; index += 1) {
85
+ if (compiled.some((re) => re.test(fileLines[index]))) matched.push(index);
86
+ }
87
+
88
+ const pathPrefix = (separator) => (withFilename ? `${searchPath}${separator}` : '');
89
+ const numberPrefix = (index, separator) => (showLineNumbers ? `${index + 1}${separator}` : '');
90
+
91
+ if (outputMode === 'files_with_matches') {
92
+ const windowed = matched.length > 0 ? [String(searchPath)] : [];
93
+ return renderPlain({
94
+ windowed, patternCapNote, outputMode, headLimit, offset, searchPath,
95
+ grepResolvedPath, workDir, globPatterns, filenameOmitted: false,
96
+ beforeN, afterN, contextN, patterns: list,
97
+ });
98
+ }
99
+ if (outputMode === 'count') {
100
+ const windowed = matched.length > 0
101
+ ? [`${pathPrefix(':')}${matched.length}`]
102
+ : [];
103
+ return renderPlain({
104
+ windowed, patternCapNote, outputMode, headLimit, offset, searchPath,
105
+ grepResolvedPath, workDir, globPatterns, filenameOmitted,
106
+ beforeN, afterN, contextN, patterns: list,
107
+ });
108
+ }
109
+
110
+ const before = Math.max(Number(beforeN) || 0, Number(contextN) || 0);
111
+ const after = Math.max(Number(afterN) || 0, Number(contextN) || 0);
112
+ const hasContext = before > 0 || after > 0;
113
+ const isMatch = new Set(matched);
114
+ const emit = new Set();
115
+ for (const index of matched) {
116
+ for (let i = Math.max(0, index - before); i <= Math.min(fileLines.length - 1, index + after); i += 1) {
117
+ emit.add(i);
118
+ }
119
+ }
120
+ const ordered = [...emit].sort((left, right) => left - right);
121
+ const lines = [];
122
+ let previous = -1;
123
+ let truncated = false;
124
+ for (const index of ordered) {
125
+ if (lines.length >= MAX_RESCUE_LINES) {
126
+ truncated = true;
127
+ break;
128
+ }
129
+ // rg separates non-adjacent context blocks; the context formatter reads
130
+ // the same marker.
131
+ if (hasContext && previous >= 0 && index > previous + 1) lines.push('--');
132
+ previous = index;
133
+ const line = fileLines[index];
134
+ if (isMatch.has(index)) {
135
+ if (onlyMatching) {
136
+ for (const re of compiled) {
137
+ const global = new RegExp(re.source, `${re.flags}g`);
138
+ for (const found of line.matchAll(global)) {
139
+ lines.push(`${pathPrefix(':')}${numberPrefix(index, ':')}${found[0]}`);
140
+ }
141
+ }
142
+ continue;
143
+ }
144
+ lines.push(`${pathPrefix(':')}${numberPrefix(index, ':')}${line}`);
145
+ continue;
146
+ }
147
+ lines.push(`${pathPrefix('-')}${numberPrefix(index, '-')}${line}`);
148
+ }
149
+
150
+ if (hasContext) {
151
+ const body = formatGrepContextOutput({
152
+ allLines: lines,
153
+ workDir,
154
+ outputMode,
155
+ filenameOmitted,
156
+ headLimit,
157
+ offset,
158
+ searchPath,
159
+ totalKnown: !truncated,
160
+ });
161
+ return withRescueNotice(patternCapNote + (body.text || noMatchBody({
162
+ patterns: list, searchPath, globPatterns,
163
+ })));
164
+ }
165
+ return renderPlain({
166
+ windowed: offset > 0 ? lines.slice(offset) : lines,
167
+ patternCapNote,
168
+ outputMode,
169
+ headLimit,
170
+ offset,
171
+ searchPath,
172
+ grepResolvedPath,
173
+ workDir,
174
+ globPatterns,
175
+ filenameOmitted,
176
+ beforeN,
177
+ afterN,
178
+ contextN,
179
+ patterns: list,
180
+ totalKnown: !truncated,
181
+ });
182
+ }
183
+
184
+ function noMatchBody({ patterns, searchPath, globPatterns }) {
185
+ const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
186
+ const globStr = globPatterns.length > 0 ? ` glob=${JSON.stringify(globPatterns)}` : '';
187
+ return `(no matches) pattern=${patternStr} path=${searchPath}${globStr}; path exists (file)`;
188
+ }
189
+
190
+ function withRescueNotice(body) {
191
+ return `${body}\n[notice] native search could not serve this file scope; the file was scanned directly.`;
192
+ }
193
+
194
+ function renderPlain({
195
+ windowed, patternCapNote, outputMode, headLimit, offset, searchPath, grepResolvedPath,
196
+ workDir, globPatterns, filenameOmitted, beforeN, afterN, contextN, patterns,
197
+ totalKnown = true,
198
+ }) {
199
+ const body = formatGrepOutput({
200
+ windowed,
201
+ totalWindowed: windowed.length,
202
+ totalKnown,
203
+ headLimit,
204
+ offset,
205
+ outputMode,
206
+ patterns,
207
+ beforeN,
208
+ afterN,
209
+ contextN,
210
+ searchPath,
211
+ grepResolvedPath,
212
+ workDir,
213
+ globPatterns,
214
+ filenameOmitted,
215
+ includeMatchCount: false,
216
+ }) || noMatchBody({ patterns, searchPath, globPatterns });
217
+ return withRescueNotice(patternCapNote + body);
218
+ }
@@ -18,9 +18,16 @@ export async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath,
18
18
  rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
19
19
  });
20
20
  if (redirected) return redirected;
21
- const msg = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
21
+ const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err));
22
22
  const hint = buildNotFoundHint(workDir, fullPath, 'List', err?.code);
23
- return msg + finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
23
+ const tail = finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
24
+ // Conclusive absence is the list's ANSWER, not a tool failure (matches
25
+ // read's `[path absent]` and git's `repo:false` policy). Genuine
26
+ // not-found only — EACCES/EPERM etc. keep failure semantics.
27
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
28
+ return `[path absent] ${safeMsg}${tail}`;
29
+ }
30
+ return `Error: ${safeMsg}${tail}`;
24
31
  }
25
32
 
26
33
  export function normalizeListHeadLimit(raw, defaultCap) {
@@ -41,6 +41,12 @@ test('list defaults to a 100-entry page with an offset continuation', async () =
41
41
  const out = await executeListTool({ path: root, hidden: true }, process.cwd());
42
42
  assert.equal(out.split('\n').filter((line) => line.endsWith('\tfile')).length, 100);
43
43
  assert.match(out, /\[entries 1-100 of 101; pass offset:100 to continue\]/);
44
+ const oversized = await executeListTool(
45
+ { path: root, hidden: true, head_limit: 200 },
46
+ process.cwd(),
47
+ );
48
+ assert.equal(oversized.split('\n').filter((line) => line.endsWith('\tfile')).length, 100);
49
+ assert.match(oversized, /\[entries 1-100 of 101; pass offset:100 to continue\]/);
44
50
  } finally {
45
51
  await rm(root, { recursive: true, force: true });
46
52
  }
@@ -13,6 +13,7 @@ import { normalizeErrorMessage } from './path-diagnostics.mjs';
13
13
  import {
14
14
  buildListCacheKey,
15
15
  DEFAULT_IGNORE_GLOBS,
16
+ rootScanIgnoreGlobs,
16
17
  } from './search-builders.mjs';
17
18
  import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
18
19
  import {
@@ -256,7 +257,11 @@ export async function executeListTool(args, workDir, options = {}) {
256
257
  const hidden = Boolean(args.hidden);
257
258
  const sort = ['name', 'mtime', 'size'].includes(args.sort) ? args.sort : 'name';
258
259
  const typeFilter = ['any', 'file', 'dir'].includes(args.type) ? args.type : 'any';
259
- const headLimit = normalizeListHeadLimit(args.head_limit, _listDefaultHeadLimit(100));
260
+ const listHeadLimitCap = _listDefaultHeadLimit(100);
261
+ const requestedHeadLimit = normalizeListHeadLimit(args.head_limit, listHeadLimitCap);
262
+ const headLimit = requestedHeadLimit === 0
263
+ ? 0
264
+ : Math.min(requestedHeadLimit, listHeadLimitCap);
260
265
  const offset = typeof args.offset === 'number' && args.offset > 0 ? args.offset : 0;
261
266
  const needsGlobalStat = sort === 'mtime' || sort === 'size';
262
267
  const includeNoise = Boolean(args.include_noise);
@@ -899,6 +904,7 @@ export async function prewarmFindEnumeration(root) {
899
904
  for (const ex of DEFAULT_IGNORE_GLOBS) {
900
905
  common.push('--glob', ex);
901
906
  }
907
+ for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(root))) common.push('--glob', ex);
902
908
  common.push('.');
903
909
  await getBroadEnumeration({
904
910
  root: normalizeOutputPath(root),
@@ -971,6 +977,7 @@ async function getTargetedFindEnumeration({
971
977
  if (!includeNoise) {
972
978
  for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
973
979
  }
980
+ for (const ex of rootScanIgnoreGlobs(root)) rgArgs.push('--glob', ex);
974
981
  rgArgs.push('.');
975
982
  try {
976
983
  const stdout = await runRgImpl(rgArgs, {
@@ -1031,7 +1038,40 @@ async function getTargetedFindEnumeration({
1031
1038
  // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
1032
1039
  // routes here for hidden backward compatibility, but the model-facing tool is
1033
1040
  // `find`.
1041
+ // A pruned tree cannot report what it never enumerated. Dependency and cache
1042
+ // directories are skipped by DEFAULT, so a file that exists only inside one —
1043
+ // a __pycache__ artifact, a vendored source, a build cache — came back as
1044
+ // "(no fuzzy match for X)", which reads as PROVEN ABSENCE and ends the search.
1045
+ // A second, noise-including pass turns that into the truth, but the default
1046
+ // answer still may not list dependency paths (that is the point of the
1047
+ // default), so the miss keeps its body and gains the one fact it was missing:
1048
+ // a match exists, and which flag surfaces it. Only a CLEAN miss retries — a
1049
+ // partial or timed-out pass says "no fuzzy match YET" and is already telling
1050
+ // the caller to narrow, so it never pays for a second walk.
1034
1051
  export async function executeFuzzyFindTool(args, workDir, options = {}) {
1052
+ const startedAt = performance.now();
1053
+ const result = await runFuzzyFindPass(args, workDir, options);
1054
+ const text = String(result ?? '');
1055
+ if (!/^\(no fuzzy match for /.test(text)) return result;
1056
+ if (args?.include_noise === true || options?._findNoiseWidened === true) return result;
1057
+ // The retry walks the same tree again. A miss that was already expensive
1058
+ // pays for itself twice, so only a cheap pass earns the second look.
1059
+ if (performance.now() - startedAt > FIND_NOISE_WIDEN_BUDGET_MS) return result;
1060
+ const widened = String(await runFuzzyFindPass(
1061
+ { ...args, include_noise: true },
1062
+ workDir,
1063
+ { ...options, _findNoiseWidened: true },
1064
+ ) ?? '');
1065
+ if (!widened || /^\(no fuzzy match/.test(widened)) return result;
1066
+ const hits = widened.split('\n').filter((line) => line && !line.startsWith('... [') && !line.startsWith('[')).length;
1067
+ return `${text}\n[notice] ${hits} match${hits === 1 ? '' : 'es'} exist inside dependency/cache trees the default scan skips`
1068
+ + ' — pass include_noise:true to list them.';
1069
+ }
1070
+
1071
+ /** A miss cheaper than this earns the one widening retry below. */
1072
+ const FIND_NOISE_WIDEN_BUDGET_MS = 2_000;
1073
+
1074
+ async function runFuzzyFindPass(args, workDir, options = {}) {
1035
1075
  const query = String(args.query ?? args.fuzzy ?? '').trim();
1036
1076
  if (!query) return 'Error: find requires query.';
1037
1077
  const inputPath = normalizeInputPath(args.path) || '.';
@@ -1096,7 +1136,13 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
1096
1136
  hidden,
1097
1137
  includeNoise,
1098
1138
  maxDepth: depth,
1099
- exclude: includeNoise ? [] : DEFAULT_IGNORE_GLOBS,
1139
+ exclude: [
1140
+ ...(includeNoise ? [] : DEFAULT_IGNORE_GLOBS),
1141
+ // Kernel-virtual trees are pruned even under include_noise:
1142
+ // they are never dependency noise, and walking them starves
1143
+ // the fuzzy inventory budget on a full-root scan.
1144
+ ...rootScanIgnoreGlobs(fullPath),
1145
+ ],
1100
1146
  }, {
1101
1147
  cwd: fullPath,
1102
1148
  timeout: FIND_FUZZY_TIMEOUT_MS,
@@ -1234,6 +1280,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
1234
1280
  if (!includeNoise) {
1235
1281
  for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
1236
1282
  }
1283
+ for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(fullPath))) ignoreGlobs.push('--glob', ex);
1237
1284
  // The narrowed pass must treat `query` as a LITERAL filename substring, not
1238
1285
  // a glob. Wrap every globset metacharacter in a single-char character class
1239
1286
  // (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
@@ -1476,6 +1523,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
1476
1523
  if (!includeNoise) {
1477
1524
  for (const ex of DEFAULT_IGNORE_GLOBS) inventoryArgs.push('--glob', ex);
1478
1525
  }
1526
+ for (const ex of rootScanIgnoreGlobs(fullPath)) inventoryArgs.push('--glob', ex);
1479
1527
  if (namePattern) {
1480
1528
  inventoryArgs.push('--iglob', nameIsGlob ? namePattern : `*${namePattern}*`);
1481
1529
  }
@@ -15,7 +15,13 @@ import { ensureGraphBinary } from '../graph-binary-fetcher.mjs';
15
15
  import { fuzzyRank, prepareFuzzyItems } from './fuzzy-match.mjs';
16
16
 
17
17
  const RESTART_BACKOFF_MS = 30_000;
18
- const REQUEST_TIMEOUT_MS = 20_000;
18
+ // Default request budget. Deliberately BELOW the read-only I/O watchdog in
19
+ // tool-exec.mjs (20s): with both at 20s the watchdog killed the call first on
20
+ // a cold whole-filesystem walk, so the soft-deadline partial the server hands
21
+ // back near its own budget never reached the model (observed as a bare
22
+ // "timed out with no partial output" on a full-root glob). The 2.5s margin
23
+ // covers caller preprocessing plus response serialization/ranking.
24
+ const REQUEST_TIMEOUT_MS = 17_500;
19
25
  const SERVER_READY_TIMEOUT_MS = 1_000;
20
26
  const CANCEL_GRACE_MS = 1_000;
21
27
  const PROCESS_FAILURES_BEFORE_BACKOFF = 2;
@@ -0,0 +1,69 @@
1
+ // Dependency/cache trees stay hidden by default, but a caller who NAMES one is
2
+ // asking for it, and a clean fuzzy miss must not report a file that exists as
3
+ // absent.
4
+ import assert from 'node:assert/strict';
5
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import test from 'node:test';
9
+
10
+ import { executeBuiltinTool } from '../builtin.mjs';
11
+
12
+ async function withNoiseTree(run) {
13
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-noise-vis-'));
14
+ try {
15
+ await mkdir(join(root, '__pycache__'), { recursive: true });
16
+ await mkdir(join(root, 'src'), { recursive: true });
17
+ await writeFile(join(root, '__pycache__', 'convert_masks.cpython-311.pyc'), 'cached MARKER blob\n');
18
+ await writeFile(join(root, 'src', 'app.py'), 'print("hello")\n');
19
+ return await run(root);
20
+ } finally {
21
+ await rm(root, { recursive: true, force: true });
22
+ }
23
+ }
24
+
25
+ test('a glob that names a pruned directory searches inside it', async () => {
26
+ await withNoiseTree(async (root) => {
27
+ const named = String(await executeBuiltinTool('glob', { pattern: '__pycache__/*.pyc' }, root));
28
+ assert.match(named, /convert_masks\.cpython-311\.pyc/);
29
+
30
+ // Unnamed: the default prune still applies, so noise stays out of a
31
+ // broad sweep.
32
+ const broad = String(await executeBuiltinTool('glob', { pattern: '**/*.pyc' }, root));
33
+ assert.match(broad, /no files found/);
34
+ });
35
+ });
36
+
37
+ test('a grep glob filter that names a pruned directory searches inside it', async () => {
38
+ await withNoiseTree(async (root) => {
39
+ const out = String(await executeBuiltinTool('grep', {
40
+ pattern: 'MARKER',
41
+ path: '.',
42
+ glob: '__pycache__/*.pyc',
43
+ mode: 'files',
44
+ }, root));
45
+ assert.match(out, /convert_masks/);
46
+ });
47
+ });
48
+
49
+ test('a clean fuzzy miss reports that the hit exists in a skipped tree', async () => {
50
+ await withNoiseTree(async (root) => {
51
+ const out = String(await executeBuiltinTool('find', { query: 'convert_masks' }, root));
52
+ assert.match(out, /no fuzzy match/);
53
+ assert.match(out, /include_noise:true/);
54
+ // The default answer still lists no dependency path.
55
+ assert.doesNotMatch(out, /convert_masks\.cpython-311\.pyc/);
56
+
57
+ // The flag it names actually produces the file.
58
+ const noisy = String(await executeBuiltinTool('find', {
59
+ query: 'convert_masks',
60
+ include_noise: true,
61
+ }, root));
62
+ assert.match(noisy, /convert_masks\.cpython-311\.pyc/);
63
+
64
+ // A query with no file behind it stays a plain miss.
65
+ const missing = String(await executeBuiltinTool('find', { query: 'no_such_artifact_here' }, root));
66
+ assert.match(missing, /no fuzzy match/);
67
+ assert.doesNotMatch(missing, /include_noise:true/);
68
+ });
69
+ });
@@ -61,20 +61,36 @@ export async function listSiblingsAsync(dir, limit = 12) {
61
61
  // and hard-capped on directories scanned, and returns up to `limit` real
62
62
  // locations so the error can name them directly. Pure best-effort; any error
63
63
  // returns [].
64
+ // A directory-count cap is not a time cap: on a slow or synthetic tree each
65
+ // readdir costs milliseconds, and one missing path burned 17.5s of a turn in
66
+ // this BFS — three times in a row — before the caller ever saw its answer.
67
+ // The hint is an optional courtesy, so it gets a wall-clock budget and reports
68
+ // an out-of-time walk as inconclusive rather than as a proven absence.
69
+ const BASENAME_SCAN_DEADLINE_MS = 750;
64
70
  const BASENAME_SCAN_SKIP_DIRS = new Set([
65
71
  'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out',
66
72
  'coverage', '.next', '.nuxt', '.turbo', '.cache', 'vendor',
67
73
  'target', '.venv', 'venv', '__pycache__', '.idea', '.vscode',
68
74
  ]);
69
- export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs = 6000 } = {}) {
75
+ export function findFileByBasename(
76
+ searchRoot,
77
+ fullPath,
78
+ { limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {},
79
+ ) {
70
80
  try {
71
81
  if (typeof searchRoot !== 'string' || !searchRoot) return [];
72
82
  const target = basename(fullPath).toLowerCase();
73
83
  if (!target) return [];
74
84
  const matches = [];
75
85
  const queue = [searchRoot];
86
+ const stopAt = Date.now() + Math.max(1, deadlineMs);
76
87
  let scanned = 0;
88
+ let expired = false;
77
89
  while (queue.length && matches.length < limit && scanned < maxDirs) {
90
+ if (Date.now() >= stopAt) {
91
+ expired = true;
92
+ break;
93
+ }
78
94
  const dir = queue.shift();
79
95
  scanned++;
80
96
  let entries;
@@ -96,9 +112,10 @@ export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs =
96
112
  }
97
113
  }
98
114
  }
99
- // Queue drained without hitting maxDirs/limit → the walk covered the
100
- // whole (non-vendor, non-hidden) tree; a miss is conclusive.
101
- matches.exhaustive = queue.length === 0;
115
+ // Queue drained without hitting maxDirs/limit/deadline → the walk
116
+ // covered the whole (non-vendor, non-hidden) tree; a miss is
117
+ // conclusive. A walk that ran out of time proves nothing.
118
+ matches.exhaustive = queue.length === 0 && !expired;
102
119
  return matches;
103
120
  } catch { return []; }
104
121
  }
@@ -146,15 +163,25 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
146
163
 
147
164
  // Same as findFileByBasename but for directories sharing the missing path's
148
165
  // final segment name (e.g. guessed `src/shared` → the real `lib/shared`).
149
- export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDirs = 6000 } = {}) {
166
+ export function findDirectoryByBasename(
167
+ searchRoot,
168
+ fullPath,
169
+ { limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {},
170
+ ) {
150
171
  try {
151
172
  if (typeof searchRoot !== 'string' || !searchRoot) return [];
152
173
  const target = basename(fullPath).toLowerCase();
153
174
  if (!target) return [];
154
175
  const matches = [];
155
176
  const queue = [searchRoot];
177
+ const stopAt = Date.now() + Math.max(1, deadlineMs);
156
178
  let scanned = 0;
179
+ let expired = false;
157
180
  while (queue.length && matches.length < limit && scanned < maxDirs) {
181
+ if (Date.now() >= stopAt) {
182
+ expired = true;
183
+ break;
184
+ }
158
185
  const dir = queue.shift();
159
186
  scanned++;
160
187
  let entries;
@@ -176,7 +203,7 @@ export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDi
176
203
  }
177
204
  }
178
205
  }
179
- matches.exhaustive = queue.length === 0;
206
+ matches.exhaustive = queue.length === 0 && !expired;
180
207
  return matches;
181
208
  } catch { return []; }
182
209
  }
@@ -0,0 +1,71 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { executeBuiltinTool } from '../builtin.mjs';
8
+
9
+ function lines(fileTag, count) {
10
+ return Array.from({ length: count }, (_, i) => `${fileTag} line-${i + 1}`).join('\n') + '\n';
11
+ }
12
+
13
+ test('multi-file glob fan-out caps the survey window at 25 lines per file', async () => {
14
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-survey-'));
15
+ try {
16
+ await writeFile(join(root, 'a.log'), lines('alpha', 200));
17
+ await writeFile(join(root, 'b.log'), lines('beta', 200));
18
+ const out = await executeBuiltinTool('read', { file_path: join(root, '*.log') }, root);
19
+ assert.equal(typeof out, 'string');
20
+ // Head of each file is present…
21
+ assert.match(out, /alpha line-1\b/);
22
+ assert.match(out, /beta line-1\b/);
23
+ assert.match(out, /alpha line-25\b/);
24
+ assert.match(out, /beta line-25\b/);
25
+ assert.doesNotMatch(out, /alpha line-26\b/);
26
+ assert.doesNotMatch(out, /beta line-26\b/);
27
+ } finally {
28
+ await rm(root, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('explicit limit is clamped to 25 lines per glob-expanded file', async () => {
33
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-explicit-'));
34
+ try {
35
+ await writeFile(join(root, 'a.log'), lines('alpha', 200));
36
+ await writeFile(join(root, 'b.log'), lines('beta', 200));
37
+ const out = await executeBuiltinTool('read', { file_path: join(root, '*.log'), limit: 150 }, root);
38
+ assert.match(out, /alpha line-25\b/);
39
+ assert.doesNotMatch(out, /alpha line-26\b/);
40
+ } finally {
41
+ await rm(root, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test('glob fan-out shares a 10 KB output budget across files', async () => {
46
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-budget-'));
47
+ try {
48
+ const payload = 'x'.repeat(300);
49
+ for (let i = 0; i < 10; i++) {
50
+ await writeFile(join(root, `${i}.log`), lines(`${i}-${payload}`, 25));
51
+ }
52
+ const out = await executeBuiltinTool('read', { file_path: join(root, '*.log'), limit: 25 }, root);
53
+ assert.ok(Buffer.byteLength(out, 'utf8') <= 10 * 1024);
54
+ assert.match(out, /0\.log/);
55
+ assert.match(out, /9\.log/);
56
+ } finally {
57
+ await rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test('single-file glob match uses the same 25-line cap', async () => {
62
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-single-'));
63
+ try {
64
+ await writeFile(join(root, 'only.log'), lines('solo', 200));
65
+ const out = await executeBuiltinTool('read', { file_path: join(root, '*.log') }, root);
66
+ assert.match(out, /solo line-25\b/);
67
+ assert.doesNotMatch(out, /solo line-26\b/);
68
+ } finally {
69
+ await rm(root, { recursive: true, force: true });
70
+ }
71
+ });
@@ -105,7 +105,12 @@ async function loadSharp() {
105
105
  _sharpPromise = (async () => {
106
106
  try {
107
107
  const mod = await import('sharp');
108
- return mod?.default || mod || null;
108
+ const sharp = mod?.default || mod || null;
109
+ // libvips' internal operation cache (default ~50MB per
110
+ // process) duplicates the JS-level resize cache above and the
111
+ // on-disk rendition cache; keep pixels out of native memory.
112
+ try { sharp?.cache(false); } catch { /* cache stays default */ }
113
+ return sharp;
109
114
  } catch {
110
115
  return null;
111
116
  }
@@ -272,6 +272,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
272
272
  hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
273
273
  const _rawMsg = err instanceof Error ? err.message : String(err);
274
274
  const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
275
+ // A conclusive "no such path" is the ANSWER to the read, not a tool
276
+ // failure (same policy as git's `repo:false`): the error envelope
277
+ // routed callers into recovery mode for an ordinary absence probe.
278
+ // Genuine not-found only — EACCES/EPERM etc. keep failure semantics.
279
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
280
+ return `[path absent] ${_safeMsg}${hint}`;
281
+ }
275
282
  return `Error: ${_safeMsg}${hint}`;
276
283
  }
277
284
  // A successful edit/apply_patch leaves a session-scoped full-file snapshot.