mixdog 0.9.135 → 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 (121) 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/help.mjs +0 -2
  6. package/src/rules/lead/01-general.md +0 -4
  7. package/src/rules/shared/01-tool.md +10 -4
  8. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  9. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  10. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  12. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +0 -5
  14. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +17 -4
  15. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  17. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +34 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  21. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +124 -18
  22. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  25. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  26. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  28. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +78 -12
  29. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +34 -3
  30. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -5
  31. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  32. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  33. package/src/runtime/channels/lib/config.mjs +14 -101
  34. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  35. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  36. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  37. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  38. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  39. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  40. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  41. package/src/runtime/media/renditions.mjs +68 -5
  42. package/src/runtime/media/renditions.test.mjs +45 -0
  43. package/src/runtime/media/store.mjs +1 -0
  44. package/src/runtime/memory/index.mjs +4 -3
  45. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  46. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  47. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  48. package/src/runtime/shared/config.mjs +8 -49
  49. package/src/runtime/shared/schedule-time.mjs +25 -0
  50. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  51. package/src/runtime/shared/schedules-db.mjs +73 -1
  52. package/src/runtime/shared/tool-card-model.mjs +1 -1
  53. package/src/runtime/shared/transcript-writer.mjs +10 -17
  54. package/src/runtime/shared/update-checker.mjs +7 -2
  55. package/src/runtime/shared/update-checker.test.mjs +33 -0
  56. package/src/session-runtime/channel-config-api.mjs +3 -20
  57. package/src/session-runtime/config-lifecycle.mjs +1 -42
  58. package/src/session-runtime/lifecycle-api.mjs +1 -19
  59. package/src/session-runtime/model-route-api.mjs +0 -2
  60. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  61. package/src/session-runtime/prewarm.mjs +2 -7
  62. package/src/session-runtime/prewarm.test.mjs +0 -1
  63. package/src/session-runtime/runtime-core.mjs +6 -185
  64. package/src/session-runtime/session-lifecycle.mjs +4 -17
  65. package/src/session-runtime/session-transcript.mjs +54 -0
  66. package/src/session-runtime/session-turn-api.mjs +4 -44
  67. package/src/session-runtime/settings-api.mjs +0 -45
  68. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  69. package/src/standalone/channel-admin.mjs +8 -150
  70. package/src/standalone/daemon.mjs +5 -48
  71. package/src/standalone/seeds.mjs +18 -1
  72. package/src/standalone/session-protocol.mjs +0 -8
  73. package/src/standalone/session-service.mjs +1 -3
  74. package/src/tui/App.jsx +0 -5
  75. package/src/tui/app/app-view.jsx +1 -2
  76. package/src/tui/app/create-app-pickers.mjs +0 -20
  77. package/src/tui/app/doctor.mjs +2 -6
  78. package/src/tui/app/prompt-submit.mjs +2 -49
  79. package/src/tui/app/settings-picker.mjs +22 -96
  80. package/src/tui/app/slash-commands.mjs +0 -2
  81. package/src/tui/app/slash-dispatch.mjs +0 -17
  82. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  83. package/src/tui/components/StatusLine.jsx +4 -4
  84. package/src/tui/dist/index.mjs +1003 -1461
  85. package/src/tui/session/context-state.mjs +0 -1
  86. package/src/tui/session/queue-helpers.mjs +1 -1
  87. package/src/tui/session/queue-helpers.test.mjs +10 -1
  88. package/src/tui/session/session-api-ext.mjs +0 -89
  89. package/src/tui/session/session-api.mjs +0 -2
  90. package/src/tui/session/tool-card-results.mjs +1 -1
  91. package/src/tui/session/tool-result-status.mjs +3 -3
  92. package/src/tui/session/tool-result-status.test.mjs +3 -3
  93. package/src/tui/session-local.mjs +0 -26
  94. package/src/workflows/default/WORKFLOW.md +13 -15
  95. package/src/workflows/solo/WORKFLOW.md +6 -7
  96. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  97. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  98. package/src/runtime/channels/lib/format.mjs +0 -190
  99. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  100. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  101. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  102. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  103. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  104. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  105. package/src/runtime/channels/lib/session-control.mjs +0 -36
  106. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  107. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  108. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  109. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  110. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  111. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  112. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  113. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  114. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  115. package/src/runtime/channels/providers/discord.mjs +0 -730
  116. package/src/runtime/channels/providers/telegram.mjs +0 -598
  117. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  118. package/src/session-runtime/remote-control.mjs +0 -126
  119. package/src/session-runtime/remote-transcript.mjs +0 -134
  120. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  121. package/src/tui/app/channel-pickers.mjs +0 -384
@@ -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
  }
@@ -567,6 +568,7 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
567
568
  deadlineMs: softDeadlineMs(remaining),
568
569
  keepWarm: opts.keepWarm === true,
569
570
  ...(opts.bulkHint === true ? { bulkHint: true } : {}),
571
+ ...(opts.mtimeTopK === true ? { mtimeTopK: true } : {}),
570
572
  });
