mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -54,12 +54,16 @@ globalThis.__mixdogBashSessionRuntimeLoaded = true;
54
54
  // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default 120 s
55
55
  // (2 min), max 600 s (10 min), BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
56
56
  // env overrides (max floored at default). Matches the one-shot bash tool
57
- // (builtin/bash-tool.mjs); per-call `timeout` still overrides, capped at
58
- // MAX_TIMEOUT_MS.
57
+ // (builtin/bash-tool.mjs): an omitted `timeout` uses the 120 s default bounded
58
+ // by MAX_TIMEOUT_MS; an explicit per-call `timeout` is honored uncapped, clamped
59
+ // only by TIMER_MAX_MS so JS/PS 32-bit timers stay valid.
59
60
  const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
60
61
  const DEFAULT_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
61
62
  const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
62
63
  const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_TIMEOUT_MS);
64
+ // JS setTimeout / PS WaitForExit(ms) are 32-bit: a delay above 2^31-1 wraps and
65
+ // fires immediately. Hard ceiling (~24.8 days) for an uncapped explicit timeout.
66
+ const TIMER_MAX_MS = 2_147_483_647;
63
67
  const IDLE_TIMEOUT_MS = 5 * 60_000;
64
68
  const MAX_SESSIONS = 10;
65
69
  const STDERR_DRAIN_MS = 25;
@@ -645,9 +649,13 @@ async function bash_session(args, cwd = process.cwd(), opts = {}) {
645
649
  }
646
650
  return requestedCwd;
647
651
  })();
648
- const rawTimeout = typeof args?.timeout === 'number' ? args.timeout : DEFAULT_TIMEOUT_MS;
649
- const timeoutMs = rawTimeout;
650
- const effectiveTimeout = Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
652
+ const hasExplicitTimeout = typeof args?.timeout === 'number' && args.timeout > 0;
653
+ const timeoutMs = hasExplicitTimeout ? args.timeout : DEFAULT_TIMEOUT_MS;
654
+ // Explicit per-call timeout uncapped (only the wmic-rewrite ceiling or the
655
+ // 32-bit TIMER_MAX_MS still bound it); omitted default keeps MAX_TIMEOUT_MS.
656
+ const effectiveTimeout = hasExplicitTimeout
657
+ ? Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || TIMER_MAX_MS)
658
+ : Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
651
659
  const resolved = _getOrCreate(requestedSessionId || args?.session_id, baseCwd, { create: args?.create === true });
652
660
  if (resolved.error) return resolved.error;
653
661
  const { id, entry } = resolved;
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  analyzeShellCommandEffects,
23
23
  foregroundLongCommandHint,
24
+ isAutobackgroundingAllowed,
24
25
  } from './shell-analysis.mjs';
25
26
  import {
26
27
  cancelBackgroundTask,
@@ -208,41 +209,56 @@ export async function executeBashTool(args, workDir, options = {}) {
208
209
  }
209
210
  // Keep foreground commands on a long tool-owned timeout. The MCP dispatch
210
211
  // layer must not add a shorter fallback ceiling when timeout is omitted.
211
- // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default
212
- // 120 s (2 min), max 600 s (10 min); BASH_DEFAULT_TIMEOUT_MS /
213
- // BASH_MAX_TIMEOUT_MS env overrides, max floored at default.
212
+ // Reference-CLI parity (opencode/codex/claude-code): sync-first, no hard
213
+ // upper ceiling on a caller-provided timeout. Default 120 s (2 min) when
214
+ // omitted; BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides keep
215
+ // their semantics (max floored at default) but only bound the *omitted*
216
+ // default — an explicit args.timeout is honored UNCAPPED.
214
217
  const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
215
218
  const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
216
- const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = DEFAULT_BASH_TIMEOUT_MS;
219
+ // Background (async / run_in_background) jobs get NO omitted default: 0
220
+ // means "unlimited" and flows unchanged through startBackgroundShellJob →
221
+ // task meta (detail.timeoutMs 0). An explicit args.timeout is still honored
222
+ // and enforced exactly as before. Sync path keeps the 120s omitted default.
223
+ const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = 0;
217
224
  const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
218
225
  const MAX_BASH_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_BASH_TIMEOUT_MS);
