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
@@ -14,6 +14,13 @@ const _shellSyntaxCheat =
14
14
  const _shellToolRouting = process.platform === 'win32'
15
15
  ? 'Use read, NOT cat/Get-Content/head/tail; list, NOT ls/dir; find/glob, NOT find; grep, NOT grep/rg/Select-String; edit/apply_patch, NOT sed/awk/heredocs/echo/Set-Content.'
16
16
  : 'Use read, NOT cat/head/tail; list, NOT ls; find/glob, NOT find; grep, NOT grep/rg; edit/apply_patch, NOT sed/awk/heredocs/echo.';
17
+ // CC parity: when background tasks are disabled for this process, drop the
18
+ // run_in_background field from the schema entirely so the model cannot burn a
19
+ // failure turn attempting it. Mirrors bash-tool's runtime guard, which stays
20
+ // as defense in depth. Process-stable env, evaluated once at module load.
21
+ const _shellBackgroundDisabled = /^(1|true|yes|on)$/i.test(
22
+ String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
23
+ );
17
24
 
18
25
  export const BUILTIN_TOOLS = [
19
26
  {
@@ -77,16 +84,18 @@ export const BUILTIN_TOOLS = [
77
84
  name: 'shell',
78
85
  title: 'Shell',
79
86
  annotations: { title: 'Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
80
- description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground; after 15s, a still-running command continues as a tracked task_id and completes by notification.`,
87
+ description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground${_shellBackgroundDisabled ? '' : ' unless run_in_background is true'}; after 15s, a still-running foreground command continues as a tracked task_id and completes by notification.`,
81
88
  inputSchema: {
82
89
  type: 'object',
83
90
  properties: {
84
91
  command: { type: 'string', description: `Command.${_shellSyntaxCheat}` },
85
- timeout_ms: {
86
- type: 'integer',
87
- minimum: 0,
88
- description: 'Hard total deadline in milliseconds; omit or use 0 to allow unlimited runtime after task promotion.',
89
- },
92
+ ...(_shellBackgroundDisabled ? {} : {
93
+ run_in_background: {
94
+ type: 'boolean',
95
+ default: false,
96
+ description: 'Start as a tracked background task immediately; use only when the result is not needed for the next step — completion arrives by notification. Default false.',
97
+ },
98
+ }),
90
99
  },
91
100
  required: ['command'],
92
101
  additionalProperties: false,
@@ -191,6 +191,7 @@ async function subscribeResultCacheInFlight(entry, signal) {
191
191
 
192
192
  export async function runResultCacheInFlight(key, compute, options = {}) {
193
193
  const subscriberSignal = options?.signal || options?.abortSignal || null;
194
+ let invalidationRetries = 0;
194
195
  for (;;) {
195
196
  const cached = cacheGet(key);
196
197
  if (cached !== null) return cached;
@@ -233,6 +234,13 @@ export async function runResultCacheInFlight(key, compute, options = {}) {
233
234
  // freshness retry, not a user-visible abort: start a new generation
234
235
  // unless this subscriber itself was cancelled.
235
236
  if (!entry.invalidated || subscriberSignal?.aborted) throw error;
237
+ // Continuous invalidation (watcher churn, eviction broadcasts)
238
+ // must not spin forever: after a few generations run the compute
239
+ // directly — uncached and no longer abortable by invalidation —
240
+ // mirroring the server-side MAX_WALK_RESTARTS cap.
241
+ if (++invalidationRetries >= 3) {
242
+ return await compute({ signal: subscriberSignal });
243
+ }
236
244
  }
237
245
  }
238
246
  }
@@ -351,9 +359,8 @@ export function getCachedReadOnlyStat(fullPath, loader = statSync, now = Date.no
351
359
  return stat;
352
360
  }
353
361
 
354
- export async function statPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
362
+ async function visitPathStats(paths, workDir, concurrency, opts, visitor) {
355
363
  const items = Array.isArray(paths) ? paths : [];
356
- const out = new Array(items.length);
357
364
  const now = Date.now();
358
365
  const inflight = new Map();
359
366
  let next = 0;
@@ -398,17 +405,31 @@ export async function statPathsForMtime(paths, workDir, concurrency = Infinity,
398
405
  if (index >= items.length) return;
399
406
  const p = items[index];
400
407
  const full = isAbsolute(p) ? p : resolveAgainstCwd(p, workDir);
408
+ let entry;
401
409
  try {
402
410
  const stat = await resolveStat(full);
403
411
  if (!stat) throw new Error('stat failed');
404
- out[index] = { path: p, full, stat, size: stat.size, mtime: stat.mtimeMs, mtimeMs: stat.mtimeMs };
412
+ entry = { path: p, full, stat, size: stat.size, mtime: stat.mtimeMs, mtimeMs: stat.mtimeMs };
405
413
  } catch {
406
- out[index] = { path: p, full, stat: null, size: 0, mtime: 0, mtimeMs: 0 };
414
+ entry = { path: p, full, stat: null, size: 0, mtime: 0, mtimeMs: 0 };
407
415
  }
416
+ visitor(entry, index);
408
417
  }
409
418
  }
410
419
  const workerCount = Math.min(Math.max(1, concurrency), Math.max(1, items.length));
411
420
  await Promise.all(Array.from({ length: workerCount }, worker));
421
+ }
422
+
423
+ export async function visitPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}, visitor = () => {}) {
424
+ await visitPathStats(paths, workDir, concurrency, opts, visitor);
425
+ }
426
+
427
+ export async function statPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
428
+ const items = Array.isArray(paths) ? paths : [];
429
+ const out = new Array(items.length);
430
+ await visitPathStats(items, workDir, concurrency, opts, (entry, index) => {
431
+ out[index] = entry;
432
+ });
412
433
  return out;
413
434
  }
414
435
 
@@ -8,7 +8,9 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
11
+ import { executeBuiltinTool } from '../builtin.mjs';
11
12
  import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
13
+ import { executePatchTool } from '../patch.mjs';
12
14
  import { closeNativePatchServerForTests } from '../patch/native-server.mjs';
13
15
 
14
16
  const ANCHOR = '#if 0\n"""\n#endif';
@@ -57,6 +59,44 @@ test('count drift falls through to the strict ambiguity reject', async (t) => {
57
59
  assert.match(String(result), /current file excerpt lines/);
58
60
  });
59
61
 
62
+ test('successful edit snapshot makes a follow-up read return unchanged', async (t) => {
63
+ const { dir, file } = makeTempFile('alpha\nkeep\n');
64
+ const sessionId = `edit-known-current-${process.pid}`;
65
+ t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
66
+
67
+ const result = await tryExecuteExternalToolAdapter('edit', {
68
+ file_path: file,
69
+ old_string: 'alpha',
70
+ new_string: 'omega',
71
+ }, dir, { sessionId });
72
+ assert.match(String(result), /^Updated /);
73
+
74
+ const reread = await executeBuiltinTool('read', { path: file }, dir, { sessionId });
75
+ assert.equal(reread, `[file unchanged: ${file.replaceAll('\\', '/')}]`);
76
+ });
77
+
78
+ test('successful apply_patch snapshot makes a follow-up read return unchanged', async (t) => {
79
+ const { dir, file } = makeTempFile('alpha\nkeep\n');
80
+ const sessionId = `patch-known-current-${process.pid}`;
81
+ t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
82
+
83
+ const result = await executePatchTool('apply_patch', {
84
+ base_path: dir,
85
+ patch: `*** Begin Patch
86
+ *** Update File: poly.c
87
+ @@
88
+ -alpha
89
+ +omega
90
+ keep
91
+ *** End Patch
92
+ `,
93
+ }, dir, { sessionId });
94
+ assert.match(String(result), /^Applied 1 File \(Native\)/);
95
+
96
+ const reread = await executeBuiltinTool('read', { path: file }, dir, { sessionId });
97
+ assert.equal(reread, `[file unchanged: ${file.replaceAll('\\', '/')}]`);
98
+ });
99
+
60
100
  test('stale read snapshot does not block a still-unique current old_string', async (t) => {
61
101
  const { dir, file } = makeTempFile('alpha\nkeep\n');
62
102
  const sessionId = `edit-stale-safe-${process.pid}`;
@@ -11,6 +11,7 @@ import { runRgWindowedLines } from '../native-search-runner.mjs';
11
11
  import { statReachable } from '../fs-reachability.mjs';
12
12
  import { dedupeFanoutMatchLines, formatGrepOutput } from './grep-output.mjs';
13
13
  import { expandGrepAnchorContextOutput } from './grep-context-expander.mjs';
14
+ import { markScopedCacheIncomplete } from '../../../session/cache/scoped-cache-outcome.mjs';
14
15
 
15
16
  export async function runGrepPatternFanout({
16
17
  args,
@@ -77,7 +78,9 @@ export async function runGrepPatternFanout({
77
78
  const pre = await runRgWindowedLines(
78
79
  prefilterArgs,
79
80
  { cwd: preSpawnCwd, signal: options.signal },
80
- { offset: 0, limit: GREP_FANOUT_PREFILTER_FILE_CAP, summaryLimit: 0 },
81
+ // Speculative whole-scope pass: bulk lane keeps it from
82
+ // competing with interactive searches for disk bandwidth.
83
+ { offset: 0, limit: GREP_FANOUT_PREFILTER_FILE_CAP, summaryLimit: 0, bulkHint: true },
81
84
  );
82
85
  return pre.complete && !pre.partial ? pre.lines : null;
83
86
  } catch { return null; }
@@ -130,15 +133,34 @@ export async function runGrepPatternFanout({
130
133
  });
131
134
  const perPatternWindow = headLimit === Infinity ? 300 : (offset + headLimit + 4);
132
135
  const combinedCap = Math.min(4000, Math.max(400, perPatternWindow * patterns.length));
136
+ // Unfiltered multi-pattern directory scans are the broad-scope shape
137
+ // that saturated the interactive pool; route them to the bulk lane.
138
+ const combinedBulkHint = normalizedGlobPatterns.length === 0 && !fileType;
133
139
  let streamed;
134
140
  try {
135
141
  streamed = await runRgWindowedLines(
136
142
  combinedArgs,
137
143
  { cwd: rgCwd, signal: options.signal },
138
- { offset: 0, limit: combinedCap, summaryLimit: 0 },
144
+ { offset: 0, limit: combinedCap, summaryLimit: 0, bulkHint: combinedBulkHint },
139
145
  );
140
146
  } catch { break combined; }
141
- if (!streamed.complete || streamed.partial) break combined;
147
+ // Cap overflow (complete:false without partial) still falls back: the
148
+ // per-pattern rescan restores correct per-pattern windows. Timeout and
149
+ // scan-error partials keep their collected lines instead — the legacy
150
+ // fallback would rescan the same scope from scratch and usually time
151
+ // out again, discarding everything the first pass already found.
152
+ if (streamed.partial ? streamed.lines.length === 0 : !streamed.complete) break combined;
153
+ const combinedPartial = streamed.partial === true;
154
+ const combinedPartialSuffix = !combinedPartial
155
+ ? ''
156
+ : streamed.timeout
157
+ ? '\n[warning] rg timed out; partial results shown. Narrow path/glob/pattern for a complete result.'
158
+ : streamed.rgStderr
159
+ ? `\n[warning] rg exit 2 (partial results): ${String(streamed.rgStderr).trim().slice(0, 300)}`
160
+ : '\n[warning] rg exit 2 (partial results)';
161
+ if (combinedPartial && options?.scopedCacheOutcome) {
162
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
163
+ }
142
164
  const adaptive = contextN > 0 && !(beforeN > 0) && !(afterN > 0);
143
165
  const byPattern = patterns.map(() => []);
144
166
  const residual = [];
@@ -175,7 +197,7 @@ export async function runGrepPatternFanout({
175
197
  filenameOmitted: false,
176
198
  headLimit,
177
199
  offset,
178
- totalKnown: true,
200
+ totalKnown: !combinedPartial,
179
201
  requestedContext: contextN,
180
202
  maxContext: GREP_AUTO_CONTEXT_LINES,
181
203
  patterns: [p],
@@ -190,7 +212,7 @@ export async function runGrepPatternFanout({
190
212
  body = formatGrepOutput({
191
213
  windowed: windowedLines,
192
214
  totalWindowed: post.length,
193
- totalKnown: true,
215
+ totalKnown: !combinedPartial,
194
216
  headLimit,
195
217
  offset,
196
218
  outputMode,
@@ -211,14 +233,15 @@ export async function runGrepPatternFanout({
211
233
  sections.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(body, seenCombined)}`);
212
234
  }
213
235
  if (noMatchPatterns.length > 0) {
214
- sections.push(`(no matches) pattern=${JSON.stringify(noMatchPatterns)} path=${searchPath}${globStr}; path exists`);
236
+ // Under a partial scan a zero-hit pattern is NOT a proven no-match.
237
+ sections.push(`(no matches${combinedPartial ? ' in partial results' : ''}) pattern=${JSON.stringify(noMatchPatterns)} path=${searchPath}${globStr}; path exists`);
215
238
  }
216
239
  if (residual.length > 0) {
217
240
  // Rust/JS regex divergence or --max-columns truncation left
218
241
  // matches no pattern claimed; surface them rather than drop.
219
242
  sections.push(`# grep (unattributed matches)\n${residual.slice(0, 40).join('\n')}`);
220
243
  }
221
- return patternCapNote + sections.join('\n\n');
244
+ return patternCapNote + sections.join('\n\n') + combinedPartialSuffix;
222
245
  }
223
246
  // Prefilter result (started above, overlapped with the combined
224
247
  // attempt): when it completed under the cap, each per-pattern grep
@@ -33,7 +33,7 @@ function dataDir() {
33
33
  || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
34
34
  }
35
35
 
36
- function pidAlive(pid) {
36
+ export function pidAlive(pid) {
37
37
  if (!Number.isInteger(pid) || pid <= 0) return false;
38
38
  try { process.kill(pid, 0); return true; }
39
39
  catch (error) { return error?.code === 'EPERM'; } // EPERM = alive, not ours
@@ -37,7 +37,7 @@ import {
37
37
  TOOL_OUTPUT_MAX_BYTES,
38
38
  } from './tool-output-limit.mjs';
39
39
  import { runRg, runRgWindowedLines } from './native-search-runner.mjs';
40
- import { tryServeFuzzySearch } from './native-search-client.mjs';
40
+ import { tryServeFuzzySearch, tryServeListMetadata } from './native-search-client.mjs';
41
41
  import { fuzzyRankMulti, prepareFuzzyItems, splitFuzzyQueryTokens } from './fuzzy-match.mjs';
42
42
  import {
43
43
  recordLocalSearchBackend,
@@ -129,6 +129,95 @@ function _metaModeOctal(mode) {
129
129
  return (mode & 0o7777).toString(8).padStart(3, '0');
130
130
  }
131
131
 
132
+ async function listMetadataForPaths(paths, workDir, options = {}) {
133
+ if (!Array.isArray(paths) || paths.length === 0) return [];
134
+ const nativeImpl = typeof options?.__tryServeListMetadata === 'function'
135
+ ? options.__tryServeListMetadata
136
+ : tryServeListMetadata;
137
+ try {
138
+ const entries = await nativeImpl(paths, {
139
+ cwd: workDir,
140
+ signal: options?.signal || options?.abortSignal || null,
141
+ timeout: 5_000,
142
+ });
143
+ if (Array.isArray(entries) && entries.length === paths.length) {
144
+ return entries.map((entry, index) => ({
145
+ fullPath: paths[index],
146
+ type: entry && !entry.error ? String(entry.type || '') : '',
147
+ stat: entry && !entry.error ? { mode: Number(entry.mode) || 0 } : null,
148
+ size: entry && !entry.error ? Math.max(0, Number(entry.size) || 0) : 0,
149
+ mtimeMs: entry && !entry.error ? Math.max(0, Number(entry.mtimeMs) || 0) : 0,
150
+ }));
151
+ }
152
+ } catch {
153
+ // Older/unavailable native helpers preserve the existing JS fallback.
154
+ }
155
+ return lstatPathsForMtime(paths, workDir, 64, { deadlineMs: 5000 });
156
+ }
157
+
158
+ async function tryNativeDeepListRows({
159
+ fullPath,
160
+ workDir,
161
+ depth,
162
+ hidden,
163
+ includeNoise,
164
+ signal,
165
+ options,
166
+ }) {
167
+ if (depth <= 1 || typeof options?.readdirImpl === 'function') return null;
168
+ const rgArgs = ['--files', '--directories', '--no-ignore', '--no-require-git'];
169
+ if (hidden) rgArgs.push('--hidden');
170
+ rgArgs.push('--max-depth', String(depth));
171
+ if (!includeNoise) {
172
+ for (const glob of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', glob);
173
+ }
174
+ rgArgs.push('--', fullPath);
175
+ try {
176
+ const served = await runRgWindowedLines(
177
+ rgArgs,
178
+ { cwd: workDir, timeout: LIST_WALK_TIMEOUT_MS, signal },
179
+ { offset: 0, limit: LIST_ABSOLUTE_CAP, nativeInventory: true },
180
+ );
181
+ if (served?.complete !== true || served?.partial === true || !Array.isArray(served?.lines)) {
182
+ return null;
183
+ }
184
+ const paths = Array.from(new Set(served.lines.map((line) => (
185
+ isAbsolute(line) ? line : resolveAgainstCwd(line, workDir)
186
+ )))).filter((path) => path !== fullPath);
187
+ const metadata = await listMetadataForPaths(paths, workDir, options);
188
+ if (!Array.isArray(metadata) || metadata.length !== paths.length) return null;
189
+ const rows = paths.map((path, index) => {
190
+ const item = metadata[index] || {};
191
+ const type = ['file', 'dir', 'symlink', 'other'].includes(item.type)
192
+ ? item.type
193
+ : (item.stat?.isDirectory?.()
194
+ ? 'dir'
195
+ : (item.stat?.isFile?.()
196
+ ? 'file'
197
+ : (item.stat?.isSymbolicLink?.() ? 'symlink' : 'other')));
198
+ return {
199
+ path,
200
+ type,
201
+ size: Number(item.size) || 0,
202
+ mtimeMs: Number(item.mtimeMs) || 0,
203
+ mode: Number(item.mode) || 0,
204
+ fullPath: path,
205
+ };
206
+ });
207
+ return {
208
+ rows,
209
+ walkResult: {
210
+ entriesVisited: rows.length,
211
+ directoriesVisited: rows.filter((row) => row.type === 'dir').length,
212
+ aborted: false,
213
+ nativeInventory: true,
214
+ },
215
+ };
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
132
221
  export async function executeListTool(args, workDir, options = {}) {
133
222
  if (typeof args.fuzzy === 'string' && args.fuzzy.length > 0) {
134
223
  return executeFuzzyFindTool({ ...args, query: args.fuzzy }, workDir, options);
@@ -216,37 +305,56 @@ export async function executeListTool(args, workDir, options = {}) {
216
305
  let truncatedByCap = false;
217
306
  const walkWarnings = [];
218
307
  const walkDeadline = Date.now() + LIST_WALK_TIMEOUT_MS;
219
- const walkResult = await walkDir(fullPath, {
308
+ const nativeDeep = await tryNativeDeepListRows({
309
+ fullPath,
310
+ workDir,
311
+ depth,
220
312
  hidden,
221
- maxDepth: depth,
222
- excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
313
+ includeNoise,
223
314
  signal: options.signal,
224
- readdirImpl: options.readdirImpl,
225
- onWarn: (dir, error, context) => walkWarnings.push({ dir, error, root: Boolean(context?.root) }),
226
- visit: (ent, entPath) => {
227
- if (Date.now() > walkDeadline) { truncatedByCap = true; return false; }
228
- const isDir = ent.isDirectory();
229
- const isFile = ent.isFile();
230
- if (typeFilter === 'file' && !isFile) return;
231
- if (typeFilter === 'dir' && !isDir) return;
232
- const entType = isDir ? 'dir' : (isFile ? 'file' : (ent.isSymbolicLink() ? 'symlink' : 'other'));
233
- rows.push({
234
- path: entPath,
235
- type: entType,
236
- size: 0,
237
- mtimeMs: 0,
238
- mode: 0,
239
- fullPath: entPath,
240
- });
241
- if (rows.length >= LIST_ABSOLUTE_CAP) {
242
- truncatedByCap = true;
243
- return false;
244
- }
245
- // Pre-sort truncation removed: a global name sort needs all
246
- // candidates collected before slicing, otherwise the visible
247
- // window depends on traversal order rather than sort order.
248
- },
315
+ options,
249
316
  });
317
+ let walkResult;
318
+ if (nativeDeep) {
319
+ for (const row of nativeDeep.rows) {
320
+ if (typeFilter === 'file' && row.type !== 'file') continue;
321
+ if (typeFilter === 'dir' && row.type !== 'dir') continue;
322
+ rows.push(row);
323
+ }
324
+ walkResult = nativeDeep.walkResult;
325
+ } else {
326
+ walkResult = await walkDir(fullPath, {
327
+ hidden,
328
+ maxDepth: depth,
329
+ excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
330
+ signal: options.signal,
331
+ readdirImpl: options.readdirImpl,
332
+ onWarn: (dir, error, context) => walkWarnings.push({ dir, error, root: Boolean(context?.root) }),
333
+ visit: (ent, entPath) => {
334
+ if (Date.now() > walkDeadline) { truncatedByCap = true; return false; }
335
+ const isDir = ent.isDirectory();
336
+ const isFile = ent.isFile();
337
+ if (typeFilter === 'file' && !isFile) return;
338
+ if (typeFilter === 'dir' && !isDir) return;
339
+ const entType = isDir ? 'dir' : (isFile ? 'file' : (ent.isSymbolicLink() ? 'symlink' : 'other'));
340
+ rows.push({
341
+ path: entPath,
342
+ type: entType,
343
+ size: 0,
344
+ mtimeMs: 0,
345
+ mode: 0,
346
+ fullPath: entPath,
347
+ });
348
+ if (rows.length >= LIST_ABSOLUTE_CAP) {
349
+ truncatedByCap = true;
350
+ return false;
351
+ }
352
+ // Pre-sort truncation removed: a global name sort needs all
353
+ // candidates collected before slicing, otherwise the visible
354
+ // window depends on traversal order rather than sort order.
355
+ },
356
+ });
357
+ }
250
358
  throwIfDirectoryWalkAborted(options.signal, walkResult, 'list walk');
251
359
  const rootFailure = walkWarnings.find((warning) => warning.root);
252
360
  if (rootFailure) {
@@ -257,9 +365,9 @@ export async function executeListTool(args, workDir, options = {}) {
257
365
  }
258
366
  recordShardDirectoryReadSuccess();
259
367
 
260
- if (needsGlobalStat && rows.length > 0) {
368
+ if (!nativeDeep && needsGlobalStat && rows.length > 0) {
261
369
  // lstat: symlinks should report own metadata, not the target's.
262
- const stats = await lstatPathsForMtime(rows.map((row) => row.fullPath), workDir, 64, { deadlineMs: 5000 });
370
+ const stats = await listMetadataForPaths(rows.map((row) => row.fullPath), workDir, options);
263
371
  for (let i = 0; i < rows.length; i++) {
264
372
  const item = stats[i];
265
373
  if (!item?.stat) continue;
@@ -281,8 +389,8 @@ export async function executeListTool(args, workDir, options = {}) {
281
389
  // stat just the visible window (a global stat already ran for
282
390
  // mtime/size sorts); `sliced` shares row objects with `rows`, so the
283
391
  // assignments land on the rendered entries.
284
- if (meta && !needsGlobalStat && sliced.length > 0) {
285
- const stats = await lstatPathsForMtime(sliced.map((row) => row.fullPath), workDir, 64, { deadlineMs: 5000 });
392
+ if (!nativeDeep && meta && !needsGlobalStat && sliced.length > 0) {
393
+ const stats = await listMetadataForPaths(sliced.map((row) => row.fullPath), workDir, options);
286
394
  for (let i = 0; i < sliced.length; i++) {
287
395
  const item = stats[i];
288
396
  if (!item?.stat) continue;
@@ -940,15 +1048,11 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
940
1048
  const nativeFuzzyImpl = typeof options?.__tryServeFuzzySearch === 'function'
941
1049
  ? options.__tryServeFuzzySearch
942
1050
  : tryServeFuzzySearch;
943
- // Space-separated fragments AND-match one path. The native fuzzy matcher
944
- // scores the query as ONE string (paths contain no spaces, so it would
945
- // always miss); multi-fragment queries go straight to the broad
946
- // enumeration + fuzzyRankMulti path below.
1051
+ // Space-separated fragments AND-match one path. The native matcher handles
1052
+ // every token independently and sums exact per-token scores.
947
1053
  const queryTokens = splitFuzzyQueryTokens(query);
948
- const multiToken = queryTokens.length > 1;
949
1054
  if (
950
1055
  headLimit > 0
951
- && !multiToken
952
1056
  && (
953
1057
  typeof options?.__runRg !== 'function'
954
1058
  || typeof options?.__tryServeFuzzySearch === 'function'
@@ -978,6 +1082,9 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
978
1082
  ? [`(no fuzzy match for "${query}")`]
979
1083
  : [...matches, ...(hasMore ? [`... (top ${headLimit}; raise limit for more)`] : [])];
980
1084
  const result = lines.join('\n');
1085
+ if (served.cacheSafe !== false) {
1086
+ cacheSet(cacheKey, result, { scopes: [fullPath] });
1087
+ }
981
1088
  if (typeof options?.onProgress === 'function') {
982
1089
  try { options.onProgress(`${matches.length} candidates`); } catch {}
983
1090
  }
@@ -1333,8 +1440,10 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
1333
1440
  if (options?.scopedCacheOutcome && (truncatedByCap || windowed.length > sliced.length)) {
1334
1441
  markScopedCacheIncomplete(options.scopedCacheOutcome);
1335
1442
  }
1336
- const findIncomplete = truncatedByCap || windowed.length > sliced.length;
1337
- if (!findIncomplete) {
1443
+ // A bounded page is still an exact result because offset/limit are keyed
1444
+ // and watcher invalidation covers the scope. Only the absolute safety cap
1445
+ // makes the computation incomplete.
1446
+ if (!truncatedByCap) {
1338
1447
  cacheSet(cacheKey, out, { scopes: [fullPath] });
1339
1448
  }
1340
1449
  return out;
@@ -37,6 +37,10 @@ export function recordNativeSearchTiming(served) {
37
37
  target[`native_${requestClass}_requests`] = (Number(target[`native_${requestClass}_requests`]) || 0) + 1;
38
38
  addNumber(target, `native_${requestClass}_queue_ms`, served.queueMs);
39
39
  addNumber(target, `native_${requestClass}_handler_ms`, served.handlerMs);
40
+ if (requestClass === 'fuzzy') {
41
+ addNumber(target, 'native_fuzzy_inventory_ms', served.inventoryMs);
42
+ addNumber(target, 'native_fuzzy_rank_ms', served.rankMs);
43
+ }
40
44
  }
41
45
 
42
46
  export function recordLocalSearchCacheHit(layer) {
@@ -42,6 +42,7 @@ function codedError(code, message, cause = null) {
42
42
 
43
43
  function requestKind(request) {
44
44
  if (request?.processSnapshot) return 'process snapshot';
45
+ if (request?.listMetadata) return 'list metadata';
45
46
  if (request?.fuzzy != null) return 'fuzzy';
46
47
  return request?.args?.includes?.('--files') ? 'file inventory' : 'content';
47
48
  }
@@ -194,6 +195,31 @@ function _teardown(error, { countFailure = true, detail = '' } = {}) {
194
195
  try { server.child.kill(); } catch {}
195
196
  }
196
197
 
198
+ export async function shutdownNativeSearchServer(reason = 'process-exit', timeoutMs = 1_000) {
199
+ const server = _server;
200
+ if (!server) return true;
201
+ const child = server.child;
202
+ const exited = new Promise((resolve) => {
203
+ child.once('exit', () => resolve(true));
204
+ child.once('error', () => resolve(true));
205
+ });
206
+ try { child.stdin?.end?.(); } catch {}
207
+ _teardown(new Error(`native search server shutdown (${reason})`), { countFailure: false });
208
+ _warmPromise = null;
209
+ let timer;
210
+ const stopped = await Promise.race([
211
+ exited,
212
+ new Promise((resolve) => {
213
+ timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
214
+ }),
215
+ ]);
216
+ if (timer) clearTimeout(timer);
217
+ if (!stopped) {
218
+ try { child.kill('SIGKILL'); } catch {}
219
+ }
220
+ return stopped;
221
+ }
222
+
197
223
  export function _bindNativeSearchServerLifecycle(child, { onError, onExit } = {}) {
198
224
  if (!child?.on) return;
199
225
  child.on('error', onError);
@@ -541,6 +567,8 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
541
567
  : 0,
542
568
  deadlineMs: softDeadlineMs(remaining),
543
569
  keepWarm: opts.keepWarm === true,
570
+ ...(opts.bulkHint === true ? { bulkHint: true } : {}),
571
+ ...(opts.mtimeTopK === true ? { mtimeTopK: true } : {}),
544
572
  });
545
573
  let response = await requestNativeWithRestart(buildRequest, execOptions, deadlineMs);
546
574
  // Deadline-swallow defense (binaries before the serve_search response-level
@@ -612,6 +640,7 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
612
640
  totalSeen: Math.max(0, Math.floor(Number(response.totalSeen) || 0)),
613
641
  partial: response.partial === true,
614
642
  timeout: response.timeout === true,
643
+ filesScanned: Math.max(0, Math.floor(Number(response.filesScanned) || 0)),
615
644
  // Unreadable/half-read files the native scanner skipped: route the count
616
645
  // through the existing rg-exit-2 partial phrasing in search-tool.mjs so
617
646
  // the model sees WHY the result may be missing matches.
@@ -687,11 +716,42 @@ export async function tryServeFuzzySearch(args, execOptions = {}) {
687
716
  cacheSafe: response.cacheSafe !== false,
688
717
  queueMs: Math.max(0, Number(response.queueMs) || 0),
689
718
  handlerMs: Math.max(0, Number(response.handlerMs) || 0),
719
+ inventoryMs: Math.max(0, Number(response.inventoryMs) || 0),
720
+ rankMs: Math.max(0, Number(response.rankMs) || 0),
690
721
  requestClass: response.class === 'fuzzy' ? 'fuzzy' : 'bulk',
691
722
  served: true,
692
723
  };
693
724
  }
694
725
 
726
+ export async function tryServeListMetadata(paths, execOptions = {}) {
727
+ if (process.env.MIXDOG_SEARCH_SERVER === '0') return null;
728
+ const list = Array.isArray(paths) ? paths.map(String) : [];
729
+ if (list.length === 0) return [];
730
+ if (list.length > 50_000) {
731
+ throw codedError('NATIVE_SEARCH_UNSUPPORTED', 'list metadata request exceeds 50000 paths');
732
+ }
733
+ const callerTimeoutMs = Number(execOptions.timeout);
734
+ const deadlineMs = Number.isFinite(callerTimeoutMs) && callerTimeoutMs > 0
735
+ ? Math.min(callerTimeoutMs, REQUEST_TIMEOUT_MS)
736
+ : Math.min(5_000, REQUEST_TIMEOUT_MS);
737
+ const response = await requestNativeWithRestart(
738
+ (server) => ({
739
+ id: ++server.sequence,
740
+ cwd: String(execOptions.cwd || process.cwd()),
741
+ listMetadata: list,
742
+ }),
743
+ execOptions,
744
+ deadlineMs,
745
+ );
746
+ if (response?.error) {
747
+ throw codedError('NATIVE_SEARCH_ERROR', String(response.error));
748
+ }
749
+ if (!Array.isArray(response?.entries) || response.entries.length !== list.length) {
750
+ throw unavailableError();
751
+ }
752
+ return response.entries;
753
+ }
754
+
695
755
  export function _resetNativeSearchClientForTest() {
696
756
  _teardown(new Error('test reset'), { countFailure: false });
697
757
  _binaryPath = undefined;
@@ -14,7 +14,10 @@ import {
14
14
  import { extractOoxmlText } from './read-office-files.mjs';
15
15
 
16
16
  function snapshotBodyWasReturnedByRead(snapshot) {
17
- return String(snapshot?.source || '').startsWith('read');
17
+ const source = String(snapshot?.source || '');
18
+ return source.startsWith('read')
19
+ || source === 'edit'
20
+ || source.startsWith('apply_patch_');
18
21
  }
19
22
 
20
23
  // BOM-only read-encoding detection. Mirrors CC fileRead.ts:34
@@ -272,6 +275,20 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
272
275
  const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
273
276
  return `Error: ${_safeMsg}${hint}`;
274
277
  }
278
+ // A successful edit/apply_patch leaves a session-scoped full-file snapshot.
279
+ // The model already knows the resulting body from the prior body plus its
280
+ // mutation, so a follow-up Read needs only the stat tuple to prove that no
281
+ // external write landed afterward. Do not reopen/hash the file body merely
282
+ // to return the unchanged stub.
283
+ const _mutationSnapshot = readStateScope ? getReadSnapshot(fullPath, readStateScope) : null;
284
+ const _mutationSource = String(_mutationSnapshot?.source || '');
285
+ if (
286
+ (_mutationSource === 'edit' || _mutationSource.startsWith('apply_patch_'))
287
+ && statMatchesSnapshot(st, _mutationSnapshot)
288
+ && snapshotCoversFullFile(_mutationSnapshot)
289
+ ) {
290
+ return `[file unchanged: ${normalizeOutputPath(filePath)}]`;
291
+ }
275
292
  // MEDIA-WINS: .pdf/.ipynb dispatch runs BEFORE any cache/snapshot fast
276
293
  // path. A media file previously read as text can carry a stale result-
277
294
  // cache or read-snapshot entry; returning that cached TEXT instead of the