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
@@ -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
  }
@@ -94,6 +104,18 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
94
104
  }
95
105
  }
96
106
 
107
+ // Per-file hard cap for glob fan-out. Sampling many files at the single-file
108
+ // default turns one call into thousands of context lines, while format/shape
109
+ // evidence needs only a small head from each file. Exact-path reads keep the
110
+ // standard defaults.
111
+ // MIXDOG_READ_GLOB_SURVEY_LIMIT overrides for A/B runs.
112
+ const READ_GLOB_SURVEY_LIMIT = 25;
113
+ const READ_GLOB_OUTPUT_BUDGET_BYTES = 10 * 1024;
114
+ function _readGlobSurveyLimit() {
115
+ const parsed = parseInt(process.env.MIXDOG_READ_GLOB_SURVEY_LIMIT ?? '', 10);
116
+ return parsed > 0 ? parsed : READ_GLOB_SURVEY_LIMIT;
117
+ }
118
+
97
119
  export async function executeReadTool(args, workDir, readStateScope, executeChildBuiltinTool, options = {}, helpers = {}) {
98
120
  const {
99
121
  classifyResultKind,
@@ -211,9 +233,23 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
211
233
  const _capNote = _globFiles.length > READ_GLOB_CAP
212
234
  ? `\n[glob expansion capped at ${READ_GLOB_CAP} files (newest first); narrow the pattern for the rest]`
213
235
  : '';
236
+ const _surveyArgs = { ...args, path: _capped, file_path: undefined };
237
+ const _survey = _readGlobSurveyLimit();
238
+ const _requestedLimit = Number(_surveyArgs.limit);
239
+ _surveyArgs.limit = Number.isFinite(_requestedLimit) && _requestedLimit > 0
240
+ ? Math.min(Math.trunc(_requestedLimit), _survey)
241
+ : _survey;
214
242
  const _expanded = await executeReadTool(
215
- { ...args, path: _capped, file_path: undefined },
216
- workDir, readStateScope, executeChildBuiltinTool, { ...options }, helpers,
243
+ _surveyArgs,
244
+ workDir, readStateScope, executeChildBuiltinTool, {
245
+ ...options,
246
+ readOutputBudgetBytes: Math.min(
247
+ Number(options?.readOutputBudgetBytes) > 0
248
+ ? Number(options.readOutputBudgetBytes)
249
+ : READ_GLOB_OUTPUT_BUDGET_BYTES,
250
+ READ_GLOB_OUTPUT_BUDGET_BYTES,
251
+ ),
252
+ }, helpers,
217
253
  );
218
254
  if (!_capNote) return _expanded;
219
255
  if (typeof _expanded === 'string') return _expanded + _capNote;
@@ -512,12 +548,18 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
512
548
  // reject_partial:true — when the caller asked for all-or-none,
513
549
  // refuse to return a mixed payload that downstream parsers
514
550
  // would have to disambiguate per-entry.
515
- if (failedReads > 0 && args.reject_partial === true) {
516
- const reasons = orderedResults
517
- .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
518
560
  .map((r) => `${normalizeOutputPath(r.path)}: ${bodyTextFor(r.body).split('\n')[0] || 'structured media read failed'}`)
519
561
  .join('; ');
520
- 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}`;
521
563
  }
522
564
  // Default: surface per-entry status tags ([ok]/[error]) so a
523
565
  // downstream classifyResultKind treats the aggregate as a
@@ -538,8 +580,8 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
538
580
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
539
581
  // Default full mode carries no information — tag only non-default modes.
540
582
  const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
541
- const status = bodyFailed(r.body) ? 'error' : 'ok';
542
583
  const textBody = bodyTextFor(r.body);
584
+ const status = batchEntryStatus(r.body, bodyFailed(r.body), textBody);
543
585
  const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
544
586
  const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
545
587
  const entryHeader = `${path}${modeTag} [${status}]${suffix}`;
@@ -582,7 +624,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
582
624
  const path = normalizeOutputPath(r.path);
583
625
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
584
626
  const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
585
- 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
+ );
586
632
  const dupKey = JSON.stringify([path, mode, r.body || '']);
587
633
  const priorIdx = _seenEntryBody.get(dupKey);
588
634
  if (priorIdx !== undefined) {
@@ -1,6 +1,11 @@
1
- import { accessSync, constants as fsConstants, statSync } from 'node:fs';
2
- import { basename, delimiter as pathDelimiter, join as pathJoin } from 'node:path';
3
- import { resolveShellFor } from './shell-runtime.mjs';
1
+ import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import {
4
+ delimiter as pathDelimiter,
5
+ dirname as pathDirname,
6
+ join as pathJoin,
7
+ resolve as pathResolveAbsolute,
8
+ } from 'node:path';
4
9
 
5
10
  export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
6
11
  'node',
@@ -23,31 +28,6 @@ export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
23
28
  'bash',
24
29
  ]);
25
30
 
26
- export const SHELL_DEPENDENCY_CANDIDATES = Object.freeze([
27
- 'git',
28
- 'rg',
29
- 'jq',
30
- 'curl',
31
- 'wget',
32
- 'gcc',
33
- 'clang',
34
- 'make',
35
- 'cmake',
36
- 'ninja',
37
- 'cargo',
38
- 'sqlite3',
39
- 'psql',
40
- 'docker',
41
- 'kubectl',
42
- 'openssl',
43
- 'tar',
44
- ]);
45
-
46
- export const SHELL_CAPABILITY_CANDIDATES = Object.freeze([
47
- ...SHELL_RUNTIME_CANDIDATES,
48
- ...SHELL_DEPENDENCY_CANDIDATES,
49
- ]);
50
-
51
31
  function _pathDirectory(value) {
52
32
  const text = String(value || '').trim();
53
33
  return text.length >= 2 && text.startsWith('"') && text.endsWith('"')
@@ -89,35 +69,94 @@ export function findPathExecutable(name, {
89
69
  return null;
90
70
  }
91
71
 
92
- export function detectPathCapabilities({
93
- candidates = SHELL_CAPABILITY_CANDIDATES,
94
- ...pathOptions
95
- } = {}) {
96
- const available = [];
97
- const unavailable = [];
98
- for (const name of candidates) {
99
- (findPathExecutable(name, pathOptions) ? available : unavailable).push(name);
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.
78
+
79
+ // Walk up for a `.git` marker instead of shelling out to `git rev-parse`: it
80
+ // costs no process spawn, and it still answers on images where the git binary
81
+ // is absent. `.git` is a directory in a normal clone and a file in a worktree
82
+ // or submodule, so both count as "inside a repository".
83
+ export function findRepositoryRoot(startDirectory, { maxDepth = 64 } = {}) {
84
+ let current;
85
+ try {
86
+ current = pathResolveAbsolute(String(startDirectory || '.'));
87
+ } catch {
88
+ return null;
100
89
  }
101
- return { available, unavailable };
90
+ for (let depth = 0; depth < maxDepth; depth += 1) {
91
+ const marker = pathJoin(current, '.git');
92
+ try {
93
+ const stats = statSync(marker);
94
+ return { root: current, gitDirectory: stats.isDirectory() ? marker : null };
95
+ } catch {}
96
+ const parent = pathDirname(current);
97
+ if (parent === current) break;
98
+ current = parent;
99
+ }
100
+ return null;
102
101
  }
103
102
 
104
- export function describeShellStartupPolicy({
105
- os = process.platform,
106
- shell = null,
103
+ function _headBranch(gitDirectory) {
104
+ if (!gitDirectory) return null;
105
+ try {
106
+ const head = readFileSync(pathJoin(gitDirectory, 'HEAD'), 'utf8').trim();
107
+ // Detached HEAD holds a raw object id and names no branch.
108
+ return /^ref:\s*refs\/heads\/(.+)$/.exec(head)?.[1] ?? null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function _repositoryChangeState(root) {
115
+ try {
116
+ const result = spawnSync('git', [
117
+ '--no-optional-locks',
118
+ '-C',
119
+ root,
120
+ 'status',
121
+ '--porcelain=v1',
122
+ '--untracked-files=normal',
123
+ ], {
124
+ encoding: 'utf8',
125
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
126
+ maxBuffer: 64 * 1024,
127
+ timeout: 1500,
128
+ windowsHide: true,
129
+ });
130
+ const output = String(result.stdout || '').trim();
131
+ if (result.status === 0) return output ? 'changes present' : 'clean';
132
+ if (result.error?.code === 'ENOBUFS' && output) return 'changes present';
133
+ } catch {}
134
+ return null;
135
+ }
136
+
137
+ // Startup snapshot, same contract as the shell line above: it states what was
138
+ // true when the session opened, not a permanent rule. `git init` or a move to
139
+ // another directory changes it, which is why this reports the observation
140
+ // rather than forbidding calls.
141
+ export function describeGitStartupState({
142
+ cwd = process.cwd(),
107
143
  ...pathOptions
108
144
  } = {}) {
109
- const { available, unavailable } = detectPathCapabilities(pathOptions);
110
- const resolvedShell = shell || basename(String(resolveShellFor('default')?.shell || 'unknown'));
111
- // Shell networking is independent from the web-search tool surface:
112
- // applyShellEgressPolicy leaves the spawn env untouched, so package
113
- // managers, source-control clients, and proxies keep working even with
114
- // MIXDOG_FEATURE_WEB_SEARCH=0. This line therefore never claims an
115
- // egress block the runtime does not enforce.
116
- 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.`;
145
+ const installed = Boolean(findPathExecutable('git', pathOptions));
146
+ if (!installed) {
147
+ return '- Git startup state: git is not installed here; git tool calls fail until something installs it.';
148
+ }
149
+ const found = findRepositoryRoot(cwd);
150
+ if (!found) {
151
+ return `- Git startup state: ${cwd} was not inside a git repository at startup; git commands that require one fail until a repository exists.`;
152
+ }
153
+ const branch = _headBranch(found.gitDirectory);
154
+ const changeState = _repositoryChangeState(found.root);
155
+ return `- Git startup state: repository root ${found.root}${branch ? ` on branch ${branch}` : ' with a detached HEAD'}${changeState ? `; ${changeState}` : ''}.`;
117
156
  }
118
157
 
119
- export function appendShellStartupPolicy(rules, tools, options = {}) {
158
+ export function appendGitStartupState(rules, tools, options = {}) {
120
159
  const text = String(rules || '').trimEnd();
121
- if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'shell')) return text;
122
- return `${text}\n${describeShellStartupPolicy(options)}`;
160
+ if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'git')) return text;
161
+ return `${text}\n${describeGitStartupState(options)}`;
123
162
  }
