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.
- package/README.md +28 -12
- package/package.json +1 -1
- package/src/headless-exec.mjs +40 -4
- package/src/headless-exec.test.mjs +116 -1
- package/src/rules/lead/01-general.md +3 -2
- package/src/rules/shared/00-general.md +1 -0
- package/src/rules/shared/10-tool-workflow.md +11 -12
- package/src/rules/shared/30-exploration.md +4 -3
- package/src/rules/shared/60-verification.md +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
- package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
- package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
- package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +13 -19
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/memory-snapshot.mjs +57 -4
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +6 -3
- package/src/runtime/shared/tool-surface.mjs +6 -0
- package/src/runtime/shared/tool-surface.test.mjs +8 -0
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/agent-dispatch-broker.mjs +25 -80
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/daemon.mjs +9 -9
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/standalone/session-runtime-host-health.test.mjs +36 -0
- package/src/standalone/session-runtime-host.mjs +26 -76
- package/src/standalone/session-runtime-worker.mjs +97 -0
- package/src/tui/App.jsx +3 -2
- package/src/tui/app/use-global-key-input.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +77 -14
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/dist/index.mjs +83 -23
- package/src/tui/lib/voice-setup.mjs +3 -2
- package/src/tui/session/completion-card-restore.test.mjs +18 -0
- package/src/tui/session/session-api-ext.mjs +2 -1
- package/src/tui/session/session-api.mjs +8 -1
- package/src/tui/session/task-wait-submit.test.mjs +80 -0
- package/src/tui/session/turn.mjs +58 -6
- package/src/tui/spinner-verbs.mjs +1 -0
- package/src/workflows/default/WORKFLOW.md +12 -19
- package/src/workflows/headless/WORKFLOW.md +11 -0
- 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
|
-
|
|
216
|
-
workDir, readStateScope, executeChildBuiltinTool, {
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 (${
|
|
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 =
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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 === '
|
|
122
|
-
return `${text}\n${
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|