mixdog 0.9.36 → 0.9.37

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 (103) 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/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  40. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  41. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  43. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  44. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  46. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  47. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  49. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  51. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  53. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  55. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  57. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  58. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  60. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  62. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  63. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  64. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  65. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  67. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  68. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  69. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  70. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  72. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  73. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  74. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  75. package/src/runtime/shared/config.mjs +98 -12
  76. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  77. package/src/session-runtime/cwd-plugins.mjs +6 -3
  78. package/src/session-runtime/model-route-api.mjs +50 -2
  79. package/src/session-runtime/provider-auth-api.mjs +8 -1
  80. package/src/session-runtime/resource-api.mjs +22 -0
  81. package/src/session-runtime/runtime-core.mjs +13 -2
  82. package/src/session-runtime/settings-api.mjs +11 -2
  83. package/src/standalone/agent-tool.mjs +15 -9
  84. package/src/standalone/explore-tool.mjs +4 -1
  85. package/src/tui/App.jsx +6 -5
  86. package/src/tui/app/transcript-window.mjs +60 -10
  87. package/src/tui/app/use-transcript-window.mjs +2 -1
  88. package/src/tui/components/ContextPanel.jsx +4 -1
  89. package/src/tui/components/ToolExecution.jsx +15 -12
  90. package/src/tui/components/TranscriptItem.jsx +1 -1
  91. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  92. package/src/tui/dist/index.mjs +467 -151
  93. package/src/tui/engine/agent-job-feed.mjs +26 -6
  94. package/src/tui/engine/notification-plan.mjs +3 -3
  95. package/src/tui/engine/render-timing.mjs +104 -8
  96. package/src/tui/engine/session-api.mjs +58 -3
  97. package/src/tui/engine/session-flow.mjs +78 -28
  98. package/src/tui/engine/tool-card-results.mjs +28 -13
  99. package/src/tui/engine/turn.mjs +232 -156
  100. package/src/tui/engine.mjs +17 -8
  101. package/src/tui/index.jsx +10 -1
  102. package/src/workflows/default/WORKFLOW.md +8 -4
  103. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -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) {
@@ -523,7 +523,12 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
523
523
  }
524
524
  parts.push(`# grep ${p}\n${body}`);
525
525
  }
526
- if (list.length > GREP_PATH_CAP) parts.push(`[capped at ${GREP_PATH_CAP} of ${list.length} paths]`);
526
+ if (list.length > GREP_PATH_CAP) {
527
+ parts.push(`[capped at ${GREP_PATH_CAP} of ${list.length} paths]`);
528
+ // Omitted paths mean the returned result cannot cover the whole
529
+ // requested path set — never let it be cached as complete.
530
+ if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
531
+ }
527
532
  return parts.join('\n\n');
528
533
  }
529
534
  args.path = list[0];
@@ -542,9 +547,15 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
542
547
  let patterns = uniqueStrings(rawPatterns.map(normalizeSearchPattern));
543
548
  const GREP_PATTERN_ARRAY_CAP = 10;
544
549
  let patternCapNote = '';
550
+ let patternCapTotal = 0;
545
551
  if (patterns.length > GREP_PATTERN_ARRAY_CAP) {
546
552
  patternCapNote = `[capped at ${GREP_PATTERN_ARRAY_CAP} of ${patterns.length} patterns]\n`;
553
+ patternCapTotal = patterns.length;
547
554
  patterns = patterns.slice(0, GREP_PATTERN_ARRAY_CAP);
555
+ // Dropping input patterns means the returned result cannot cover the
556
+ // full requested pattern set — never cache it as complete. Applies to
557
+ // every downstream path (fan-out, chunk-merge, single combined).
558
+ if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
548
559
  }
