mixdog 0.9.36 → 0.9.38
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/package.json +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -168,10 +168,15 @@ function _prependDestructiveWarning(command, text) {
|
|
|
168
168
|
return `${warnings.map((w) => `⚠️ ${w}`).join('\n')}\n${text}`;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
export function formatShellToolFailure(message) {
|
|
172
|
+
const text = String(message ?? '').replace(/^Error:\s*/i, '').trim() || 'shell tool failed';
|
|
173
|
+
return `Error: [shell-tool-failed] ${text}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
171
176
|
export async function executeBashTool(args, workDir, options = {}) {
|
|
172
177
|
const requestedCwd = args.cwd ?? args.workdir;
|
|
173
178
|
const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
|
|
174
|
-
if (cwdResult.error) return cwdResult.error;
|
|
179
|
+
if (cwdResult.error) return formatShellToolFailure(cwdResult.error);
|
|
175
180
|
// Session cwd carry-over (no live shell): when the model
|
|
176
181
|
// passes an explicit cwd it wins and updates the store on the next probe;
|
|
177
182
|
// otherwise reuse the last stored cwd for this session if it still exists.
|
|
@@ -199,7 +204,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
199
204
|
// persistent:true. Run the full sweep here so both paths share the
|
|
200
205
|
// same blocklist before dispatch.
|
|
201
206
|
const _policyBlock = checkExecPolicyMessage(_rawCmd);
|
|
202
|
-
if (_policyBlock) return _policyBlock;
|
|
207
|
+
if (_policyBlock) return formatShellToolFailure(_policyBlock);
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
// An empty-string session_id is NOT a persistent-session request: `typeof
|
|
@@ -208,7 +213,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
208
213
|
// error, which models then retry in a loop. Require a non-blank id.
|
|
209
214
|
if (args.persistent === true || (typeof args.session_id === 'string' && args.session_id.trim().length > 0)) {
|
|
210
215
|
if (process.platform === 'win32') {
|
|
211
|
-
return '
|
|
216
|
+
return formatShellToolFailure('persistent shell sessions are disabled on Windows native-shell mode; run one-shot PowerShell commands without persistent/session_id.');
|
|
212
217
|
}
|
|
213
218
|
const { executeBashSessionTool } = await import('../bash-session.mjs');
|
|
214
219
|
let persistAbort = null;
|
|
@@ -227,7 +232,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
227
232
|
}
|
|
228
233
|
|
|
229
234
|
let command = args.command;
|
|
230
|
-
if (!command) return '
|
|
235
|
+
if (!command) return formatShellToolFailure('command is required');
|
|
231
236
|
|
|
232
237
|
// Resolve the shell up front so shell-type-specific handling (PS-only wmic
|
|
233
238
|
// rewrite, PS UTF-8 prefix) can gate on it. kind 'default' is byte-identical
|
|
@@ -238,9 +243,9 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
238
243
|
const resolvedSpec = resolveShellFor(shellKind);
|
|
239
244
|
if (!resolvedSpec) {
|
|
240
245
|
if (shellKind === 'bash') {
|
|
241
|
-
return "
|
|
246
|
+
return formatShellToolFailure("Git Bash not found — install Git for Windows or omit shell:'bash'.");
|
|
242
247
|
}
|
|
243
|
-
return "
|
|
248
|
+
return formatShellToolFailure("pwsh (PowerShell) not found — install PowerShell or omit shell:'powershell'.");
|
|
244
249
|
}
|
|
245
250
|
|
|
246
251
|
// wmic→PowerShell rewrite is PowerShell-only; never mangle a command bound
|
|
@@ -251,7 +256,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
251
256
|
const wmicRewrite = resolvedSpec.shellType === 'powershell'
|
|
252
257
|
? maybeRewriteWmicProcessCommand(command)
|
|
253
258
|
: null;
|
|
254
|
-
if (wmicRewrite?.error) return
|
|
259
|
+
if (wmicRewrite?.error) return formatShellToolFailure(wmicRewrite.error);
|
|
255
260
|
if (wmicRewrite?.command) command = wmicRewrite.command;
|
|
256
261
|
|
|
257
262
|
// PowerShell hygiene preflight (Windows PS-only; POSIX no-op): losslessly
|
|
@@ -262,19 +267,19 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
262
267
|
shellType: resolvedSpec.shellType,
|
|
263
268
|
shellName: resolvedSpec.shell,
|
|
264
269
|
});
|
|
265
|
-
if (psHygiene.block) return psHygiene.block;
|
|
270
|
+
if (psHygiene.block) return formatShellToolFailure(psHygiene.block);
|
|
266
271
|
command = psHygiene.command;
|
|
267
272
|
|
|
268
273
|
const _execPolicyBlock = checkExecPolicyMessage(command);
|
|
269
274
|
if (_execPolicyBlock) {
|
|
270
|
-
return _execPolicyBlock;
|
|
275
|
+
return formatShellToolFailure(_execPolicyBlock);
|
|
271
276
|
}
|
|
272
277
|
|
|
273
278
|
let shellEffects;
|
|
274
279
|
try {
|
|
275
280
|
shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
|
|
276
281
|
} catch (err) {
|
|
277
|
-
return
|
|
282
|
+
return formatShellToolFailure(normalizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
278
283
|
}
|
|
279
284
|
// Keep foreground commands on a long tool-owned timeout. The MCP dispatch
|
|
280
285
|
// layer must not add a shorter fallback ceiling when timeout is omitted.
|
|
@@ -314,7 +319,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
314
319
|
: Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
|
|
315
320
|
const mergeStderr = args.merge_stderr === true;
|
|
316
321
|
const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
|
|
317
|
-
if (longForegroundHint) return longForegroundHint;
|
|
322
|
+
if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
|
|
318
323
|
// Auto-background threshold. Reference-CLI parity: sync commands run to
|
|
319
324
|
// their timeout without any default auto-promotion, so the default is 0
|
|
320
325
|
// (disabled) for ALL callers. It is an explicit opt-in only: set
|
|
@@ -347,7 +352,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
347
352
|
try {
|
|
348
353
|
wrappedCommand = await wrapCommandWithSnapshot(shell, command);
|
|
349
354
|
} catch (wrapErr) {
|
|
350
|
-
return `
|
|
355
|
+
return formatShellToolFailure(`shell snapshot wrapper failed — ${normalizeErrorMessage(wrapErr instanceof Error ? wrapErr.message : String(wrapErr))}`);
|
|
351
356
|
}
|
|
352
357
|
} else {
|
|
353
358
|
wrappedCommand = command;
|
|
@@ -367,7 +372,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
367
372
|
// claude.exe pid (server-main threads callerSession.clientHostPid).
|
|
368
373
|
clientHostPid: options?.clientHostPid,
|
|
369
374
|
});
|
|
370
|
-
if (job && job.error) return
|
|
375
|
+
if (job && job.error) return formatShellToolFailure(job.error);
|
|
371
376
|
let task;
|
|
372
377
|
try {
|
|
373
378
|
task = registerBackgroundTask({
|
|
@@ -395,7 +400,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
395
400
|
});
|
|
396
401
|
} catch (err) {
|
|
397
402
|
try { killShellJob(job.jobId); } catch { /* best effort cleanup */ }
|
|
398
|
-
return
|
|
403
|
+
return formatShellToolFailure(normalizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
399
404
|
}
|
|
400
405
|
// Wire a one-shot completion push so the dispatching session learns
|
|
401
406
|
// the background task finished (no polling tool is auto-driven). The
|
|
@@ -525,7 +530,9 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
525
530
|
: (result.killed ? 'SIGKILL' : (result.signal || null));
|
|
526
531
|
const exitCode = signal ? null : result.exitCode;
|
|
527
532
|
const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
|
|
528
|
-
const
|
|
533
|
+
const shellToolFailed = result.failurePhase === 'tool' || !!result.outputCaptureError;
|
|
534
|
+
const shellRunFailed = !shellToolFailed && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne));
|
|
535
|
+
const isReallyErrored = shellToolFailed || shellRunFailed;
|
|
529
536
|
const _driftNote = '';
|
|
530
537
|
// Distinct timeout marker so callers see "killed by timeout after Nms"
|
|
531
538
|
// vs an external signal (e.g. user Ctrl-C, OOM kill). result.timedOut
|
|
@@ -538,11 +545,16 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
538
545
|
const timeoutHint = result.timedOut
|
|
539
546
|
? ` — command killed after ${timeout} ms; if it legitimately needs longer, retry with a larger timeout`
|
|
540
547
|
: '';
|
|
541
|
-
const
|
|
542
|
-
? `[
|
|
543
|
-
: (
|
|
544
|
-
? `[signal: ${signal}]`
|
|
545
|
-
: (
|
|
548
|
+
const statusDetail = shellToolFailed
|
|
549
|
+
? `[${result.outputCaptureError ? 'output capture failed' : (result.failureReason || 'tool failed')}]`
|
|
550
|
+
: (result.timedOut
|
|
551
|
+
? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
|
|
552
|
+
: (signal
|
|
553
|
+
? `[signal: ${signal}]`
|
|
554
|
+
: (shellRunFailed ? `[exit code: ${exitCode}]` : '')));
|
|
555
|
+
const statusMarker = shellToolFailed
|
|
556
|
+
? `[shell-tool-failed] ${statusDetail}`
|
|
557
|
+
: (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : '');
|
|
546
558
|
const errorPrefix = isReallyErrored ? 'Error: ' : '';
|
|
547
559
|
if (mergeStderr) {
|
|
548
560
|
// Post-exit concatenation. True chunk-level interleaving would
|
|
@@ -569,6 +581,9 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
569
581
|
const sizeKb = Math.round((result.stderrFileSize || 0) / 1024);
|
|
570
582
|
spillBlock += `\n[stderr: ${normalizeOutputPath(result.stderrPath)} (${sizeKb} KB)]`;
|
|
571
583
|
}
|
|
584
|
+
if (result.outputCaptureError) {
|
|
585
|
+
spillBlock += `\n[tool capture error: ${normalizeErrorMessage(result.outputCaptureError?.message || String(result.outputCaptureError))}]`;
|
|
586
|
+
}
|
|
572
587
|
const warningBlock = [
|
|
573
588
|
wmicRewrite?.note || '',
|
|
574
589
|
].filter(Boolean).join('\n');
|
|
@@ -31,7 +31,7 @@ function _shellMaxTimeoutMs() {
|
|
|
31
31
|
// the process lifetime, so this is evaluated once at module load.
|
|
32
32
|
const _shellSyntaxCheat =
|
|
33
33
|
process.platform === 'win32'
|
|
34
|
-
? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, &&
|
|
34
|
+
? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, if && is unsupported use ;, $PID is reserved.'
|
|
35
35
|
: '';
|
|
36
36
|
|
|
37
37
|
export const BUILTIN_TOOLS = [
|
|
@@ -39,7 +39,7 @@ export const BUILTIN_TOOLS = [
|
|
|
39
39
|
name: 'read',
|
|
40
40
|
title: 'Mixdog Read',
|
|
41
41
|
annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
42
|
-
description: 'Read verified file path(s)
|
|
42
|
+
description: 'Read verified file path(s); guessed path → find first (same turn as other probes). Batch paths/regions as real arrays in one call. Not for directory listing.',
|
|
43
43
|
inputSchema: {
|
|
44
44
|
type: 'object',
|
|
45
45
|
properties: {
|
|
@@ -109,7 +109,7 @@ export const BUILTIN_TOOLS = [
|
|
|
109
109
|
name: 'grep',
|
|
110
110
|
title: 'Mixdog Grep',
|
|
111
111
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
112
|
-
description: 'Exact text/regex in
|
|
112
|
+
description: 'Exact text/regex in verified scope (project root counts as verified — unscoped search needs no find). Only a guessed path fragment → find first, same turn. No path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
|
|
113
113
|
inputSchema: {
|
|
114
114
|
type: 'object',
|
|
115
115
|
properties: {
|
|
@@ -125,14 +125,14 @@ export const BUILTIN_TOOLS = [
|
|
|
125
125
|
{ type: 'string' },
|
|
126
126
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
127
127
|
],
|
|
128
|
-
description: 'Verified file or
|
|
128
|
+
description: 'Verified file/dir, or project root; guessed → find first.',
|
|
129
129
|
},
|
|
130
130
|
glob: {
|
|
131
131
|
anyOf: [
|
|
132
132
|
{ type: 'string' },
|
|
133
133
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
134
134
|
],
|
|
135
|
-
description: 'Glob filter
|
|
135
|
+
description: 'Glob filter; no guessed src/** under path ".".',
|
|
136
136
|
},
|
|
137
137
|
output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad: files_with_matches/count; narrow: content_with_context.' },
|
|
138
138
|
head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
|
|
@@ -148,7 +148,7 @@ export const BUILTIN_TOOLS = [
|
|
|
148
148
|
name: 'glob',
|
|
149
149
|
title: 'Mixdog Glob',
|
|
150
150
|
annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
151
|
-
description: '
|
|
151
|
+
description: 'Exact glob from verified roots (project root is verified). Guessed root/name → find first, same turn. No path "." + guessed src/**. Batch arrays.',
|
|
152
152
|
inputSchema: {
|
|
153
153
|
type: 'object',
|
|
154
154
|
properties: {
|
|
@@ -164,7 +164,7 @@ export const BUILTIN_TOOLS = [
|
|
|
164
164
|
{ type: 'string' },
|
|
165
165
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
166
166
|
],
|
|
167
|
-
description: '
|
|
167
|
+
description: 'Verified base dir(s) or project root; guessed → find first. Batch path[].',
|
|
168
168
|
},
|
|
169
169
|
head_limit: { type: 'number', description: 'Max entries.' },
|
|
170
170
|
offset: { type: 'number', description: 'Skip entries.' },
|
|
@@ -176,7 +176,7 @@ export const BUILTIN_TOOLS = [
|
|
|
176
176
|
name: 'find',
|
|
177
177
|
title: 'Mixdog Find Files',
|
|
178
178
|
annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
179
|
-
description: '
|
|
179
|
+
description: 'Partial path/name lookup incl dot dirs; verify roots before grep/glob. Batch query[].',
|
|
180
180
|
inputSchema: {
|
|
181
181
|
type: 'object',
|
|
182
182
|
properties: {
|
|
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
|
|
|
197
197
|
name: 'list',
|
|
198
198
|
title: 'Mixdog List Directory',
|
|
199
199
|
annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
200
|
-
description: 'List verified directories.
|
|
200
|
+
description: 'List verified directories (project root included). Guessed dir → find first. Batch dirs as path[].',
|
|
201
201
|
inputSchema: {
|
|
202
202
|
type: 'object',
|
|
203
203
|
properties: {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { formatMtime, formatListSize } from './list-formatting.mjs';
|
|
33
33
|
import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
|
|
34
34
|
import { runRg } from './rg-runner.mjs';
|
|
35
|
+
import { hasSpareCapacity as childSpawnHasSpareCapacity } from '../../../../shared/child-spawn-gate.mjs';
|
|
35
36
|
import { fuzzyRank } from './fuzzy-match.mjs';
|
|
36
37
|
import { assertPathReachable } from './fs-reachability.mjs';
|
|
37
38
|
|
|
@@ -84,16 +85,30 @@ export async function executeListTool(args, workDir, options = {}) {
|
|
|
84
85
|
const capped = list.length > 10;
|
|
85
86
|
const targets = capped ? list.slice(0, 10) : list;
|
|
86
87
|
if (targets.length > 1) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
// Bounded parallel fan-out: each target is an independent listing
|
|
89
|
+
// (own guard/cache/reachability), so run them concurrently instead
|
|
90
|
+
// of serially. Bodies land in a fixed-index array so the emitted
|
|
91
|
+
// section order still matches the caller's `path[]` order — only the
|
|
92
|
+
// wall-clock cost changes. Concurrency is capped so a 10-path batch
|
|
93
|
+
// cannot exhaust the child-spawn / FS-handle budget.
|
|
94
|
+
const LIST_FANOUT_CONCURRENCY = 4;
|
|
95
|
+
const bodies = new Array(targets.length);
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
const runWorker = async () => {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const i = cursor++;
|
|
100
|
+
if (i >= targets.length) return;
|
|
101
|
+
try {
|
|
102
|
+
bodies[i] = await executeListTool({ ...args, path: targets[i] }, workDir, options);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
bodies[i] = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
|
|
105
|
+
}
|
|
94
106
|
}
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
};
|
|
108
|
+
await Promise.all(
|
|
109
|
+
Array.from({ length: Math.min(LIST_FANOUT_CONCURRENCY, targets.length) }, runWorker),
|
|
110
|
+
);
|
|
111
|
+
const sections = targets.map((p, i) => `# list ${p}\n${bodies[i]}`);
|
|
97
112
|
if (capped) sections.push(`... [capped at 10 of ${list.length} paths]`);
|
|
98
113
|
return sections.join('\n\n');
|
|
99
114
|
}
|
|
@@ -349,8 +364,9 @@ export async function executeTreeTool(args, workDir, options = {}) {
|
|
|
349
364
|
// key with in-flight promise dedup (N concurrent callers share ONE sweep)
|
|
350
365
|
// plus a short TTL for serial reuse. Truncated/partial sweeps are
|
|
351
366
|
// known-incomplete and are NEVER cached.
|
|
352
|
-
const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt }
|
|
367
|
+
const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt, gen }
|
|
353
368
|
const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial}>
|
|
369
|
+
let FIND_ENUM_GEN = 0;
|
|
354
370
|
|
|
355
371
|
// The broad enumeration is a DERIVED cache the scope/path invalidation layer
|
|
356
372
|
// does not otherwise know about — a file created/renamed after a sweep would
|
|
@@ -358,6 +374,7 @@ const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial
|
|
|
358
374
|
// write-invalidation event (TTL remains the secondary bound). Full clear is
|
|
359
375
|
// fine: entries are cheap to rebuild.
|
|
360
376
|
registerCacheInvalidationListener(() => {
|
|
377
|
+
FIND_ENUM_GEN += 1;
|
|
361
378
|
FIND_ENUM_CACHE.clear();
|
|
362
379
|
FIND_ENUM_INFLIGHT.clear();
|
|
363
380
|
});
|
|
@@ -390,18 +407,31 @@ function parseRgFileList(stdout) {
|
|
|
390
407
|
// must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
|
|
391
408
|
// narrowing); the cache key is the 4 dims only, so any caller producing an
|
|
392
409
|
// equivalent sweep for the same dims reuses the result.
|
|
393
|
-
async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg }) {
|
|
410
|
+
async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg, bestEffort = false }) {
|
|
394
411
|
const ttl = findEnumTtlMs();
|
|
395
412
|
const key = findEnumKey({ root, hidden, depth, includeNoise });
|
|
396
413
|
if (ttl > 0) {
|
|
397
414
|
const hit = FIND_ENUM_CACHE.get(key);
|
|
398
|
-
if (hit && hit.expiresAt > Date.now()) {
|
|
415
|
+
if (hit && hit.gen === FIND_ENUM_GEN && hit.expiresAt > Date.now()) {
|
|
399
416
|
return { files: hit.files, truncated: false, partial: false };
|
|
400
417
|
}
|
|
401
418
|
if (hit) FIND_ENUM_CACHE.delete(key); // expired
|
|
402
|
-
const inflight = FIND_ENUM_INFLIGHT.get(key);
|
|
403
|
-
if (inflight) return inflight;
|
|
404
419
|
}
|
|
420
|
+
// Single-flight is independent of the persistent TTL cache. Even when
|
|
421
|
+
// MIXDOG_FIND_ENUM_CACHE_TTL_MS=0 disables reuse across calls, concurrent
|
|
422
|
+
// query[] fan-out should still share the one broad `rg --files` sweep
|
|
423
|
+
// instead of spawning N identical enumerations.
|
|
424
|
+
const inflight = FIND_ENUM_INFLIGHT.get(key);
|
|
425
|
+
if (inflight) return inflight;
|
|
426
|
+
// Non-competing prewarm: past the cache/single-flight fast paths this runs
|
|
427
|
+
// a fresh `rg` sweep (a child-spawn slot). Best-effort warmers skip that
|
|
428
|
+
// spawn when the gate has no spare capacity, returning a known-incomplete
|
|
429
|
+
// result — and skipping BEFORE registering FIND_ENUM_INFLIGHT so a real
|
|
430
|
+
// caller is never attached to (or cache-poisoned by) a skipped warm.
|
|
431
|
+
if (bestEffort && !childSpawnHasSpareCapacity()) {
|
|
432
|
+
return { files: [], truncated: false, partial: true };
|
|
433
|
+
}
|
|
434
|
+
const genAtStart = FIND_ENUM_GEN;
|
|
405
435
|
const run = (async () => {
|
|
406
436
|
const stdout = await runRgImpl(rgArgs, { cwd });
|
|
407
437
|
const truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
|
|
@@ -409,17 +439,18 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs,
|
|
|
409
439
|
const files = parseRgFileList(stdout);
|
|
410
440
|
// Never cache a truncated/partial sweep — it is known-incomplete, so a
|
|
411
441
|
// later query with a larger head_limit must re-run the enumeration.
|
|
412
|
-
|
|
413
|
-
|
|
442
|
+
// Also never let an in-flight prewarm/real sweep repopulate after a
|
|
443
|
+
// write invalidation cleared the cache during the sweep.
|
|
444
|
+
if (ttl > 0 && !truncated && !partial && FIND_ENUM_GEN === genAtStart) {
|
|
445
|
+
FIND_ENUM_CACHE.set(key, { files, expiresAt: Date.now() + ttl, gen: genAtStart });
|
|
414
446
|
}
|
|
415
447
|
return { files, truncated, partial };
|
|
416
448
|
})();
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
449
|
+
FIND_ENUM_INFLIGHT.set(key, run);
|
|
450
|
+
try { return await run; }
|
|
451
|
+
finally {
|
|
452
|
+
if (FIND_ENUM_INFLIGHT.get(key) === run) FIND_ENUM_INFLIGHT.delete(key);
|
|
421
453
|
}
|
|
422
|
-
return run;
|
|
423
454
|
}
|
|
424
455
|
|
|
425
456
|
// Best-effort warm of the broad enumeration for a root using the `find` tool's
|
|
@@ -435,7 +466,7 @@ export async function prewarmFindEnumeration(root) {
|
|
|
435
466
|
await getBroadEnumeration({
|
|
436
467
|
root: normalizeOutputPath(root),
|
|
437
468
|
hidden, depth, includeNoise,
|
|
438
|
-
rgArgs, cwd: root,
|
|
469
|
+
rgArgs, cwd: root, bestEffort: true,
|
|
439
470
|
});
|
|
440
471
|
} catch { /* best-effort warm; never surface */ }
|
|
441
472
|
}
|
|
@@ -450,16 +481,31 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
450
481
|
const capped = list.length > 5;
|
|
451
482
|
const targets = capped ? list.slice(0, 5) : list;
|
|
452
483
|
if (targets.length > 1) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
484
|
+
// Bounded parallel fan-out: every query shares ONE broad `rg --files`
|
|
485
|
+
// sweep via the enumeration cache's in-flight single-flight dedup, so
|
|
486
|
+
// running them concurrently collapses N broad sweeps into one and
|
|
487
|
+
// overlaps the tiny per-query narrowed passes. Bodies land at a fixed
|
|
488
|
+
// index so the emitted section order still matches the caller's
|
|
489
|
+
// query[] order — only wall-clock cost changes. Concurrency is capped
|
|
490
|
+
// so a 5-query batch cannot exhaust the child-spawn budget.
|
|
491
|
+
const FIND_FANOUT_CONCURRENCY = 4;
|
|
492
|
+
const bodies = new Array(targets.length);
|
|
493
|
+
let cursor = 0;
|
|
494
|
+
const runWorker = async () => {
|
|
495
|
+
for (;;) {
|
|
496
|
+
const i = cursor++;
|
|
497
|
+
if (i >= targets.length) return;
|
|
498
|
+
try {
|
|
499
|
+
bodies[i] = await executeFuzzyFindTool({ ...args, query: targets[i] }, workDir, options);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
bodies[i] = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
|
|
502
|
+
}
|
|
460
503
|
}
|
|
461
|
-
|
|
462
|
-
|
|
504
|
+
};
|
|
505
|
+
await Promise.all(
|
|
506
|
+
Array.from({ length: Math.min(FIND_FANOUT_CONCURRENCY, targets.length) }, runWorker),
|
|
507
|
+
);
|
|
508
|
+
const sections = targets.map((q, i) => `# find ${q}\n${bodies[i]}`);
|
|
463
509
|
if (capped) sections.push(`... [capped at 5 of ${list.length} queries]`);
|
|
464
510
|
return sections.join('\n\n');
|
|
465
511
|
}
|
|
@@ -24,11 +24,20 @@ function _stripLineCoordForReach(s) {
|
|
|
24
24
|
.replace(/:\d+(?:-\d+)?(?::.*)?$/, '');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Batch fan-out cap: array/object read shapes read only the first N entries
|
|
28
|
+
// (the >N slice below truncates the rest with a `_batchCapNote`). The
|
|
29
|
+
// reachability preflight must honour the SAME cap so a path that will be
|
|
30
|
+
// capped away is never stat-probed and can never reject the batch via a guard.
|
|
31
|
+
const READ_BATCH_PATH_CAP = 10;
|
|
32
|
+
|
|
27
33
|
function _collectReachCandidates(p) {
|
|
28
34
|
const out = [];
|
|
29
35
|
const push = (s) => { if (typeof s === 'string' && s) out.push(s); };
|
|
30
36
|
if (typeof p === 'string') push(p);
|
|
31
|
-
|
|
37
|
+
// Only the first READ_BATCH_PATH_CAP entries survive the fan-out cap, so
|
|
38
|
+
// preflight only the paths that will actually be read. Capped-away paths
|
|
39
|
+
// (incl. UNC/device) must NOT be probed or allowed to fail the batch.
|
|
40
|
+
else if (Array.isArray(p)) for (const e of p.slice(0, READ_BATCH_PATH_CAP)) push((e && typeof e === 'object') ? (e.path ?? e.file_path) : e);
|
|
32
41
|
return out;
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -66,11 +75,17 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
|
|
|
66
75
|
// line-coordinate strip only needs to land in the right directory — exact
|
|
67
76
|
// suffix parsing is not required for the stat to be representative.
|
|
68
77
|
const candidates = [];
|
|
78
|
+
const seenFull = new Set();
|
|
69
79
|
for (const raw of _collectReachCandidates(rawPath)) {
|
|
70
80
|
const stripped = _stripLineCoordForReach(normalizeInputPath(raw));
|
|
71
81
|
const full = resolveAgainstCwd(stripped, workDir);
|
|
72
82
|
const guardMsg = _guardedReadError(stripped, helpers) || _guardedReadError(full, helpers);
|
|
73
83
|
if (guardMsg) return guardMsg;
|
|
84
|
+
// Dedup by resolved path so a batch repeating the same file (or the
|
|
85
|
+
// same union window) issues one stat probe, not one per entry —
|
|
86
|
+
// bounding the preflight's FS work to the distinct target set.
|
|
87
|
+
if (seenFull.has(full)) continue;
|
|
88
|
+
seenFull.add(full);
|
|
74
89
|
candidates.push(full);
|
|
75
90
|
}
|
|
76
91
|
if (candidates.length === 0) return null;
|
|
@@ -113,7 +128,14 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
113
128
|
args.path = coerceReadFamilyPathArg(args.path, workDir);
|
|
114
129
|
// Reachability preflight up front (all shapes) — before readPathStringGuardError /
|
|
115
130
|
// image stat, all of which can touch sync FS.
|
|
116
|
-
|
|
131
|
+
// options._skipReachPreflight: set only by the batch dispatcher on its
|
|
132
|
+
// child reads (below). The parent batch call already ran this exact
|
|
133
|
+
// preflight over EVERY candidate path in the array (_collectReachCandidates
|
|
134
|
+
// covers array/object shapes), so re-running it per child re-stats the same
|
|
135
|
+
// mounts N times. The UNC/device/ADS string guards still run inside the
|
|
136
|
+
// child (readPathStringGuardError / image fast-path) — only the async
|
|
137
|
+
// reachability stat is skipped, never the security guards.
|
|
138
|
+
if (options?._skipReachPreflight !== true) {
|
|
117
139
|
const _reErr = await _readReachPreflight(args.path, workDir, helpers);
|
|
118
140
|
if (_reErr) return _reErr;
|
|
119
141
|
}
|
|
@@ -289,7 +311,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
289
311
|
// document/image content-block object that would stringify to
|
|
290
312
|
// "[object Object]" and drop the payload. Scalar reads (the
|
|
291
313
|
// single-file path below) keep the rich block shapes.
|
|
292
|
-
const body = await executeChildBuiltinTool('read', readEntry, workDir, { suppressReadUnchangedStub: true, mediaTextOnly: true });
|
|
314
|
+
const body = await executeChildBuiltinTool('read', readEntry, workDir, { suppressReadUnchangedStub: true, mediaTextOnly: true, _skipReachPreflight: true });
|
|
293
315
|
results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body };
|
|
294
316
|
};
|
|
295
317
|
const key = entry.path || `#missing-${index}`;
|
|
@@ -31,6 +31,7 @@ export function buildGrepCacheKey(parts) {
|
|
|
31
31
|
onlyMatching,
|
|
32
32
|
pcre2,
|
|
33
33
|
withFilename,
|
|
34
|
+
patternCapTotal = 0,
|
|
34
35
|
} = parts;
|
|
35
36
|
return [
|
|
36
37
|
'grep',
|
|
@@ -50,6 +51,10 @@ export function buildGrepCacheKey(parts) {
|
|
|
50
51
|
Array.isArray(fileType) ? fileType.join('\x01') : (fileType || ''),
|
|
51
52
|
pcre2 ? 'p1' : 'p0',
|
|
52
53
|
withFilename ? 'H1' : 'H0',
|
|
54
|
+
// Cap total keeps a capped request (first-N of M patterns, carrying the
|
|
55
|
+
// "[capped at N of M]" notice) from colliding with an exact N-pattern
|
|
56
|
+
// request or with a differently-capped one (of 15 vs of 20).
|
|
57
|
+
'pc' + String(patternCapTotal || 0),
|
|
53
58
|
].join('|');
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -126,13 +131,15 @@ export function buildGrepRgArgs(parts) {
|
|
|
126
131
|
return rgArgs;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
export function buildGlobCacheKey({ patterns, basePath, headLimit, offset, extraIgnore, sort }) {
|
|
134
|
+
export function buildGlobCacheKey({ patterns, basePath, headLimit, offset, extraIgnore, sort, patternCapTotal = 0 }) {
|
|
130
135
|
// extraIgnore (rg ignore globs from _extraIgnoreDirs) alters which files
|
|
131
136
|
// match, so it MUST partake in the key — otherwise calls that differ only
|
|
132
137
|
// by extra ignores collide and return stale over-/under-filtered results.
|
|
133
138
|
// Sorted so the same ignore set in any order maps to one key.
|
|
134
139
|
const extra = Array.isArray(extraIgnore) && extraIgnore.length ? [...extraIgnore].sort().join('\x01') : '';
|
|
135
|
-
|
|
140
|
+
// patternCapTotal: a capped pattern set (first-N of M) must not collide with
|
|
141
|
+
// an exact N-pattern request or a differently-capped one.
|
|
142
|
+
return ['glob', patterns.join('\x01'), basePath, headLimit ?? '', offset ?? '', sort || 'natural', extra, 'pc' + String(patternCapTotal || 0)].join('|');
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
export function buildListCacheKey(parts) {
|