mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -19,7 +19,6 @@ import { existsSync } from 'node:fs';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { resolve as pathResolve, dirname as pathDirname, join as pathJoin } from 'node:path';
22
- import { startChildGuardian } from '../../../shared/child-guardian.mjs';
23
22
  import { getPluginData } from '../config.mjs';
24
23
  import { ensureTokenBinary, findCachedTokenBinary } from '../tools/token-binary-fetcher.mjs';
25
24
 
@@ -112,9 +111,8 @@ function _ensureServer() {
112
111
  child.stdin?.unref?.();
113
112
  child.stdout?.unref?.();
114
113
  child.stdin?.on?.('error', () => { /* EPIPE on teardown */ });
115
- try {
116
- startChildGuardian({ childPid: child.pid, label: 'mixdog-token', orphanGraceMs: 8000, forceGraceMs: 3000 });
117
- } catch { /* guardian best-effort */ }
114
+ // The native server exits when its stdio owner closes. A second Electron
115
+ // guardian duplicated that same ownership signal while retaining ~100 MB.
118
116
  server.ready = _request(server, OP_PING, null, READY_TIMEOUT_MS).then((count) => count !== null);
119
117
  _server = server;
120
118
  return server;
@@ -117,6 +117,12 @@ export async function processToolBatch(ctx) {
117
117
  role: 'tool',
118
118
  content: _stub,
119
119
  toolCallId: call.id,
120
+ // Explicitly NOT a success: no tool executed for this call.
121
+ // 'skipped' keeps the transcript/UI non-error (a benign
122
+ // dedup, not a failure) while the unresolved-tool-failure
123
+ // stop hook refuses to count it as the executed success
124
+ // that would resolve a prior failure.
125
+ toolKind: 'skipped',
120
126
  });
121
127
  continue;
122
128
  }
@@ -137,6 +143,10 @@ export async function processToolBatch(ctx) {
137
143
  role: 'tool',
138
144
  content: _stub,
139
145
  toolCallId: call.id,
146
+ // Same rule as the intra-turn stub above: the earlier
147
+ // result is replayed from context, nothing executed, so
148
+ // this can never resolve a prior tool failure.
149
+ toolKind: 'skipped',
140
150
  });