@@ -0,0 +1,56 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import test from 'node:test';
7
+
8
+ import { describeGitStartupState } from './runtime-capabilities.mjs';
9
+
10
+ const gitAvailable = spawnSync('git', ['--version'], {
11
+ encoding: 'utf8',
12
+ windowsHide: true,
13
+ }).status === 0;
14
+
15
+ test('Git startup state reports clean and dirty repository snapshots', {
16
+ skip: gitAvailable ? false : 'git is unavailable',
17
+ }, () => {
18
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-git-startup-'));
19
+ try {
20
+ assert.equal(spawnSync('git', ['init', '-q'], {
21
+ cwd: root,
22
+ encoding: 'utf8',
23
+ windowsHide: true,
24
+ }).status, 0);
25
+
26
+ const clean = describeGitStartupState({
27
+ cwd: root,
28
+ capabilities: { available: ['git'] },
29
+ });
30
+ assert.match(clean, /Git startup state: repository root /);
31
+ assert.match(clean, /; clean\.$/);
32
+
33
+ writeFileSync(join(root, 'dirty.txt'), 'dirty\n');
34
+ const dirty = describeGitStartupState({
35
+ cwd: root,
36
+ capabilities: { available: ['git'] },
37
+ });
38
+ assert.match(dirty, /; changes present\.$/);
39
+ } finally {
40
+ rmSync(root, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ test('Git startup state preserves the non-repository message', () => {
45
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-nonrepo-startup-'));
46
+ try {
47
+ const state = describeGitStartupState({
48
+ cwd: root,
49
+ capabilities: { available: ['git'] },
50
+ });
51
+ assert.match(state, /was not inside a git repository at startup/);
52
+ assert.doesNotMatch(state, /; (?:clean|changes present)\./);
53
+ } finally {
54
+ rmSync(root, { recursive: true, force: true });
55
+ }
56
+ });
@@ -13,6 +13,20 @@ export const DEFAULT_IGNORE_GLOBS = [
13
13
  '!**/lpt[1-9]',
14
14
  ];
15
15
 
16
+ // Kernel-virtual trees mounted at the filesystem root hold no user files and
17
+ // can stall or poison a full-root walk: procfs re-enumerates every pid on each
18
+ // pass and /proc/kcore presents as a terabyte-scale sparse file. A scan rooted
19
+ // at `/` prunes them up front; the globs are ROOT-ANCHORED (no `**/` prefix),
20
+ // so a scan rooted at or inside one of them (path /proc/...) keeps full
21
+ // visibility, and ordinary project directories named `proc`/`sys`/`dev` are
22
+ // never affected because the exclusion only applies when the walk root is `/`.
23
+ export function rootScanIgnoreGlobs(resolvedRoot, platform = process.platform) {
24
+ if (platform === 'win32') return [];
25
+ return String(resolvedRoot || '') === '/'
26
+ ? ['!proc/**', '!sys/**', '!dev/**']
27
+ : [];
28
+ }
29
+
16
30
  export function buildGrepCacheKey(parts) {
17
31
  const {
18
32
  patterns,
@@ -123,11 +137,22 @@ export function buildGrepRgArgs(parts) {
123
137
  // globs (no trailing /**) always apply. searchPath is normalized to forward
124
138
  // slashes and de-trailing-slashed for the comparison.
125
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
+ );
126
150
  for (const ex of DEFAULT_IGNORE_GLOBS) {
127
151
  const m = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
128
152
  if (m) {
129
153
  const name = m[1];
130
154
  if (_sp === name || _sp.endsWith(`/${name}`) || _sp.includes(`/${name}/`) || _sp.startsWith(`${name}/`)) continue;
155
+ if (_namedByGlobs.has(name)) continue;
131
156
  }
132
157
  rgArgs.push('--glob', ex);
133
158
  }
@@ -0,0 +1,16 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { rootScanIgnoreGlobs } from './search-builders.mjs';
4
+
5
+ test('root-anchored kernel-tree prunes apply only to a posix filesystem-root scan', () => {
6
+ assert.deepEqual(
7
+ rootScanIgnoreGlobs('/', 'linux'),
8
+ ['!proc/**', '!sys/**', '!dev/**'],
9
+ );
10
+ // Scans rooted elsewhere — including inside a kernel tree — are untouched.
11
+ assert.deepEqual(rootScanIgnoreGlobs('/proc', 'linux'), []);
12
+ assert.deepEqual(rootScanIgnoreGlobs('/home/user/project', 'linux'), []);
13
+ assert.deepEqual(rootScanIgnoreGlobs('', 'linux'), []);
14
+ // Windows has no kernel-virtual mounts; the helper is a no-op there.
15
+ assert.deepEqual(rootScanIgnoreGlobs('/', 'win32'), []);
16
+ });
@@ -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,
@@ -48,6 +49,7 @@ import {
48
49
  buildGrepCacheKey,
49
50
  buildGrepRgArgs,
50
51
  DEFAULT_IGNORE_GLOBS,
52
+ rootScanIgnoreGlobs,
51
53
  } from './search-builders.mjs';
52
54
  import { runRg, runRgWindowedLines, rgSupportsPcre2 } from './native-search-runner.mjs';
53
55
  import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
@@ -408,13 +410,28 @@ export async function executeGlobTool(args, workDir, options = {}) {
408
410
  const base = String(rel).split('/').filter(Boolean).pop() || '';
409
411
  return base !== '' && !hasGlobMagic(base);
410
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)));
411
423
  for (const ex of DEFAULT_IGNORE_GLOBS) {
412
- if (explicitBasenames && /^!\*\*\/[^/]+\/\*\*$/.test(ex)) continue;
424
+ const pruned = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
425
+ if (pruned && (explicitBasenames || namedDirs.has(pruned[1]))) continue;
413
426
  rgArgs.push('--glob', ex);
414
427
  }
415
428
  for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
416
429
  for (const rel of rels) rgArgs.push('--glob', rel);
417
430
  const rgCwd = resolvedForSearchRoot(root);
431
+ // Root-anchored kernel-tree prunes trail every positive glob so
432
+ // rg's later-glob-wins rule can never re-admit /proc//sys//dev on a
433
+ // full-root scan.
434
+ for (const ex of rootScanIgnoreGlobs(rgCwd)) rgArgs.push('--glob', ex);
418
435
  rgArgs.push('.');
419
436
  const cwdStat = await statCached(rgCwd);
420
437
  if (cwdStat.err) {
@@ -589,6 +606,18 @@ export async function executeGlobTool(args, workDir, options = {}) {
589
606
  : (rgStdoutTruncated ? '\n... [truncated at rg stdout cap (20MB); results incomplete]' : '')
590
607
  + (rgStdoutPartial ? '\n... [warning] rg exit 2 (partial results); listing may be incomplete' : '');
591
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
+ }
592
621
  let emptyDiag = '';
593
622
  if (capped.length === 0 && rgErrors.length === 0) {
594
623
  const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
@@ -69,6 +69,10 @@ import { runGrepPathFanout } from './lib/grep-path-fanout.mjs';
69
69
  import { runGrepPatternFanout } from './lib/grep-pattern-fanout.mjs';
70
70
  import { runGrepChunkMerge } from './lib/grep-chunk-merge.mjs';
71
71
  import { runGrepFixedStringFallback } from './lib/grep-fixed-fallback.mjs';
72
+ import {
73
+ MAX_RESCUE_BYTES as GREP_RESCUE_MAX_BYTES,
74
+ runGrepSingleFileRescue,
75
+ } from './lib/grep-single-file-rescue.mjs';
72
76
 
73
77
  // A single glob string may pack multiple filters
74
78
  // separated by whitespace or commas, e.g. "*.ts,*.tsx" or "*.ts *.tsx". Split
@@ -95,6 +99,15 @@ function _grepDefaultHeadLimit() {
95
99
  return parsed > 0 ? parsed : 250;
96
100
  }
97
101
 
102
+ // One explicit FILE is scanned directly by the native server — there is no tree
103
+ // to walk — so a long deadline on a file scope can only ever be spent QUEUED
104
+ // behind another request's walk. A scope rooted in a virtual filesystem
105
+ // (/proc, /sys) is exactly that walk, and it starved the searches behind it:
106
+ // a six-line /proc/self/status failed after 17.7s while a plain read of the
107
+ // same file answered in 7ms. Bound the file case to a fraction of the turn and
108
+ // let the JS rescue below answer it instead of spending the turn waiting.
109
+ const SINGLE_FILE_SEARCH_DEADLINE_MS = 2_500;
110
+
98
111
  function _grepContextCharBudget(options = {}) {
99
112
  const explicit = Number(options?._grepContextCharBudget);
100
113
  if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
@@ -542,7 +555,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
542
555
  fileType,
543
556
  onlyMatching: false,
544
557
  });
545
- const probeOut = await runRg(probeArgs, { cwd: rgSpawnCwd, signal: sharedSignal });
558
+ const probeOut = await runRg(probeArgs, searchExecOptions);
546
559
  if (String(probeOut).split('\n').some(Boolean)) {
547
560
  return ' (case-insensitive would match — try -i)';
548
561
  }
@@ -560,6 +573,18 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
560
573
  : null;
561
574
 
562
575
  const GREP_CONTENT_HARD_CAP = 300;
576
+ const singleFileScope = grepStat.isFile();
577
+ // Bound ONLY the file sizes the JS rescue below can actually answer. A
578
+ // bigger file keeps the full budget, so the short deadline can never cost
579
+ // a caller an answer it would otherwise have received. (A procfs file
580
+ // reports size 0 and is therefore always bounded — exactly the case that
581
+ // motivated the bound.)
582
+ const boundSingleFile = singleFileScope && Number(grepStat.size) <= GREP_RESCUE_MAX_BYTES;
583
+ const searchExecOptions = {
584
+ cwd: rgSpawnCwd,
585
+ signal: sharedSignal,
586
+ ...(boundSingleFile ? { timeout: SINGLE_FILE_SEARCH_DEADLINE_MS } : {}),
587
+ };
563
588
  try {
564
589
  const callerExplicitUnlimited = headLimitCoerced === 0;
565
590
  const effectiveHeadLimit = headLimit === Infinity
@@ -620,7 +645,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
620
645
  let ctxPartialSuffix = '';
621
646
  const streamed = await runRgWindowedLines(
622
647
  anchorArgs,
623
- { cwd: rgSpawnCwd, signal: sharedSignal },
648
+ searchExecOptions,
624
649
  { offset: 0, limit: anchorCap, summaryLimit: 0 },
625
650
  );
626
651
  let ctxTotalKnown = streamed.complete;
@@ -689,7 +714,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
689
714
  ? GREP_CONTEXT_LINE_HARD_CAP
690
715
  : Math.min(GREP_CONTEXT_LINE_HARD_CAP, Math.max(200, blockBudget * Math.max(1, perBlock) + 8));
691
716
  let ctxPartialSuffix = '';
692
- const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal }, { offset: 0, limit: lineCap, summaryLimit: 0 });
717
+ const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, { offset: 0, limit: lineCap, summaryLimit: 0 });
693
718
  const allLines = streamed.lines;
694
719
  let ctxTotalKnown = streamed.complete;
695
720
  if (streamed.partial) {
@@ -739,7 +764,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
739
764
  let rgPartialSuffix = '';
740
765
  if (effectiveHeadLimit !== Infinity) {
741
766
  const summaryLimit = outputMode === 'content' ? 120 : 0;
742
- const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal }, {
767
+ const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, {
743
768
  offset,
744
769
  limit: effectiveHeadLimit,
745
770
  summaryLimit,
@@ -757,7 +782,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
757
782
  : '\n[warning] rg exit 2 (partial results)';
758
783
  }
759
784
  } else {
760
- const stdout = await runRg(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal });
785
+ const stdout = await runRg(rgArgs, searchExecOptions);
761
786
  const allLines = String(stdout).split('\n').filter(Boolean);
762
787
  windowed = offset > 0 ? allLines.slice(offset) : allLines;
763
788
  totalWindowed = windowed.length;
@@ -857,6 +882,36 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
857
882
  }
858
883
  catch (err) {
859
884
  if (sharedSignal?.aborted) throw err;
885
+ // A file scope the native server could not serve is answered by reading
886
+ // the file; only a scanner that cannot reproduce the request declines.
887
+ const nativeMissed = err?.code === 'NATIVE_SEARCH_TIMEOUT'
888
+ || err?.code === 'NATIVE_SEARCH_UNAVAILABLE'
889
+ || err?.code === 'NATIVE_SEARCH_UNSUPPORTED';
890
+ if (nativeMissed && singleFileScope) {
891
+ const rescued = await runGrepSingleFileRescue({
892
+ filePath: grepResolvedPath,
893
+ searchPath,
894
+ patterns,
895
+ caseInsensitive,
896
+ multilineMode,
897
+ onlyMatching: args['-o'] === true,
898
+ fileType,
899
+ outputMode,
900
+ showLineNumbers,
901
+ withFilename: forceGrepFilename,
902
+ filenameOmitted,
903
+ beforeN,
904
+ afterN,
905
+ contextN,
906
+ headLimit,
907
+ offset,
908
+ workDir,
909
+ grepResolvedPath,
910
+ patternCapNote,
911
+ globPatterns: normalizedGlobPatterns,
912
+ });
913
+ if (rescued !== null) return rescued;
914
+ }
860
915
  if (isRgRegexParseError(err) && !multilineMode) {
861
916
  // Fixed-string rescue lives in lib/grep-fixed-fallback.mjs;
862
917
  // null falls through to the original rg error below.