mixdog 0.9.117 → 0.9.119

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 (25) hide show
  1. package/README.md +19 -26
  2. package/package.json +1 -1
  3. package/scripts/tool-stress.mjs +5 -3
  4. package/src/output-styles/detailed.md +11 -10
  5. package/src/output-styles/minimal.md +2 -2
  6. package/src/output-styles/simple.md +7 -7
  7. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +26 -1
  8. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +6 -10
  9. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +235 -31
  10. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +36 -0
  11. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +26 -3
  12. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +33 -71
  13. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +50 -1
  14. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  15. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +39 -5
  16. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -5
  17. package/src/runtime/channels/providers/discord-attachments.mjs +6 -2
  18. package/src/runtime/channels/providers/telegram.mjs +94 -8
  19. package/src/runtime/channels/providers/telegram.test.mjs +75 -0
  20. package/src/runtime/shared/tool-card-model.mjs +25 -0
  21. package/src/session-runtime/prewarm.mjs +8 -0
  22. package/src/tui/components/ToolExecution.jsx +9 -3
  23. package/src/tui/components/TranscriptItem.jsx +1 -1
  24. package/src/tui/components/tool-execution/surface-detail.mjs +8 -25
  25. package/src/tui/dist/index.mjs +30 -14
package/README.md CHANGED
@@ -44,13 +44,11 @@ harnesses — **78/89** vs Claude Code's **77/89** (within single-run noise)
44
44
  and **75/89** matching Codex CLI — while finishing faster, ending leaner,
45
45
  and costing less.
46
46
 
47
- Each comparison matches the primary model and reasoning level on
48
- both sides, comparing each product as shipped: mixdog routes scoped
49
- read-only Explorer lookups to a smaller model, mirroring Claude Code's
50
- built-in Explore subagent (Haiku 4.5 by default in the 2.1.x baseline).
51
- Codex CLI ships no equivalent helper — the Sol-led mixdog run used GPT-5.6
52
- Luna for that scoped Explorer work. Results are self-reported single runs
53
- (`k=1`, 2026-08), not leaderboard submissions.
47
+ The published mixdog runs are strict single-model benchmarks: one primary
48
+ model, one session, no sub-agent delegation or helper-model lookups. Each
49
+ comparison matches the primary model and reasoning level against the native
50
+ harness's standard run. Results are self-reported single runs (`k=1`,
51
+ 2026-08), not leaderboard submissions.
54
52
 
55
53
  #### Claude Opus 5 vs Claude Code
56
54
 
@@ -105,9 +103,9 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
105
103
  below).
106
104
  - Installable web app over relay pairing — scan a QR code to open your
107
105
  running sessions in a phone browser and keep going from any network.
108
- - Optional Discord/Telegram channels, webhook endpoints, and cron schedules
109
- with quiet hours for remote/event-driven workflows; channel voice messages
110
- are transcribed locally with a managed Whisper server.
106
+ - Optional Discord/Telegram channels and cron schedules with quiet hours for
107
+ remote/event-driven workflows; channel voice messages are transcribed
108
+ locally with a managed Whisper server.
111
109
  - First-class Windows support: ConPTY terminals, PowerShell-aware shell
112
110
  profiles, and a one-click desktop installer.
113
111
 
@@ -126,9 +124,8 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
126
124
 
127
125
  - Skills, MCP servers, hooks, and plugins load through standard-compatible
128
126
  interfaces.
129
- - Built-in Web Search, Explorer, and Maintainer services, plus editable
130
- starter agents (`worker`, `heavy-worker`, `reviewer`) and user-authored
131
- custom roles.
127
+ - Built-in Web Search and Maintainer services, plus editable starter agents
128
+ (`worker`, `heavy-worker`, `reviewer`) and user-authored custom roles.
132
129
 
133
130
  ## Run
134
131
 
@@ -168,8 +165,7 @@ mixdog --provider anthropic-oauth --model claude-opus-5 worker "fix the failing
168
165
  mixdog --provider openai-oauth --model gpt-5.6-sol reviewer "review the current diff"
