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.
Files changed (115) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. 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 'Error: persistent shell sessions are disabled on Windows native-shell mode; run one-shot PowerShell commands without persistent/session_id.';
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 'Error: command is required';
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 "Error: Git Bash not found — install Git for Windows or omit shell:'bash'.";
246
+ return formatShellToolFailure("Git Bash not found — install Git for Windows or omit shell:'bash'.");
242
247
  }
243
- return "Error: pwsh (PowerShell) not found — install PowerShell or omit shell:'powershell'.";
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 `Error: ${wmicRewrite.error}`;
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 `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
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 `Error: shell snapshot wrapper failed — ${normalizeErrorMessage(wrapErr instanceof Error ? wrapErr.message : String(wrapErr))}`;
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 `Error: ${job.error}`;
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 `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
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 isReallyErrored = !!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne);
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 statusMarker = result.timedOut
542
- ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
543
- : (signal
544
- ? `[signal: ${signal}]`
545
- : (isReallyErrored ? `[exit code: ${exitCode}]` : ''));
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:\\, && 미지원 ; 사용, $PID 예약.'
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). Unknown path → find first. Batch paths/regions as real arrays in one call. Not for directory listing.',
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 a verified file/dir scope. Unknown scope → find/glob first. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
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 dir. Array = several scopes.',
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 to narrow scope.',
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: 'Find files by exact glob from verified roots. Batch patterns and roots as arrays in one call.',
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: 'Base directory/directories. Batch multiple roots as path[] in one call.',
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: 'Find files by partial path/name, including dot-directories. Use for unverified path/name guesses; returns verified paths. Batch query[].',
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. Unknown dir → find first. Batch dirs as path[].',
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
- const sections = [];
88
- for (const p of targets) {
89
- let body;
90
- try {
91
- body = await executeListTool({ ...args, path: p }, workDir, options);
92
- } catch (err) {
93
- body = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
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
- sections.push(`# list ${p}\n${body}`);
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
- if (ttl > 0 && !truncated && !partial) {
413
- FIND_ENUM_CACHE.set(key, { files, expiresAt: Date.now() + ttl });
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
- if (ttl > 0) {
418
- FIND_ENUM_INFLIGHT.set(key, run);
419
- try { return await run; }
420
- finally { FIND_ENUM_INFLIGHT.delete(key); }
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
- const sections = [];
454
- for (const q of targets) {
455
- let body;
456
- try {
457
- body = await executeFuzzyFindTool({ ...args, query: q }, workDir, options);
458
- } catch (err) {
459
- body = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
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
- sections.push(`# find ${q}\n${body}`);
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
- else if (Array.isArray(p)) for (const e of p) push((e && typeof e === 'object') ? (e.path ?? e.file_path) : e);
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
- return ['glob', patterns.join('\x01'), basePath, headLimit ?? '', offset ?? '', sort || 'natural', extra].join('|');
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) {