mixdog 0.9.150 → 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 (53) hide show
  1. package/README.md +28 -11
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +16 -3
  4. package/src/headless-exec.test.mjs +44 -1
  5. package/src/rules/shared/30-exploration.md +2 -1
  6. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  7. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  8. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  9. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
  11. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  12. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
  17. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  21. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
  22. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
  25. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
  26. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  31. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  36. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  39. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  40. package/src/runtime/memory/index.mjs +2 -4
  41. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  42. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  43. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  44. package/src/runtime/memory/lib/query-handlers.mjs +7 -6
  45. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  46. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  47. package/src/runtime/shared/pristine-execution.mjs +5 -2
  48. package/src/session-runtime/lifecycle-api.mjs +1 -0
  49. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  50. package/src/standalone/channel-worker.mjs +31 -1
  51. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  52. package/src/standalone/session-client.mjs +23 -3
  53. package/src/tui/session/turn.mjs +12 -0
@@ -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
+ }
@@ -1038,7 +1038,40 @@ async function getTargetedFindEnumeration({
1038
1038
  // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
1039
1039
  // routes here for hidden backward compatibility, but the model-facing tool is
1040
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.
1041
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 = {}) {
1042
1075
  const query = String(args.query ?? args.fuzzy ?? '').trim();
1043
1076
  if (!query) return 'Error: find requires query.';
1044
1077
  const inputPath = normalizeInputPath(args.path) || '.';
@@ -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
  }
@@ -10,6 +10,16 @@ import { coerceReadFamilyPathArg, hasGlobMagic } from './path-utils.mjs';
10
10
  import { readIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
11
11
  import { currentToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
12
12
 
13
+ // Per-entry status in a batch. A conclusively missing file is not a failed
14
+ // read — that is why its body is `[path absent]` and not `Error:` — but it is
15
+ // not `ok` either: the header said `missing.png [ok]` above a body saying the
16
+ // file does not exist, which is the one line a skimming reader trusts. Absence
17
+ // gets its own tag so the header and the body agree.
18
+ function batchEntryStatus(body, failed, textBody) {
19
+ if (failed) return 'error';
20
+ return /^\s*\[path absent\]/.test(String(textBody ?? body ?? '')) ? 'absent' : 'ok';
21
+ }
22
+
13
23
  function hasLineCoordinate(path) {
14
24
  return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
15
25
  }
@@ -538,12 +548,18 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
538
548
  // reject_partial:true — when the caller asked for all-or-none,
539
549
  // refuse to return a mixed payload that downstream parsers
540
550
  // would have to disambiguate per-entry.
541
- if (failedReads > 0 && args.reject_partial === true) {
542
- const reasons = orderedResults
543
- .filter((r) => bodyFailed(r.body))
551
+ // All-or-none is about DELIVERY: an entry that returned no content did
552
+ // not deliver, whether it failed outright or was conclusively absent.
553
+ // Counting only hard failures let a batch with a missing file through
554
+ // as a mixed payload — exactly what the caller asked not to receive.
555
+ const undelivered = orderedResults.filter(
556
+ (r) => batchEntryStatus(r.body, bodyFailed(r.body), bodyTextFor(r.body)) !== 'ok',
557
+ );
558
+ if (undelivered.length > 0 && args.reject_partial === true) {
559
+ const reasons = undelivered
544
560
  .map((r) => `${normalizeOutputPath(r.path)}: ${bodyTextFor(r.body).split('\n')[0] || 'structured media read failed'}`)
545
561
  .join('; ');
546
- return `Error: batch read rejected (${failedReads} of ${orderedResults.length} failed; reject_partial:true) — ${reasons}`;
562
+ return `Error: batch read rejected (${undelivered.length} of ${orderedResults.length} failed; reject_partial:true) — ${reasons}`;
547
563
  }
548
564
  // Default: surface per-entry status tags ([ok]/[error]) so a
549
565
  // downstream classifyResultKind treats the aggregate as a
@@ -564,8 +580,8 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
564
580
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
565
581
  // Default full mode carries no information — tag only non-default modes.
566
582
  const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
567
- const status = bodyFailed(r.body) ? 'error' : 'ok';
568
583
  const textBody = bodyTextFor(r.body);
584
+ const status = batchEntryStatus(r.body, bodyFailed(r.body), textBody);
569
585
  const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
570
586
  const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
571
587
  const entryHeader = `${path}${modeTag} [${status}]${suffix}`;
@@ -608,7 +624,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
608
624
  const path = normalizeOutputPath(r.path);
609
625
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
610
626
  const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
611
- const status = classifyResultKind(String(r.body || '')) === 'error' ? 'error' : 'ok';
627
+ const status = batchEntryStatus(
628
+ r.body,
629
+ classifyResultKind(String(r.body || '')) === 'error',
630
+ String(r.body || ''),
631
+ );
612
632
  const dupKey = JSON.stringify([path, mode, r.body || '']);
613
633
  const priorIdx = _seenEntryBody.get(dupKey);
614
634
  if (priorIdx !== undefined) {
@@ -1,13 +1,11 @@
1
1
  import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import {
4
- basename,
5
4
  delimiter as pathDelimiter,
6
5
  dirname as pathDirname,
7
6
  join as pathJoin,
8
7
  resolve as pathResolveAbsolute,
9
8
  } from 'node:path';
10
- import { resolveShellFor } from './shell-runtime.mjs';
11
9
 
12
10
  export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
13
11
  'node',
@@ -30,31 +28,6 @@ export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
30
28
  'bash',
31
29
  ]);
32
30
 
33
- export const SHELL_DEPENDENCY_CANDIDATES = Object.freeze([
34
- 'git',
35
- 'rg',
36
- 'jq',
37
- 'curl',
38
- 'wget',
39
- 'gcc',
40
- 'clang',
41
- 'make',
42
- 'cmake',
43
- 'ninja',
44
- 'cargo',
45
- 'sqlite3',
46
- 'psql',
47
- 'docker',
48
- 'kubectl',
49
- 'openssl',
50
- 'tar',
51
- ]);
52
-
53
- export const SHELL_CAPABILITY_CANDIDATES = Object.freeze([
54
- ...SHELL_RUNTIME_CANDIDATES,
55
- ...SHELL_DEPENDENCY_CANDIDATES,
56
- ]);
57
-
58
31
  function _pathDirectory(value) {
59
32
  const text = String(value || '').trim();
60
33
  return text.length >= 2 && text.startsWith('"') && text.endsWith('"')
@@ -96,35 +69,12 @@ export function findPathExecutable(name, {
96
69
  return null;
97
70
  }
98
71
 
99
- export function detectPathCapabilities({
100
- candidates = SHELL_CAPABILITY_CANDIDATES,
101
- ...pathOptions
102
- } = {}) {
103
- const available = [];
104
- const unavailable = [];
105
- for (const name of candidates) {
106
- (findPathExecutable(name, pathOptions) ? available : unavailable).push(name);
107
- }
108
- return { available, unavailable };
109
- }
110
-
111
- export function describeShellStartupPolicy({
112
- os = process.platform,
113
- shell = null,
114
- // Accept a pre-computed scan so a caller emitting several startup lines
115
- // walks PATH once instead of once per line.
116
- capabilities = null,
117
- ...pathOptions
118
- } = {}) {
119
- const { available, unavailable } = capabilities ?? detectPathCapabilities(pathOptions);
120
- const resolvedShell = shell || basename(String(resolveShellFor('default')?.shell || 'unknown'));
121
- // Shell networking is independent from the web-search tool surface:
122
- // applyShellEgressPolicy leaves the spawn env untouched, so package
123
- // managers, source-control clients, and proxies keep working even with
124
- // MIXDOG_FEATURE_WEB_SEARCH=0. This line therefore never claims an
125
- // egress block the runtime does not enforce.
126
- return `- Shell startup environment: OS=${os}; shell=${resolvedShell}; available=${available.join(', ') || 'none'}; unavailable=${unavailable.join(', ') || 'none'}. For shell commands, treat every unavailable entry as absent. Invoke one only if the same command first installs it or exposes it on PATH.`;
127
- }
72
+ // A startup PATH inventory is deliberately not rendered into the prompt: it
73
+ // would be measured in this process's PATH while commands run under the
74
+ // login-shell snapshot (shell-snapshot.mjs), which does not exist until the
75
+ // first shell call, and it would freeze at session start while an install
76
+ // during the session changes the answer. bash-tool's _exitClassDiagnostic
77
+ // reports a missing command after it has run, from the environment that ran it.
128
78
 
129
79
  // Walk up for a `.git` marker instead of shelling out to `git rev-parse`: it
130
80
  // costs no process spawn, and it still answers on images where the git binary
@@ -190,12 +140,9 @@ function _repositoryChangeState(root) {
190
140
  // rather than forbidding calls.
191
141
  export function describeGitStartupState({
192
142
  cwd = process.cwd(),
193
- capabilities = null,
194
143
  ...pathOptions
195
144
  } = {}) {
196
- const installed = capabilities
197
- ? capabilities.available.includes('git')
198
- : Boolean(findPathExecutable('git', pathOptions));
145
+ const installed = Boolean(findPathExecutable('git', pathOptions));
199
146
  if (!installed) {
200
147
  return '- Git startup state: git is not installed here; git tool calls fail until something installs it.';
201
148
  }
@@ -213,9 +160,3 @@ export function appendGitStartupState(rules, tools, options = {}) {
213
160
  if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'git')) return text;
214
161
  return `${text}\n${describeGitStartupState(options)}`;
215
162
  }
216
-
217
- export function appendShellStartupPolicy(rules, tools, options = {}) {
218
- const text = String(rules || '').trimEnd();
219
- if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'shell')) return text;
220
- return `${text}\n${describeShellStartupPolicy(options)}`;
221
- }
@@ -137,11 +137,22 @@ export function buildGrepRgArgs(parts) {
137
137
  // globs (no trailing /**) always apply. searchPath is normalized to forward
138
138
  // slashes and de-trailing-slashed for the comparison.
139
139
  const _sp = String(searchPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
140
+ // A positive --glob filter that NAMES a pruned directory is asking to
141
+ // search inside it; only the path operand used to count, so
142
+ // `glob:"__pycache__/*.pyc"` matched nothing while the files were there.
143
+ // Negative filters never re-admit their own target.
144
+ const _namedByGlobs = new Set(
145
+ (Array.isArray(globPatterns) ? globPatterns : [])
146
+ .filter((g) => typeof g === 'string' && g && !g.startsWith('!'))
147
+ .flatMap((g) => g.replace(/\\/g, '/').split('/'))
148
+ .filter((segment) => segment && !/[*?[\]{}]/.test(segment)),
149
+ );
140
150
  for (const ex of DEFAULT_IGNORE_GLOBS) {
141
151
  const m = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
142
152
  if (m) {
143
153
  const name = m[1];
144
154
  if (_sp === name || _sp.endsWith(`/${name}`) || _sp.includes(`/${name}/`) || _sp.startsWith(`${name}/`)) continue;
155
+ if (_namedByGlobs.has(name)) continue;
145
156
  }
146
157
  rgArgs.push('--glob', ex);
147
158
  }
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
3
3
  import { createHash } from 'crypto';
4
4
  import { isAbsolute, resolve } from 'path';
5
5
  import { trueCasePath } from './path-utils.mjs';
6
+ import { expandAbsoluteGlobs } from './lib/absolute-glob-expand.mjs';
6
7
  import {
7
8
  canonicalizeGlobSlashes,
8
9
  coerceReadFamilyPathArg,
@@ -409,8 +410,19 @@ export async function executeGlobTool(args, workDir, options = {}) {
409
410
  const base = String(rel).split('/').filter(Boolean).pop() || '';
410
411
  return base !== '' && !hasGlobMagic(base);
411
412
  });
413
+ // A pattern that NAMES a pruned directory is asking for it: only the
414
+ // wildcard basename decided this before, so `node_modules/**/index.js`
415
+ // was answered while `__pycache__/*.pyc` returned "(no files found)"
416
+ // for a file sitting right there. The yield is per-directory — every
417
+ // other prune stays on — and a negative pattern never counts as a
418
+ // request for its own target.
419
+ const namedDirs = new Set([
420
+ ...rels.flatMap((rel) => String(rel).replace(/^!/, '').split('/')),
421
+ ...String(root || '').replace(/\\/g, '/').split('/'),
422
+ ].filter((segment) => segment && !hasGlobMagic(segment)));
412
423
  for (const ex of DEFAULT_IGNORE_GLOBS) {
413
- if (explicitBasenames && /^!\*\*\/[^/]+\/\*\*$/.test(ex)) continue;
424
+ const pruned = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
425
+ if (pruned && (explicitBasenames || namedDirs.has(pruned[1]))) continue;
414
426
  rgArgs.push('--glob', ex);
415
427
  }
416
428
  for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
@@ -594,6 +606,18 @@ export async function executeGlobTool(args, workDir, options = {}) {
594
606
  : (rgStdoutTruncated ? '\n... [truncated at rg stdout cap (20MB); results incomplete]' : '')
595
607
  + (rgStdoutPartial ? '\n... [warning] rg exit 2 (partial results); listing may be incomplete' : '');
596
608
  const errSuffix = (rgErrors.length > 0 ? `\n... [warning] ${rgErrors.join(' | ')}` : '') + truncSuffix;
609
+ // An absolute pattern that enumerated nothing may still have real matches
610
+ // the inventory walker cannot see: it does not follow symlinked
611
+ // directories, and /sys, /proc and friends are made of them. Expand those
612
+ // patterns straight off the filesystem before declaring the miss.
613
+ if (capped.length === 0 && rgErrors.length === 0) {
614
+ const direct = await expandAbsoluteGlobs(patterns, {
615
+ limit: headLimit === Infinity ? 50 : offset + headLimit,
616
+ });
617
+ const window = offset > 0 ? direct.slice(offset) : direct;
618
+ const shown = headLimit === Infinity ? window : window.slice(0, headLimit);
619
+ if (shown.length > 0) return globPatternCapNote + shown.join('\n');
620
+ }
597
621
  let emptyDiag = '';
598
622
  if (capped.length === 0 && rgErrors.length === 0) {
599
623
  const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);