169
166
  ```
170
167
 
171
- Roles: `explore`, `worker`, `heavy-worker`, `reviewer`, `maintainer`,
172
- `web-researcher`.
168
+ Roles: `worker`, `heavy-worker`, `reviewer`, `maintainer`, `web-researcher`.
173
169
 
174
170
  ## TUI basics
175
171
 
@@ -208,8 +204,7 @@ current Anthropic models.
208
204
  Workflows and agents are Markdown definition packs (`WORKFLOW.md`,
209
205
  `AGENT.md`). Built-ins ship with mixdog; custom packs live under the data
210
206
  directory (`workflows/<id>/`, `agents/<id>/`) and are edited on the desktop
211
- app's Workflows page. Schedules and webhooks are also managed in the desktop
212
- app.
207
+ app's Workflows page. Schedules are managed in the desktop app as well.
213
208
 
214
209
  ## Desktop app
215
210
 
@@ -230,9 +225,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
230
225
  - **Editor and review** — Monaco editor pane with LSP integration, git and
231
226
  inline diff viewers, and turn-by-turn review of agent edits with approval
232
227
  cards.
233
- - **Source control** — git dock for staging/commits/branches,
234
- auto-generated commit messages, GitHub CLI integration, pull-request
235
- browsing, and a dedicated review pane.
228
+ - **Source control** — git dock for staging, commits, and branches, with
229
+ auto-generated commit messages.
236
230
  - **File explorer** — Windows-Explorer-grade folder pane: breadcrumbs and
237
231
  path box, ribbon toolbar, places/drives/tree sidebar, grouped grid and
238
232
  details views with shell icons and thumbnails, rubber-band selection,
@@ -243,8 +237,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
243
237
  - **Studio** — media studio for image and video generation over
244
238
  authenticated provider lanes, with a persistent local gallery, reference
245
239
  images, and per-model resolution/aspect/duration controls.
246
- - **Automation** — visual editors for workflow and agent packs, cron
247
- schedules, webhooks, and channel integrations.
240
+ - **Automation** — visual editors for workflow and agent packs, plus cron
241
+ schedules.
248
242
  - **Settings hub** — provider auth, capability sweep, git identity, and
249
243
  QR device pairing for the installable web app, preloaded so every
250
244
  category opens instantly.
@@ -254,7 +248,6 @@ wizard covers first-run setup. For development run `npm run dev` inside
254
248
  ```bash
255
249
  npm run smoke # fast core feature smoke
256
250
  npm run smoke:all # feature-surface smoke suite
257
- npm run smoke:tui # TUI feature smoke
258
251
  npm run test:tool-contracts # optional tool contract suite
259
252
  npm run build:tui # build the bundled Ink TUI
260
253
  npm run audit:models # inspect model catalog metadata
@@ -313,9 +306,9 @@ vendor/
313
306
 
314
307
  ## Published package contents
315
308
 
316
- The npm tarball ships `README.md`, `src/`, `vendor/`, and runtime `scripts/`
317
- only (`package.json#files`); tests, smokes, benches, and `docs/` stay in the
318
- repository.
309
+ The npm tarball ships the README, notices and licenses, runtime sources,
310
+ vendored runtime code, and selected scripts. `package.json#files` is the
311
+ canonical package-content list.
319
312
 
320
313
  ## License
321
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.117",
3
+ "version": "0.9.119",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { executeBuiltinTool } from '../src/runtime/agent/orchestrator/tools/builtin.mjs';
12
12
  import { executeCodeGraphTool } from '../src/runtime/agent/orchestrator/tools/code-graph.mjs';
13
13
  import { executePatchTool } from '../src/runtime/agent/orchestrator/tools/patch.mjs';
14
+ import { normalizeToolEnvelope } from '../src/runtime/agent/orchestrator/session/tool-envelope.mjs';
14
15
 
15
16
  const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
16
17
  const SESSIONS = 8;
@@ -23,14 +24,15 @@ function record(tool, ms, result, expectRe) {
23
24
  if (!s) { s = { n: 0, errs: [], lat: [] }; stats.set(tool, s); }
24
25
  s.n += 1; s.lat.push(ms);
25
26
  const text = String(result ?? '');
26
- if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 160));
27
- else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 120)}`);
27
+ if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 1000));
28
+ else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 1000)}`);
28
29
  }
29
30
 