571
573
  let response = await requestNativeWithRestart(buildRequest, execOptions, deadlineMs);
572
574
  // Deadline-swallow defense (binaries before the serve_search response-level
@@ -638,6 +640,7 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
638
640
  totalSeen: Math.max(0, Math.floor(Number(response.totalSeen) || 0)),
639
641
  partial: response.partial === true,
640
642
  timeout: response.timeout === true,
643
+ filesScanned: Math.max(0, Math.floor(Number(response.filesScanned) || 0)),
641
644
  // Unreadable/half-read files the native scanner skipped: route the count
642
645
  // through the existing rg-exit-2 partial phrasing in search-tool.mjs so
643
646
  // the model sees WHY the result may be missing matches.
@@ -713,11 +716,42 @@ export async function tryServeFuzzySearch(args, execOptions = {}) {
713
716
  cacheSafe: response.cacheSafe !== false,
714
717
  queueMs: Math.max(0, Number(response.queueMs) || 0),
715
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),
716
721
  requestClass: response.class === 'fuzzy' ? 'fuzzy' : 'bulk',
717
722
  served: true,
718
723
  };
719
724
  }
720
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
+
721
755
  export function _resetNativeSearchClientForTest() {
722
756
  _teardown(new Error('test reset'), { countFailure: false });
723
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
@@ -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,8 +391,11 @@ 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
401
  // Explicit literal basenames (no glob magic in the final segment)
@@ -391,6 +447,24 @@ export async function executeGlobTool(args, workDir, options = {}) {
391
447
  };
392
448
  }
393
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
+ }
394
468
  if (canWindowNatural) {
395
469
  const served = await runRgWindowedLines(
396
470
  rgArgs,
@@ -440,6 +514,9 @@ export async function executeGlobTool(args, workDir, options = {}) {
440
514
  if (run.stdoutPartial) rgStdoutPartial = true;
441
515
  if (run.windowIncomplete) rgWindowIncomplete = true;
442
516
  if (run.cacheSafe === false) rgCacheUnsafe = true;
517
+ if (run.nativeMtime) {
518
+ nativeMtimeTotal = (nativeMtimeTotal ?? 0) + Math.max(0, Number(run.totalSeen) || 0);
519
+ }
443
520
  for (const p of run.paths) {
444
521
  allFiles.push(p);
445
522
  if (allFiles.length >= accumCap) {
@@ -454,34 +531,59 @@ export async function executeGlobTool(args, workDir, options = {}) {
454
531
 
455
532
  const unique = Array.from(new Set(allFiles));
456
533
  let orderedPaths;
457
- if (sortMode === 'mtime') {
458
- // Default mtime sorting stats every match. Bound the post-rg stat
459
- // phase so a hung mount cannot pin glob until the agent stall
460
- // watchdog fires.
461
- 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 });
462
538
  const statted = [];
463
539
  const unstatted = [];
464
- for (const entry of withStatAll) {
540
+ for (const entry of withStat) {
465
541
  if (!entry) continue;
466
542
  if (entry.stat != null) statted.push(entry);
467
543
  else unstatted.push(entry);
468
544
  }
469
- statted.sort((a, b) => {
470
- const dm = b.mtime - a.mtime;
471
- if (dm !== 0) return dm;
472
- return globMtimeTiePath(a).localeCompare(globMtimeTiePath(b));
473
- });
474
- // A deadline-expired or failed stat degrades ORDERING only, never
475
- // completeness: unsortable entries keep their walk order at the tail.
476
- 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
+ }
477
579
  } else {
478
580
  orderedPaths = unique.map((entry) => isAbsolute(entry) ? resolve(entry) : resolveAgainstCwd(entry, workDir));
479
581
  }
480
- const totalBeforeOffset = orderedPaths.length;
582
+ const totalBeforeOffset = nativeMtimeTotal ?? unique.length;
481
583
  const windowed = offset > 0 ? orderedPaths.slice(offset) : orderedPaths;
482
584
  const capped = (headLimit === Infinity ? windowed : windowed.slice(0, headLimit))
483
585
  .map((abs) => relativeSearchResultPath(abs, workDir));
484
- const remaining = windowed.length - capped.length;
586
+ const remaining = Math.max(0, totalBeforeOffset - offset - capped.length);
485
587
  const truncSuffix = accumTruncated
486
588
  ? '\n... [truncated at accumulation cap (50000)]'
487
589
  : (rgStdoutTruncated ? '\n... [truncated at rg stdout cap (20MB); results incomplete]' : '')
@@ -507,8 +609,12 @@ export async function executeGlobTool(args, workDir, options = {}) {
507
609
  if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0)) {
508
610
  markScopedCacheIncomplete(options.scopedCacheOutcome);
509
611
  }
510
- const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0;
511
- 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) {
512
618
  cacheSet(cacheKey, out, { scopes: [...groups.keys()].map((root) => resolvedForSearchRoot(root)) });
513
619
  }
514
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
+ });