549
560
  if (patterns.length === 0) {
550
561
  if (args.glob || hasGlobMagic(args.path)) {
@@ -725,16 +736,18 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
725
736
  && !options._grepPatternFanout) {
726
737
  const seen = new Set();
727
738
  const subOptions = { ...options, _grepPatternFanout: true };
728
- const parts = [];
729
- for (const p of patterns) {
730
- let sub;
739
+ // Each pattern is an INDEPENDENT grep; run them concurrently and then
740
+ // apply dedup/section assembly in the original pattern order so the
741
+ // shared `seen` set and output text stay byte-identical to the
742
+ // sequential version.
743
+ const subs = await Promise.all(patterns.map(async (p) => {
731
744
  try {
732
- sub = await executeGrepTool({ ...args, pattern: p }, workDir, executeChildBuiltinTool, readStateScope, subOptions);
745
+ return await executeGrepTool({ ...args, pattern: p }, workDir, executeChildBuiltinTool, readStateScope, subOptions);
733
746
  } catch (err) {
734
- sub = `Error: ${err && err.message ? err.message : err}`;
747
+ return `Error: ${err && err.message ? err.message : err}`;
735
748
  }
736
- parts.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(sub, seen)}`);
737
- }
749
+ }));
750
+ const parts = patterns.map((p, i) => `# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(subs[i], seen)}`);
738
751
  return patternCapNote + parts.join('\n\n');
739
752
  }
740
753
 
@@ -749,20 +762,27 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
749
762
  ...(outputMode === 'count' ? { output_mode: 'content' } : {}),
750
763
  };
751
764
  const chunkMergeOptions = { ...options, _grepChunkMerge: true };
765
+ // Fetch every chunk concurrently with the full aggregate budget, then
766
+ // replay the sequential room-based accumulation over the results in
767
+ // chunk order. rg output order is deterministic, so slicing this
768
+ // superset by the sequential `room` yields identical lines, and the
769
+ // re-extract with `room` reproduces the same truncation flags — the
770
+ // merged output and `truncatedAggregate` are byte-identical to the
771
+ // old serial loop while the grep calls overlap.
772
+ const chunkBodies = await Promise.all(patternChunks.map((chunk) => executeGrepTool(
773
+ { ...chunkBaseArgs, pattern: chunk, head_limit: aggregateBudget },
774
+ workDir,
775
+ executeChildBuiltinTool,
776
+ readStateScope,
777
+ chunkMergeOptions,
778
+ )));
752
779
  const mergedRaw = [];
753
- for (const chunk of patternChunks) {
780
+ for (const chunkBody of chunkBodies) {
754
781
  if (mergedRaw.length >= aggregateBudget) {
755
782
  truncatedAggregate = true;
756
783
  break;
757
784
  }
758
785
  const room = aggregateBudget - mergedRaw.length;
759
- const chunkBody = await executeGrepTool(
760
- { ...chunkBaseArgs, pattern: chunk, head_limit: room },
761
- workDir,
762
- executeChildBuiltinTool,
763
- readStateScope,
764
- chunkMergeOptions,
765
- );
766
786
  const extracted = extractGrepChunkResultLines(chunkBody, room);
767
787
  if (extracted.error) return extracted.error.startsWith('Error:') ? extracted.error : `Error: ${extracted.error}`;
768
788
  const slice = extracted.lines.slice(0, room);
@@ -784,6 +804,16 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
784
804
  const sliced = offset > 0 ? merged.slice(offset) : merged;
785
805
  const limit = headLimit === Infinity ? sliced.length : headLimit;
786
806
  const windowed = limit === Infinity ? sliced : sliced.slice(0, limit);
807
+ // Cache-outcome fidelity: the concurrent chunk fetches run with the full
808
+ // aggregateBudget as head_limit, so a child may report itself complete
809
+ // even though the replay above trimmed its lines to a smaller `room`
810
+ // (truncatedAggregate), the final head_limit window dropped lines, or an
811
+ // offset paged past earlier matches. Mirror the other grep return paths
812
+ // and mark the scoped cache incomplete so a partial/paged chunk-merge
813
+ // result is never cached as whole.
814
+ if (options?.scopedCacheOutcome && (truncatedAggregate || offset > 0 || windowed.length < sliced.length)) {
815
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
816
+ }
787
817
  if (!windowed.length) {
788
818
  const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
789
819
  const globStr = normalizedGlobPatterns.length > 0 ? ` glob=${JSON.stringify(normalizedGlobPatterns)}` : '';
@@ -791,7 +821,10 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
791
821
  }
792
822
  return formatGrepOutput({
793
823
  windowed,
794
- totalWindowed: merged.length,
824
+ // POST-offset total so formatGrepOutput's remaining/total math (which
825
+ // re-adds `offset`) does not conjure phantom "more" results from the
826
+ // offset-skipped prefix.
827
+ totalWindowed: sliced.length,
795
828
  totalKnown: !truncatedAggregate,
796
829
  headLimit,
797
830
  offset,
@@ -828,6 +861,10 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
828
861
  fileType,
829
862
  pcre2: pcre2Mode,
830
863
  withFilename: forceGrepFilename,
864
+ // Capped requests carry the "[capped at N of M]" notice; key on the
865
+ // original count so they never collide with an exact N-pattern request
866
+ // (or a differently-capped one) in the internal result cache.
867
+ patternCapTotal,
831
868
  });
832
869
  // Single-file grep registers a whole-file read snapshot (parity with
833
870
  // apply_patch), satisfying the read-before-edit guard while keeping drift
@@ -1193,17 +1230,22 @@ export async function executeGlobTool(args, workDir, options = {}) {
1193
1230
  .filter((p) => p && !seen.has(p) && seen.add(p));
1194
1231
  if (list.length > 1) {
1195
1232
  const capped = list.slice(0, GLOB_PATH_CAP);
1196
- const parts = [];
1197
- for (const p of capped) {
1198
- let body;
1233
+ // Independent per-path globs run concurrently; assemble sections in
1234
+ // the original (capped) order so output stays identical.
1235
+ const bodies = await Promise.all(capped.map(async (p) => {
1199
1236
  try {
1200
- body = await executeGlobTool({ ...args, path: p }, workDir, options);
1237
+ return await executeGlobTool({ ...args, path: p }, workDir, options);
1201
1238
  } catch (err) {
1202
- body = `Error: ${err && err.message ? err.message : err}`;
1239
+ return `Error: ${err && err.message ? err.message : err}`;
1203
1240
  }
1204
- parts.push(`# glob ${p}\n${body}`);
1241
+ }));
1242
+ const parts = capped.map((p, i) => `# glob ${p}\n${bodies[i]}`);
1243
+ if (list.length > GLOB_PATH_CAP) {
1244
+ parts.push(`[capped at ${GLOB_PATH_CAP} of ${list.length} paths]`);
1245
+ // Omitted paths mean the returned listing is not the whole
1246
+ // requested set — never cache it as complete.
1247
+ if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
1205
1248
  }
1206
- if (list.length > GLOB_PATH_CAP) parts.push(`[capped at ${GLOB_PATH_CAP} of ${list.length} paths]`);
1207
1249
  return parts.join('\n\n');
1208
1250
  }
1209
1251
  args.path = list[0] ?? '.';
@@ -1238,9 +1280,15 @@ export async function executeGlobTool(args, workDir, options = {}) {
1238
1280
  }
1239
1281
  const GLOB_PATTERN_ARRAY_CAP = 10;
1240
1282
  let globPatternCapNote = '';
1283
+ let globPatternCapTotal = 0;
1241
1284
  if (patterns.length > GLOB_PATTERN_ARRAY_CAP) {
1242
1285
  globPatternCapNote = `[capped at ${GLOB_PATTERN_ARRAY_CAP} of ${patterns.length} patterns]\n`;
1286
+ globPatternCapTotal = patterns.length;
1243
1287
  patterns = patterns.slice(0, GLOB_PATTERN_ARRAY_CAP);
1288
+ // Omitted patterns: mark the scoped cache incomplete and (below) key the
1289
+ // internal cache on the original count so a capped glob never collides
1290
+ // with an exact N-pattern request or is served as the whole set.
1291
+ if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
1244
1292
  }
1245
1293
 
1246
1294
  const basePaths = (Array.isArray(args.path) && args.path.length > 0)
@@ -1339,7 +1387,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
1339
1387
  .map((root) => normalizeOutputPath(resolvedForSearchRoot(root)))
1340
1388
  .sort()
1341
1389
  .join('\x01');
1342
- const cacheKey = buildGlobCacheKey({ patterns, basePath: cacheBasePath, headLimit, offset, extraIgnore: extraIgnoreGlobs, sort: sortMode });
1390
+ const cacheKey = buildGlobCacheKey({ patterns, basePath: cacheBasePath, headLimit, offset, extraIgnore: extraIgnoreGlobs, sort: sortMode, patternCapTotal: globPatternCapTotal });
1343
1391
  const cached = cacheGet(cacheKey);
1344
1392
  if (cached !== null) return cached;
1345
1393
 
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  executeBashTool,
18
18
  executeTaskTool,
19
+ formatShellToolFailure,
19
20
  } from './builtin/bash-tool.mjs';
20
21
  import {
21
22
  executeFindFilesTool,
@@ -464,7 +465,7 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
464
465
  }
465
466
  const toolName = canonicalizeBuiltinToolName(name);
466
467
  const argError = validateBuiltinArgs(toolName, args);
467
- if (argError) return argError;
468
+ if (argError) return toolName === 'shell' ? formatShellToolFailure(argError) : argError;
468
469
  // Fallback live-progress emit for direct callers (in-process toolExecutor
469
470
  // path). The MCP dispatch path already fired the central start message and
470
471
  // sets progressStarted:true, so guard against a double-emit there. No-op
@@ -13,7 +13,7 @@
13
13
  // worker at tools/.
14
14
  import { resolve as pathResolve } from 'node:path';
15
15
  import { Worker } from 'node:worker_threads';
16
- import { acquire as acquireChildSpawnSlot } from '../../../../shared/child-spawn-gate.mjs';
16
+ import { acquire as acquireChildSpawnSlot, hasSpareCapacity as childSpawnHasSpareCapacity } from '../../../../shared/child-spawn-gate.mjs';
17
17
  import {
18
18
  canonicalGraphCwd as _canonicalGraphCwd,
19
19
  codeGraphCache as _codeGraphCache,
@@ -55,8 +55,10 @@ const _inflightAsyncBuilds = new Map();
55
55
 
56
56
  export function prewarmCodeGraph(cwd) {
57
57
  if (!cwd) return;
58
- // Reuse the buildCodeGraphAsync single-flight path. Fire-and-forget.
59
- buildCodeGraphAsync(cwd).catch(() => { /* best-effort */ });
58
+ // Reuse the buildCodeGraphAsync single-flight path. Fire-and-forget, and
59
+ // best-effort: skip a fresh worker spawn when the child-spawn gate is busy
60
+ // so this warm never queues ahead of real code_graph/find queries.
61
+ buildCodeGraphAsync(cwd, null, { bestEffort: true }).catch(() => { /* best-effort */ });
60
62
  }
61
63
 
62
64
  export function prewarmCodeGraphSymbols(cwd, symbols, { language = null } = {}) {
@@ -64,7 +66,7 @@ export function prewarmCodeGraphSymbols(cwd, symbols, { language = null } = {})
64
66
  const wanted = (Array.isArray(symbols) ? symbols : [symbols])
65
67
  .map((s) => String(s || '').trim())
66
68
  .filter(Boolean);
67
- buildCodeGraphAsync(cwd).then((graph) => {
69
+ buildCodeGraphAsync(cwd, null, { bestEffort: true }).then((graph) => {
68
70
  if (!graph) return;
69
71
  for (const symbol of wanted) {
70
72
  try { _lookupCandidateNodes(graph, symbol, language); } catch { /* best-effort */ }
@@ -80,7 +82,7 @@ export function prewarmCodeGraphIfProject(cwd) {
80
82
  return true;
81
83
  }
82
84
 
83
- export async function buildCodeGraphAsync(cwd, signal = null) {
85
+ export async function buildCodeGraphAsync(cwd, signal = null, { bestEffort = false } = {}) {
84
86
  if (signal?.aborted) throw new Error('aborted');
85
87
  const graphCwd = _canonicalGraphCwd(cwd);
86
88
  const cached = _codeGraphCache.get(graphCwd);
@@ -107,6 +109,13 @@ export async function buildCodeGraphAsync(cwd, signal = null) {
107
109
  (e) => { cleanup(); throw e; },
108
110
  );
109
111
  }
112
+ // Non-competing prewarm: once past the cache/single-flight fast paths a
113
+ // fresh build must spawn the worker (a child-spawn slot). Best-effort warmers
114
+ // skip that spawn when the gate has no spare capacity, returning null so the
115
+ // caller no-ops — and skipping BEFORE registering the in-flight entry so a
116
+ // concurrent REAL caller is never attached to (and thus never starved by) a
117
+ // skipped warm.
118
+ if (bestEffort && !childSpawnHasSpareCapacity()) return null;
110
119
  const _genAtStart = _getCodeGraphGen(graphCwd);
111
120
  let _worker = null;
112
121
  const promise = new Promise((resolve, reject) => {
@@ -151,11 +160,12 @@ export async function buildCodeGraphAsync(cwd, signal = null) {
151
160
  w.once('message', (msg) => {
152
161
  try {
153
162
  if (msg && msg.ok && msg.graph && typeof msg.signature === 'string') {
154
- if (_getCodeGraphGen(graphCwd) === _genAtStart) {
163
+ const genStillCurrent = _getCodeGraphGen(graphCwd) === _genAtStart;
164
+ if (genStillCurrent) {
155
165
  _setCodeGraphCache(graphCwd, { ts: Date.now(), signature: msg.signature, graph: msg.graph });
156
166
  _setDiskCodeGraphEntry(graphCwd, msg.graph);
157
167
  }
158
- settle(msg.graph);
168
+ settle(genStillCurrent ? msg.graph : new Error('code-graph build invalidated during prewarm'));
159
169
  } else {
160
170
  settle(new Error('code-graph prewarm worker failed'));
161
171
  }
@@ -360,8 +360,8 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
360
360
  const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
361
361
  ? Math.min(500, Math.floor(rawLimit))
362
362
  : null;
363
- await _prewarmReferenceSourceText(graph, symbol, lang);
364
- const refResult = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix });
363
+ const _refNodes = await _prewarmReferenceSourceText(graph, symbol, lang);
364
+ const refResult = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix, nodes: _refNodes });
365
365
  return narrowedByCaller ? refResult : _augmentNoHitDiagnostic(refResult, '(no references)', graph, cwd, symbol);
366
366
  }
367
367
 
@@ -390,12 +390,12 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
390
390
  const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
391
391
  ? Math.min(500, Math.floor(rawLimit))
392
392
  : null;
393
- await _prewarmReferenceSourceText(graph, symbol, lang);
393
+ const _callerNodes = await _prewarmReferenceSourceText(graph, symbol, lang);
394
394
  const depth = Math.max(1, Math.min(5, Math.floor(Number(args?.depth) || 1)));
395
395
  if (depth > 1) {
396
396
  return _formatTransitiveCallers(graph, symbol, cwd, { language: lang, depth, page: args?.page });
397
397
  }
398
- const refs = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix });
398
+ const refs = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix, nodes: _callerNodes });
399
399
  const callerResult = _formatCallerReferences(graph, symbol, refs, userLimit ? { limit: userLimit } : undefined);
400
400
  return narrowedByCaller ? callerResult : _augmentNoHitDiagnostic(callerResult, '(no callers)', graph, cwd, symbol);
401
401
  }
@@ -575,13 +575,16 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
575
575
  const symbolList = _collectGraphSymbolList(args);
576
576
  if (symbolList.length > 1) {
577
577
  return (async () => {
578
- const sections = [];
579
- for (const sym of symbolList) {
578
+ // Concurrent fan-out: the underlying graph build is single-flight
579
+ // and cached per cwd (buildCodeGraphAsync), so parallel sections
580
+ // share one build instead of serializing on it. Order and
581
+ // per-section error isolation are preserved by the indexed map.
582
+ const sections = await Promise.all(symbolList.map(async (sym) => {
580
583
  let body;
581
584
  try { body = await dispatchOne({ ...args, symbol: sym, symbols: undefined }); }
582
585
  catch (e) { body = `Error: ${e?.message || String(e)}`; }
583
- sections.push(`# ${batchMode} ${sym}\n${body}`);
584
- }
586
+ return `# ${batchMode} ${sym}\n${body}`;
587
+ }));
585
588
  return sections.join('\n\n');
586
589
  })();
587
590
  }
@@ -594,13 +597,12 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
594
597
  if (fileList.length > 1) {
595
598
  const capped = fileList._capped;
596
599
  return (async () => {
597
- const sections = [];
598
- for (const f of fileList) {
600
+ const sections = await Promise.all(fileList.map(async (f) => {
599
601
  let body;
600
602
  try { body = await dispatchOne({ ...args, file: f, files: undefined }); }
601
603
  catch (e) { body = `Error: ${e?.message || String(e)}`; }
602
- sections.push(`# ${batchMode} ${f}\n${body}`);
603
- }
604
+ return `# ${batchMode} ${f}\n${body}`;
605
+ }));
604
606
  if (capped) sections.push(`Note: file list capped at ${CODE_GRAPH_FILE_BATCH_CAP} entries.`);
605
607
  return sections.join('\n\n');
606
608
  })();
@@ -119,6 +119,7 @@ export function _deserializeGraph(cwd, payload) {
119
119
  export function _attachGraphRuntimeCaches(graph) {
120
120
  if (!graph || typeof graph !== 'object') return graph;
121
121
  if (!graph._referenceSearchCache) graph._referenceSearchCache = new Map();
122
+ if (!graph._keywordSearchCache) graph._keywordSearchCache = new Map();
122
123
  if (!graph._maskedLinesCache) graph._maskedLinesCache = new Map();
123
124
  if (!graph._sourceLinesCache) graph._sourceLinesCache = new Map();
124
125
  if (!graph._sourceTextCache) graph._sourceTextCache = new Map();
@@ -144,6 +145,9 @@ export function _estimateGraphRuntimeCacheBytes(graph) {
144
145
  for (const memo of graph._referenceSearchCache?.values() || []) {
145
146
  total += Buffer.byteLength(String(memo || ''), 'utf8');
146
147
  }
148
+ for (const memo of graph._keywordSearchCache?.values() || []) {
149
+ total += Buffer.byteLength(String(memo || ''), 'utf8');
150
+ }
147
151
  return total;
148
152
  }
149
153
 
@@ -153,6 +157,7 @@ export function _clearGraphRuntimeCaches(graph) {
153
157
  graph._maskedLinesCache?.clear();
154
158
  graph._sourceLinesCache?.clear();
155
159
  graph._referenceSearchCache?.clear();
160
+ graph._keywordSearchCache?.clear();
156
161
  graph._symbolTokenIndex?.clear();
157
162
  graph._symbolTokenIndexDirty = true;
158
163
  }