219
226
  const defaultTimeoutMs = runInBackground
220
227
  ? DEFAULT_BACKGROUND_BASH_TIMEOUT_MS
221
228
  : DEFAULT_BASH_TIMEOUT_MS;
222
- const rawTimeout = (typeof args.timeout === 'number' && args.timeout > 0)
223
- ? args.timeout : defaultTimeoutMs;
224
- const timeoutMs = rawTimeout;
225
- const timeout = Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS);
229
+ const hasExplicitTimeout = typeof args.timeout === 'number' && args.timeout > 0;
230
+ const timeoutMs = hasExplicitTimeout ? args.timeout : defaultTimeoutMs;
231
+ // Explicit caller timeout is uncapped (only the wmic-rewrite ceiling, when
232
+ // present, still bounds it); the omitted default keeps the MAX ceiling.
233
+ // JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
234
+ // 2^31-1 wraps to a tiny/negative value and fires immediately. Clamp the
235
+ // uncapped explicit timeout once here (~24.8 days ceiling) so every
236
+ // downstream timer — foreground, background job, hard-stop watcher — stays
237
+ // valid without per-site guards.
238
+ const TIMER_MAX_MS = 2_147_483_647;
239
+ // timeoutMs <= 0 (omitted background default) means unlimited: pass it
240
+ // through untouched — the min() clamps below must not turn 0 into a bound.
241
+ const timeout = timeoutMs <= 0
242
+ ? 0
243
+ : (hasExplicitTimeout
244
+ ? Math.min(timeoutMs, wmicRewrite?.timeoutMs || TIMER_MAX_MS)
245
+ : Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
226
246
  const mergeStderr = args.merge_stderr === true;
227
247
  const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
228
248
  if (longForegroundHint) return longForegroundHint;
229
- // Auto-background threshold (CC ASSISTANT_BLOCKING_BUDGET_MS analogue):
230
- // a foreground one-shot that is still running after this many ms is
231
- // detached into a tracked shell-job instead of blocking the tool call
232
- // indefinitely. Only the foreground one-shot path uses it — never
233
- // run_in_background (already detached) or persistent sessions (handled
234
- // far above). Capped below the hard timeout so the 600 s upper bound
235
- // stays a separate, later ceiling.
236
- // Soft config: reuse the sync task-wait budget (30 s, see waitForShellJob
237
- // default in executeTaskTool) as the default promotion threshold. Override
238
- // with MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms). This is a soft hint,
239
- // not a hard cap — the value is clamped below `timeout` so the hard ceiling
240
- // stays a separate, later bound.
249
+ // Auto-background threshold. Reference-CLI parity: sync commands run to
250
+ // their timeout without any default auto-promotion, so the default is 0
251
+ // (disabled) for ALL callers. It is an explicit opt-in only: set
252
+ // MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms) to re-enable detaching a
253
+ // still-running foreground one-shot into a tracked shell-job. When enabled,
254
+ // the value stays a soft hint clamped below `timeout` so the hard ceiling
255
+ // remains a separate, later bound. Never applies to run_in_background
256
+ // (already detached) or persistent sessions (handled far above).
241
257
  const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
242
258
  const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
243
259
  ? Math.floor(_autoBgEnvMs)
244
- : 30_000;
245
- const autoBackgroundMs = runInBackground
260
+ : 0;
261
+ const autoBackgroundMs = (runInBackground || DEFAULT_AUTO_BACKGROUND_MS <= 0)
246
262
  ? 0
247
263
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
248
264
 
@@ -348,6 +364,19 @@ export async function executeBashTool(args, workDir, options = {}) {
348
364
  : wrapBashWithCwdProbe(wrappedCommand, _stateFile);
349
365
  }
350
366
  } catch { syncCommand = wrappedCommand; }