141
151
  try {
142
152
  appendAgentTrace({
@@ -172,6 +182,15 @@ export async function processToolBatch(ctx) {
172
182
  role: 'tool',
173
183
  content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
174
184
  toolCallId: call.id,
185
+ // The call is still UNRESOLVED — the guard only skips the
186
+ // re-execution. Tagging it as an error keeps it a failure
187
+ // for every downstream consumer, notably the turn stop
188
+ // hook (a skip must never read as a success that resolves
189
+ // an earlier failure).
190
+ toolKind: 'error',
191
+ // …but nothing was dispatched, so this skip is not a real
192
+ // tool failure and must never ARM the stop hook either.
193
+ guardSkip: true,
175
194
  });
176
195
  continue;
177
196
  }
@@ -151,11 +151,9 @@ function resolveBash() {
151
151
  // Map<id, { proc, lastUsed, stdoutBuf, stderrBuf, busy }>
152
152
  const _sessions = new Map();
153
153
  let _reaperTimer = null;
154
- // R17 parent-exit hook installed exactly once at first _spawnSession. Without
155
- // it, persistent bash shells orphan on server-main death (the async
156
- // 'process-exit' path at the bottom of this module never gets to run its
157
- // awaits when the host dies abruptly). Sync iteration of _sessions + direct
158
- // _killProcessTree (sync taskkill on win, sync process.kill on posix).
154
+ // R17 terminating-signal hooks installed exactly once at first _spawnSession.
155
+ // The module-level synchronous exit drain below owns normal process teardown;
156
+ // these handlers cover signals that another listener may consume before exit.
159
157
  let _parentExitInstalled = false;
160
158
  function _installParentExitHook() {
161
159
  if (_parentExitInstalled) return;
@@ -165,7 +163,6 @@ function _installParentExitHook() {
165
163
  try { _killProcessTree(s.proc); } catch { /* ignore */ }
166
164
  }
167
165
  };
168
- try { process.on('exit', sweep); } catch { /* ignore */ }
169
166
  // For terminating signals, sweep children then restore default POSIX
170
167
  // termination. A sweep-only handler swallows the signal and leaves the
171
168
  // process alive, so after sweeping we remove our handler and re-raise the
@@ -367,6 +364,7 @@ function _spawnSession(id, initialCwd = process.cwd(), resourceLease = null) {
367
364
  childPid: proc.pid,
368
365
  childGroupPid: proc.pid,
369
366
  label: 'bash-session',
367
+ protectHostMemory: true,
370
368
  });
371
369
  proc.stdout.setEncoding('utf-8');
372
370
  proc.stderr.setEncoding('utf-8');
@@ -36,7 +36,7 @@ export const BUILTIN_TOOLS = [
36
36
  name: 'read',
37
37
  title: 'Mixdog Read',
38
38
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
39
- description: 'Read file contents; guessed path/name → find first. Batch paths/regions as real arrays: path[] or {path,offset,limit}[] regions in one call. Not for directories.',
39
+ description: 'Read file contents. Batch paths/regions as real arrays: path[] or {path,offset,limit}[] regions in one call. Not for directories.',
40
40
  inputSchema: {
41
41
  type: 'object',
42
42
  properties: {
@@ -74,8 +74,8 @@ export const BUILTIN_TOOLS = [
74
74
  name: 'shell',
75
75
  title: 'Mixdog Shell',
76
76
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
77
- description: 'Run programs/change state; not file inspection. Calls in one turn run parallel; '
78
- + 'combine order-dependent commands. Use async for sleep/watch/dev loops.'
77
+ description: 'Run programs/change state; not file inspection. '
78
+ + 'Combine order-dependent commands into one command. Use async for sleep/watch/dev loops.'
79
79
  + `${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
80
80
  inputSchema: {
81
81
  type: 'object',
@@ -123,7 +123,7 @@ export const BUILTIN_TOOLS = [
123
123
  { type: 'string' },
124
124
  { type: 'array', items: { type: 'string' }, minItems: 1 },
125
125
  ],
126
- description: 'Text/regex; pattern[] batches variants in one call. path[] batches verified scopes only; file/span reads use read path[] regions.',
126
+ description: 'Text/regex; pattern[] batches variants in one call.',
127
127
  },
128
128
  path: {
129
129
  anyOf: [
@@ -154,7 +154,7 @@ export const BUILTIN_TOOLS = [
154
154
  name: 'glob',
155
155
  title: 'Mixdog Glob',
156
156
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
157
- description: 'Match exact glob patterns from verified base directories (project root is verified); guessed root/name → find first. Batch pattern[]/path[].',
157
+ description: 'Match exact glob patterns from verified base directories (project root is verified). Batch pattern[]/path[].',
158
158
  inputSchema: {
159
159
  type: 'object',
160
160
  properties: {
@@ -351,7 +351,7 @@ export async function executeTreeTool(args, workDir, options = {}) {
351
351
  // plus a short TTL for serial reuse. Truncated/partial sweeps are
352
352
  // known-incomplete and are NEVER cached.
353
353
  const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt, gen }
354
- const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial}>
354
+ const FIND_ENUM_INFLIGHT = new Map(); // key -> { promise, controller, subscribers }
355
355
  let FIND_ENUM_GEN = 0;
356
356
 
357
357
  // The broad enumeration is a DERIVED cache the scope/path invalidation layer
@@ -388,13 +388,56 @@ function parseRgFileList(stdout) {
388
388
  .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
389
389
  }
390
390
 
391
+ function findEnumerationAbortError(signal) {
392
+ const reason = signal?.reason;
393
+ return reason instanceof Error ? reason : new Error(String(reason || 'find canceled'));
394
+ }
395
+
396
+ function subscribeToFindEnumeration(key, entry, signal = null) {
397
+ const token = {};
398
+ entry.subscribers.add(token);
399
+ return new Promise((resolve, reject) => {
400
+ let done = false;
401
+ const detach = () => {
402
+ if (signal instanceof AbortSignal) {
403
+ try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
404
+ }
405
+ };
406
+ const settle = (fn, value) => {
407
+ if (done) return;
408
+ done = true;
409
+ entry.subscribers.delete(token);
410
+ detach();
411
+ fn(value);
412
+ };
413
+ function onAbort() {
414
+ const error = findEnumerationAbortError(signal);
415
+ settle(reject, error);
416
+ // A shared broad sweep survives one caller canceling, but when every
417
+ // subscriber is gone it is useless work and must release its rg.
418
+ if (entry.subscribers.size === 0) {
419
+ try { entry.controller.abort(error); } catch { /* ignore */ }
420
+ if (FIND_ENUM_INFLIGHT.get(key) === entry) FIND_ENUM_INFLIGHT.delete(key);
421
+ }
422
+ }
423
+ if (signal instanceof AbortSignal) {
424
+ if (signal.aborted) { onAbort(); return; }
425
+ signal.addEventListener('abort', onAbort, { once: true });
426
+ }
427
+ entry.promise.then(
428
+ (value) => settle(resolve, value),
429
+ (error) => settle(reject, error),
430
+ );
431
+ });
432
+ }
433
+
391
434
  // Run (or reuse) the broad `rg --files` sweep for a root. Returns
392
435
  // { files, truncated, partial }. The returned `files` array is SHARED — callers
393
436
  // must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
394
437
  // narrowing); the cache key includes every enumeration-affecting dimension, so
395
438
  // any caller producing an
396
439
  // equivalent sweep for the same dims reuses the result.
397
- async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode, rgArgs, cwd, runRgImpl = runRg, bestEffort = false }) {
440
+ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode, rgArgs, cwd, runRgImpl = runRg, bestEffort = false, signal = null }) {
398
441
  const ttl = findEnumTtlMs();
399
442
  const key = findEnumKey({ root, hidden, depth, includeNoise, ignoreMode });
400
443
  if (ttl > 0) {
@@ -409,7 +452,7 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
409
452
  // query[] fan-out should still share the one broad `rg --files` sweep
410
453
  // instead of spawning N identical enumerations.
411
454
  const inflight = FIND_ENUM_INFLIGHT.get(key);
412
- if (inflight) return inflight;
455
+ if (inflight) return subscribeToFindEnumeration(key, inflight, signal);
413
456
  // Non-competing prewarm: past the cache/single-flight fast paths this runs
414
457
  // a fresh `rg` sweep (a child-spawn slot). Best-effort warmers skip that
415
458
  // spawn when the gate has no spare capacity, returning a known-incomplete
@@ -419,8 +462,10 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
419
462
  return { files: [], truncated: false, partial: true };
420
463
  }
421
464
  const genAtStart = FIND_ENUM_GEN;
422
- const run = (async () => {
423
- const stdout = await runRgImpl(rgArgs, { cwd });
465
+ const controller = new AbortController();
466
+ const entry = { promise: null, controller, subscribers: new Set() };
467
+ entry.promise = (async () => {
468
+ const stdout = await runRgImpl(rgArgs, { cwd, signal: controller.signal });
424
469
  const truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
425
470
  const partial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
426
471
  const files = parseRgFileList(stdout);
@@ -433,11 +478,12 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
433
478
  }
434
479
  return { files, truncated, partial };
435
480
  })();
436
- FIND_ENUM_INFLIGHT.set(key, run);
437
- try { return await run; }
438
- finally {
439
- if (FIND_ENUM_INFLIGHT.get(key) === run) FIND_ENUM_INFLIGHT.delete(key);
440
- }
481
+ FIND_ENUM_INFLIGHT.set(key, entry);
482
+ const cleanup = () => {
483
+ if (FIND_ENUM_INFLIGHT.get(key) === entry) FIND_ENUM_INFLIGHT.delete(key);
484
+ };
485
+ entry.promise.then(cleanup, cleanup);
486
+ return subscribeToFindEnumeration(key, entry, signal);
441
487
  }
442
488
 
443
489
  // Best-effort warm of the broad enumeration for a root using the `find` tool's
@@ -458,6 +504,43 @@ export async function prewarmFindEnumeration(root) {
458
504
  } catch { /* best-effort warm; never surface */ }
459
505
  }
460
506
 
507
+ function escapeFindGlobLiteral(value) {
508
+ return String(value).replace(/[*?[\]{}]/g, (char) => `[${char}]`);
509
+ }
510
+
511
+ async function getTargetedFindEnumeration({
512
+ context,
513
+ queries,
514
+ root,
515
+ hidden,
516
+ depth,
517
+ includeNoise,
518
+ runRgImpl,
519
+ signal,
520
+ }) {
521
+ const terms = [...new Set((queries || []).map((query) => String(query || '').trim()).filter(Boolean))];
522
+ const key = JSON.stringify([root, hidden, depth ?? '', includeNoise, terms]);
523
+ const runs = context?.targetedRuns;
524
+ if (runs?.has(key)) return runs.get(key);
525
+ const run = (async () => {
526
+ const rgArgs = ['--files', '--no-ignore'];
527
+ if (hidden) rgArgs.push('--hidden');
528
+ if (depth != null) rgArgs.push('--max-depth', String(depth));
529
+ for (const query of terms) rgArgs.push('--iglob', `*${escapeFindGlobLiteral(query)}*`);
530
+ rgArgs.push('.');
531
+ const stdout = await runRgImpl(rgArgs, { cwd: root, signal });
532
+ const paths = parseRgFileList(stdout).filter((path) =>
533
+ includeNoise || !path.split('/').some((segment) => NOISE_DIR_NAMES.has(segment)));
534
+ return {
535
+ files: paths,
536
+ truncated: Boolean(stdout && typeof stdout === 'object' && stdout.truncated),
537
+ partial: Boolean(stdout && typeof stdout === 'object' && stdout.partial),
538
+ };
539
+ })();
540
+ if (runs) runs.set(key, run);
541
+ return run;
542
+ }
543
+
461
544
  // Fuzzy filename search (nucleo-style): collect the file
462
545
  // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
463
546
  // routes here for hidden backward compatibility, but the model-facing tool is
@@ -468,30 +551,22 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
468
551
  const capped = list.length > 5;
469
552
  const targets = capped ? list.slice(0, 5) : list;
470
553
  if (targets.length > 1) {
471
- // Bounded parallel fan-out: every query shares ONE broad `rg --files`
472
- // sweep via the enumeration cache's in-flight single-flight dedup, so
473
- // running them concurrently collapses N broad sweeps into one and
474
- // overlaps the tiny per-query narrowed passes. Bodies land at a fixed
475
- // index so the emitted section order still matches the caller's
476
- // query[] order — only wall-clock cost changes. Concurrency is capped
477
- // so a 5-query batch cannot exhaust the child-spawn budget.
478
- const FIND_FANOUT_CONCURRENCY = 4;
479
- const bodies = new Array(targets.length);
480
- let cursor = 0;
481
- const runWorker = async () => {
482
- for (;;) {
483
- const i = cursor++;
484
- if (i >= targets.length) return;
485
- try {
486
- bodies[i] = await executeFuzzyFindTool({ ...args, query: targets[i] }, workDir, options);
487
- } catch (err) {
488
- bodies[i] = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
489
- }
490
- }
554
+ // Full parallelism is intentional. All sections share one common
555
+ // broad enumeration and one unioned ignored/truncated targeted pass,
556
+ // but ranking and output remain isolated per query.
557
+ const batchContext = {
558
+ queries: targets,
559
+ targetedRuns: new Map(),
560
+ itemsByFiles: new WeakMap(),
491
561
  };
492
- await Promise.all(
493
- Array.from({ length: Math.min(FIND_FANOUT_CONCURRENCY, targets.length) }, runWorker),
494
- );
562
+ const nestedOptions = { ...options, __findBatchContext: batchContext };
563
+ const bodies = await Promise.all(targets.map(async (query) => {
564
+ try {
565
+ return await executeFuzzyFindTool({ ...args, query }, workDir, nestedOptions);
566
+ } catch (err) {
567
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
568
+ }
569
+ }));
495
570
  const sections = targets.map((q, i) => `# find ${q}\n${bodies[i]}`);
496
571
  if (capped) sections.push(`... [capped at 5 of ${list.length} queries]`);
497
572
  return sections.join('\n\n');
@@ -551,7 +626,6 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
551
626
  // literal path separator and does NOT escape. So a query like "[slug].tsx"
552
627
  // still produces the intended `*[[]slug[]].tsx*` include instead of a
553
628
  // character-class that matches one of s/l/u/g.
554
- const escapeGlobLiteral = (s) => s.replace(/[*?[\]{}]/g, (c) => `[${c}]`);
555
629
  // Test-only seam: allow a caller to inject a runRg stand-in (e.g. to
556
630
  // simulate a truncated broad pass) without touching the production path.
557
631
  // Never set on the real tool-execution options object.
@@ -576,13 +650,19 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
576
650
  rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
577
651
  cwd: fullPath,
578
652
  runRgImpl,
653
+ signal: options.signal,
579
654
  });
580
655
  } catch (err) {
581
656
  return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
582
657
  }
583
658
  let rgTruncated = broadEnum.truncated;
584
659
  let rgPartial = broadEnum.partial;
585
- const passOneItems = broadEnum.files.map((path) => ({ path }));
660
+ const batchContext = options?.__findBatchContext;
661
+ let passOneItems = batchContext?.itemsByFiles?.get(broadEnum.files);
662
+ if (!passOneItems) {
663
+ passOneItems = broadEnum.files.map((path) => ({ path }));
664
+ batchContext?.itemsByFiles?.set(broadEnum.files, passOneItems);
665
+ }
586
666
  // Any single path segment is an exact-name candidate, including names with
587
667
  // spaces, extensionless names, and literal glob characters. The comparison
588
668
  // below is direct, so glob syntax has no special meaning in this decision.
@@ -590,57 +670,36 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
590
670
  const queryLower = query.toLowerCase();
591
671
  const passOneHasExactBasename = !exactFilenameQuery || broadEnum.files.some((path) =>
592
672
  path.slice(path.lastIndexOf('/') + 1).toLowerCase() === queryLower);
593
- const passOneHasCandidate = fuzzyRank(query, passOneItems, 1).length > 0;
594
- let fallbackRan = false;
595
- let fallbackPaths = [];
596
- // Only pay for the hardened tree walk when common discovery has no answer,
597
- // or when an exact filename could be hidden by .gitignore. Reuse the
598
- // hardened broad cache shared with find_files: its key distinguishes
599
- // ignoreMode, so this cannot collide with the common pass.
600
- if (!includeNoise && (!passOneHasCandidate || !passOneHasExactBasename)) {
673
+ const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
674
+ const passOneRanked = fuzzyRank(query, passOneItems, rankLimit);
675
+ const passOneHasCandidate = passOneRanked.length > 0;
676
+ let targetedProbeRan = false;
677
+ let targetedPaths = [];
678
+ // A full --no-ignore enumeration can exceed 20MB, then trigger one more
679
+ // whole-tree narrowed pass PER query. Probe only requested names instead,
680
+ // unioning every query in this find batch into one rg process.
681
+ if (rgTruncated || rgPartial || (!includeNoise && (!passOneHasCandidate || !passOneHasExactBasename))) {
601
682
  try {
602
- const fallbackArgs = ['--files', '--no-ignore', ...baseRgArgs.slice(2), ...ignoreGlobs, '.'];
603
- const fallbackEnum = await getBroadEnumeration({
604
- root: normalizeOutputPath(fullPath),
605
- hidden, depth, includeNoise, ignoreMode: 'all',
606
- rgArgs: fallbackArgs, cwd: fullPath, runRgImpl,
683
+ const targeted = await getTargetedFindEnumeration({
684
+ context: batchContext,
685
+ queries: batchContext?.queries || [query],
686
+ root: fullPath,
687
+ hidden,
688
+ depth,
689
+ includeNoise,
690
+ runRgImpl,
691
+ signal: options.signal,
607
692
  });
608
- rgTruncated ||= fallbackEnum.truncated;
609
- rgPartial ||= fallbackEnum.partial;
610
- fallbackPaths = fallbackEnum.files;
611
- fallbackRan = true;
693
+ rgTruncated ||= targeted.truncated;
694
+ rgPartial ||= targeted.partial;
695
+ targetedPaths = targeted.files.filter((path) => path.toLowerCase().includes(queryLower));
696
+ targetedProbeRan = true;
612
697
  } catch {
613
- // This required fallback leaves the result incomplete. Surface the
614
- // existing partial warning and prevent the final output cache from
615
- // preserving a pass-one-only answer.
698
+ if (options.signal?.aborted) throw findEnumerationAbortError(options.signal);
616
699
  rgPartial = true;
617
700
  }
618
701
  }
619
- // Narrowed enumeration: only files whose NAME contains the query
620
- // (case-insensitive substring glob). This output is tiny and effectively
621
- // never truncated, so exact/substring hits are guaranteed to reach ranking
622
- // regardless of whether the broad pass was cut at the cap. Best-effort:
623
- // failures here never fail the tool — the broad pass still stands.
624
- let narrowPaths = [];
625
- // Only spawn the per-query narrowed rg pass when the broad enumeration was
626
- // cut (cap/exit-2): an untruncated broad pass already contains every file,
627
- // so every narrowed substring hit is a broad-set duplicate that the
628
- // dedup below drops — running it would add zero items. Skipping it there
629
- // removes a redundant full-tree walk per query with identical output.
630
- if (rgTruncated || rgPartial) try {
631
- // A positive --iglob whitelist makes ripgrep re-admit paths its own
632
- // `!**/<noise>/**` negations would otherwise exclude (the whitelist
633
- // wins regardless of glob order), so noise dirs are pruned in JS here
634
- // instead — matching the broad pass's effective exclusion set.
635
- const narrowBaseArgs = fallbackRan
636
- ? ['--files', '--no-ignore', ...baseRgArgs.slice(2)]
637
- : baseRgArgs;
638
- const narrowStdout = await runRgImpl([...narrowBaseArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
639
- narrowPaths = parseRgFiles(narrowStdout).filter((p) =>
640
- includeNoise || !p.split('/').some((seg) => NOISE_DIR_NAMES.has(seg)));
641
- } catch { /* best-effort backstop; broad pass already collected */ }
642
- // Merge pass one + fallback + narrowed, deduplicating by path. Pass-one
643
- // order always wins so the fallback cannot perturb common-path ranking.
702
+ // Merge common + targeted results, preserving common-path order.
644
703
  const seen = new Set();
645
704
  const items = [];
646
705
  for (const p of broadEnum.files) {
@@ -648,18 +707,14 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
648
707
  seen.add(p);
649
708
  items.push({ path: p });
650
709
  }
651
- for (const p of fallbackPaths) {
710
+ for (const p of targetedPaths) {
652
711
  if (seen.has(p)) continue;
653
712
  seen.add(p);
654
713
  items.push({ path: p });
655
714
  }
656
- for (const p of narrowPaths) {
657
- if (seen.has(p)) continue;
658
- seen.add(p);
659
- items.push({ path: p });
660
- }
661
- const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
662
- const rankedRaw = fuzzyRank(query, items, rankLimit);
715
+ const rankedRaw = targetedPaths.length > 0
716
+ ? fuzzyRank(query, items, rankLimit)
717
+ : passOneRanked;
663
718
  const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
664
719
  const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
665
720
  // Build output lines uniformly for the hit and no-match cases so a
@@ -671,7 +726,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
671
726
  if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise head_limit for more)`);
672
727
  if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
673
728
  if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
674
- if (!fallbackRan && headLimit > 0 && fuzzyRank(query, passOneItems, headLimit).length >= headLimit) {
729
+ if (!targetedProbeRan && headLimit > 0 && passOneRanked.length >= headLimit) {
675
730
  lines.push('[gitignored trees not searched; retry with include_noise:true]');
676
731
  }
677
732
  const result = lines.join('\n');
@@ -834,12 +889,13 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
834
889
  root: normalizeOutputPath(fullPath),
835
890
  hidden, depth, includeNoise, ignoreMode: 'all',
836
891
  rgArgs, cwd: fullPath,
892
+ signal: options.signal,
837
893
  });
838
894
  rgStdoutTruncated = enumRes.truncated;
839
895
  rgStdoutPartial = enumRes.partial;
840
896
  relPaths = enumRes.files;
841
897
  } else {
842
- const stdout = await runRg(rgArgs, { cwd: fullPath });
898
+ const stdout = await runRg(rgArgs, { cwd: fullPath, signal: options.signal });
843
899
  rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
844
900
  rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
845
901
  relPaths = parseRgFileList(stdout);
@@ -5,6 +5,7 @@ import { extname } from 'path';
5
5
  import { normalizeInputPath } from './path-utils.mjs';
6
6
  import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
7
7
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
8
+ import { recordReadPathRedirect } from './snapshot-store.mjs';
8
9
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
9
10
  import { formatBinaryReadPreview } from './binary-file.mjs';
10
11
 
@@ -247,6 +248,11 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
247
248
  opts,
248
249
  helpers,
249
250
  ),
251
+ onRedirect: (requested, target) => recordReadPathRedirect(
252
+ requested,
253
+ resolveAgainstCwd(normalizeInputPath(target), workDir),
254
+ readStateScope,
255
+ ),
250
256
  });
251
257
  if (redirected) return redirected;
252
258
  const similar = findSimilarFile(fullPath);