mixdog 0.9.134 → 0.9.136

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 (133) hide show
  1. package/package.json +3 -1
  2. package/scripts/tool-stress.mjs +8 -1
  3. package/src/agents/maintainer/agent.json +1 -1
  4. package/src/defaults/skills/setup/SKILL.md +8 -43
  5. package/src/headless-exec.mjs +26 -9
  6. package/src/headless-exec.test.mjs +55 -2
  7. package/src/help.mjs +0 -2
  8. package/src/rules/lead/01-general.md +0 -4
  9. package/src/rules/shared/01-tool.md +12 -4
  10. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  11. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  12. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  13. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  14. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -6
  18. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +25 -4
  19. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
  21. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  23. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +138 -19
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  33. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  34. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
  35. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
  36. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  37. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +165 -17
  38. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +162 -0
  39. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +36 -8
  40. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  41. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  42. package/src/runtime/channels/lib/config.mjs +14 -101
  43. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  44. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  45. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  46. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  48. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  49. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  50. package/src/runtime/media/renditions.mjs +68 -5
  51. package/src/runtime/media/renditions.test.mjs +45 -0
  52. package/src/runtime/media/store.mjs +1 -0
  53. package/src/runtime/memory/index.mjs +4 -3
  54. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  55. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  56. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  57. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  58. package/src/runtime/shared/config.mjs +8 -49
  59. package/src/runtime/shared/schedule-time.mjs +25 -0
  60. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  61. package/src/runtime/shared/schedules-db.mjs +73 -1
  62. package/src/runtime/shared/tool-card-model.mjs +1 -1
  63. package/src/runtime/shared/transcript-writer.mjs +10 -17
  64. package/src/runtime/shared/update-checker.mjs +7 -2
  65. package/src/runtime/shared/update-checker.test.mjs +33 -0
  66. package/src/session-runtime/channel-config-api.mjs +3 -20
  67. package/src/session-runtime/config-lifecycle.mjs +1 -42
  68. package/src/session-runtime/lifecycle-api.mjs +10 -21
  69. package/src/session-runtime/model-route-api.mjs +0 -2
  70. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  71. package/src/session-runtime/prewarm.mjs +11 -7
  72. package/src/session-runtime/prewarm.test.mjs +0 -1
  73. package/src/session-runtime/runtime-core.mjs +20 -186
  74. package/src/session-runtime/session-lifecycle.mjs +4 -17
  75. package/src/session-runtime/session-transcript.mjs +54 -0
  76. package/src/session-runtime/session-turn-api.mjs +4 -44
  77. package/src/session-runtime/settings-api.mjs +0 -45
  78. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  79. package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
  80. package/src/standalone/channel-admin.mjs +8 -150
  81. package/src/standalone/channel-transport.mjs +13 -0
  82. package/src/standalone/daemon.mjs +19 -49
  83. package/src/standalone/seeds.mjs +18 -1
  84. package/src/standalone/session-protocol.mjs +0 -8
  85. package/src/standalone/session-service.mjs +1 -3
  86. package/src/tui/App.jsx +0 -5
  87. package/src/tui/app/app-view.jsx +1 -2
  88. package/src/tui/app/create-app-pickers.mjs +0 -20
  89. package/src/tui/app/doctor.mjs +2 -6
  90. package/src/tui/app/prompt-submit.mjs +2 -49
  91. package/src/tui/app/settings-picker.mjs +22 -96
  92. package/src/tui/app/slash-commands.mjs +0 -2
  93. package/src/tui/app/slash-dispatch.mjs +0 -17
  94. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  95. package/src/tui/components/StatusLine.jsx +4 -4
  96. package/src/tui/dist/index.mjs +1003 -1461
  97. package/src/tui/session/context-state.mjs +0 -1
  98. package/src/tui/session/queue-helpers.mjs +1 -1
  99. package/src/tui/session/queue-helpers.test.mjs +10 -1
  100. package/src/tui/session/session-api-ext.mjs +0 -89
  101. package/src/tui/session/session-api.mjs +0 -2
  102. package/src/tui/session/tool-card-results.mjs +1 -1
  103. package/src/tui/session/tool-result-status.mjs +3 -3
  104. package/src/tui/session/tool-result-status.test.mjs +3 -3
  105. package/src/tui/session-local.mjs +0 -26
  106. package/src/workflows/default/WORKFLOW.md +13 -15
  107. package/src/workflows/solo/WORKFLOW.md +6 -7
  108. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  109. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  110. package/src/runtime/channels/lib/format.mjs +0 -190
  111. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  112. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  113. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  114. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  116. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  117. package/src/runtime/channels/lib/session-control.mjs +0 -36
  118. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  119. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  120. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  121. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  122. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  123. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  124. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  125. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  126. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  127. package/src/runtime/channels/providers/discord.mjs +0 -730
  128. package/src/runtime/channels/providers/telegram.mjs +0 -598
  129. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  130. package/src/session-runtime/remote-control.mjs +0 -126
  131. package/src/session-runtime/remote-transcript.mjs +0 -134
  132. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  133. package/src/tui/app/channel-pickers.mjs +0 -384