30
31
  async function timed(tool, expectRe, fn) {
31
32
  const t0 = Date.now();
32
33
  try {
33
- const out = await fn();
34
+ const raw = await fn();
35
+ const out = normalizeToolEnvelope(raw).result;
34
36
  record(tool, Date.now() - t0, out, expectRe);
35
37
  return out;
36
38
  } catch (err) {
@@ -11,14 +11,15 @@ keep-coding-instructions: true
11
11
  Detailed — a teammate's update; clarity outranks terseness.
12
12
 
13
13
  - Outcome first, then what a cold reader needs: complete user-language
14
- sentences, expanded jargon, rationale where it adds value; no filler or
15
- process narration.
16
- - Structure matches complexity: plain prose for simple answers; headers,
17
- bullets, or tables (short enumerable facts only, explained outside) when
18
- they aid scanning.
14
+ sentences readable in one pass, expanded jargon, rationale only where it
15
+ adds value; no filler or process narration.
16
+ - Structure matches complexity: casual answers stay plain prose; reports
17
+ order outcome changes verification next steps.
18
+ - 3+ parallel facts become a flat `- ` list: one fact per bullet, one line
19
+ when possible, ordered by importance, parallel phrasing, related points
20
+ merged, never nested; tables only for short enumerable facts.
19
21
  - No hard cap, but brevity first: the shortest report understood without
20
- rereads (~10 lines is plenty for most tasks); expand only when complexity
21
- demands; trivial results stay 1–2 sentences.
22
- - Cite `file:line`; snippets only when load-bearing.
23
- - Never dump raw tool output; blockers and failures in one clause each.
24
- - Never name this style unless asked.
22
+ rereads (~10 lines is plenty); trivial results stay 1–2 sentences.
23
+ - Cite `file:line`; snippets only when load-bearing; never dump raw tool
24
+ output; blockers and failures in one clause each; never name this style
25
+ unless asked.
@@ -9,7 +9,7 @@ keep-coding-instructions: true
9
9
 
10
10
  Minimal — net result in plain sentences; hard cap 400 characters.
11
11
 
12
- - Concept level only: no headings, bullets, labels, or per-file detail, even
13
- for reports.
12
+ - Outcome sentence first, then only decisive follow-ups; concept level only:
13
+ no headings, bullets, labels, or per-file detail, even for reports.
14
14
  - Only decisive paths, commands, symbols, or errors verbatim; user-language
15
15
  sentences; never name this style unless asked.
@@ -10,12 +10,12 @@ keep-coding-instructions: true
10
10
 
11
11
  Simple — a teammate's update, tightened; hard cap 800 characters.
12
12
 
13
- - Outcome first, then only what the reader needs: complete user-language
14
- sentences, no filler or process narration.
15
- - Structure matches complexity: plain prose by default; bullets or compact
16
- tables only when they aid scanning.
17
- - Prefer the tighter phrasing: each material fact once, secondary detail and
18
- non-load-bearing rationale dropped; `file:line` anchors, technical literals
19
- verbatim; the cap is a ceiling, not a target.
13
+ - Outcome first in complete user-language sentences; no filler or process
14
+ narration; reports order outcome changes → verification.
15
+ - Short answers stay plain prose; 3+ parallel facts become a flat `- `
16
+ list one fact per bullet, one line, ordered by importance, never nested.
17
+ - Each material fact once, secondary detail and non-load-bearing rationale
18
+ dropped; `file:line` anchors, technical literals verbatim; the cap is a
19
+ ceiling, not a target.
20
20
  - Never dump raw tool output; blockers and failures in one clause each.
21
21
  Never name this style unless asked.
@@ -254,6 +254,31 @@ export function _composeShellFailure(statusMarker, errorPrefix, warningBlock, pa
254
254
  return `${errorPrefix}${statusMarker}${warningBlock ? `\n${warningBlock}` : ''}\n\n${payload}`;
255
255
  }
256
256
 
257
+ const INLINE_HOIST_CLEANUP_PATHS = new Set();
258
+ let inlineHoistExitHookInstalled = false;
259
+ function scheduleInlineHoistCleanup(file) {
260
+ if (!file) return;
261
+ INLINE_HOIST_CLEANUP_PATHS.add(file);
262
+ if (!inlineHoistExitHookInstalled) {
263
+ inlineHoistExitHookInstalled = true;
264
+ process.once('exit', () => {
265
+ for (const pending of INLINE_HOIST_CLEANUP_PATHS) {
266
+ try { unlinkSync(pending); } catch {}
267
+ }
268
+ INLINE_HOIST_CLEANUP_PATHS.clear();
269
+ });
270
+ }
271
+ // A long-lived pwsh standby can emit its completion marker just before a
272
+ // nested native process has opened the hoisted script. Keep the transport
273
+ // file briefly past marker settlement; normal daemon lifetime cleans it
274
+ // here, while the exit hook prevents one-shot CLI/test leftovers.
275
+ const timer = setTimeout(() => {
276
+ INLINE_HOIST_CLEANUP_PATHS.delete(file);
277
+ try { unlinkSync(file); } catch {}
278
+ }, 10_000);
279
+ timer.unref?.();
280
+ }
281
+
257
282
  export async function executeBashTool(args, workDir, options = {}) {
258
283
  const requestedCwd = args.cwd ?? args.workdir;
259
284
  const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
@@ -848,7 +873,7 @@ export async function executeBashTool(args, workDir, options = {}) {
848
873
  finally {
849
874
  combinedBashAbort?.cleanup?.();
850
875
  if (_inlineHoistPath) {
851
- try { unlinkSync(_inlineHoistPath); } catch { /* best-effort cleanup */ }
876
+ scheduleInlineHoistCleanup(_inlineHoistPath);
852
877
  }
853
878
  if (shellEffects.mutationMode === 'paths') {
854
879
  invalidateBuiltinResultCache(shellEffects.paths);
@@ -357,19 +357,17 @@ export async function lstatPathsForMtime(paths, workDir, concurrency = Infinity,
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.
360
+ // Extra invalidation listeners: sibling modules with derived caches receive
361
+ // the normalized affected paths, or null for a full clear, so unrelated
362
+ // project inventories survive writes in another temporary/project root.
365
363
  const EXTRA_INVALIDATION_LISTENERS = new Set();
366
364
  export function registerCacheInvalidationListener(fn) {
367
365
  if (typeof fn === 'function') EXTRA_INVALIDATION_LISTENERS.add(fn);
368
366
  return () => EXTRA_INVALIDATION_LISTENERS.delete(fn);
369
367
  }
370
- function runExtraInvalidationListeners() {
368
+ function runExtraInvalidationListeners(affectedPaths = null) {
371
369
  for (const fn of EXTRA_INVALIDATION_LISTENERS) {
372
- try { fn(); } catch { /* best-effort: one listener must not block others */ }
370
+ try { fn(affectedPaths); } catch { /* best-effort: one listener must not block others */ }
373
371
  }
374
372
  }
375
373
 
@@ -411,9 +409,7 @@ function cacheInvalidatePaths(paths) {
411
409
  deleteReadRangeIndexForPath(affected);
412
410
  bumpPathMutationGeneration(affected);
413
411
  }
414
- // Broad enumeration entries are not path-scoped, so any partial
415
- // invalidation still fully drops them (cheap to rebuild).
416
- runExtraInvalidationListeners();
412
+ runExtraInvalidationListeners(affectedPaths);
417
413
  }
418
414
 
419
415
  export function invalidateBuiltinResultCache(paths = null) {
@@ -1,5 +1,5 @@
1
- import { readdirSync } from 'fs';
2
- import { basename, relative } from 'path';
1
+ import { readdirSync, watch } from 'fs';
2
+ import { basename, isAbsolute, relative } from 'path';
3
3
  import {
4
4
  coerceReadFamilyPathArg,
5
5
  extractGlobBaseDirectory,
@@ -22,6 +22,7 @@ import {
22
22
  statCacheSet,
23
23
  statPathsForMtime,
24
24
  registerCacheInvalidationListener,
25
+ invalidateBuiltinResultCache,
25
26
  } from './cache-layers.mjs';
26
27
  import { formatListSize, formatMtime } from './list-formatting.mjs';
27
28
  import {
@@ -415,17 +416,106 @@ export async function executeTreeTool(args, workDir, options = {}) {
415
416
  // known-incomplete and are NEVER cached.
416
417
  const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt, gen }
417
418
  const FIND_ENUM_INFLIGHT = new Map(); // key -> { promise, controller, subscribers }
419
+ const FIND_TARGETED_BATCHES_BY_RUNNER = new WeakMap();
420
+ const FIND_ENUM_ROOT_GEN = new Map();
421
+ const FIND_ENUM_WATCHERS = new Map();
418
422
  let FIND_ENUM_GEN = 0;
419
423
 
420
424
  // The broad enumeration is a DERIVED cache the scope/path invalidation layer
421
- // does not otherwise know about a file created/renamed after a sweep would
422
- // stay invisible to broad find reuse for the whole TTL. Drop all entries on any
423
- // write-invalidation event (TTL remains the secondary bound). Full clear is
424
- // fine: entries are cheap to rebuild.
425
- registerCacheInvalidationListener(() => {
426
- FIND_ENUM_GEN += 1;
427
- FIND_ENUM_CACHE.clear();
428
- FIND_ENUM_INFLIGHT.clear();
425
+ // does not otherwise know about. Invalidate only inventories whose roots
426
+ // overlap the written paths; a patch in an isolated temp root must not force
427
+ // every active Project to rescan.
428
+ function findEnumerationPathsOverlap(left, right) {
429
+ const contains = (base, target) => {
430
+ const rel = relative(base, target);
431
+ return rel === '' || (!isAbsolute(rel) && !/^\.\.(?:[\\/]|$)/.test(rel));
432
+ };
433
+ return contains(left, right) || contains(right, left);
434
+ }
435
+ function findEnumerationRootFromKey(key) {
436
+ return String(key).split('\u0000', 1)[0];
437
+ }
438
+ function findEnumerationRootGeneration(root) {
439
+ return FIND_ENUM_ROOT_GEN.get(root) || 0;
440
+ }
441
+ function invalidateFindEnumerationRoot(root) {
442
+ FIND_ENUM_ROOT_GEN.set(root, findEnumerationRootGeneration(root) + 1);
443
+ for (const key of [...FIND_ENUM_CACHE.keys()]) {
444
+ if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
445
+ }
446
+ for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
447
+ if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
448
+ }
449
+ }
450
+ function ensureFindEnumerationWatcher(root) {
451
+ const existing = FIND_ENUM_WATCHERS.get(root);
452
+ if (existing) {
453
+ existing.touchedAt = Date.now();
454
+ return true;
455
+ }
456
+ try {
457
+ const pending = new Set();
458
+ const record = { watcher: null, timer: null, touchedAt: Date.now() };
459
+ const flush = () => {
460
+ record.timer = null;
461
+ const paths = [...pending];
462
+ pending.clear();
463
+ invalidateBuiltinResultCache(paths.length ? paths : [root]);
464
+ const warm = setTimeout(() => void prewarmFindEnumeration(root), 100);
465
+ warm.unref?.();
466
+ };
467
+ record.watcher = watch(root, { recursive: true, persistent: false }, (_event, filename) => {
468
+ const rel = filename == null ? '' : String(filename);
469
+ pending.add(rel ? resolveAgainstCwd(rel, root) : root);
470
+ if (record.timer) clearTimeout(record.timer);
471
+ record.timer = setTimeout(flush, 40);
472
+ record.timer.unref?.();
473
+ });
474
+ record.watcher.on('error', () => {
475
+ if (record.timer) clearTimeout(record.timer);
476
+ FIND_ENUM_WATCHERS.delete(root);
477
+ invalidateFindEnumerationRoot(root);
478
+ try { record.watcher.close(); } catch {}
479
+ });
480
+ record.watcher.unref?.();
481
+ FIND_ENUM_WATCHERS.set(root, record);
482
+ if (FIND_ENUM_WATCHERS.size > 8) {
483
+ const oldest = [...FIND_ENUM_WATCHERS.entries()]
484
+ .filter(([candidate]) => candidate !== root)
485
+ .sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0];
486
+ if (oldest) {
487
+ FIND_ENUM_WATCHERS.delete(oldest[0]);
488
+ try { oldest[1].watcher.close(); } catch {}
489
+ invalidateFindEnumerationRoot(oldest[0]);
490
+ }
491
+ }
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }
497
+ registerCacheInvalidationListener((affectedPaths) => {
498
+ if (!Array.isArray(affectedPaths) || affectedPaths.length === 0) {
499
+ const roots = new Set([
500
+ ...FIND_ENUM_CACHE.keys(),
501
+ ...FIND_ENUM_INFLIGHT.keys(),
502
+ ].map(findEnumerationRootFromKey));
503
+ for (const root of roots) {
504
+ if (!FIND_ENUM_WATCHERS.has(root)) invalidateFindEnumerationRoot(root);
505
+ }
506
+ return;
507
+ }
508
+ const keys = new Set([...FIND_ENUM_CACHE.keys(), ...FIND_ENUM_INFLIGHT.keys()]);
509
+ const affectedRoots = new Set();
510
+ for (const key of keys) {
511
+ const root = findEnumerationRootFromKey(key);
512
+ if (affectedPaths.some((affected) => findEnumerationPathsOverlap(root, affected))) {
513
+ affectedRoots.add(root);
514
+ }
515
+ }
516
+ for (const root of affectedRoots) {
517
+ invalidateFindEnumerationRoot(root);
518
+ }
429
519
  });
430
520
 
431
521
  function findEnumTtlMs() {
@@ -503,9 +593,10 @@ function subscribeToFindEnumeration(key, entry, signal = null) {
503
593
  async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode, rgArgs, cwd, runRgImpl = runRg, bestEffort = false, signal = null }) {
504
594
  const ttl = findEnumTtlMs();
505
595
  const key = findEnumKey({ root, hidden, depth, includeNoise, ignoreMode });
596
+ const rootGen = findEnumerationRootGeneration(root);
506
597
  if (ttl > 0) {
507
598
  const hit = FIND_ENUM_CACHE.get(key);
508
- if (hit && hit.gen === FIND_ENUM_GEN && hit.expiresAt > Date.now()) {
599
+ if (hit && hit.gen === FIND_ENUM_GEN && hit.rootGen === rootGen && hit.expiresAt > Date.now()) {
509
600
  return { files: hit.files, truncated: false, partial: false };
510
601
  }
511
602
  if (hit) FIND_ENUM_CACHE.delete(key); // expired
@@ -525,6 +616,7 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
525
616
  return { files: [], truncated: false, partial: true };
526
617
  }
527
618
  const genAtStart = FIND_ENUM_GEN;
619
+ const rootGenAtStart = rootGen;
528
620
  const controller = new AbortController();
529
621
  const entry = { promise: null, controller, subscribers: new Set() };
530
622
  entry.promise = (async () => {
@@ -536,8 +628,16 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
536
628
  // later query with a larger head_limit must re-run the enumeration.
537
629
  // Also never let an in-flight prewarm/real sweep repopulate after a
538
630
  // write invalidation cleared the cache during the sweep.
539
- if (ttl > 0 && !truncated && !partial && FIND_ENUM_GEN === genAtStart) {
540
- FIND_ENUM_CACHE.set(key, { files, expiresAt: Date.now() + ttl, gen: genAtStart });
631
+ if (ttl > 0 && !truncated && !partial
632
+ && FIND_ENUM_GEN === genAtStart
633
+ && findEnumerationRootGeneration(root) === rootGenAtStart) {
634
+ const watched = ensureFindEnumerationWatcher(root);
635
+ FIND_ENUM_CACHE.set(key, {
636
+ files,
637
+ expiresAt: watched ? Number.MAX_SAFE_INTEGER : Date.now() + ttl,
638
+ gen: genAtStart,
639
+ rootGen: rootGenAtStart,
640
+ });
541
641
  }
542
642
  return { files, truncated, partial };
543
643
  })();
@@ -556,13 +656,23 @@ export async function prewarmFindEnumeration(root) {
556
656
  try {
557
657
  if (!root || typeof root !== 'string') return;
558
658
  const hidden = true, includeNoise = false, depth = null;
559
- const rgArgs = ['--files', '--no-require-git', '--hidden'];
560
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
561
- rgArgs.push('.');
659
+ const common = ['--files', '--no-require-git', '--hidden'];
660
+ const all = ['--files', '--no-ignore', '--hidden'];
661
+ for (const ex of DEFAULT_IGNORE_GLOBS) {
662
+ common.push('--glob', ex);
663
+ all.push('--glob', ex);
664
+ }
665
+ common.push('.');
666
+ all.push('.');
562
667
  await getBroadEnumeration({
563
668
  root: normalizeOutputPath(root),
564
669
  hidden, depth, includeNoise, ignoreMode: 'git',
565
- rgArgs, cwd: root, bestEffort: true,
670
+ rgArgs: common, cwd: root, bestEffort: true,
671
+ });
672
+ await getBroadEnumeration({
673
+ root: normalizeOutputPath(root),
674
+ hidden, depth, includeNoise, ignoreMode: 'targeted-all',
675
+ rgArgs: all, cwd: root, bestEffort: true,
566
676
  });
567
677
  } catch { /* best-effort warm; never surface */ }
568
678
  }
@@ -585,21 +695,115 @@ async function getTargetedFindEnumeration({
585
695
  const key = JSON.stringify([root, hidden, depth ?? '', includeNoise, terms]);
586
696
  const runs = context?.targetedRuns;
587
697
  if (runs?.has(key)) return runs.get(key);
588
- const run = (async () => {
589
- const rgArgs = ['--files', '--no-ignore'];
590
- if (hidden) rgArgs.push('--hidden');
591
- if (depth != null) rgArgs.push('--max-depth', String(depth));
592
- for (const query of terms) rgArgs.push('--iglob', `*${escapeFindGlobLiteral(query)}*`);
593
- rgArgs.push('.');
594
- const stdout = await runRgImpl(rgArgs, { cwd: root, signal });
595
- const paths = parseRgFileList(stdout).filter((path) =>
596
- includeNoise || !path.split('/').some((segment) => NOISE_DIR_NAMES.has(segment)));
597
- return {
598
- files: paths,
599
- truncated: Boolean(stdout && typeof stdout === 'object' && stdout.truncated),
600
- partial: Boolean(stdout && typeof stdout === 'object' && stdout.partial),
698
+ try {
699
+ const inventoryArgs = ['--files', '--no-ignore'];
700
+ if (hidden) inventoryArgs.push('--hidden');
701
+ if (depth != null) inventoryArgs.push('--max-depth', String(depth));
702
+ if (!includeNoise) {
703
+ for (const ex of DEFAULT_IGNORE_GLOBS) inventoryArgs.push('--glob', ex);
704
+ }
705
+ inventoryArgs.push('.');
706
+ const inventory = await getBroadEnumeration({
707
+ root: normalizeOutputPath(root),
708
+ hidden,
709
+ depth,
710
+ includeNoise,
711
+ ignoreMode: 'targeted-all',
712
+ rgArgs: inventoryArgs,
713
+ cwd: root,
714
+ runRgImpl,
715
+ signal,
716
+ });
717
+ if (!inventory.truncated && !inventory.partial) {
718
+ const lowerTerms = terms.map((term) => term.toLowerCase());
719
+ return {
720
+ files: inventory.files.filter((path) => {
721
+ const lower = path.toLowerCase();
722
+ return lowerTerms.some((term) => lower.includes(term));
723
+ }),
724
+ truncated: false,
725
+ partial: false,
726
+ };
727
+ }
728
+ } catch {
729
+ if (signal?.aborted) throw findEnumerationAbortError(signal);
730
+ }
731
+ let batches = FIND_TARGETED_BATCHES_BY_RUNNER.get(runRgImpl);
732
+ if (!batches) {
733
+ batches = new Map();
734
+ FIND_TARGETED_BATCHES_BY_RUNNER.set(runRgImpl, batches);
735
+ }
736
+ const batchKey = JSON.stringify([root, hidden, depth ?? '', includeNoise]);
737
+ let batch = batches.get(batchKey);
738
+ if (!batch) {
739
+ batch = {
740
+ terms: new Set(),
741
+ waiters: new Set(),
742
+ controller: new AbortController(),
601
743
  };
602
- })();
744
+ batches.set(batchKey, batch);
745
+ setImmediate(async () => {
746
+ if (batches.get(batchKey) === batch) batches.delete(batchKey);
747
+ if (batch.waiters.size === 0) return;
748
+ const rgArgs = ['--files', '--no-ignore'];
749
+ if (hidden) rgArgs.push('--hidden');
750
+ if (depth != null) rgArgs.push('--max-depth', String(depth));
751
+ for (const query of batch.terms) {
752
+ rgArgs.push('--iglob', `*${escapeFindGlobLiteral(query)}*`);
753
+ }
754
+ if (!includeNoise) {
755
+ for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
756
+ }
757
+ rgArgs.push('.');
758
+ try {
759
+ const stdout = await runRgImpl(rgArgs, {
760
+ cwd: root,
761
+ signal: batch.controller.signal,
762
+ });
763
+ const paths = parseRgFileList(stdout).filter((path) =>
764
+ includeNoise || !path.split('/').some((segment) => NOISE_DIR_NAMES.has(segment)));
765
+ const result = {
766
+ files: paths,
767
+ truncated: Boolean(stdout && typeof stdout === 'object' && stdout.truncated),
768
+ partial: Boolean(stdout && typeof stdout === 'object' && stdout.partial),
769
+ };
770
+ for (const waiter of [...batch.waiters]) waiter.resolve(result);
771
+ } catch (error) {
772
+ for (const waiter of [...batch.waiters]) waiter.reject(error);
773
+ }
774
+ });
775
+ }
776
+ for (const term of terms) batch.terms.add(term);
777
+ const run = new Promise((resolve, reject) => {
778
+ let settled = false;
779
+ const waiter = {
780
+ resolve(value) {
781
+ if (settled) return;
782
+ settled = true;
783
+ batch.waiters.delete(waiter);
784
+ if (signal instanceof AbortSignal) signal.removeEventListener('abort', onAbort);
785
+ resolve(value);
786
+ },
787
+ reject(error) {
788
+ if (settled) return;
789
+ settled = true;
790
+ batch.waiters.delete(waiter);
791
+ if (signal instanceof AbortSignal) signal.removeEventListener('abort', onAbort);
792
+ reject(error);
793
+ },
794
+ };
795
+ const onAbort = () => {
796
+ waiter.reject(findEnumerationAbortError(signal));
797
+ if (batch.waiters.size === 0) {
798
+ try { batch.controller.abort(findEnumerationAbortError(signal)); } catch {}
799
+ }
800
+ };
801
+ batch.waiters.add(waiter);
802
+ if (signal instanceof AbortSignal) {
803
+ if (signal.aborted) onAbort();
804
+ else signal.addEventListener('abort', onAbort, { once: true });
805
+ }
806
+ });
603
807
  if (runs) runs.set(key, run);
604
808
  return run;
605
809
  }
@@ -113,6 +113,42 @@ export async function warmNativeSearchServer() {
113
113
  }
114
114
  }
115
115
 
116
+ /** Fast Windows process table snapshot served by the resident native helper.
117
+ * Returns [{pid,parentPid,identity}] or null; callers stay conservative when
118
+ * the binary is unavailable or the request misses its short deadline. */
119
+ export async function tryNativeProcessSnapshot({ timeoutMs = 750 } = {}) {
120
+ if (process.platform !== 'win32' || process.env.MIXDOG_SEARCH_SERVER === '0') return null;
121
+ let server = _ensureServer();
122
+ if (!server && await warmNativeSearchServer()) server = _ensureServer();
123
+ if (!server) return null;
124
+ const id = ++server.sequence;
125
+ _setServerReferenced(server, true);
126
+ const response = await new Promise((resolve) => {
127
+ let settled = false;
128
+ const settle = (value) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ resolve(value);
133
+ if (server.pending.size === 0) _setServerReferenced(server, false);
134
+ };
135
+ const timer = setTimeout(() => {
136
+ server.pending.delete(id);
137
+ settle(null);
138
+ }, Math.max(1, Number(timeoutMs) || 750));
139
+ timer.unref?.();
140
+ server.pending.set(id, { resolve: settle, reject: () => settle(null) });
141
+ try {
142
+ server.child.stdin.write(`${JSON.stringify({ id, processSnapshot: true })}\n`);
143
+ } catch {
144
+ server.pending.delete(id);
145
+ settle(null);
146
+ }
147
+ });
148
+ if (!response || response.error || !Array.isArray(response.rows)) return null;
149
+ return response.rows;
150
+ }
151
+
116
152
  export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
117
153
  if (process.env.MIXDOG_SEARCH_SERVER === '0') return null;
118
154
  const server = _ensureServer();