367
+ // Promote-at-timeout (CC shouldAutoBackground parity). When a
368
+ // foreground one-shot hits its timeout and is still running, adopt it
369
+ // as a background job (task_id + notify) instead of tree-killing it.
370
+ // Opt-outs restore the old kill behavior: (a) disallowed sleep-like
371
+ // base commands (isAutobackgroundingAllowed), (b) the truthy
372
+ // MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env. Never applies to
373
+ // run_in_background (already detached, handled above).
374
+ const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
375
+ String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
376
+ );
377
+ const backgroundOnTimeout = !runInBackground
378
+ && !_bgTasksDisabled
379
+ && isAutobackgroundingAllowed(command, shellType);
351
380
  const result = await execShellCommand({
352
381
  shell, shellArg, shellArgs, command: syncCommand,
353
382
  env: spawnEnv,
@@ -355,6 +384,9 @@ export async function executeBashTool(args, workDir, options = {}) {
355
384
  timeoutMs: timeout,
356
385
  abortSignal: combinedBashAbort,
357
386
  autoBackgroundMs,
387
+ // On a foreground timeout, promote the still-running child to a
388
+ // tracked background job (unlimited) instead of killing it.
389
+ backgroundOnTimeout,
358
390
  // Threaded so an auto-backgrounded foreground job is stamped with
359
391
  // the dispatching terminal's claude.exe pid (per-terminal scope).
360
392
  clientHostPid: options?.clientHostPid,
@@ -388,7 +420,10 @@ export async function executeBashTool(args, workDir, options = {}) {
388
420
  stdout: result.stdoutPath ? normalizeOutputPath(result.stdoutPath) : null,
389
421
  stderr: (!mergeStderr && result.stderrPath) ? normalizeOutputPath(result.stderrPath) : null,
390
422
  cwd: bashWorkDir,
391
- timeoutMs: timeout,
423
+ // Adopted foreground jobs run unlimited (timeoutMs 0)
424
+ // — matches the async default; the original
425
+ // foreground timeout no longer bounds the promoted job.
426
+ timeoutMs: 0,
392
427
  },
393
428
  resultType: 'shell_task_result',
394
429
  cancel: () => killShellJob(result.jobId),
@@ -430,8 +465,11 @@ export async function executeBashTool(args, workDir, options = {}) {
430
465
  // Timeout marker carries an inline recovery hint so the caller can
431
466
  // act in one round (increase ceiling or detach) instead of repeating
432
467
  // the same command and hitting the same wall.
468
+ const timeoutHint = result.timedOut
469
+ ? ` — command killed after ${timeout} ms; if it legitimately needs longer, retry with a larger timeout`
470
+ : '';
433
471
  const statusMarker = result.timedOut
434
- ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]`
472
+ ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
435
473
  : (signal
436
474
  ? `[signal: ${signal}]`
437
475
  : (isReallyErrored ? `[exit code: ${exitCode}]` : ''));
@@ -12,10 +12,11 @@ import {
12
12
  executionModeSchemaDescription,
13
13
  } from '../../../../shared/background-tasks.mjs';
14
14
 
15
- // Shell timeout envelope surfaced in the tool schema. Mirrors Claude Code:
16
- // default 120 s / max 600 s, BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
17
- // env overrides, max floored at default. Keep in sync with
18
- // builtin/bash-tool.mjs and bash-session.mjs.
15
+ // Shell timeout envelope surfaced in the tool schema. Reference-CLI parity:
16
+ // default 120 s when omitted; an explicit timeout is honored uncapped.
17
+ // BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides only bound the
18
+ // omitted default (max floored at default). Keep in sync with
19
+ // builtin/bash-tool.mjs.
19
20
  function _shellDefaultTimeoutMs() {
20
21
  const parsed = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
21
22
  return parsed > 0 ? parsed : 120_000;
@@ -72,7 +73,7 @@ export const BUILTIN_TOOLS = [
72
73
  properties: {
73
74
  command: { type: 'string', description: 'Command.' },
74
75
  cwd: { type: 'string', description: 'Working directory. Persists across shell calls in a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
75
- timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min); long-running commands may raise it up to ${_shellMaxTimeoutMs()} (${_shellMaxTimeoutMs() / 60000} min).` },
76
+ timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
76
77
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
77
78
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
78
79
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
@@ -167,7 +168,7 @@ export const BUILTIN_TOOLS = [
167
168
  name: 'find',
168
169
  title: 'Mixdog Find Files',
169
170
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
170
- description: 'Find files by partial path/name. Returns verified paths. Batch names as query[].',
171
+ description: 'Find files by partial path/name, searching dot-directories (e.g. .git-adjacent, .mixdog) too. The right tool for locating a file at an unknown, possibly machine-wide path — run it from a verified broad root. Returns verified paths. Batch names as query[].',
171
172
  inputSchema: {
172
173
  type: 'object',
173
174
  properties: {
@@ -357,6 +357,22 @@ export async function lstatPathsForMtime(paths, workDir, concurrency = 64, opts
357
357
  return out;
358
358
  }
359
359
 
360
+ // Extra invalidation listeners: sibling modules with their own derived caches
361
+ // (e.g. the broad find-enumeration cache in list-tool) register a clear
362
+ // callback here so every write-invalidation event that drops the result/stat/
363
+ // raw caches also drops theirs. Full clear is intentional — those entries are
364
+ // cheap to rebuild and a path-scoped diff is not worth the coupling.
365
+ const EXTRA_INVALIDATION_LISTENERS = new Set();
366
+ export function registerCacheInvalidationListener(fn) {
367
+ if (typeof fn === 'function') EXTRA_INVALIDATION_LISTENERS.add(fn);
368
+ return () => EXTRA_INVALIDATION_LISTENERS.delete(fn);
369
+ }
370
+ function runExtraInvalidationListeners() {
371
+ for (const fn of EXTRA_INVALIDATION_LISTENERS) {
372
+ try { fn(); } catch { /* best-effort: one listener must not block others */ }
373
+ }
374
+ }
375
+
360
376
  function cacheInvalidateAll() {
361
377
  RESULT_CACHE.clear();
362
378
  RESULT_CACHE_INFLIGHT.clear();
@@ -366,6 +382,7 @@ function cacheInvalidateAll() {
366
382
  RAW_CONTENT_CACHE_BYTES = 0;
367
383
  PATH_MUTATION_GENERATIONS.clear();
368
384
  PATH_MUTATION_GLOBAL_GENERATION += 1;
385
+ runExtraInvalidationListeners();
369
386
  }
370
387
 
371
388
  function cacheInvalidatePaths(paths) {
@@ -394,6 +411,9 @@ function cacheInvalidatePaths(paths) {
394
411
  deleteReadRangeIndexForPath(affected);
395
412
  bumpPathMutationGeneration(affected);
396
413
  }
414
+ // Broad enumeration entries are not path-scoped, so any partial
415
+ // invalidation still fully drops them (cheap to rebuild).
416
+ runExtraInvalidationListeners();
397
417
  }
398
418
 
399
419
  export function invalidateBuiltinResultCache(paths = null) {
@@ -83,6 +83,20 @@ export function fuzzyScore(query, str) {
83
83
  return score;
84
84
  }
85
85
 
86
+ // Below this per-query-char score, a subsequence-only match (query chars in
87
+ // order but scattered mid-word, no contiguity/boundary structure) is treated
88
+ // as noise rather than a real hit. Contiguous substring / basename matches
89
+ // bypass the floor entirely — an exact hit must never be filtered.
90
+ const SUBSEQUENCE_MIN_PER_CHAR = 4;
91
+
92
+ // Separator/case-insensitive normalization used for the "contiguous substring"
93
+ // strong-match test. Mirrors the query normalization in fuzzyScore so
94
+ // "tool-events.log" matches a "tool-events.log" basename or a ".../tool-events.log"
95
+ // path regardless of separators.
96
+ function normalizeForContains(s) {
97
+ return String(s || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
98
+ }
99
+
86
100
  /**
87
101
  * Rank candidates by fuzzy score against `query`, dropping non-matches.
88
102
  * @param {string} query
@@ -91,13 +105,30 @@ export function fuzzyScore(query, str) {
91
105
  * @returns {Array<{item:object, score:number}>} sorted desc, then path asc
92
106
  */
93
107
  export function fuzzyRank(query, items, limit = 0) {
108
+ const normQuery = normalizeForContains(query);
109
+ // Floor scales with query length: a scattered subsequence earns ~1 point
110
+ // per char, while any contiguous run (+5/char) or word-boundary hit
111
+ // (+8) pushes a genuine match well past 4/char.
112
+ const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
94
113
  const scored = [];
95
114
  for (const item of items) {
96
- const pathScore = fuzzyScore(query, item.path);
97
- const base = String(item.path || '').split(/[\\/]/).pop() || '';
115
+ const p = String(item.path || '');
116
+ const pathScore = fuzzyScore(query, p);
117
+ const base = p.split(/[\\/]/).pop() || '';
98
118
  const baseScore = fuzzyScore(query, base);
99
119
  const sc = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
100
- if (Number.isFinite(sc)) scored.push({ item, score: sc });
120
+ if (!Number.isFinite(sc)) continue;
121
+ // Strong match: the query (separators stripped) is a contiguous
122
+ // substring of the basename or the full path. These ALWAYS pass so an
123
+ // exact substring/basename hit can never be starved out as noise.
124
+ const strong = normQuery.length > 0
125
+ && (normalizeForContains(base).includes(normQuery)
126
+ || normalizeForContains(p).includes(normQuery));
127
+ // Otherwise it is subsequence-only: keep it only if it clears the
128
+ // per-char floor. Weak scattered matches (the pgAdmin-style junk that
129
+ // merely contains the query chars in order) fall below it and drop out.
130
+ if (!strong && sc < floor) continue;
131
+ scored.push({ item, score: sc });
101
132
  }
102
133
  scored.sort((a, b) => (b.score - a.score) || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
103
134
  return limit > 0 ? scored.slice(0, limit) : scored;
@@ -22,6 +22,7 @@ import {
22
22
  getCachedReadOnlyStat,
23
23
  statPathsForMtime,
24
24
  lstatPathsForMtime,
25
+ registerCacheInvalidationListener,
25
26
  } from './cache-layers.mjs';
26
27
  import {
27
28
  compileSimpleGlob,
@@ -339,6 +340,106 @@ export async function executeTreeTool(args, workDir, options = {}) {
339
340
  return out;
340
341
  }
341
342
 
343
+ // ── Broad-enumeration cache (shared `rg --files` sweep) ──────────────────
344
+ // A `rg --files` sweep of a root depends ONLY on (root, hidden, depth,
345
+ // includeNoise) — NOT on the per-query narrowing. Yet both the fuzzy-find
346
+ // broad pass and the find_files broad fast path re-run that full sweep for
347
+ // every query item AND for every concurrent caller (measured 1-4s each when
348
+ // 8 explorer sub-sessions hit the same root). Cache the PARSED file list per
349
+ // key with in-flight promise dedup (N concurrent callers share ONE sweep)
350
+ // plus a short TTL for serial reuse. Truncated/partial sweeps are
351
+ // known-incomplete and are NEVER cached.
352
+ const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt }
353
+ const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial}>
354
+
355
+ // The broad enumeration is a DERIVED cache the scope/path invalidation layer
356
+ // does not otherwise know about — a file created/renamed after a sweep would
357
+ // stay invisible to broad find reuse for the whole TTL. Drop all entries on any
358
+ // write-invalidation event (TTL remains the secondary bound). Full clear is
359
+ // fine: entries are cheap to rebuild.
360
+ registerCacheInvalidationListener(() => {
361
+ FIND_ENUM_CACHE.clear();
362
+ FIND_ENUM_INFLIGHT.clear();
363
+ });
364
+
365
+ function findEnumTtlMs() {
366
+ const raw = process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
367
+ if (raw == null || raw === '') return 30000;
368
+ const n = Number(raw);
369
+ if (!Number.isFinite(n) || n < 0) return 30000; // malformed → default
370
+ return Math.floor(n); // 0 = disabled
371
+ }
372
+
373
+ function findEnumKey({ root, hidden, depth, includeNoise }) {
374
+ return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}`;
375
+ }
376
+
377
+ // Parse `rg --files` stdout into the same normalized relative-path list both
378
+ // broad passes build (strip trailing CR, drop empties, strip leading `./`,
379
+ // forward-slash). Module-level so the cache and both call sites agree.
380
+ function parseRgFileList(stdout) {
381
+ return String(stdout)
382
+ .split('\n')
383
+ .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
384
+ .filter((p) => p.length > 0)
385
+ .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
386
+ }
387
+
388
+ // Run (or reuse) the broad `rg --files` sweep for a root. Returns
389
+ // { files, truncated, partial }. The returned `files` array is SHARED — callers
390
+ // must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
391
+ // narrowing); the cache key is the 4 dims only, so any caller producing an
392
+ // equivalent sweep for the same dims reuses the result.
393
+ async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg }) {
394
+ const ttl = findEnumTtlMs();
395
+ const key = findEnumKey({ root, hidden, depth, includeNoise });
396
+ if (ttl > 0) {
397
+ const hit = FIND_ENUM_CACHE.get(key);
398
+ if (hit && hit.expiresAt > Date.now()) {
399
+ return { files: hit.files, truncated: false, partial: false };
400
+ }
401
+ if (hit) FIND_ENUM_CACHE.delete(key); // expired
402
+ const inflight = FIND_ENUM_INFLIGHT.get(key);
403
+ if (inflight) return inflight;
404
+ }
405
+ const run = (async () => {
406
+ const stdout = await runRgImpl(rgArgs, { cwd });
407
+ const truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
408
+ const partial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
409
+ const files = parseRgFileList(stdout);
410
+ // Never cache a truncated/partial sweep — it is known-incomplete, so a
411
+ // 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 });
414
+ }
415
+ return { files, truncated, partial };
416
+ })();
417
+ if (ttl > 0) {
418
+ FIND_ENUM_INFLIGHT.set(key, run);
419
+ try { return await run; }
420
+ finally { FIND_ENUM_INFLIGHT.delete(key); }
421
+ }
422
+ return run;
423
+ }
424
+
425
+ // Best-effort warm of the broad enumeration for a root using the `find` tool's
426
+ // DEFAULT flags (hidden:true, includeNoise:false, depth:unbounded). Swallows
427
+ // all errors — a failed prewarm must never surface or block the caller.
428
+ export async function prewarmFindEnumeration(root) {
429
+ try {
430
+ if (!root || typeof root !== 'string') return;
431
+ const hidden = true, includeNoise = false, depth = null;
432
+ const rgArgs = ['--files', '--no-ignore', '--hidden'];
433
+ for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
434
+ rgArgs.push('.');
435
+ await getBroadEnumeration({
436
+ root: normalizeOutputPath(root),
437
+ hidden, depth, includeNoise,
438
+ rgArgs, cwd: root,
439
+ });
440
+ } catch { /* best-effort warm; never surface */ }
441
+ }
442
+
342
443
  // Fuzzy filename search (nucleo-style): collect the file
343
444
  // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
344
445
  // routes here for hidden backward compatibility, but the model-facing tool is
@@ -372,7 +473,11 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
372
473
  const fullPath = resolveAgainstCwd(inputPath, workDir);
373
474
  const guardFull = listGuardPath(fullPath);
374
475
  if (guardFull) return guardFull;
375
- const hidden = Boolean(args.hidden);
476
+ // Fuzzy find defaults to searching dot-directories (hidden:true) so
477
+ // machine-wide discovery reaches paths like ~/.mixdog/data/…; callers
478
+ // opt out with hidden:false. .git and other noise dirs are still pruned
479
+ // via DEFAULT_IGNORE_GLOBS below (unless include_noise).
480
+ const hidden = args.hidden === false ? false : true;
376
481
  const includeNoise = Boolean(args.include_noise);
377
482
  // head_limit:0 means "no cap" per list semantics; default is intentionally
378
483
  // compact so ambiguous discovery does not dump a huge candidate list.
@@ -392,36 +497,108 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
392
497
  });
393
498
  const cached = cacheGet(cacheKey);
394
499
  if (cached !== null) return cached;
395
- const rgArgs = ['--files'];
396
- if (hidden) rgArgs.push('--hidden');
397
- if (depth != null) rgArgs.push('--max-depth', String(depth));
500
+ // --no-ignore: match the find_files fast path contract — do not consult
501
+ // .gitignore, so a .gitignored-but-present file is still discoverable.
502
+ // Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
503
+ // Shared rg flags for both enumeration passes below.
504
+ const baseRgArgs = ['--files', '--no-ignore'];
505
+ if (hidden) baseRgArgs.push('--hidden');
506
+ if (depth != null) baseRgArgs.push('--max-depth', String(depth));
507
+ // Noise-exclusion globs are kept SEPARATE and always appended LAST (after
508
+ // any positive --iglob). ripgrep's "last matching glob wins" rule means a
509
+ // positive include placed after these negations would re-admit e.g.
510
+ // `.git/<query>` — so the exclusions must trail the narrowed include.
511
+ const ignoreGlobs = [];
398
512
  if (!includeNoise) {
399
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
513
+ for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
400
514
  }
401
- rgArgs.push('.');
402
- let stdout;
403
- try {
404
- stdout = await runRg(rgArgs, { cwd: fullPath });
405
- } catch (err) {
406
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
407
- }
408
- const items = String(stdout)
515
+ // The narrowed pass must treat `query` as a LITERAL filename substring, not
516
+ // a glob. Wrap every globset metacharacter in a single-char character class
517
+ // (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
518
+ // globset honors on Windows, where a backslash-escape (`\*`) is read as a
519
+ // literal path separator and does NOT escape. So a query like "[slug].tsx"
520
+ // still produces the intended `*[[]slug[]].tsx*` include instead of a
521
+ // character-class that matches one of s/l/u/g.
522
+ const escapeGlobLiteral = (s) => s.replace(/[*?[\]{}]/g, (c) => `[${c}]`);
523
+ // Test-only seam: allow a caller to inject a runRg stand-in (e.g. to
524
+ // simulate a truncated broad pass) without touching the production path.
525
+ // Never set on the real tool-execution options object.
526
+ const runRgImpl = (options && typeof options.__runRg === 'function') ? options.__runRg : runRg;
527
+ const parseRgFiles = (stdout) => String(stdout)
409
528
  .split('\n')
410
529
  // Strip only the trailing CR from rg's line split — do NOT trim, or a
411
530
  // filename with leading/trailing spaces would be corrupted.
412
531
  .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
413
532
  .filter((p) => p.length > 0)
414
- .map((p) => ({ path: normalizeOutputPath(p.replace(/^\.[/\\]/, '')) }));
533
+ .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
534
+ // Broad enumeration: every file under the scope, ranked by fuzzy score.
535
+ // Subject to rg's 20MB/20s cap — an exact-name hit deep in a huge tree can
536
+ // be dropped by cap lottery, so it is backstopped by the narrowed pass.
537
+ // Shared across queries/concurrent callers via the broad-enumeration cache
538
+ // (keyed on root+hidden+depth+includeNoise, i.e. exactly this pass's args).
539
+ let broadEnum;
540
+ try {
541
+ broadEnum = await getBroadEnumeration({
542
+ root: normalizeOutputPath(fullPath),
543
+ hidden, depth, includeNoise,
544
+ rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
545
+ cwd: fullPath,
546
+ runRgImpl,
547
+ });
548
+ } catch (err) {
549
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
550
+ }
551
+ const rgTruncated = broadEnum.truncated;
552
+ const rgPartial = broadEnum.partial;
553
+ // Narrowed enumeration: only files whose NAME contains the query
554
+ // (case-insensitive substring glob). This output is tiny and effectively
555
+ // never truncated, so exact/substring hits are guaranteed to reach ranking
556
+ // regardless of whether the broad pass was cut at the cap. Best-effort:
557
+ // failures here never fail the tool — the broad pass still stands.
558
+ let narrowPaths = [];
559
+ try {
560
+ // A positive --iglob whitelist makes ripgrep re-admit paths its own
561
+ // `!**/<noise>/**` negations would otherwise exclude (the whitelist
562
+ // wins regardless of glob order), so noise dirs are pruned in JS here
563
+ // instead — matching the broad pass's effective exclusion set.
564
+ const narrowStdout = await runRgImpl([...baseRgArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
565
+ narrowPaths = parseRgFiles(narrowStdout).filter((p) =>
566
+ includeNoise || !p.split('/').some((seg) => NOISE_DIR_NAMES.has(seg)));
567
+ } catch { /* best-effort backstop; broad pass already collected */ }
568
+ // Merge broad + narrowed, deduplicating by path (broad order preserved,
569
+ // narrowed-only exact-name candidates appended).
570
+ const seen = new Set();
571
+ const items = [];
572
+ for (const p of broadEnum.files) {
573
+ if (seen.has(p)) continue;
574
+ seen.add(p);
575
+ items.push({ path: p });
576
+ }
577
+ for (const p of narrowPaths) {
578
+ if (seen.has(p)) continue;
579
+ seen.add(p);
580
+ items.push({ path: p });
581
+ }
415
582
  const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
416
583
  const rankedRaw = fuzzyRank(query, items, rankLimit);
417
584
  const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
418
585
  const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
419
- if (ranked.length === 0) return `(no fuzzy match for "${query}")`;
420
- const out = ranked.map((r) => r.item.path).join('\n');
421
- const result = hasMore
422
- ? `${out}\n... (top ${headLimit}; raise head_limit for more)`
423
- : out;
424
- cacheSet(cacheKey, result, { scopes: [fullPath] });
586
+ // Build output lines uniformly for the hit and no-match cases so a
587
+ // truncated/partial broad pass ALWAYS surfaces its warning — otherwise a
588
+ // cut-off enumeration that happened to drop the sole match would silently
589
+ // report "(no fuzzy match …)" as if the tree were exhaustively searched.
590
+ const noMatch = ranked.length === 0;
591
+ const lines = noMatch ? [`(no fuzzy match for "${query}")`] : ranked.map((r) => r.item.path);
592
+ if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise head_limit for more)`);
593
+ if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
594
+ if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
595
+ const result = lines.join('\n');
596
+ // Do not cache a truncated/partial enumeration — the broad ranking is
597
+ // known-incomplete, so a later call with a larger head_limit must re-run.
598
+ // A no-match result is also left uncached (mirrors the prior early return).
599
+ if (!noMatch && !rgTruncated && !rgPartial) {
600
+ cacheSet(cacheKey, result, { scopes: [fullPath] });
601
+ }
425
602
  if (typeof options?.onProgress === 'function') {
426
603
  try { options.onProgress(`${ranked.length} candidates`); } catch { /* best-effort */ }
427
604
  }
@@ -563,14 +740,30 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
563
740
  // pre-filter matches the JS matcher; explicit globs pass through.
564
741
  if (namePattern) rgArgs.push('--iglob', nameIsGlob ? namePattern : `*${namePattern}*`);
565
742
  rgArgs.push('.');
566
- const stdout = await runRg(rgArgs, { cwd: fullPath });
567
- rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
568
- rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
743
+ // No `name` filter → this is the pure broad sweep (rgArgs match the
744
+ // fuzzy-find broad pass), so share it via the enumeration cache.
745
+ // With a `name` filter the sweep is narrowed by --iglob and must
746
+ // NOT hit the broad cache — run it directly.
747
+ let relPaths;
748
+ if (!namePattern) {
749
+ const enumRes = await getBroadEnumeration({
750
+ root: normalizeOutputPath(fullPath),
751
+ hidden, depth, includeNoise,
752
+ rgArgs, cwd: fullPath,
753
+ });
754
+ rgStdoutTruncated = enumRes.truncated;
755
+ rgStdoutPartial = enumRes.partial;
756
+ relPaths = enumRes.files;
757
+ } else {
758
+ const stdout = await runRg(rgArgs, { cwd: fullPath });
759
+ rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
760
+ rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
761
+ relPaths = parseRgFileList(stdout);
762
+ }
569
763
  const candidates = [];
570
- for (const line of String(stdout).split('\n')) {
571
- const trimmed = line.trim();
572
- if (!trimmed) continue;
573
- const candidate = resolveAgainstCwd(normalizeInputPath(trimmed), fullPath);
764
+ for (const rel of relPaths) {
765
+ if (!rel) continue;
766
+ const candidate = resolveAgainstCwd(normalizeInputPath(rel), fullPath);
574
767
  if (!matchesFindNamePattern(basename(candidate), candidate)) continue;
575
768
  candidates.push(candidate);
576
769
  if (candidates.length >= FIND_ABSOLUTE_CAP) {