@@ -476,6 +476,7 @@ export async function streamSmartReadSummary(fullPath, st, source = 'read_smart_
476
476
  };
477
477
 
478
478
  const deadline = Date.now() + READ_STREAM_TIMEOUT_MS;
479
+ let scanCompleted = false;
479
480
  try {
480
481
  while (position < st.size) {
481
482
  if (Date.now() > deadline) throw new Error(`read timed out after ${READ_STREAM_TIMEOUT_MS}ms`);
@@ -513,10 +514,12 @@ export async function streamSmartReadSummary(fullPath, st, source = 'read_smart_
513
514
  : Buffer.concat(pendingParts, pendingBytes);
514
515
  finishLine(lineBuf, currentLineStartByte, st.size);
515
516
  }
517
+ scanCompleted = true;
516
518
  } finally {
517
- await fh.close().catch(() => {});
519
+ if (!scanCompleted) await fh.close().catch(() => {});
518
520
  }
519
521
 
522
+ try {
520
523
  const tailLen = Math.min(tailCount, SMART_READ_TAIL_LINES);
521
524
  const tailOffsets = [];
522
525
  for (let i = tailCount - tailLen; i < tailCount; i++) {
@@ -530,14 +533,9 @@ export async function streamSmartReadSummary(fullPath, st, source = 'read_smart_
530
533
  const byteLen = Math.max(0, lastEnd - firstStart);
531
534
  let tailWindow = Buffer.alloc(0);
532
535
  if (byteLen > 0) {
533
- const tailFh = await fsPromises.open(fullPath, 'r');
534
- try {
535
- tailWindow = Buffer.allocUnsafe(byteLen);
536
- const { bytesRead } = await tailFh.read(tailWindow, 0, byteLen, firstStart);
537
- if (bytesRead < byteLen) tailWindow = tailWindow.subarray(0, bytesRead);
538
- } finally {
539
- await tailFh.close().catch(() => {});
540
- }
536
+ tailWindow = Buffer.allocUnsafe(byteLen);
537
+ const { bytesRead } = await fh.read(tailWindow, 0, byteLen, firstStart);
538
+ if (bytesRead < byteLen) tailWindow = tailWindow.subarray(0, bytesRead);
541
539
  }
542
540
  for (const entry of tailOffsets) {
543
541
  let rawBuf = tailWindow.subarray(entry.startByte - firstStart, entry.endByte - firstStart);
@@ -604,4 +602,7 @@ export async function streamSmartReadSummary(fullPath, st, source = 'read_smart_
604
602
  rangeHashes,
605
603
  },
606
604
  };
605
+ } finally {
606
+ await fh.close().catch(() => {});
607
+ }
607
608
  }
@@ -60,6 +60,7 @@ import {
60
60
  cacheSet,
61
61
  runResultCacheInFlight,
62
62
  statPathsForMtime,
63
+ visitPathsForMtime,
63
64
  } from './cache-layers.mjs';
64
65
  import { recordLocalSearchCacheHit } from './local-search-telemetry.mjs';
65
66
  import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX, hasUnsupportedRipgrepRegex } from './arg-guard.mjs';
@@ -121,6 +122,58 @@ function _globDefaultHeadLimit() {
121
122
  return parsed > 0 ? parsed : 100;
122
123
  }
123
124
 
125
+ function compareGlobMtimeEntries(a, b) {
126
+ const dm = b.mtime - a.mtime;
127
+ if (dm !== 0) return dm;
128
+ return globMtimeTiePath(a).localeCompare(globMtimeTiePath(b));
129
+ }
130
+
131
+ function retainBestEntries(heap, candidate, limit, compare) {
132
+ if (heap.length < limit) {
133
+ heap.push(candidate);
134
+ for (let index = heap.length - 1; index > 0;) {
135
+ const parent = Math.floor((index - 1) / 2);
136
+ if (compare(heap[index], heap[parent]) <= 0) break;
137
+ [heap[index], heap[parent]] = [heap[parent], heap[index]];
138
+ index = parent;
139
+ }
140
+ return;
141
+ }
142
+ if (compare(candidate, heap[0]) >= 0) return;
143
+ heap[0] = candidate;
144
+ for (let index = 0;;) {
145
+ const left = index * 2 + 1;
146
+ const right = left + 1;
147
+ let worst = index;
148
+ if (left < heap.length && compare(heap[left], heap[worst]) > 0) worst = left;
149
+ if (right < heap.length && compare(heap[right], heap[worst]) > 0) worst = right;
150
+ if (worst === index) break;
151
+ [heap[index], heap[worst]] = [heap[worst], heap[index]];
152
+ index = worst;
153
+ }
154
+ }
155
+
156
+ export function _createGlobMtimeTopK(limit) {
157
+ const cap = Math.max(1, Math.floor(Number(limit) || 1));
158
+ const statted = [];
159
+ const unstatted = [];
160
+ return {
161
+ add(entry, index) {
162
+ if (entry?.stat != null) {
163
+ retainBestEntries(statted, entry, cap, compareGlobMtimeEntries);
164
+ } else {
165
+ retainBestEntries(unstatted, { ...entry, _walkIndex: index }, cap,
166
+ (a, b) => a._walkIndex - b._walkIndex);
167
+ }
168
+ },
169
+ values() {
170
+ statted.sort(compareGlobMtimeEntries);
171
+ unstatted.sort((a, b) => a._walkIndex - b._walkIndex);
172
+ return [...statted, ...unstatted].slice(0, cap);
173
+ },
174
+ };
175
+ }
176
+
124
177
  function _grepContextCharBudget(options = {}) {
125
178
  const explicit = Number(options?._grepContextCharBudget);
126
179
  if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
@@ -338,11 +391,27 @@ export async function executeGlobTool(args, workDir, options = {}) {
338
391
  let rgStdoutPartial = false;
339
392
  let rgWindowIncomplete = false;
340
393
  let rgCacheUnsafe = false;
394
+ let nativeMtimeTotal = null;
341
395
  const accumCap = 50000;
342
396
  const canWindowNatural = sortMode === 'natural' && headLimit !== Infinity;
397
+ const canWindowMtime = sortMode === 'mtime'
398
+ && headLimit !== Infinity;
343
399
  const groupRuns = await Promise.all(globGroups.map(async ([root, rels]) => {
344
400
  const rgArgs = ['--files', '--hidden'];
345
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
401
+ // Explicit literal basenames (no glob magic in the final segment)
402
+ // name a concrete file: honor rg's later-glob-wins contract and let
403
+ // the lookup descend dependency-noise dirs, which the native walker
404
+ // would otherwise prune before the pattern could ever match. Wildcard
405
+ // basenames keep the noise prunes; device-name globs (no trailing
406
+ // /**) always apply.
407
+ const explicitBasenames = rels.length > 0 && rels.every((rel) => {
408
+ const base = String(rel).split('/').filter(Boolean).pop() || '';
409
+ return base !== '' && !hasGlobMagic(base);
410
+ });
411
+ for (const ex of DEFAULT_IGNORE_GLOBS) {
412
+ if (explicitBasenames && /^!\*\*\/[^/]+\/\*\*$/.test(ex)) continue;
413
+ rgArgs.push('--glob', ex);
414
+ }
346
415
  for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
347
416
  for (const rel of rels) rgArgs.push('--glob', rel);
348
417
  const rgCwd = resolvedForSearchRoot(root);
@@ -378,6 +447,24 @@ export async function executeGlobTool(args, workDir, options = {}) {
378
447
  };
379
448
  }
380
449
  try {
450
+ if (canWindowMtime) {
451
+ const served = await runRgWindowedLines(
452
+ rgArgs,
453
+ { cwd: rgCwd, signal: sharedSignal },
454
+ { offset: 0, limit: offset + headLimit + 1, mtimeTopK: true },
455
+ );
456
+ return {
457
+ error: null,
458
+ paths: served.lines.map((line) =>
459
+ isAbsolute(line) ? line : resolveAgainstCwd(line, rgCwd)),
460
+ stdoutTruncated: false,
461
+ stdoutPartial: served.partial === true,
462
+ windowIncomplete: false,
463
+ cacheSafe: served.cacheSafe !== false,
464
+ nativeMtime: true,
465
+ totalSeen: served.totalSeen,
466
+ };
467
+ }
381
468
  if (canWindowNatural) {
382
469
  const served = await runRgWindowedLines(
383
470
  rgArgs,
@@ -427,6 +514,9 @@ export async function executeGlobTool(args, workDir, options = {}) {
427
514
  if (run.stdoutPartial) rgStdoutPartial = true;
428
515
  if (run.windowIncomplete) rgWindowIncomplete = true;
429
516
  if (run.cacheSafe === false) rgCacheUnsafe = true;
517
+ if (run.nativeMtime) {
518
+ nativeMtimeTotal = (nativeMtimeTotal ?? 0) + Math.max(0, Number(run.totalSeen) || 0);
519
+ }
430
520
  for (const p of run.paths) {
431
521
  allFiles.push(p);
432
522
  if (allFiles.length >= accumCap) {
@@ -441,34 +531,59 @@ export async function executeGlobTool(args, workDir, options = {}) {
441
531
 
442
532
  const unique = Array.from(new Set(allFiles));
443
533
  let orderedPaths;
444
- if (sortMode === 'mtime') {
445
- // Default mtime sorting stats every match. Bound the post-rg stat
446
- // phase so a hung mount cannot pin glob until the agent stall
447
- // watchdog fires.
448
- const withStatAll = await statPathsForMtime(unique, workDir, 64, { deadlineMs: 5000 });
534
+ if (nativeMtimeTotal !== null && globGroups.length === 1) {
535
+ orderedPaths = unique;
536
+ } else if (nativeMtimeTotal !== null) {
537
+ const withStat = await statPathsForMtime(unique, workDir, 64, { deadlineMs: 5000 });
449
538
  const statted = [];
450
539
  const unstatted = [];
451
- for (const entry of withStatAll) {
540
+ for (const entry of withStat) {
452
541
  if (!entry) continue;
453
542
  if (entry.stat != null) statted.push(entry);
454
543
  else unstatted.push(entry);
455
544
  }
456
- statted.sort((a, b) => {
457
- const dm = b.mtime - a.mtime;
458
- if (dm !== 0) return dm;
459
- return globMtimeTiePath(a).localeCompare(globMtimeTiePath(b));
460
- });
461
- // A deadline-expired or failed stat degrades ORDERING only, never
462
- // completeness: unsortable entries keep their walk order at the tail.
463
- orderedPaths = [...statted, ...unstatted].map((entry) => entry.full || resolveAgainstCwd(entry.path, workDir));
545
+ statted.sort(compareGlobMtimeEntries);
546
+ orderedPaths = [...statted, ...unstatted]
547
+ .map((entry) => entry.full || resolveAgainstCwd(entry.path, workDir));
548
+ } else if (sortMode === 'mtime') {
549
+ // Default mtime sorting stats every match. Bound the post-rg stat
550
+ // phase so a hung mount cannot pin glob until the agent stall
551
+ // watchdog fires.
552
+ if (headLimit === Infinity) {
553
+ const withStatAll = await statPathsForMtime(unique, workDir, 64, { deadlineMs: 5000 });
554
+ const statted = [];
555
+ const unstatted = [];
556
+ for (const entry of withStatAll) {
557
+ if (!entry) continue;
558
+ if (entry.stat != null) statted.push(entry);
559
+ else unstatted.push(entry);
560
+ }
561
+ statted.sort(compareGlobMtimeEntries);
562
+ // A deadline-expired or failed stat degrades ORDERING only, never
563
+ // completeness: unsortable entries keep their walk order at the tail.
564
+ orderedPaths = [...statted, ...unstatted]
565
+ .map((entry) => entry.full || resolveAgainstCwd(entry.path, workDir));
566
+ } else {
567
+ // Scan and stat every match for exact global ordering, but retain
568
+ // only the requested page plus one lookahead row. This changes
569
+ // O(N log N) sorting and O(N) result retention to O(N log K) and
570
+ // O(K) without narrowing the searched candidate set.
571
+ const selector = _createGlobMtimeTopK(
572
+ Math.min(unique.length, offset + headLimit + 1),
573
+ );
574
+ await visitPathsForMtime(unique, workDir, 64, { deadlineMs: 5000 },
575
+ (entry, index) => selector.add(entry, index));
576
+ orderedPaths = selector.values()
577
+ .map((entry) => entry.full || resolveAgainstCwd(entry.path, workDir));
578
+ }
464
579
  } else {
465
580
  orderedPaths = unique.map((entry) => isAbsolute(entry) ? resolve(entry) : resolveAgainstCwd(entry, workDir));
466
581
  }
467
- const totalBeforeOffset = orderedPaths.length;
582
+ const totalBeforeOffset = nativeMtimeTotal ?? unique.length;
468
583
  const windowed = offset > 0 ? orderedPaths.slice(offset) : orderedPaths;
469
584
  const capped = (headLimit === Infinity ? windowed : windowed.slice(0, headLimit))
470
585
  .map((abs) => relativeSearchResultPath(abs, workDir));
471
- const remaining = windowed.length - capped.length;
586
+ const remaining = Math.max(0, totalBeforeOffset - offset - capped.length);
472
587
  const truncSuffix = accumTruncated
473
588
  ? '\n... [truncated at accumulation cap (50000)]'
474
589
  : (rgStdoutTruncated ? '\n... [truncated at rg stdout cap (20MB); results incomplete]' : '')
@@ -494,8 +609,12 @@ export async function executeGlobTool(args, workDir, options = {}) {
494
609
  if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0)) {
495
610
  markScopedCacheIncomplete(options.scopedCacheOutcome);
496
611
  }
497
- const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0;
498
- if (!globIncomplete && !rgCacheUnsafe) {
612
+ // Pagination is not an incomplete computation: limit/offset are part of
613
+ // the cache key, and the native watcher invalidates the scoped page on any
614
+ // relevant namespace/mtime change. Cache exact pages even when more rows
615
+ // exist; only a genuinely partial scan must bypass reuse.
616
+ const globComputationIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgErrors.length > 0;
617
+ if (!globComputationIncomplete && !rgCacheUnsafe) {
499
618
  cacheSet(cacheKey, out, { scopes: [...groups.keys()].map((root) => resolvedForSearchRoot(root)) });
500
619
  }
501
620
  // ② completion progress (claude "Found N" parity). Best-effort, no-op
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { _createGlobMtimeTopK } from './search-glob-tool.mjs';
5
+
6
+ function entry(path, mtime, stat = {}) {
7
+ return { path, full: path, mtime, mtimeMs: mtime, stat };
8
+ }
9
+
10
+ test('glob mtime top-K exactly matches full ordering and preserves failed-stat walk order', () => {
11
+ const entries = [
12
+ entry('z-old', 1),
13
+ entry('b-tie', 9),
14
+ entry('failed-first', 0, null),
15
+ entry('a-tie', 9),
16
+ entry('newest', 12),
17
+ entry('failed-second', 0, null),
18
+ entry('middle', 5),
19
+ ];
20
+ const selector = _createGlobMtimeTopK(6);
21
+ const completionOrder = [5, 1, 6, 2, 4, 0, 3];
22
+ for (const index of completionOrder) selector.add(entries[index], index);
23
+
24
+ assert.deepEqual(
25
+ selector.values().map((item) => item.path),
26
+ ['newest', 'a-tie', 'b-tie', 'middle', 'z-old', 'failed-first'],
27
+ );
28
+ });
29
+
30
+ test('glob mtime top-K retains only the requested bound', () => {
31
+ const selector = _createGlobMtimeTopK(3);
32
+ for (let index = 0; index < 100; index += 1) {
33
+ selector.add(entry(`file-${String(index).padStart(3, '0')}`, index), index);
34
+ }
35
+ assert.deepEqual(
36
+ selector.values().map((item) => item.path),
37
+ ['file-099', 'file-098', 'file-097'],
38
+ );
39
+ });
@@ -520,7 +520,13 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
520
520
  // Skipped for pattern arrays and when `-i` is already set.
521
521
  const caseHintSuffix = async () => {
522
522
  const caseInsensitive = args['-i'] === true;
523
- if (caseInsensitive || patterns.length !== 1 || !/[A-Za-z]/.test(patterns[0])) return '';
523
+ // A broad no-match must not trigger a second whole-tree scan just to
524
+ // produce an advisory casing hint. The requested case-sensitive result
525
+ // is already complete; retain the hint only for explicit file scopes.
526
+ if (grepStat.isDirectory()
527
+ || caseInsensitive
528
+ || patterns.length !== 1
529
+ || !/[A-Za-z]/.test(patterns[0])) return '';
524
530
  try {
525
531
  const probeArgs = buildGrepRgArgs({
526
532
  patterns,
@@ -568,7 +568,10 @@ export function preflightPowerShellHygiene(command, { shellType, shellName } = {
568
568
  // regex/search args are never mistaken for real syntax.
569
569
  const masked = maskQuotedRegions(rewritten);
570
570
 
571
- for (const segment of shellSplitSegments(rewritten)) {
571
+ // Only command heads matter here. Scan the quote-masked copy so PowerShell
572
+ // literals use PowerShell escaping rules (`\` is literal, backtick escapes)
573
+ // and nested bash snippets remain opaque.
574
+ for (const segment of shellSplitSegments(masked)) {
572
575
  for (const stage of shellSplitPipelineSegments(segment)) {
573
576
  const tokens = stripShellProbeWrappers(shellTokenize(stage) || []);
574
577
  if (tokens.length === 0) continue;
@@ -1,8 +1,16 @@
1
1
  import os from 'node:os';
2
2
  import {
3
+ modelVisibleToolCompletionMessage,
3
4
  normalizeToolNotifyContext,
4
5
  notifyToolCompletion,
5
6
  } from '../../../../shared/tool-execution-contract.mjs';
7
+ // Runtime-only bindings (used inside completion delivery, never at module
8
+ // eval), so the static cycle through session/manager.mjs is safe — same
9
+ // pattern as loop/tool-exec.mjs.
10
+ import {
11
+ enqueuePendingMessage,
12
+ markCompletionEntry,
13
+ } from '../../session/manager.mjs';
6
14
  import {
7
15
  completeBackgroundTask,
8
16
  } from '../../../../shared/background-tasks.mjs';
@@ -22,6 +30,8 @@ import {
22
30
  } from './lib/shell-job-insights.mjs';
23
31
  import {
24
32
  completeShellJobRecord,
33
+ listShellJobRecords,
34
+ pidAlive,
25
35
  publishShellJobRecord,
26
36
  } from './lib/shell-job-records.mjs';
27
37
  import {
@@ -86,7 +96,7 @@ function shellJobTaskStatus(status) {
86
96
  return 'failed';
87
97
  }
88
98
 
89
- function buildShellCompletion(jobId, detail) {
99
+ export function buildShellCompletion(jobId, detail) {
90
100
  const startedAtMs = Date.parse(detail?.startedAt || '');
91
101
  const finishedAtMs = Date.parse(detail?.finishedAt || '') || Date.now();
92
102
  const elapsedMs = Number.isFinite(startedAtMs)
@@ -247,7 +257,12 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
247
257
  instruction: completion.instruction,
248
258
  terminalReason: 'shell-native-event',
249
259
  });
250
- if (!completedTask && ctx && typeof ctx.notifyFn === 'function') {
260
+ if (!completedTask) {
261
+ // Delivery order: owner notifyFn when present, else the owner
262
+ // session's pending queue. A ctx-less finish (daemon restarted
263
+ // between job start and completion, or a watcher armed without a
264
+ // notify context) previously dropped the completion silently.
265
+ const owner = String(ctx?.callerSessionId || detail.ownerSessionId || '');
251
266
  notifyToolCompletion({
252
267
  surface: 'shell',
253
268
  id: jobId,
@@ -255,7 +270,19 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
255
270
  text: completion.body,
256
271
  resultType: 'shell_task_result',
257
272
  instruction: completion.instruction,
258
- context: ctx,
273
+ context: ctx || { callerSessionId: owner },
274
+ enqueueFallback: (sessionId, message, meta) => {
275
+ let visible = modelVisibleToolCompletionMessage(message, meta);
276
+ // Bodyless envelopes (a finished command with no output)
277
+ // fail the persistence gate's result-body requirement;
278
+ // retry with an explicit "(no output)" section rather
279
+ // than dropping the completion.
280
+ if (!visible && !/\n\s*\n/.test(String(message || ''))) {
281
+ visible = modelVisibleToolCompletionMessage(`${message}\n\n(no output)`, meta);
282
+ }
283
+ if (!visible) return false;
284
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
285
+ },
259
286
  logPrefix: 'shell-jobs',
260
287
  });
261
288
  }
@@ -287,6 +314,56 @@ export async function adoptForegroundShellJob({
287
314
  return native;
288
315
  }
289
316
 
317
+ // Daemon-restart recovery: records left 'running' by a dead daemon can never
318
+ // finish through the live watcher path — their completion (and output) used to
319
+ // vanish silently. Finalize each dead-pid record and push one completion
320
+ // notice to the owner session's pending queue. Content-addressed completion
321
+ // ids keep multi-shard reconciliation idempotent.
322
+ let _recoveredCompletionsReconciled = false;
323
+ export async function reconcileRecoveredShellJobCompletions() {
324
+ if (_recoveredCompletionsReconciled) return 0;
325
+ _recoveredCompletionsReconciled = true;
326
+ let records = [];
327
+ try { records = await listShellJobRecords(); } catch { return 0; }
328
+ let notified = 0;
329
+ for (const record of records) {
330
+ if (!record || record.terminal) continue;
331
+ if (getNativeTask(record.jobId)) continue; // live in this daemon
332
+ if (pidAlive(Number(record.pid) || 0)) continue; // survived the restart
333
+ const detail = {
334
+ ...record,
335
+ status: 'failed',
336
+ error: 'daemon restarted while this shell task was running; its outcome was lost. Re-run the command if the result is still needed.',
337
+ };
338
+ try { await completeShellJobRecord(record.jobId, detail); } catch { /* best-effort */ }
339
+ const owner = String(record.ownerSessionId || '');
340
+ if (!owner) continue;
341
+ try {
342
+ const completion = buildShellCompletion(record.jobId, detail);
343
+ const delivered = notifyToolCompletion({
344
+ surface: 'shell',
345
+ id: record.jobId,
346
+ status: completion.taskStatus,
347
+ // The pending-queue persistence gate requires a blank-line
348
+ // separated result body; a bodyless bracketed envelope would
349
+ // be dropped. The restart notice IS the result here.
350
+ text: `${completion.body}\n\n${detail.error}`,
351
+ resultType: 'shell_task_result',
352
+ instruction: completion.instruction,
353
+ context: { callerSessionId: owner },
354
+ enqueueFallback: (sessionId, message, meta) => {
355
+ const visible = modelVisibleToolCompletionMessage(message, meta);
356
+ if (!visible) return false;
357
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
358
+ },
359
+ logPrefix: 'shell-jobs-recovery',
360
+ });
361
+ if (delivered) notified += 1;
362
+ } catch { /* best-effort */ }
363
+ }
364
+ return notified;
365
+ }
366
+
290
367
  export async function shutdownShellJobs(_reason = 'runtime-close', { scope = null } = {}) {
291
368
  const scoped = Boolean(scope);
292
369
  const ownerSessionId = scoped ? String(scope.ownerSessionId || '') : '';
@@ -3,6 +3,7 @@
3
3
  // race), isCodeGraphTool. Extracted verbatim from code-graph.mjs.
4
4
  import { resolve as pathResolve, isAbsolute, relative as pathRelative, basename as pathBasename, extname } from 'node:path';
5
5
  import { homedir as osHomedir } from 'node:os';
6
+ import { createHash } from 'node:crypto';
6
7
  import { existsSync, statSync } from 'node:fs';
7
8
  import { readFile } from 'node:fs/promises';
8
9
  import { normalizeInputPath, toDisplayPath } from '../builtin.mjs';
@@ -29,6 +30,8 @@ import {
29
30
  _stripEmptyArgs,
30
31
  } from './project-root.mjs';
31
32
  import { buildCodeGraphAsync, prewarmCodeGraph, prewarmCodeGraphSymbols } from './build.mjs';
33
+ import { _fileInfoFromRustRecord, _runGraphFiles } from './graph-binary.mjs';
34
+ import { _attachGraphRuntimeCaches } from './graph-model.mjs';
32
35
  import {
33
36
  _isFilesystemRootPath,
34
37
  collectTrustedCodeGraphRoots,
@@ -87,6 +90,74 @@ function _outlineLanguageForPath(file) {
87
90
  return null;
88
91
  }
89
92
 
93
+ const _exactFileGraphInflight = new Map();
94
+ const _exactFileGraphCache = new Map();
95
+ const EXACT_FILE_GRAPH_CACHE_MAX = 64;
96
+
97
+ async function _buildExactFileGraph(cwd, abs, signal = null) {
98
+ const root = pathResolve(cwd);
99
+ const file = pathResolve(abs);
100
+ const rel = _graphRel(file, root);
101
+ const key = `${root}\0${rel}`;
102
+ const sourceText = await readFile(file, { encoding: 'utf8', signal: signal || undefined });
103
+ const sourceHash = createHash('sha256').update(sourceText).digest('hex');
104
+ const cached = _exactFileGraphCache.get(key);
105
+ if (cached?.sourceHash === sourceHash) {
106
+ _exactFileGraphCache.delete(key);
107
+ _exactFileGraphCache.set(key, cached);
108
+ return cached.graph;
109
+ }
110
+ const existing = _exactFileGraphInflight.get(key);
111
+ if (existing?.sourceHash === sourceHash) return existing.promise;
112
+ const pending = (async () => {
113
+ const records = await _runGraphFiles(root, [rel], [], signal);
114
+ const record = records.find((item) => _graphRel(pathResolve(root, item.rel), root) === rel);
115
+ if (!record) return null;
116
+ const info = _fileInfoFromRustRecord(record, root);
117
+ const node = {
118
+ abs: info.abs,
119
+ rel: info.rel,
120
+ lang: info.lang,
121
+ fingerprint: info.fingerprint,
122
+ rawImports: info.rawImports,
123
+ resolvedImportsRel: [],
124
+ resolvedImports: [],
125
+ importedBy: [],
126
+ packageName: info.packageName,
127
+ namespaceName: info.namespaceName,
128
+ goPackageName: info.goPackageName,
129
+ topLevelTypes: info.topLevelTypes,
130
+ tokenSymbols: info.tokenSymbols,
131
+ symbols: Array.isArray(info.symbols) ? info.symbols : [],
132
+ };
133
+ const graph = _attachGraphRuntimeCaches({
134
+ cwd: root,
135
+ nodes: new Map([[node.rel, node]]),
136
+ reverse: new Map(),
137
+ builtAt: Date.now(),
138
+ signature: info.fingerprint || '',
139
+ truncated: false,
140
+ });
141
+ graph._sourceTextCache.set(node.rel, {
142
+ fingerprint: node.fingerprint || '',
143
+ text: sourceText,
144
+ });
145
+ _exactFileGraphCache.delete(key);
146
+ _exactFileGraphCache.set(key, { sourceHash, graph });
147
+ while (_exactFileGraphCache.size > EXACT_FILE_GRAPH_CACHE_MAX) {
148
+ _exactFileGraphCache.delete(_exactFileGraphCache.keys().next().value);
149
+ }
150
+ return graph;
151
+ })();
152
+ const inflight = { sourceHash, promise: pending };
153
+ _exactFileGraphInflight.set(key, inflight);
154
+ try {
155
+ return await pending;
156
+ } finally {
157
+ if (_exactFileGraphInflight.get(key) === inflight) _exactFileGraphInflight.delete(key);
158
+ }
159
+ }
160
+
90
161
  async function _mapWithConcurrency(values, mapper) {
91
162
  const out = new Array(values.length);
92
163
  let cursor = 0;
@@ -693,9 +764,17 @@ async function findSymbolTool(args, cwd, signal = null, options = {}) {
693
764
  else prewarmCodeGraph(cwd);
694
765
  return `prewarm scheduled: cwd=${cwd} symbols=${symbols.length}${symbols.length ? ` (${symbols.slice(0, 5).join(',')}${symbols.length > 5 ? `,+${symbols.length - 5}` : ''})` : ''}`;
695
766
  }
696
- const graph = await buildCodeGraphAsync(cwd, signal, {
697
- excludedProjectRoots: options?.excludedProjectRoots,
698
- });
767
+ const normFile = normalizeInputPath(args?.file);
768
+ const abs = normFile ? (isAbsolute(normFile) ? pathResolve(normFile) : pathResolve(cwd, normFile)) : null;
769
+ let exactFile = false;
770
+ if (abs && _outlineLanguageForPath(abs)) {
771
+ try { exactFile = statSync(abs).isFile(); } catch { exactFile = false; }
772
+ }
773
+ const graph = exactFile
774
+ ? await _buildExactFileGraph(cwd, abs, signal)
775
+ : await buildCodeGraphAsync(cwd, signal, {
776
+ excludedProjectRoots: options?.excludedProjectRoots,
777
+ });
699
778
  if (!graph) throw new Error(`find_symbol: cwd '${cwd}' is not an indexed/known project root or contains zero eligible files`);
700
779
  if (options?.scopedCacheOutcome && graph.truncated) {
701
780
  markScopedCacheIncomplete(options.scopedCacheOutcome);
@@ -703,8 +782,6 @@ async function findSymbolTool(args, cwd, signal = null, options = {}) {
703
782
  const symbol = String(args?.symbol || '').trim();
704
783
  const language = String(args?.language || '').trim() || null;
705
784
  const limit = Math.max(1, Math.min(50, Number(args?.limit || 20)));
706
- const normFile = normalizeInputPath(args?.file);
707
- const abs = normFile ? (isAbsolute(normFile) ? pathResolve(normFile) : pathResolve(cwd, normFile)) : null;
708
785
  const fileRel = abs ? _graphRel(abs, cwd) : null;
709
786
  if (fileRel && !graph.nodes.get(fileRel)) {
710
787
  return _appendSameBasenameHint(`Error: find_symbol: file not found in graph: ${normFile}`, normFile, graph);
@@ -248,7 +248,7 @@ export function _runGraphWalk(absRoot) { return _runGraphBinaryJsonl(absRoot, []
248
248
  // topLevelTypes}. Rust builds the index + resolves over ALL nodes (fresh +
249
249
  // reused) and emits fresh rels as full records, reused rels as lightweight
250
250
  // {rel, resolvedImports, importedBy}.
251
- export function _runGraphFiles(absRoot, rels, reusedMetas) {
251
+ export function _runGraphFiles(absRoot, rels, reusedMetas, signal = null) {
252
252
  const lines = Array.isArray(reusedMetas)
253
253
  ? reusedMetas.map((m) => JSON.stringify({
254
254
  rel: m.rel,
@@ -260,7 +260,7 @@ export function _runGraphFiles(absRoot, rels, reusedMetas) {
260
260
  topLevelTypes: Array.isArray(m.topLevelTypes) ? m.topLevelTypes : [],
261
261
  }))
262
262
  : [];
263
- return _runGraphBinaryJsonl(absRoot, ['--files', ...rels], lines);
263
+ return _runGraphBinaryJsonl(absRoot, ['--files', ...rels], lines, signal);
264
264
  }
265
265
 
266
266
  // Map a Rust FileRecord (rel/lang/fp/tokens/rawImports/resolvedImports/
@@ -78,26 +78,10 @@ export function scrubLoaderVars(env) {
78
78
  return _continueScrubLoaderVars(env);
79
79
  }
80
80
 
81
- // Headless "web off" reaches shell children too: `mixdog exec` pins
82
- // MIXDOG_FEATURE_WEB_SEARCH=0 (default; --web-search opts back in) and every
83
- // model-spawned subprocess then receives the standard proxy variables pointed
84
- // at a dead loopback endpoint. curl/wget/git-http/pip/apt honor these and
85
- // fail fast on public hosts, while NO_PROXY keeps loopback traffic
86
- // (task-local servers) working. Raw-socket clients bypass proxy variables —
87
- // this is a uniform egress policy for the common tooling path, not a sandbox.
88
- // The runtime's own provider calls are unaffected: this mutates only the
89
- // child spawn env, never process.env.
90
- const SHELL_EGRESS_DEAD_PROXY = 'http://127.0.0.1:1';
91
- const SHELL_EGRESS_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'FTP_PROXY', 'RSYNC_PROXY'];
81
+ // Web-search availability is independent from ordinary shell networking.
82
+ // Keep this boundary explicit so disabling the search tool never blocks
83
+ // package managers, source-control clients, or user-configured proxies.
92
84
  export function applyShellEgressPolicy(env) {
93
- if (!env || typeof env !== 'object') return env;
94
- if (String(process.env.MIXDOG_FEATURE_WEB_SEARCH ?? '') !== '0') return env;
95
- for (const name of SHELL_EGRESS_PROXY_VARS) {
96
- env[name] = SHELL_EGRESS_DEAD_PROXY;
97
- env[name.toLowerCase()] = SHELL_EGRESS_DEAD_PROXY;
98
- }
99
- env.NO_PROXY = 'localhost,127.0.0.1,::1';
100
- env.no_proxy = env.NO_PROXY;
101
85
  return env;
102
86
  }
103
87
 
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { applyShellEgressPolicy } from './env-scrub.mjs';
5
+
6
+ test('web-search disabled preserves ordinary shell network environment', () => {
7
+ const previous = process.env.MIXDOG_FEATURE_WEB_SEARCH;
8
+ process.env.MIXDOG_FEATURE_WEB_SEARCH = '0';
9
+ try {
10
+ const env = {
11
+ HTTP_PROXY: 'http://proxy.example:8080',
12
+ HTTPS_PROXY: 'http://proxy.example:8080',
13
+ NO_PROXY: 'localhost',
14
+ };
15
+ assert.equal(applyShellEgressPolicy(env), env);
16
+ assert.deepEqual(env, {
17
+ HTTP_PROXY: 'http://proxy.example:8080',
18
+ HTTPS_PROXY: 'http://proxy.example:8080',
19
+ NO_PROXY: 'localhost',
20
+ });
21
+ } finally {
22
+ if (previous === undefined) delete process.env.MIXDOG_FEATURE_WEB_SEARCH;
23
+ else process.env.MIXDOG_FEATURE_WEB_SEARCH = previous;
24
+ }
25
+ });